taler-typescript-core

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

commit 8928496bcb990c89d191194df5aa80c2621f144f
parent b4d1c980d688c68628b0293b2f313c28990cf17f
Author: Florian Dold <dold@taler.net>
Date:   Thu, 30 Jul 2026 22:35:55 +0200

wallet-core: use local time to distinguish purse gone reasons

The exchange answers "gone" whether a purse lapsed, was deleted by its owner
or was already decided, so each handler picked one cause and reported it
unconditionally: peer-pull-debit called every gone purse expired, peer-push-
credit called every one of them an abort. The purse expiration is in the
contract terms, so the local clock settles it without trusting the exchange.

Issue: https://bugs.taler.net/n/11654

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts | 8+++-----
Mpackages/taler-harness/src/integrationtests/test-peer-pull.ts | 21+++++++++++++--------
Mpackages/taler-harness/src/integrationtests/test-peer-push.ts | 36++++++++++++++++++++++--------------
Mpackages/taler-util/src/errors.ts | 18+++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 19+++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 75++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++-----------
7 files changed, 173 insertions(+), 62 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts b/packages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts @@ -114,13 +114,11 @@ export async function runPeerPullDebitPurseGoneTest(t: GlobalTestState) { await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, { transactionId: prepare.transactionId, }); + // The invoice was called off well before its expiration, so the payer has + // to report it as aborted rather than as expired. await wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare.transactionId, - txState: [ - { major: TransactionMajorState.Aborted }, - { major: TransactionMajorState.Expired }, - { major: TransactionMajorState.Failed }, - ], + txState: { major: TransactionMajorState.Aborted }, }); await wallet2.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts @@ -36,12 +36,17 @@ const purse_expiration = AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })), ); -const stFailedOrExpired = [ +/** + * States a wallet ends up in when it loses the race for an invoice: the purse + * is gone by the time it deposits, which aborts the payment, and a deposit + * that got as far as the exchange fails instead. + */ +const stLostRace = [ { - major: TransactionMajorState.Failed, + major: TransactionMajorState.Aborted, }, { - major: TransactionMajorState.Expired, + major: TransactionMajorState.Failed, }, ]; @@ -213,7 +218,7 @@ export async function runPeerPullTest(t: GlobalTestState) { }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, - txState: stFailedOrExpired, + txState: stLostRace, timeout: { seconds: 10 }, }), wallet4.call(WalletApiOperation.TestingWaitTransactionState, { @@ -314,14 +319,14 @@ export async function runPeerPullTest(t: GlobalTestState) { }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, - txState: stFailedOrExpired, + txState: stLostRace, timeout: { seconds: 10 }, }), ]), Promise.all([ wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare2.transactionId, - txState: stFailedOrExpired, + txState: stLostRace, timeout: { seconds: 10 }, }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { @@ -367,14 +372,14 @@ export async function runPeerPullTest(t: GlobalTestState) { wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare2.transactionId, txState: { - major: TransactionMajorState.Expired, + major: TransactionMajorState.Aborted, }, timeout: { seconds: 10 }, }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, txState: { - major: TransactionMajorState.Expired, + major: TransactionMajorState.Aborted, }, timeout: { seconds: 10 }, }), diff --git a/packages/taler-harness/src/integrationtests/test-peer-push.ts b/packages/taler-harness/src/integrationtests/test-peer-push.ts @@ -36,15 +36,17 @@ const purseExpiration = AbsoluteTime.toProtocolTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })), ); -const stFinalUnsuccessful = [ - { - major: TransactionMajorState.Failed, - }, +/** + * States the receiver of a push payment ends up in when the payment never + * reaches it: another wallet merged the purse first, or the sender called the + * payment off. Neither of those is an expiry, so "expired" must not show up. + */ +const stNotExpired = [ { - major: TransactionMajorState.Expired, + major: TransactionMajorState.Aborted, }, { - major: TransactionMajorState.Aborted, + major: TransactionMajorState.Failed, }, ]; @@ -233,13 +235,13 @@ export async function runPeerPushTest(t: GlobalTestState) { }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, - txState: stFinalUnsuccessful, + txState: stNotExpired, }), ]), Promise.all([ wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare2.transactionId, - txState: stFinalUnsuccessful, + txState: stNotExpired, }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, @@ -341,13 +343,13 @@ export async function runPeerPushTest(t: GlobalTestState) { }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, - txState: stFinalUnsuccessful, + txState: stNotExpired, }), ]), Promise.all([ wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare2.transactionId, - txState: stFinalUnsuccessful, + txState: stNotExpired, }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, @@ -391,14 +393,16 @@ export async function runPeerPushTest(t: GlobalTestState) { await wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare2.transactionId, - txState: stFinalUnsuccessful, + txState: stNotExpired, }); t.logStep("waiting for wallet3"); await wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, - txState: stFinalUnsuccessful, + txState: { + major: TransactionMajorState.Aborted, + }, }); }); @@ -539,11 +543,15 @@ export async function runPeerPushTest(t: GlobalTestState) { }), wallet2.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare2.transactionId, - txState: stFinalUnsuccessful, + txState: { + major: TransactionMajorState.Expired, + }, }), wallet3.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: prepare3.transactionId, - txState: stFinalUnsuccessful, + txState: { + major: TransactionMajorState.Expired, + }, }), ]); } diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts @@ -34,6 +34,21 @@ import { type empty = Record<string, never>; +/** + * Why the exchange does not have the purse of a peer payment anymore. + * + * The exchange answers with the same status whether the purse lapsed, was + * deleted or was already decided, so the reason is derived from the purse + * expiration in the contract terms and the local clock. + */ +interface PurseGoneDetails { + /** + * The purse expiration has passed, i.e. the payment lapsed instead of being + * called off by the other wallet or completed by someone else. + */ + purseExpired: boolean; +} + export interface DetailsMap { [TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING]: { exchangeResponse: any; @@ -250,7 +265,8 @@ export interface DetailsMap { bankAccountId: string; }; [TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND]: empty; - [TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE]: empty; + [TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE]: PurseGoneDetails; + [TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE]: PurseGoneDetails; [TalerErrorCode.WALLET_PEER_PULL_DEBIT_ALREADY_PAID]: empty; [TalerErrorCode.WALLET_TOKENS_IN_USE]: { tokenFamilyHash: string; diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -16,6 +16,7 @@ */ import { + AbsoluteTime, AmountJson, Amounts, ExchangePurseStatus, @@ -177,3 +178,21 @@ export function isPurseDeposited(purse: ExchangePurseStatus): boolean { !TalerProtocolTimestamp.isNever(depositTimestamp) ); } + +/** + * Check whether a purse that the exchange reports as gone lapsed. + * + * The exchange answers "gone" for a purse that reached its expiration, for + * one that its owner deleted and for one that was already decided because + * another wallet got there first. The purse expiration is part of the + * contract terms, so the local clock separates the first cause from the other + * two without having to trust the exchange on it: a purse that is not due yet + * cannot have lapsed. + */ +export function isPurseGoneByExpiration( + purseExpiration: TalerProtocolTimestamp, +): boolean { + return AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp(purseExpiration), + ); +} diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -61,6 +61,7 @@ import { encodeCrock, getRandomBytes, j2s, + makeErrorDetail, } from "@gnu-taler/taler-util"; import { PreviousPayCoins, @@ -92,6 +93,7 @@ import { import { getTotalPeerPaymentCost, isPurseDeposited, + isPurseGoneByExpiration, queryCoinInfosForSelection, } from "./pay-peer-common.js"; import { createRefreshGroup } from "./refresh.js"; @@ -223,7 +225,12 @@ export class PeerPullDebitTransactionContext implements TransactionContext { }); } - async expireTransaction(fromSt: PeerPullDebitRecordStatus): Promise<void> { + /** + * Terminate the transaction because the exchange does not have the purse of + * the invoice anymore, which happens when the invoice lapsed, when the + * payee withdrew it and when another wallet paid it first. + */ + async purseGoneTransaction(fromSt: PeerPullDebitRecordStatus): Promise<void> { const { wex } = this; await wex.runWalletDbTx(async (tx) => { const [rec, h] = await this.getRecordHandle(tx); @@ -251,8 +258,20 @@ export class PeerPullDebitTransactionContext implements TransactionContext { ); rec.abortRefreshGroupId = refresh.refreshGroupId; } - rec.status = PeerPullDebitRecordStatus.Expired; - await h.update(rec, "expire"); + const ct = await tx.getContractTerms(rec.contractTermsHash); + checkDbInvariant(!!ct, "contract terms for P2P payment not found"); + if (isPurseGoneByExpiration(ct.contractTermsRaw.purse_expiration)) { + rec.status = PeerPullDebitRecordStatus.Expired; + await h.update(rec, "expire"); + } else { + rec.status = PeerPullDebitRecordStatus.Aborted; + rec.abortReason = makeErrorDetail( + TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE, + { purseExpired: false }, + "this invoice was already paid or withdrawn by the payee", + ); + await h.update(rec, "purse-gone"); + } }); } @@ -508,8 +527,9 @@ async function processPeerPullDebitDialogProposed( case "ok": break; case HttpStatusCode.Gone: - // Exchange says that purse doesn't exist anymore => expired! - await ctx.expireTransaction(PeerPullDebitRecordStatus.DialogProposed); + // The purse is gone: the invoice lapsed, the payee withdrew it, or + // another wallet paid it. + await ctx.purseGoneTransaction(PeerPullDebitRecordStatus.DialogProposed); return TaskRunResult.finished(); case HttpStatusCode.NotFound: await ctx.failTransaction(pullIni.status, resp.detail); @@ -650,7 +670,7 @@ async function processPeerPullDebitPendingDeposit( case "ok": continue; case HttpStatusCode.Gone: { - await ctx.expireTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status); return TaskRunResult.finished(); } case HttpStatusCode.Conflict: @@ -986,17 +1006,41 @@ async function internalPreparePeerPullDebit( pursePub: pursePub, }); + let contractTerms: PeerContractTerms; + + // Decoded before asking for the purse status, because the purse expiration + // in here is what tells a lapsed purse from a deleted one. + if (dec.contractTerms) { + contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); + // FIXME: Check that the purseStatus balance matches contract terms amount + } else { + // FIXME: In this case, where do we get the purse expiration from?! + // https://bugs.gnunet.org/view.php?id=7706 + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, + {}, + "invoices without contract terms are not supported yet", + ); + } + const resp = await runWithProgressRetries(wex, () => exchangeClient.getPurseStatusAtMerge(pursePub), ); switch (resp.case) { case "ok": break; - case HttpStatusCode.Gone: + case HttpStatusCode.Gone: { + const purseExpired = isPurseGoneByExpiration( + contractTerms.purse_expiration, + ); throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE, - {}, + { purseExpired }, + purseExpired + ? "this invoice expired" + : "this invoice was already paid or withdrawn by the payee", ); + } case HttpStatusCode.NotFound: throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, @@ -1018,21 +1062,6 @@ async function internalPreparePeerPullDebit( const peerPullDebitId = encodeCrock(getRandomBytes(32)); - let contractTerms: PeerContractTerms; - - if (dec.contractTerms) { - contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); - // FIXME: Check that the purseStatus balance matches contract terms amount - } else { - // FIXME: In this case, where do we get the purse expiration from?! - // https://bugs.gnunet.org/view.php?id=7706 - throw TalerError.fromDetail( - TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED, - {}, - "invoices without contract terms are not supported yet", - ); - } - const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms); // FIXME: Why don't we compute the totalCost here?! diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -54,6 +54,7 @@ import { encodeCrock, getRandomBytes, j2s, + makeErrorDetail, } from "@gnu-taler/taler-util"; import { PendingTaskType, @@ -98,7 +99,11 @@ import { isKycOperationDue, runKycCheckAlgo, } from "./kyc.js"; -import { getMergeReserveInfo, isPurseMerged } from "./pay-peer-common.js"; +import { + getMergeReserveInfo, + isPurseGoneByExpiration, + isPurseMerged, +} from "./pay-peer-common.js"; import { BalanceEffect, constructTransactionIdentifier, @@ -625,12 +630,18 @@ async function internalPreparePeerPushCredit( switch (resp.case) { case "ok": break; - case HttpStatusCode.Gone: + case HttpStatusCode.Gone: { + const purseExpired = isPurseGoneByExpiration( + contractTerms.purse_expiration, + ); throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE, - {}, - "the sender aborted this payment", + { purseExpired }, + purseExpired + ? "this payment expired" + : "the sender aborted this payment", ); + } case HttpStatusCode.NotFound: throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_CONTRACT_NOT_FOUND, @@ -929,7 +940,17 @@ async function processPendingMerge( await h.update(rec, "merge-conflict"); }); return TaskRunResult.finished(); - case HttpStatusCode.Gone: + case HttpStatusCode.Gone: { + const purseExpired = isPurseGoneByExpiration( + contractTerms.purse_expiration, + ); + const reason = makeErrorDetail( + TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE, + { purseExpired }, + purseExpired + ? "this payment expired" + : "the sender aborted this payment", + ); await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { @@ -938,10 +959,12 @@ async function processPendingMerge( switch (rec.status) { case PeerPushCreditStatus.PendingMergeKycRequired: case PeerPushCreditStatus.PendingMerge: { - // Note that we do *not* go to an expired state here, - // because we also get a Gone status when the other - // wallet aborted the P2P payment. - rec.status = PeerPushCreditStatus.Failed; + if (purseExpired) { + rec.status = PeerPushCreditStatus.Expired; + } else { + rec.status = PeerPushCreditStatus.Failed; + rec.failReason = reason; + } break; } default: @@ -950,6 +973,7 @@ async function processPendingMerge( await h.update(rec, "merge-gone"); }); return TaskRunResult.finished(); + } case HttpStatusCode.Forbidden: case HttpStatusCode.NotFound: await ctx.failTransaction(peerInc.status, mergeResp.detail); @@ -1085,7 +1109,8 @@ async function processPeerPushDebitDialogProposed( case "ok": break; case HttpStatusCode.Gone: - // Exchange says that purse doesn't exist anymore => expired! + // The purse is gone: either it lapsed or the sender called the + // payment off. await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { @@ -1093,7 +1118,18 @@ async function processPeerPushDebitDialogProposed( } switch (rec.status) { case PeerPushCreditStatus.DialogProposed: { - rec.status = PeerPushCreditStatus.Aborted; + const ct = await tx.getContractTerms(rec.contractTermsHash); + checkDbInvariant(!!ct, "contract terms for P2P payment not found"); + if (isPurseGoneByExpiration(ct.contractTermsRaw.purse_expiration)) { + rec.status = PeerPushCreditStatus.Expired; + } else { + rec.status = PeerPushCreditStatus.Aborted; + rec.abortReason = makeErrorDetail( + TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE, + { purseExpired: false }, + "the sender aborted this payment", + ); + } break; } default: