taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit a72cb0208c7202a69af1c886d08f2f568041df89
parent acf564b9e4996043b8ce9cd8dcdca1fb291d211a
Author: Florian Dold <dold@taler.net>
Date:   Thu, 30 Jul 2026 14:18:38 +0200

wallet-core: raise coded errors for client-visible failures

Bad arguments, missing records, KYC limits and peer-payment outcomes were
plain exceptions, so every one of them reached clients as
WALLET_UNEXPECTED_EXCEPTION with nothing to branch on. Paying an invoice
also reported the peer-push insufficient-balance code rather than the pull
one, and parseTransactionIdentifier threw for some malformed identifiers
while returning undefined for others, so the throwing paths escaped past
its callers' own checks.

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-peer-pull.ts | 16+++++++++-------
Mpackages/taler-harness/src/integrationtests/test-peer-push.ts | 101+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/taler-wallet-core/src/coinSelection.ts | 12++----------
Mpackages/taler-wallet-core/src/deposits.ts | 51++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-wallet-core/src/donau.ts | 26++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/exchanges.ts | 63++++++++++++++++++++++++++++++++++++++++++---------------------
Mpackages/taler-wallet-core/src/instructedAmountConversion.ts | 14+++++++++++---
Mpackages/taler-wallet-core/src/mailbox.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 28++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 70+++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 12+++++++++---
Mpackages/taler-wallet-core/src/pay-template.ts | 32++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/recoup.ts | 27+++++++++++++--------------
Mpackages/taler-wallet-core/src/refresh.ts | 7++++++-
Mpackages/taler-wallet-core/src/requests.ts | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/taldir.ts | 48++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-core/src/tokenFamilies.ts | 16++++++++++++----
Mpackages/taler-wallet-core/src/tokenSelection.ts | 8+++++++-
Mpackages/taler-wallet-core/src/transactions.test.ts | 8++++++--
Mpackages/taler-wallet-core/src/transactions.ts | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/wallet.ts | 11++++++++++-
Mpackages/taler-wallet-core/src/withdraw.ts | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
24 files changed, 828 insertions(+), 297 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts @@ -144,7 +144,7 @@ export async function runPeerPullTest(t: GlobalTestState) { ); t.assertTrue( insufficient_balance.errorDetail.code === - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + TalerErrorCode.WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE, ); const unknown_purse = await t.assertThrowsTalerErrorAsync(async () => @@ -153,10 +153,9 @@ export async function runPeerPullTest(t: GlobalTestState) { "taler+http://pay-pull/localhost:8081/MQP1DP1J94ZZWNQS7TRDF1KJZ7V8H74CZF41V90FKXBPN5GNRN6G", }), ); - // FIXME this should fail with a proper error code t.assertTrue( unknown_purse.errorDetail.code === - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, ); } @@ -231,10 +230,9 @@ export async function runPeerPullTest(t: GlobalTestState) { talerUri: tx.talerUri!, }), ); - // FIXME this should fail with a proper error code t.assertTrue( completed_purse.errorDetail.code === - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + TalerErrorCode.WALLET_PEER_PULL_DEBIT_ALREADY_PAID, ); } @@ -387,10 +385,14 @@ export async function runPeerPullTest(t: GlobalTestState) { talerUri: tx.talerUri!, }), ); - // FIXME this should fail with a proper error code + // Whether the exchange drops the contract along with the purse or keeps + // it and only reports the purse as gone decides which of the two the + // wallet sees, so accept either. t.assertTrue( aborted_contract.errorDetail.code === - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND || + aborted_contract.errorDetail.code === + TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE, ); } diff --git a/packages/taler-harness/src/integrationtests/test-peer-push.ts b/packages/taler-harness/src/integrationtests/test-peer-push.ts @@ -158,10 +158,9 @@ export async function runPeerPushTest(t: GlobalTestState) { "taler+http://pay-push/localhost:8081/MQP1DP1J94ZZWNQS7TRDF1KJZ7V8H74CZF41V90FKXBPN5GNRN6G", }), ); - // FIXME this should fail with a proper error code t.assertTrue( unknown_purse.errorDetail.code === - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, ); } @@ -403,57 +402,63 @@ export async function runPeerPushTest(t: GlobalTestState) { }); }); - await t.runSpanAsync("P2P push abort after the purse was merged", async () => { - const tx = await initPeerPushDebit("abort after merge"); + await t.runSpanAsync( + "P2P push abort after the purse was merged", + async () => { + const tx = await initPeerPushDebit("abort after merge"); - const prepare4 = await wallet4.call( - WalletApiOperation.PreparePeerPushCredit, - { - talerUri: tx.talerUri!, - }, - ); + const prepare4 = await wallet4.call( + WalletApiOperation.PreparePeerPushCredit, + { + talerUri: tx.talerUri!, + }, + ); - // Suspend the sender so that it does not observe the merge on its own. - // The abort below is then the first request that learns about it, which is - // the ordering that leaves the exchange answering the purse deletion with - // a conflict. - await wallet1.call(WalletApiOperation.SuspendTransaction, { - transactionId: tx.transactionId, - }); - await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: tx.transactionId, - txState: { - major: TransactionMajorState.Suspended, - minor: TransactionMinorState.Ready, - }, - }); + // Suspend the sender so that it does not observe the merge on its own. + // The abort below is then the first request that learns about it, which is + // the ordering that leaves the exchange answering the purse deletion with + // a conflict. + await wallet1.call(WalletApiOperation.SuspendTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.Ready, + }, + }); - await wallet4.call(WalletApiOperation.ConfirmPeerPushCredit, { - transactionId: prepare4.transactionId, - }); - await wallet4.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: prepare4.transactionId, - txState: { - major: TransactionMajorState.Done, - }, - }); + await wallet4.call(WalletApiOperation.ConfirmPeerPushCredit, { + transactionId: prepare4.transactionId, + }); + await wallet4.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepare4.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); - // The recipient has the money, so the payment cannot be taken back. - await wallet1.call(WalletApiOperation.AbortTransaction, { - transactionId: tx.transactionId, - }); - await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: tx.transactionId, - txState: { - major: TransactionMajorState.Done, - }, - }); + // The recipient has the money, so the payment cannot be taken back. + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); - const finalTx = await wallet1.call(WalletApiOperation.GetTransactionById, { - transactionId: tx.transactionId, - }); - t.assertDeepEqual(finalTx.abortReason, undefined); - }); + const finalTx = await wallet1.call( + WalletApiOperation.GetTransactionById, + { + transactionId: tx.transactionId, + }, + ); + t.assertDeepEqual(finalTx.abortReason, undefined); + }, + ); await t.runSpanAsync("P2P push abort before create purse", async () => { // Make sure the reserve can't be created. diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -228,12 +228,7 @@ async function internalSelectPayCoins( > { let restrictWireMethod; if (req.depositPaytoUri) { - const parsedPayto = Result.orUndefined( - Paytos.fromString(req.depositPaytoUri), - ); - if (!parsedPayto) { - throw Error("invalid payto URI"); - } + const parsedPayto = Paytos.parseOrThrow(req.depositPaytoUri); restrictWireMethod = parsedPayto.targetType; if (restrictWireMethod !== req.restrictWireMethod) { logger.warn(`conflicting payto URI and wire method restriction`); @@ -1601,10 +1596,7 @@ export async function getMaxDepositAmount( }, ); if (req.depositPaytoUri) { - const p = Result.orUndefined(Paytos.fromString(req.depositPaytoUri)); - if (!p) { - throw Error("invalid payto URI"); - } + const p = Paytos.parseOrThrow(req.depositPaytoUri); restrictWireMethod = p.targetType; } const candidateRes = await selectPayCandidates(wex, tx, { diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -147,6 +147,7 @@ import { applyNotifyTransition, constructTransactionIdentifier, isUnsuccessfulTransaction, + makeInvalidTransactionIdError, parseTransactionIdentifier, } from "./transactions.js"; import { @@ -1996,10 +1997,7 @@ export async function internalCheckDepositGroup( wex: WalletExecutionContext, req: CheckDepositRequest, ): Promise<CheckDepositResponse> { - const p = Result.orUndefined(Paytos.fromString(req.depositPaytoUri)); - if (!p) { - throw Error("invalid payto URI"); - } + const p = Paytos.parseOrThrow(req.depositPaytoUri); const amount = Amounts.parseOrThrow(req.amount); const currency = Amounts.currencyOf(amount); @@ -2009,7 +2007,11 @@ export async function internalCheckDepositGroup( }); if (exchangeInfos.length == 0) { - throw Error("no exchanges possible for deposit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency, wireMethod: p.targetType }, + "no exchange in the wallet can be used for this deposit", + ); } const depositFeeLimit = amount; @@ -2099,12 +2101,7 @@ async function internalCreateDepositGroup( wex: WalletExecutionContext, req: CreateDepositGroupRequest, ): Promise<CreateDepositGroupResponse> { - const depositPayto = Result.orUndefined( - Paytos.fromString(req.depositPaytoUri), - ); - if (!depositPayto) { - throw Error("invalid payto URI"); - } + const depositPayto = Paytos.parseOrThrow(req.depositPaytoUri); const amount = Amounts.parseOrThrow(req.amount); const currency = amount.currency; @@ -2115,7 +2112,11 @@ async function internalCreateDepositGroup( }); if (exchangeInfos.length == 0) { - throw Error("no exchanges possible for deposit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency, wireMethod: depositPayto.targetType }, + "no exchange in the wallet can be used for this deposit", + ); } const now = AbsoluteTime.now(); @@ -2168,7 +2169,14 @@ async function internalCreateDepositGroup( } if (checkDepositHardLimitExceeded(exchanges, req.amount)) { - throw Error("deposit would exceed hard KYC limit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { + exchangeBaseUrl: exchanges[0]?.exchangeBaseUrl, + requestedAmount: req.amount, + }, + "deposit would exceed a hard limit of the exchange", + ); } let merchantPair: EddsaKeyPairStrings; @@ -2186,7 +2194,11 @@ async function internalCreateDepositGroup( coins[0].exchangeBaseUrl, ); } else { - throw Error("refusing to create deposit with zero coins"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "amount" }, + "refusing to create a deposit that would not spend any coins", + ); } const noncePair = await wex.cryptoApi.createEddsaKeypair({}); @@ -2232,8 +2244,10 @@ async function internalCreateDepositGroup( contractData.contractTerms.version !== undefined && contractData.contractTerms.version !== MerchantContractVersion.V0 ) { - throw Error( - `unsupported contract version ${contractData.contractTerms.version}`, + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + `this wallet does not support contract version ${contractData.contractTerms.version}`, ); } @@ -2243,7 +2257,10 @@ async function internalCreateDepositGroup( if (req.transactionId) { const txId = parseTransactionIdentifier(req.transactionId); if (!txId || txId.tag !== TransactionType.Deposit) { - throw Error("invalid transaction ID"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.Deposit, + ); } depositGroupId = txId.depositGroupId; } else { diff --git a/packages/taler-wallet-core/src/donau.ts b/packages/taler-wallet-core/src/donau.ts @@ -53,6 +53,8 @@ import { SignedTokenEnvelope, stringToBytes, succeedOrThrow, + TalerError, + TalerErrorCode, } from "@gnu-taler/taler-util"; import { ConfigRecordKey, @@ -552,7 +554,11 @@ export async function acceptDonauBlindSigs( donauBlindedSigs: SignedTokenEnvelope[], ): Promise<void> { if (donauPlanchets.length != donauBlindedSigs.length) { - throw Error(); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + `donau returned ${donauBlindedSigs.length} signatures for ${donauPlanchets.length} planchets`, + ); } const client = new DonauHttpClient(donauBaseUrl); @@ -581,11 +587,19 @@ export async function acceptDonauBlindSigs( } } if (!unitKey) { - throw Error("donation unit key not found"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "donau signed with a donation unit key that it does not offer", + ); } logger.info(`found unit key ${j2s(unitKey)}`); if (myBlindSig.cipher !== DenomKeyType.Rsa) { - throw Error("only RSA supported"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + `this wallet only supports RSA donation units, donau used ${myBlindSig.cipher}`, + ); } const unblindSig = await wex.cryptoApi.rsaUnblind({ pk: unitKey.donation_unit_pub.rsa_public_key, @@ -608,7 +622,11 @@ export async function acceptDonauBlindSigs( sig: unblindSig.sig, }); if (!verifyRes.valid) { - throw Error("invalid donau signature"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "the donation receipt signature from donau is invalid", + ); } sigs.push({ cipher: "RSA", diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -171,6 +171,7 @@ import { BalanceEffect, applyNotifyTransition, constructTransactionIdentifier, + makeTransactionActionUnsupportedError, rematerializeTransactions, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; @@ -616,7 +617,11 @@ async function validateWireInfo( isValid = v; } if (!isValid) { - throw Error("exchange acct signature invalid"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_SIGNATURE_INVALID, + {}, + `signature of exchange account ${a.payto_uri} is invalid`, + ); } } logger.trace("account validation done"); @@ -645,7 +650,11 @@ async function validateWireInfo( isValid = v; } if (!isValid) { - throw Error("exchange wire fee signature invalid"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_SIGNATURE_INVALID, + {}, + `signature of the exchange's wire fee for ${wireMethod} is invalid`, + ); } feeList.push(fee); } @@ -684,7 +693,11 @@ async function validateSignKeys( isValid = v; } if (!isValid) { - throw Error("exchange signing key signature invalid: " + sk.key); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_SIGNATURE_INVALID, + {}, + `signature of the exchange's signing key ${sk.key} is invalid`, + ); } } } @@ -709,7 +722,11 @@ async function validateGlobalFees( } if (!isValid) { - throw Error("exchange global fees signature invalid: " + gf.master_sig); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_SIGNATURE_INVALID, + {}, + `signature of the exchange's global fees is invalid`, + ); } egf.push({ accountFee: Amounts.stringify(gf.account_fee), @@ -1267,8 +1284,10 @@ function checkExpectedMasterPub( expectedMasterPub: string | undefined, ): void { if (expectedMasterPub && summary.masterPub !== expectedMasterPub) { - throw Error( - "public key of the exchange does not match expected public key", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_SIGNATURE_INVALID, + {}, + "the exchange's master public key does not match the expected one", ); } } @@ -2589,27 +2608,19 @@ export class DenomLossTransactionContext implements TransactionContext { } userAbortTransaction(): Promise<void> { - throw new Error( - "Method not implemented - DenomLossTransactionContext.userAbortTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "abort"); } userSuspendTransaction(): Promise<void> { - throw new Error( - "Method not implemented - DenomLossTransactionContext.userSuspendTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "suspend"); } userResumeTransaction(): Promise<void> { - throw new Error( - "Method not implemented - DenomLossTransactionContext.userResumeTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "resume"); } userFailTransaction(): Promise<void> { - throw new Error( - "Method not implemented - DenomLossTransactionContext.userResumeTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "fail"); } async userDeleteTransaction(): Promise<void> { @@ -2751,7 +2762,9 @@ export async function getExchangePaytoUri( return account.payto_uri; } } - throw Error( + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { wireMethod: supportedTargetTypes[0] }, `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s( supportedTargetTypes, )}`, @@ -3010,7 +3023,11 @@ export async function getExchangeDetailedInfo( }); if (!exchange) { - throw Error(`exchange with base url "${exchangeBaseurl}" not found`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND, + {}, + `exchange with base URL "${exchangeBaseurl}" not found`, + ); } const denoms = exchange.denominations.map((d) => ({ @@ -4092,7 +4109,11 @@ export async function checkExchangeInScopeTx( return gr != null; } case ScopeType.Auditor: - throw Error("auditor scope not supported yet"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "scopeInfo" }, + "the auditor scope is not supported yet", + ); } } diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -22,6 +22,8 @@ import { Amounts, ConvertAmountRequest, Duration, + TalerError, + TalerErrorCode, TransactionAmountMode, TransactionType, checkDbInvariant, @@ -71,7 +73,11 @@ function getOperationType(txType: TransactionType): OperationType { ? OperationType.Debit : undefined; if (!operationType) { - throw Error(`operation type ${txType} not yet supported`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "type" }, + `conversion for operation type ${txType} is not supported`, + ); } return operationType; } @@ -149,8 +155,10 @@ async function getAvailableCoins( exchangeDetails.wireInfo.feesForType[filters.wireMethod]; if (!wireMethodWithDates) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`, + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { wireMethod: filters.wireMethod }, + `exchange ${exchangeBaseUrl} does not support wire method ${filters.wireMethod}`, ); } const wireMethodFee = wireMethodWithDates.find((x) => { diff --git a/packages/taler-wallet-core/src/mailbox.ts b/packages/taler-wallet-core/src/mailbox.ts @@ -40,6 +40,8 @@ import { NotificationType, Result, SendTalerUriMailboxMessageRequest, + TalerError, + TalerErrorCode, TalerMailboxInstanceHttpClient, TalerProtocolTimestamp, TalerSignaturePurpose, @@ -130,7 +132,11 @@ export async function registerMailbox( const vMsg = new DataView(messageHeader); const vExpNbo = new DataView(expNboBuffer); if (mailboxConf.expiration.t_s == "never") { - throw Error("mailbox can not expire, invalid"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + { mailboxBaseUrl: mailboxConf.mailboxBaseUrl }, + "a mailbox registration must have an expiration", + ); } vExpNbo.setBigUint64(0, BigInt(mailboxConf.expiration.t_s)); const digestBuffer = new Uint8Array([ @@ -167,7 +173,11 @@ export async function registerMailbox( case "ok": return resp.body; case HttpStatusCode.Forbidden: - throw Error("Access to Mailbox API unauthorized"); + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_FORBIDDEN, + {}, + "the mailbox service refused the registration", + ); case HttpStatusCode.PaymentRequired: return resp.body; } @@ -218,8 +228,14 @@ export async function createNewMailbox( const resp = await registerMailbox(wex, mailboxConf); if (resp.status == "payment-required") { if (!resp.talerUri) { - throw Error( - "payment required to register mailbox but no Taler URI given", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: mailboxConf.mailboxBaseUrl, + requestMethod: "POST", + httpStatusCode: HttpStatusCode.PaymentRequired, + }, + "the mailbox service asked for payment but did not say how to pay", ); } mailboxConf.payUri = Result.orElse( @@ -275,7 +291,11 @@ export async function refreshMailbox( message_size = resConf.body.message_body_bytes; break; default: - throw Error("unable to get mailbox service config"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + { mailboxBaseUrl: mailboxConf.mailboxBaseUrl }, + "could not read the configuration of the mailbox service", + ); } const res = await mailboxClient.getMessages({ hMailbox: mailboxConf.hAddress, @@ -287,8 +307,14 @@ export async function refreshMailbox( const messages = res.body.messages; const now = TalerProtocolTimestamp.now(); if (messages.byteLength % message_size !== 0) { - throw Error( - `mailbox messages response not a multiple of message size! (${messages.byteLength} % ${message_size} != 0)`, + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: mailboxConf.mailboxBaseUrl, + requestMethod: "GET", + httpStatusCode: HttpStatusCode.Ok, + }, + `mailbox messages response is not a multiple of the message size (${messages.byteLength} % ${message_size} != 0)`, ); } // FIXME: if we have reached the maximum number of @@ -343,7 +369,11 @@ export async function refreshMailbox( messages: [], }; // No new messages; default: - throw Error("unexpected mailbox messages response empty"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + { mailboxBaseUrl: mailboxConf.mailboxBaseUrl }, + "the mailbox service did not return the messages", + ); } } @@ -358,7 +388,11 @@ function encryptTalerUriMessage( const talerUriBytes = stringToBytes(talerUri); const paddingLength = paddedMessageSize - 4 - talerUriBytes.length - 16 - 32; if (paddingLength < 0) { - throw new Error("talerUri is too long for the requested paddedMessageSize"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + {}, + "the taler:// URI does not fit into a message of this mailbox service", + ); } const header = new Uint8Array(4); const v = new DataView(header.buffer); @@ -393,7 +427,11 @@ export async function sendTalerUriMessage( paddedMessageSize = resConf.body.message_body_bytes; break; default: - throw Error("unable to get mailbox service config"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + { mailboxBaseUrl: req.contact.mailboxBaseUri }, + "could not read the configuration of the mailbox service", + ); } const resKeys = await mailboxClient.getMailboxInfo( req.contact.mailboxAddress, @@ -404,7 +442,11 @@ export async function sendTalerUriMessage( keys = resKeys.body; break; default: - throw Error("unable to get mailbox keys"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + { mailboxBaseUrl: req.contact.mailboxBaseUri }, + "could not read the keys of the recipient's mailbox", + ); } const encryptedMessage = encryptTalerUriMessage( decodeCrock(keys.encryption_key), @@ -419,6 +461,10 @@ export async function sendTalerUriMessage( case "ok": return {}; default: - throw Error("Failed to send message"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, + { mailboxBaseUrl: req.contact.mailboxBaseUri }, + "the mailbox service did not accept the message", + ); } } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -170,6 +170,9 @@ import { BalanceEffect, constructTransactionIdentifier, isUnsuccessfulTransaction, + makeInvalidTransactionIdError, + makeTransactionActionUnsupportedError, + makeTransactionNotFoundError, parseTransactionIdentifier, } from "./transactions.js"; import { @@ -240,7 +243,7 @@ export class PayMerchantTransactionContext implements TransactionContext { const proposalId = this.proposalId; const purchaseRec = await tx.getPurchase(proposalId); if (!purchaseRec) { - throw Error("not found"); + throw makeTransactionNotFoundError(this.transactionId); } const txState = computePayMerchantTransactionState(purchaseRec); @@ -685,7 +688,7 @@ export class RefundTransactionContext implements TransactionContext { ): Promise<Transaction | undefined> { const refundRecord = await tx.getRefundGroup(this.refundGroupId); if (!refundRecord) { - throw Error("not found"); + throw makeTransactionNotFoundError(this.transactionId); } const maybeContractData = await lookupMaybeContractData( tx, @@ -774,19 +777,19 @@ export class RefundTransactionContext implements TransactionContext { } userSuspendTransaction(): Promise<void> { - throw new Error("Unsupported operation"); + throw makeTransactionActionUnsupportedError(this.transactionId, "suspend"); } userAbortTransaction(): Promise<void> { - throw new Error("Unsupported operation"); + throw makeTransactionActionUnsupportedError(this.transactionId, "abort"); } userResumeTransaction(): Promise<void> { - throw new Error("Unsupported operation"); + throw makeTransactionActionUnsupportedError(this.transactionId, "resume"); } userFailTransaction(): Promise<void> { - throw new Error("Unsupported operation"); + throw makeTransactionActionUnsupportedError(this.transactionId, "fail"); } } @@ -1415,7 +1418,11 @@ async function generateSlate( const choice = contractData.choices[choiceIndex]; const output = choice.outputs[outputIndex]; if (output.type !== MerchantContractOutputType.Token) { - throw new Error(`unsupported contract output type ${output.type}`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + `this wallet does not support contract output type "${output.type}"`, + ); } const family = contractData.token_families[output.token_family_slug]; @@ -2211,7 +2218,7 @@ export async function getChoicesForPayment( ): Promise<GetChoicesForPaymentResult> { const parsedTx = parseTransactionIdentifier(transactionId); if (parsedTx?.tag !== TransactionType.Payment) { - throw Error("expected payment transaction ID"); + throw makeInvalidTransactionIdError(transactionId, TransactionType.Payment); } const proposalId = parsedTx.proposalId; const { proposal, d } = await wex.runWalletDbTx(async (tx) => { @@ -2239,7 +2246,7 @@ export async function getChoicesForPayment( }); if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); + throw makeTransactionNotFoundError(transactionId); } if (!d) { @@ -2299,7 +2306,11 @@ export async function getChoicesForPayment( choiceIndex, ); if (!available) { - throw Error("choice index not specified for contract v1"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "choiceIndex" }, + "a contract v1 payment requires a choice index", + ); } let amountEffective: AmountJson | undefined = undefined; @@ -2410,7 +2421,11 @@ async function calculateDefaultChoice( }; case MerchantContractVersion.V1: if (contractTerms.choices.length === 0) - throw Error(`contract v1 has no choices`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, + {}, + "a contract v1 must offer at least one choice", + ); // If there's only one choice, use it. if ( @@ -2502,7 +2517,7 @@ export async function confirmPay( const parsedTx = parseTransactionIdentifier(transactionId); if (parsedTx?.tag !== TransactionType.Payment) { - throw Error("expected payment transaction ID"); + throw makeInvalidTransactionIdError(transactionId, TransactionType.Payment); } const proposalId = parsedTx.proposalId; const ctx = new PayMerchantTransactionContext(wex, proposalId); @@ -2514,12 +2529,19 @@ export async function confirmPay( }); if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); + throw makeTransactionNotFoundError(transactionId); } const d = await expectProposalDownload(wex, proposal); if (!d) { - throw Error("proposal is in invalid state"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(proposal), + debugStateNum: proposal.purchaseStatus, + }, + "the contract terms of this payment have not been downloaded yet", + ); } const existingPurchase = await wex.runWalletDbTx(async (tx) => { @@ -2545,7 +2567,11 @@ export async function confirmPay( choiceIndex !== undefined && choiceIndex !== existingPurchase.choiceIndex ) { - throw Error(`cannot change choice index of existing purchase`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "choiceIndex" }, + "the choice index of an existing purchase cannot be changed", + ); } logger.trace("confirmPay: submitting payment for existing purchase"); @@ -2573,7 +2599,11 @@ export async function confirmPay( choiceIndex, ); if (!available) { - throw Error("choice index not specified for contract v1"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "choiceIndex" }, + "a contract v1 payment requires a choice index", + ); } const currency = Amounts.currencyOf(amountRaw); @@ -2607,7 +2637,14 @@ export async function confirmPay( switch (selectTokensResult.type) { case "failure": { logger.warn("not confirming payment, insufficient tokens"); - throw Error("insufficient tokens"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(proposal), + debugStateNum: proposal.purchaseStatus, + }, + "the wallet does not hold the tokens that this choice requires", + ); } } @@ -3326,7 +3363,10 @@ async function processPurchasePay( wex, ); const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - merchantClient.demostratePayment(download.contractTerms.order_id, reqBody), + merchantClient.demostratePayment( + download.contractTerms.order_id, + reqBody, + ), ); logger.trace(`/paid response status: ${resp.response.status}`); if (isOrderUnknown(resp)) { @@ -3821,23 +3861,29 @@ export async function sharePayment( }); if (!proposalId) { - throw Error(`no proposal found for order id ${orderId}`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PURCHASE_NOT_FOUND, + { merchantBaseUrl, orderId }, + `no purchase found for order ${orderId}`, + ); } const ctx = new PayMerchantTransactionContext(wex, proposalId); + // "gone" and "wrong state" need different error codes, so they cannot both + // come back as undefined. const result = await wex.runWalletDbTx(async (tx) => { const [p, h] = await ctx.getRecordHandle(tx); if (!p) { logger.warn("purchase does not exist anymore"); - return undefined; + return { type: "gone" as const }; } if ( p.purchaseStatus !== PurchaseStatus.DialogProposed && p.purchaseStatus !== PurchaseStatus.DialogShared ) { // FIXME: purchase can be shared before being paid - return undefined; + return { type: "bad-state" as const, purchase: p }; } if (p.purchaseStatus === PurchaseStatus.DialogProposed) { p.purchaseStatus = PurchaseStatus.DialogShared; @@ -3845,6 +3891,7 @@ export async function sharePayment( await h.update(p, "share"); } return { + type: "ok" as const, proposalId: p.proposalId, nonce: p.noncePriv, session: p.lastSessionId ?? p.downloadSessionId, @@ -3852,8 +3899,22 @@ export async function sharePayment( }; }); - if (result === undefined) { - throw Error("This purchase can't be shared"); + switch (result.type) { + case "ok": + break; + case "gone": + throw makeTransactionNotFoundError(ctx.transactionId); + case "bad-state": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(result.purchase), + debugStateNum: result.purchase.purchaseStatus, + }, + "this payment can only be shared before it is paid", + ); + default: + assertUnreachable(result); } // schedule a task to watch for the status @@ -4230,7 +4291,9 @@ async function processPurchaseAbortingRefund( coins: abortingCoins, }; - logger.trace(`making order abort request for ${download.contractTerms.order_id}`); + logger.trace( + `making order abort request for ${download.contractTerms.order_id}`, + ); const merchantClient = walletMerchantClient( download.contractTerms.merchant_base_url, @@ -4276,8 +4339,11 @@ async function processPurchaseAbortingRefund( const refunds: MerchantCoinRefundStatus[] = []; if (abortResp.refunds.length != abortingCoins.length) { - // FIXME: define error code! - throw Error("invalid order abort response"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + `merchant returned ${abortResp.refunds.length} refunds for ${abortingCoins.length} aborted coins`, + ); } for (let i = 0; i < abortResp.refunds.length; i++) { @@ -4427,11 +4493,12 @@ export async function startRefundQueryForUri( talerUri: string, ): Promise<StartRefundQueryForUriResponse> { const parsedUri = Result.orUndefined(TalerUris.parse(talerUri)); - if (!parsedUri) { - throw Error("invalid taler:// URI"); - } - if (parsedUri.type !== TalerUriAction.Refund) { - throw Error("expected taler://refund URI"); + if (parsedUri?.type !== TalerUriAction.Refund) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { talerUri, expectedAction: TalerUriAction.Refund }, + "expected a taler://refund URI", + ); } const purchaseRecord = await wex.runWalletDbTx(async (tx) => { return tx.getPurchaseByUrlAndOrderId( diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -30,6 +30,7 @@ import { PeerContractTerms, ScopeInfo, ScopeType, + TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, @@ -1096,7 +1097,11 @@ export async function internalCheckPeerPullCredit( url: req.exchangeBaseUrl, }; } else { - throw Error("client must either specify exchangeBaseUrl or restrictScope"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "restrictScope" }, + "either exchangeBaseUrl or restrictScope must be specified", + ); } logger.trace("checking peer-pull-credit fees"); @@ -1108,7 +1113,11 @@ export async function internalCheckPeerPullCredit( ); if (!exchangeUrl) { - throw Error("no exchange found for initiating a peer pull payment"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency: restrictScope.currency }, + "no exchange in the wallet can be used to create an invoice", + ); } logger.trace(`found ${exchangeUrl} as preferred exchange`); @@ -1168,7 +1177,11 @@ async function internalInitiatePeerPullPayment( } if (!maybeExchangeBaseUrl) { - throw Error("no exchange found for initiating a peer pull payment"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency }, + "no exchange in the wallet can be used to create an invoice", + ); } const exchangeBaseUrl = maybeExchangeBaseUrl; @@ -1179,7 +1192,14 @@ async function internalInitiatePeerPullPayment( if ( checkPeerCreditHardLimitExceeded(exchange, req.partialContractTerms.amount) ) { - throw Error("peer credit would exceed hard KYC limit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { + exchangeBaseUrl, + requestedAmount: req.partialContractTerms.amount, + }, + "this invoice would exceed a hard limit of the exchange", + ); } const mergeReserveInfo = await getMergeReserveInfo(wex, { diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -99,6 +99,8 @@ import { BalanceEffect, constructTransactionIdentifier, isUnsuccessfulTransaction, + makeInvalidTransactionIdError, + makeTransactionNotFoundError, parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; @@ -569,7 +571,7 @@ async function processPeerPullDebitPendingDeposit( switch (coinSelRes.type) { case "failure": throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + TalerErrorCode.WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, @@ -726,7 +728,12 @@ export async function processPeerPullDebit( tx.getPeerPullDebit(peerPullDebitId), ); if (!peerPullInc) { - throw Error("peer pull debit not found"); + throw makeTransactionNotFoundError( + constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + ); } switch (peerPullInc.status) { @@ -754,7 +761,10 @@ export async function confirmPeerPullDebit( ): Promise<AcceptPeerPullPaymentResponse> { const parsed = parseTransactionIdentifier(req.transactionId); if (!parsed || parsed.tag !== TransactionType.PeerPullDebit) { - throw Error("invalid peer-pull-debit transaction identifier"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.PeerPullDebit, + ); } const peerPullInc = await wex.runWalletDbTx(async (tx) => @@ -762,9 +772,7 @@ export async function confirmPeerPullDebit( ); if (peerPullInc == null) { - throw Error( - `can't accept unknown incoming p2p pull payment (${req.transactionId})`, - ); + throw makeTransactionNotFoundError(req.transactionId); } const ctx = new PeerPullDebitTransactionContext(wex, parsed.peerPullDebitId); @@ -791,7 +799,7 @@ export async function confirmPeerPullDebit( switch (coinSelRes.type) { case "failure": throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + TalerErrorCode.WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, @@ -860,7 +868,11 @@ async function internalPreparePeerPullDebit( req: PreparePeerPullDebitRequest, ): Promise<PreparePeerPullDebitResponse> { if (!req.talerUri && !req.transactionId) { - throw Error("either talerUri or transactionId must be specified"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "talerUri" }, + "either talerUri or transactionId must be specified", + ); } let uri: TalerPayPullUri | undefined; @@ -869,7 +881,11 @@ async function internalPreparePeerPullDebit( TalerUris.parseRestricted(req.talerUri, TalerUriAction.PayPull), ); if (!uri) { - throw Error("got invalid taler://pay-push URI"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { talerUri: req.talerUri, expectedAction: TalerUriAction.PayPull }, + "expected a taler://pay-pull URI", + ); } } @@ -877,7 +893,10 @@ async function internalPreparePeerPullDebit( if (req.transactionId) { const parsedRes = parseTransactionIdentifier(req.transactionId); if (!parsedRes || parsedRes.tag !== TransactionType.PeerPullDebit) { - throw Error("got invalid transaction ID"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.PeerPullDebit, + ); } parsedTxId = parsedRes.peerPullDebitId; } @@ -933,7 +952,7 @@ async function internalPreparePeerPullDebit( } if (!uri) { - throw Error("transaction not found, use talerUri instead"); + throw makeTransactionNotFoundError(req.transactionId!); } const exchangeBaseUrl = uri.exchangeBaseUrl; @@ -951,8 +970,11 @@ async function internalPreparePeerPullDebit( case "ok": break; case HttpStatusCode.NotFound: - // FIXME: appropriated error code - throw Error("unknown P2P contract"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, + {}, + "the exchange does not know this peer-to-peer contract", + ); default: assertUnreachable(contractResp); } @@ -976,16 +998,22 @@ async function internalPreparePeerPullDebit( {}, ); case HttpStatusCode.NotFound: - // FIXME: appropriated error code - throw Error("unknown peer pull debit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, + {}, + "the exchange does not know the purse of this invoice", + ); default: assertUnreachable(resp); } if (isPurseDeposited(resp.body)) { logger.info("purse completed by another wallet"); - // FIXME: appropriated error code - throw Error("peer pull debit already completed"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PULL_DEBIT_ALREADY_PAID, + {}, + "this invoice has already been paid", + ); } const peerPullDebitId = encodeCrock(getRandomBytes(32)); @@ -998,7 +1026,11 @@ async function internalPreparePeerPullDebit( } else { // FIXME: In this case, where do we get the purse expiration from?! // https://bugs.gnunet.org/view.php?id=7706 - throw Error("pull payments without contract terms not supported yet"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + "invoices without contract terms are not supported yet", + ); } const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms); @@ -1025,7 +1057,7 @@ async function internalPreparePeerPullDebit( switch (coinSelRes.type) { case "failure": throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + TalerErrorCode.WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, }, diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -30,6 +30,8 @@ import { PreparePeerPushCreditRequest, PreparePeerPushCreditResponse, Result, + TalerError, + TalerErrorCode, TalerErrorDetail, TalerPayPushUri, TalerPreciseTimestamp, @@ -101,6 +103,8 @@ import { BalanceEffect, constructTransactionIdentifier, isUnsuccessfulTransaction, + makeInvalidTransactionIdError, + makeTransactionNotFoundError, parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; @@ -489,7 +493,11 @@ async function internalPreparePeerPushCredit( req: PreparePeerPushCreditRequest, ): Promise<PreparePeerPushCreditResponse> { if (!req.talerUri && !req.transactionId) { - throw Error("either talerUri or transactionId must be specified"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "talerUri" }, + "either talerUri or transactionId must be specified", + ); } let uri: TalerPayPushUri | undefined; @@ -498,7 +506,11 @@ async function internalPreparePeerPushCredit( TalerUris.parseRestricted(req.talerUri, TalerUriAction.PayPush), ); if (!uri) { - throw Error("got invalid taler://pay-push URI"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { talerUri: req.talerUri, expectedAction: TalerUriAction.PayPush }, + "expected a taler://pay-push URI", + ); } } @@ -506,7 +518,10 @@ async function internalPreparePeerPushCredit( if (req.transactionId) { const parsedRes = parseTransactionIdentifier(req.transactionId); if (!parsedRes || parsedRes.tag !== TransactionType.PeerPushCredit) { - throw Error("got invalid transaction ID"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.PeerPushCredit, + ); } parsedTxId = parsedRes.peerPushCreditId; } @@ -568,7 +583,7 @@ async function internalPreparePeerPushCredit( } if (!uri) { - throw Error("transaction not found, use talerUri instead"); + throw makeTransactionNotFoundError(req.transactionId!); } const exchangeBaseUrl = uri.exchangeBaseUrl; @@ -587,8 +602,11 @@ async function internalPreparePeerPushCredit( case "ok": break; case HttpStatusCode.NotFound: - // FIXME: appropriated error code - throw Error("unknown P2P contract"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, + {}, + "the exchange does not know this peer-to-peer contract", + ); default: assertUnreachable(contractResp); } @@ -608,11 +626,17 @@ async function internalPreparePeerPushCredit( case "ok": break; case HttpStatusCode.Gone: - // FIXME: appropriated error code - throw Error("aborted peer push credit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE, + {}, + "the sender aborted this payment", + ); case HttpStatusCode.NotFound: - // FIXME: appropriated error code - throw Error("unknown peer push credit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, + {}, + "the exchange does not know the purse of this payment", + ); default: assertUnreachable(resp); } @@ -1269,10 +1293,16 @@ async function internalConfirmPeerPushCredit( ): Promise<AcceptPeerPushPaymentResponse> { const parsedTx = parseTransactionIdentifier(req.transactionId); if (!parsedTx) { - throw Error("invalid transaction ID"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.PeerPushCredit, + ); } if (parsedTx.tag !== TransactionType.PeerPushCredit) { - throw Error("invalid transaction ID type"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.PeerPushCredit, + ); } const ctx = new PeerPushCreditTransactionContext( wex, @@ -1297,9 +1327,7 @@ async function internalConfirmPeerPushCredit( }); if (!res) { - throw Error( - `can't accept unknown incoming p2p push payment (${req.transactionId})`, - ); + throw makeTransactionNotFoundError(req.transactionId); } const peerInc = res.peerInc; @@ -1311,7 +1339,14 @@ async function internalConfirmPeerPushCredit( requireExchangeTosAcceptedOrThrow(wex, exchange); if (checkPeerCreditHardLimitExceeded(exchange, res.contractTerms.amount)) { - throw Error("peer credit would exceed hard KYC limit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { + exchangeBaseUrl: peerInc.exchangeBaseUrl, + requestedAmount: res.contractTerms.amount, + }, + "accepting this payment would exceed a hard limit of the exchange", + ); } await wex.runWalletDbTx(async (tx) => { diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -457,7 +457,11 @@ async function internalCheckPeerPushDebit( restrictScope, ); if (!exchangeBaseUrl) { - throw Error("no exchange found for payment"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency }, + "no exchange in the wallet can be used for this payment", + ); } return { @@ -1091,8 +1095,10 @@ export async function initiatePeerPushDebit( const currency = Amounts.currencyOf(instructedAmount); if (req.exchangeBaseUrl != null && req.restrictScope != null) { - throw Error( - "initiatePeerPushDebit: exchangeBaseUrl and restrictScope are mutually exclusive", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "restrictScope" }, + "exchangeBaseUrl and restrictScope are mutually exclusive", ); } diff --git a/packages/taler-wallet-core/src/pay-template.ts b/packages/taler-wallet-core/src/pay-template.ts @@ -25,6 +25,8 @@ import { PreparePayTemplateRequest, Result, succeedOrThrow, + TalerError, + TalerErrorCode, TalerMerchantApi, TalerMerchantInstanceHttpClient, TalerPayTemplateUri, @@ -114,7 +116,11 @@ export function applyTemplateUriOverrides( // We don't support overrides for this template type. break; default: - throw Error("unsupported template type"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + "this wallet does not support this order template type", + ); } } @@ -156,7 +162,11 @@ export function applyTemplateUserOverrides( // We don't support overrides for this template type. break; default: - throw Error("unsupported template type"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + "this wallet does not support this order template type", + ); } } @@ -183,7 +193,14 @@ async function internalCheckPayForTemplate( ), ); if (!parsedUri) { - throw Error("invalid taler-template URI"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { + talerUri: req.talerPayTemplateUri, + expectedAction: TalerUriAction.PayTemplate, + }, + "expected a taler://pay-template URI", + ); } const merchantApi = new TalerMerchantInstanceHttpClient( @@ -239,7 +256,14 @@ export async function instantiateTemplateRaw( ); if (!parsedUri) { - throw Error("invalid taler-template URI"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { + talerUri: req.talerPayTemplateUri, + expectedAction: TalerUriAction.PayTemplate, + }, + "expected a taler://pay-template URI", + ); } const merchantApi = new TalerMerchantInstanceHttpClient( diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -58,7 +58,10 @@ import { } from "./db-common.js"; import { CoinSourceType } from "./db-indexeddb.js"; import { createRefreshGroup } from "./refresh.js"; -import { constructTransactionIdentifier } from "./transactions.js"; +import { + constructTransactionIdentifier, + makeTransactionActionUnsupportedError, +} from "./transactions.js"; import { WalletExecutionContext, getDenomInfo, @@ -431,27 +434,19 @@ export class RecoupTransactionContext implements TransactionContext { } userAbortTransaction(): Promise<void> { - throw new Error( - "Method not implemented RecoupTransactionContext.userAbortTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "abort"); } userSuspendTransaction(): Promise<void> { - throw new Error( - "Method not implemented RecoupTransactionContext.userSuspendTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "suspend"); } userResumeTransaction(): Promise<void> { - throw new Error( - "Method not implemented RecoupTransactionContext.userResumeTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "resume"); } userFailTransaction(): Promise<void> { - throw new Error( - "Method not implemented RecoupTransactionContext.userFailTransaction", - ); + throw makeTransactionActionUnsupportedError(this.transactionId, "fail"); } async userDeleteTransaction(): Promise<void> { @@ -479,7 +474,11 @@ export class RecoupTransactionContext implements TransactionContext { lookupFullTransaction( tx: WalletDbTransaction, ): Promise<Transaction | undefined> { - throw new Error("Method not implemented."); + throw makeTransactionActionUnsupportedError( + this.transactionId, + "lookup", + "recoup transactions are not materialized and cannot be looked up", + ); } } diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -108,6 +108,7 @@ import { import { constructTransactionIdentifier, isUnsuccessfulTransaction, + makeTransactionActionUnsupportedError, } from "./transactions.js"; import { EXCHANGE_COINS_LOCK, @@ -284,7 +285,11 @@ export class RefreshTransactionContext implements TransactionContext { async userAbortTransaction(): Promise<void> { // Refresh transactions only support fail, not abort. - throw new Error("refresh transactions cannot be aborted"); + throw makeTransactionActionUnsupportedError( + this.transactionId, + "abort", + "refresh transactions cannot be aborted, only failed", + ); } async userResumeTransaction(): Promise<void> { diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -475,7 +475,11 @@ async function handleGetBankAccountById( return tx.getBankAccount(req.bankAccountId); }); if (!acct) { - throw Error(`bank account ${req.bankAccountId} not found`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_BANK_ACCOUNT_NOT_FOUND, + { bankAccountId: req.bankAccountId }, + `bank account ${req.bankAccountId} not found`, + ); } return acct; } @@ -490,7 +494,11 @@ async function forgetBankAccount( await wex.runWalletDbTx(async (tx) => { const account = await tx.getBankAccount(bankAccountId); if (!account) { - throw Error(`account not found: ${bankAccountId}`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_BANK_ACCOUNT_NOT_FOUND, + { bankAccountId }, + `bank account ${bankAccountId} not found`, + ); } await tx.deleteBankAccount(account.bankAccountId); }); @@ -610,7 +618,11 @@ function requireIdbBackend( ): IdbWalletDbHandle { const idb = wex.ws.idbOnly; if (!idb) { - throw Error(`${what} is only supported on the IndexedDB backend`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED, + { backend: wex.ws.db.name }, + `${what} is only supported on the IndexedDB backend`, + ); } return idb; } @@ -680,7 +692,11 @@ async function recoverStoredBackup( const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { const backupMeta = await tx.backupMeta.get(name); if (!backupMeta) { - throw Error("backup not found"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "name" }, + `stored backup ${name} not found`, + ); } const backupData = await tx.backupData.get(name); if (!backupData) { @@ -708,7 +724,14 @@ async function handlePrepareWithdrawExchange( ): Promise<PrepareWithdrawExchangeResponse> { const parsedUri = Result.orUndefined(TalerUris.parse(req.talerUri)); if (parsedUri?.type !== TalerUriAction.WithdrawExchange) { - throw Error("expected a taler://withdraw-exchange URI"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { + talerUri: req.talerUri, + expectedAction: TalerUriAction.WithdrawExchange, + }, + "expected a taler://withdraw-exchange URI", + ); } const exchangeBaseUrl = parsedUri.exchangeBaseUrl; @@ -728,7 +751,11 @@ async function handlePrepareWithdrawExchange( if (parsedUri.amount) { const amt = Amounts.parseOrThrow(parsedUri.amount); if (amt.currency !== exchange.currency) { - throw Error("mismatch of currency (URI vs exchange)"); + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_CURRENCY_MISMATCH, + {}, + `mismatch of currency (URI says ${amt.currency}, exchange uses ${exchange.currency})`, + ); } } return { @@ -906,7 +933,11 @@ async function handleAddExchange( if (req.uri.startsWith("taler")) { const p = Result.orUndefined(TalerUris.parse(req.uri)); if (p?.type !== TalerUriAction.AddExchange) { - throw Error("invalid taler://add-exchange/ URI"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TALER_URI_MALFORMED, + { talerUri: req.uri, expectedAction: TalerUriAction.AddExchange }, + "expected a taler://add-exchange/ URI", + ); } exchangeBaseUrl = p.exchangeBaseUrl; } else if (req.allowCompletion) { @@ -920,14 +951,24 @@ async function handleAddExchange( } else if (req.uri.startsWith("http")) { const canonUrl = canonicalizeBaseUrl(req.uri); if (req.uri != canonUrl) { - throw Error("exchange base URL must be canonicalized"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "uri" }, + `exchange base URL must be canonicalized (expected "${canonUrl}")`, + ); } exchangeBaseUrl = req.uri; } else { - throw Error("AddExchangeRequest.uri must be http(s):// or taler://"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "uri" }, + "AddExchangeRequest.uri must be http(s):// or taler://", + ); } } else { - throw Error( + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "uri" }, "AddExchangeRequest must either specify uri or exchangeBaseUrl", ); } @@ -994,9 +1035,7 @@ async function handleAddBankAccount( wex: WalletExecutionContext, req: AddBankAccountRequest, ): Promise<AddBankAccountResponse> { - if (Result.isError(Paytos.fromString(req.paytoUri))) { - throw Error("invalid payto"); - } + Paytos.parseOrThrow(req.paytoUri); const acctId = await wex.runWalletDbTx(async (tx) => { let currencies = req.currencies; let myId: string; @@ -1048,7 +1087,11 @@ async function handleTestingGetReserveHistory( return tx.getReserveByReservePub(req.reservePub); }); if (!reserve) { - throw Error("no reserve pub found"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "reservePub" }, + `no reserve found for reserve public key ${req.reservePub}`, + ); } const sigResp = await wex.cryptoApi.signReserveHistoryReq({ reservePriv: reserve.reservePriv, @@ -1113,10 +1156,7 @@ async function handleGetBankingChoicesForPayto( wex: WalletExecutionContext, req: GetBankingChoicesForPaytoRequest, ): Promise<GetBankingChoicesForPaytoResponse> { - const parsedPayto = Result.orUndefined(Paytos.fromString(req.paytoUri)); - if (!parsedPayto) { - throw Error("invalid payto URI"); - } + const parsedPayto = Paytos.parseOrThrow(req.paytoUri); const amount = parsedPayto.params["amount"]; if (!amount) { logger.warn("payto URI has no amount"); @@ -1284,11 +1324,12 @@ async function handleStartRefundQuery( req: StartRefundQueryRequest, ): Promise<EmptyObject> { const txIdParsed = parseTransactionIdentifier(req.transactionId); - if (!txIdParsed) { - throw Error("invalid transaction ID"); - } - if (txIdParsed.tag !== TransactionType.Payment) { - throw Error("expected payment transaction ID"); + if (txIdParsed?.tag !== TransactionType.Payment) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "transactionId" }, + `expected a payment transaction ID, got "${req.transactionId}"`, + ); } await startQueryRefund(wex, txIdParsed.proposalId); return {}; @@ -1636,7 +1677,11 @@ async function handleExportDbToFile( ): Promise<ExportDbToFileResponse> { const db = wex.ws.db; if (!db.exportToFile) { - throw Error(`the ${db.name} backend cannot export the database to a file`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED, + { backend: db.name }, + `the ${db.name} backend cannot export the database to a file`, + ); } // Called as a method: the sqlite implementation reads this.ndb, and // extracting the function first would drop the receiver. @@ -1674,7 +1719,9 @@ async function handleImportDbFromFile( if (req.path.endsWith(".json")) { const db = wex.ws.db; if (!db.readBackupJson) { - throw Error( + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED, + { backend: db.name }, `the ${db.name} backend cannot read a database dump from a file`, ); } @@ -1683,7 +1730,11 @@ async function handleImportDbFromFile( dump, }); } else { - throw Error("DB file import only supports .json files at the moment"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "path" }, + "DB file import only supports .json files at the moment", + ); } } @@ -1800,7 +1851,11 @@ export async function handleTestingRunFixup( }); return {}; } - throw Error("fixup not found"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "id" }, + `fixup "${req.id}" not found`, + ); } async function handleGetVersion( @@ -1955,7 +2010,11 @@ export async function handleTestingCorruptWithdrawalCoinSel( ): Promise<EmptyObject> { const txId = parseTransactionIdentifier(req.transactionId); if (txId?.tag !== TransactionType.Withdrawal) { - throw Error("expected withdrawal transaction ID"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "transactionId" }, + `expected a withdrawal transaction ID, got "${req.transactionId}"`, + ); } await wex.runWalletDbTx(async (tx) => { const wg = await tx.getWithdrawalGroup(txId.withdrawalGroupId); @@ -2526,7 +2585,11 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { [WalletApiOperation.Recycle]: { codec: codecForEmptyObject(), handler: async (wex, req) => { - throw Error("not implemented"); + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_FEATURE_NOT_IMPLEMENTED, + {}, + "this wallet-core operation is declared but not implemented yet", + ); }, }, [WalletApiOperation.ExportDb]: { @@ -2685,7 +2748,11 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { [WalletApiOperation.ListAssociatedRefreshes]: { codec: codecForAny(), handler: async (wex, req) => { - throw Error("not implemented"); + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_FEATURE_NOT_IMPLEMENTED, + {}, + "this wallet-core operation is declared but not implemented yet", + ); }, }, [WalletApiOperation.GetBankingChoicesForPayto]: { @@ -2727,7 +2794,9 @@ export async function dispatchRequestInternal( payload: unknown, ): Promise<WalletCoreResponseType<typeof operation>> { if (!wex.ws.initCalled && !isWalletInitOperation(operation)) { - throw Error( + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, + {}, `wallet must be initialized before running operation ${operation}`, ); } diff --git a/packages/taler-wallet-core/src/taldir.ts b/packages/taler-wallet-core/src/taldir.ts @@ -80,7 +80,11 @@ export async function lookupAlias( } break; default: - throw Error("unable to get directory service config"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: lookupRequest.taldirBaseUrl }, + "could not read the configuration of the directory service", + ); } // SHA-512(len($ALIASTYPE)+len($ALIAS)||$ALIASTYPE||$ALIAS) const hAliasBuffer = createHAliasBuffer( @@ -97,7 +101,11 @@ export async function lookupAlias( case HttpStatusCode.NotFound: return {}; default: - throw Error("unexpected taldir messages response empty"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: lookupRequest.taldirBaseUrl }, + "the directory service did not answer the alias lookup", + ); } } @@ -122,11 +130,17 @@ export async function registerAlias( case "ok": return res.body; case HttpStatusCode.NotFound: - throw Error( - "taldir reported not found, this means that the alias type was not supported", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: registerRequest.taldirBaseUrl }, + `the directory service does not support the alias type "${registerRequest.aliasType}"`, ); default: - throw Error("unexpected taldir messages response empty"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: registerRequest.taldirBaseUrl }, + "the directory service did not accept the registration request", + ); } } @@ -162,14 +176,28 @@ export async function completeAliasRegistration( case "ok": return {}; case HttpStatusCode.NotFound: - throw Error("taldir reported that this registration was not found"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: completionRequest.taldirBaseUrl }, + "the directory service does not know this registration, it may have expired", + ); case HttpStatusCode.Forbidden: - throw TalerError.fromDetail(TalerErrorCode.GENERIC_FORBIDDEN, {}); + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_FORBIDDEN, + {}, + "the directory service rejected the answer to the challenge", + ); case HttpStatusCode.TooManyRequests: - throw Error( - "taldir reported that too many tries have been made to solve this registration challenge", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: completionRequest.taldirBaseUrl }, + "too many attempts have been made to answer this registration challenge", ); default: - throw Error("unexpected taldir messages response empty"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ALIAS_REGISTRATION_FAILED, + { directoryBaseUrl: completionRequest.taldirBaseUrl }, + "the directory service did not accept the registration", + ); } } diff --git a/packages/taler-wallet-core/src/tokenFamilies.ts b/packages/taler-wallet-core/src/tokenFamilies.ts @@ -28,6 +28,8 @@ import { MerchantContractTokenKind, MerchantInfo, SubscriptionListDetail, + TalerError, + TalerErrorCode, } from "@gnu-taler/taler-util"; import { WalletToken } from "./db-common.js"; import { WalletExecutionContext } from "./index.js"; @@ -214,9 +216,12 @@ export async function deleteDiscount( } } - // FIXME: proper GANA error if (inUse) { - throw Error("One or more tokens in this family are in use"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TOKENS_IN_USE, + { tokenFamilyHash }, + "one or more tokens in this family are in use", + ); } for (const token of tokens) { @@ -250,9 +255,12 @@ export async function deleteSubscription( } } - // FIXME: proper GANA error if (inUse) { - throw Error("One or more tokens in this family are in use"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TOKENS_IN_USE, + { tokenFamilyHash }, + "one or more tokens in this family are in use", + ); } for (const token of tokens) { diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -25,6 +25,8 @@ import { MerchantContractTokenDetails, MerchantContractTokenKind, PaymentTokenAvailabilityDetails, + TalerError, + TalerErrorCode, TalerProtocolTimestamp, TokenAvailabilityHint, } from "@gnu-taler/taler-util"; @@ -182,7 +184,11 @@ export async function selectPayTokensInTx( const proposal = await tx.getPurchase(req.proposalId); if (!proposal) { - throw Error(`proposal ${req.proposalId} could not be found`); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PURCHASE_NOT_FOUND, + {}, + `proposal ${req.proposalId} could not be found`, + ); } var tokensRequested = 0; diff --git a/packages/taler-wallet-core/src/transactions.test.ts b/packages/taler-wallet-core/src/transactions.test.ts @@ -53,6 +53,10 @@ test("an unknown transaction type does not parse", (t) => { }); test("a malformed transaction identifier is rejected", (t) => { - assert.throws(() => parseTransactionIdentifier("txn:deposit")); - assert.throws(() => parseTransactionIdentifier("nottxn:deposit:x")); + // Every kind of rejection reports the same way, so that callers raise one + // coded error instead of the shape of the failure deciding whether they see + // an exception or a missing value. + assert.strictEqual(parseTransactionIdentifier("txn:deposit"), undefined); + assert.strictEqual(parseTransactionIdentifier("nottxn:deposit:x"), undefined); + assert.strictEqual(parseTransactionIdentifier(""), undefined); }); diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -27,6 +27,8 @@ import { Logger, NotificationType, ScopeType, + TalerError, + TalerErrorCode, Transaction, TransactionByIdRequest, TransactionIdStr, @@ -98,7 +100,11 @@ function shouldSkipCurrency( } case ScopeType.Auditor: { // same currency and same auditor - throw Error("filering balance in auditor scope is not implemented"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "scopeInfo" }, + "filtering transactions by auditor scope is not implemented", + ); } default: assertUnreachable(transactionsRequest.scopeInfo); @@ -130,6 +136,59 @@ const txOrder: { [t in TransactionType]: number } = { [TransactionType.DenomLoss]: 13, }; +/** + * Error for a transaction identifier that the client made up: either it does + * not parse at all, or it denotes a different transaction type than the + * operation accepts. + */ +export function makeInvalidTransactionIdError( + transactionId: string, + expectedType?: TransactionType, +): TalerError { + const hint = + expectedType == null + ? `invalid transaction identifier "${transactionId}"` + : `expected a ${expectedType} transaction identifier, got "${transactionId}"`; + return TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "transactionId" }, + hint, + ); +} + +/** + * Error for a well-formed transaction identifier that the wallet has no + * record for. Distinct from {@link makeInvalidTransactionIdError}: the client + * asked for something reasonable, it just isn't (or is no longer) there. + */ +export function makeTransactionNotFoundError( + transactionId: string, +): TalerError { + return TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + { transactionId }, + `transaction ${transactionId} not found`, + ); +} + +/** + * Error for an action (abort/suspend/resume/fail) that this kind of + * transaction does not offer at all. Not to be confused with + * WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, which means the action exists + * but the transaction is currently in a state that does not allow it. + */ +export function makeTransactionActionUnsupportedError( + transactionId: string, + action: "abort" | "suspend" | "resume" | "fail" | "lookup", + hint?: string, +): TalerError { + return TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_ACTION_UNSUPPORTED, + { transactionId, action }, + hint ?? `transaction ${transactionId} cannot be ${action}ed`, + ); +} + export async function getTransactionById( wex: WalletExecutionContext, req: TransactionByIdRequest, @@ -137,7 +196,7 @@ export async function getTransactionById( const parsedTx = parseTransactionIdentifier(req.transactionId); if (!parsedTx) { - throw Error("invalid transaction ID"); + throw makeInvalidTransactionIdError(req.transactionId); } switch (parsedTx.tag) { @@ -160,7 +219,11 @@ export async function getTransactionById( }), ); if (!txDetails) { - throw Error("transaction not found"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + { transactionId: req.transactionId }, + `transaction ${req.transactionId} not found`, + ); } return txDetails; } @@ -318,8 +381,10 @@ async function findOffsetTransaction( if (req.offsetTimestamp) { closestTimestamp = timestampPreciseToDb(req.offsetTimestamp); } else { - throw Error( - "offset transaction not found and no offset timestamp specified", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + { transactionId: req.offsetTransactionId }, + `offset transaction ${req.offsetTransactionId} not found and no offset timestamp specified`, ); } } else if (req?.offsetTimestamp) { @@ -629,6 +694,10 @@ export function constructTransactionIdentifier( /** * Parse a transaction identifier string into a typed, structured representation. + * + * Returns undefined for every kind of malformed identifier, so that callers can + * report one coded error instead of the shape of the failure deciding whether + * they see an exception or a missing value. */ export function parseTransactionIdentifier( transactionId: string, @@ -636,13 +705,13 @@ export function parseTransactionIdentifier( const txnParts = transactionId.split(":"); if (txnParts.length < 3) { - throw Error("id should have al least 3 parts separated by ':'"); + return undefined; } const [prefix, type, ...rest] = txnParts; if (prefix != "txn") { - throw Error("invalid transaction identifier"); + return undefined; } switch (type) { @@ -702,7 +771,7 @@ function maybeTaskFromTransaction( const parsedTx = parseTransactionIdentifier(transactionId); if (!parsedTx) { - throw Error("invalid transaction identifier"); + throw makeInvalidTransactionIdError(transactionId); } // FIXME: We currently don't cancel active long-polling tasks here. @@ -804,7 +873,7 @@ async function getContextForTransaction( ): Promise<TransactionContext> { const tx = parseTransactionIdentifier(transactionId); if (!tx) { - throw Error("invalid transaction ID"); + throw makeInvalidTransactionIdError(transactionId); } switch (tx.tag) { case TransactionType.Deposit: @@ -828,7 +897,11 @@ async function getContextForTransaction( return new RefundTransactionContext(wex, tx.refundGroupId); case TransactionType.Recoup: //return new RecoupTransactionContext(ws, tx.recoupGroupId); - throw new Error("not yet supported"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_ACTION_UNSUPPORTED, + { transactionId, action: "lookup" }, + "recoup transactions cannot be inspected yet", + ); case TransactionType.DenomLoss: return new DenomLossTransactionContext(wex, tx.denomLossEventId); default: diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -371,7 +371,16 @@ async function handleTxRetries<T>( logger.error( `updating exchange already failed in execution context, not retrying`, ); - throw exn; + // UnverifiedDenomError and OutdatedExchangeError are internal + // signals for "update the exchange and try again". Once that has + // been tried, they escape to the client, and there they have to + // carry a code instead of surfacing as an unexpected exception. + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_ENTRY_OUTDATED, + { exchangeBaseUrl: url }, + `the wallet's information about ${url} is outdated and could not be refreshed`, + exn, + ); } // Prevent both recursion and multiple updates // per wallet execution context. diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -187,6 +187,8 @@ import { import { constructTransactionIdentifier, isUnsuccessfulTransaction, + makeInvalidTransactionIdError, + makeTransactionNotFoundError, parseTransactionIdentifier, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; @@ -2195,7 +2197,9 @@ async function processWithdrawalGroupAbortingBank( ), ); if (!uriResult) { - throw Error(`can't parse withdrawal URL ${wgInfo.bankInfo.talerWithdrawUri}`); + throw Error( + `can't parse withdrawal URL ${wgInfo.bankInfo.talerWithdrawUri}`, + ); } logger.info(`aborting withdrawal ${uriResult.withdrawalOperationId}`); const bankClient = walletBankIntegrationClient( @@ -2798,8 +2802,10 @@ export async function getExchangeWithdrawalInfo( if (exchange.currency != instructedAmount.currency) { // Specifying the amount in the conversion input currency is not yet supported. // We might add support for it later. - throw new Error( - `withdrawal only supported when specifying target currency ${exchange.currency}`, + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_CURRENCY_MISMATCH, + {}, + `withdrawal is only supported when the amount is given in the exchange's currency (${exchange.currency})`, ); } @@ -3075,7 +3081,10 @@ async function registerReserveWithBank( ); } const uriResult = Result.orUndefined( - TalerUris.parseRestricted(bankInfo.talerWithdrawUri, TalerUriAction.Withdraw), + TalerUris.parseRestricted( + bankInfo.talerWithdrawUri, + TalerUriAction.Withdraw, + ), ); if (!uriResult) { throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`); @@ -3761,21 +3770,27 @@ export async function confirmWithdrawal( req.amount == null ? undefined : Amounts.parseOrThrow(req.amount); if (parsedTx?.tag !== TransactionType.Withdrawal) { - throw Error("invalid withdrawal transaction ID"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.Withdrawal, + ); } const withdrawalGroup = await wex.runWalletDbTx((tx) => tx.getWithdrawalGroup(parsedTx.withdrawalGroupId), ); if (!withdrawalGroup) { - throw Error("withdrawal group not found"); + throw makeTransactionNotFoundError(req.transactionId); } if ( withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated ) { - throw Error("not a bank integrated withdrawal"); + throw makeInvalidTransactionIdError( + req.transactionId, + TransactionType.Withdrawal, + ); } await wex.runWalletDbTx(async (tx) => { @@ -3799,7 +3814,14 @@ export async function confirmWithdrawal( requireExchangeTosAcceptedOrThrow(wex, exchange); if (req.amount && checkWithdrawalHardLimitExceeded(exchange, req.amount)) { - throw Error("withdrawal would exceed hard KYC limit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { + exchangeBaseUrl: exchange.exchangeBaseUrl, + requestedAmount: req.amount, + }, + "withdrawal would exceed a hard limit of the exchange", + ); } const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; @@ -3844,8 +3866,10 @@ export async function confirmWithdrawal( instructedAmount, }); } else if (!withdrawalGroup.isForeignAccount) { - throw Error( - "Confirming withdrawals with non-foreign accounts and flexible amount is not supported. Consider adding external-confirmation=1 to the taler://withdraw URI.", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "amount" }, + "confirming a withdrawal with a non-foreign account and a flexible amount is not supported; consider adding external-confirmation=1 to the taler://withdraw URI", ); } @@ -3853,10 +3877,7 @@ export async function confirmWithdrawal( if (senderWire && !withdrawalGroup.isForeignAccount) { logger.info(`sender wire is ${senderWire}`); - const parsedSenderWire = Result.orUndefined(Paytos.fromString(senderWire)); - if (!parsedSenderWire) { - throw Error("invalid payto URI"); - } + const parsedSenderWire = Paytos.parseOrThrow(senderWire); let acceptable = false; if (withdrawalAccountList.length == 0) { logger.warn(`empty list of withdrawal accounts`); @@ -3888,8 +3909,10 @@ export async function confirmWithdrawal( // Might be acceptable if it's a withdrawal from a // foreign account that is not properly marked as such. logger.warn("no account acceptable by the exchange"); - throw Error( - `Exchange ${selectedExchange} not usable for withdrawal, as account ${senderWire} is not acceptable to the exchange`, + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency: instructedCurrency }, + `exchange ${selectedExchange} does not accept withdrawals to account ${senderWire}`, ); } @@ -4049,8 +4072,10 @@ export async function acceptBankIntegratedWithdrawal( if (p.info.amount == null) { if (req.amount == null) { if (p.info.editableAmount) { - throw Error( - "amount required, as withdrawal operation has flexible amount", + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "amount" }, + "an amount is required, as this withdrawal operation has a flexible amount", ); } // Amount will be determined by the bank only after withdrawal has @@ -4067,8 +4092,10 @@ export async function acceptBankIntegratedWithdrawal( Amounts.cmp(p.info.amount, req.amount) != 0 && !p.info.editableAmount ) { - throw Error( - `mismatched amount, amount is fixed by bank (${p.info.amount}) but client provided different amount (${req.amount})`, + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "amount" }, + `the amount is fixed by the bank (${p.info.amount}), but the client asked for ${req.amount}`, ); } amount = Amounts.stringify(req.amount); @@ -4250,13 +4277,19 @@ export async function createManualWithdrawal( const exchange = await fetchFreshExchangeWithRetryNow(wex, exchangeBaseUrl); if (exchange.currency != amount.currency) { - throw Error( - "manual withdrawal with conversion from foreign currency is not yet supported", + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_CURRENCY_MISMATCH, + {}, + `manual withdrawal in ${amount.currency} from an exchange that uses ${exchange.currency} is not yet supported`, ); } if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) { - throw Error("withdrawal would exceed hard KYC limit"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + { exchangeBaseUrl, requestedAmount: Amounts.stringify(req.amount) }, + "withdrawal would exceed a hard limit of the exchange", + ); } let reserveKeyPair: EddsaKeyPairStrings; @@ -4381,7 +4414,11 @@ export async function internalGetWithdrawalDetailsForAmount( ); } if (!exchangeBaseUrl) { - throw Error("could not find exchange for withdrawal"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE, + { currency: req.restrictScope?.currency }, + "no exchange in the wallet can be used for this withdrawal", + ); } const wi = await getExchangeWithdrawalInfo( wex,