taler-typescript-core

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

commit 6d2bf179e48ab4c6de1b95bd180df250461fb0d6
parent 7418438f1189174482f75e5e9e7460b5b4160887
Author: Florian Dold <dold@taler.net>
Date:   Thu, 30 Jul 2026 23:03:59 +0200

wallet-core: report an expired push purse as expired on the sender side

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

Diffstat:
Mpackages/taler-wallet-core/src/balance.ts | 2++
Mpackages/taler-wallet-core/src/db-common.ts | 8++++++++
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 8+++++++-
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
4 files changed, 74 insertions(+), 12 deletions(-)

diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -563,6 +563,8 @@ export async function getBalancesInsideTransaction( switch (ppdRecord.status) { case PeerPushDebitStatus.AbortingDeletePurse: case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: case PeerPushDebitStatus.PendingCreatePurse: diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -2106,9 +2106,17 @@ export enum PeerPushDebitStatus { PendingReady = 0x0100_0001, AbortingDeletePurse = 0x0103_0000, + /** + * The purse is gone because it expired, and the coins that went into it + * have to be reclaimed. Same clean-up as AbortingDeletePurse, but since + * nobody called the payment off it ends up expired instead of aborted. + */ + ExpiredDeletePurse = 0x0103_0003, + SuspendedCreatePurse = 0x0110_0000, SuspendedReady = 0x0110_0001, SuspendedAbortingDeletePurse = 0x0113_0000, + SuspendedExpiredDeletePurse = 0x0113_0003, Done = 0x0500_0000, Aborted = 0x0503_0000, diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -3315,15 +3315,21 @@ export const conformanceCases: ConformanceCase[] = [ const done = makePeerPushDebit("ppd-2"); done.status = PeerPushDebitStatus.Done; await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(done)); + // The clean-up after an expired purse still has to reclaim the coins, + // so it must be picked up like any other unfinished transaction. + const expiring = makePeerPushDebit("ppd-3"); + expiring.status = PeerPushDebitStatus.ExpiredDeletePurse; + await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(expiring)); const active = await runner.runReadWriteTx((tx) => tx.getActivePeerPushDebits(), ); t.ok(active.map((r) => r.pursePub).includes(ck("ppd-1"))); + t.ok(active.map((r) => r.pursePub).includes(ck("ppd-3"))); t.ok(!active.map((r) => r.pursePub).includes(ck("ppd-2"))); t.equal( (await runner.runReadWriteTx((tx) => tx.listAllPeerPushDebits())) .length, - 2, + 3, ); await runner.runReadWriteTx((tx) => tx.deletePeerPushDebit(ck("ppd-1"))); t.equal( diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -92,6 +92,7 @@ import { import { getTotalPeerPaymentCost, getTotalPeerPaymentCostInTx, + isPurseGoneByExpiration, isPurseMerged, queryCoinInfosForSelection, } from "./pay-peer-common.js"; @@ -248,9 +249,13 @@ export class PeerPushDebitTransactionContext implements TransactionContext { case PeerPushDebitStatus.AbortingDeletePurse: rec.status = PeerPushDebitStatus.SuspendedAbortingDeletePurse; break; + case PeerPushDebitStatus.ExpiredDeletePurse: + rec.status = PeerPushDebitStatus.SuspendedExpiredDeletePurse; + break; case PeerPushDebitStatus.PendingReady: rec.status = PeerPushDebitStatus.SuspendedReady; break; + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: case PeerPushDebitStatus.SuspendedAbortingDeletePurse: case PeerPushDebitStatus.SuspendedReady: case PeerPushDebitStatus.SuspendedCreatePurse: @@ -287,8 +292,10 @@ export class PeerPushDebitTransactionContext implements TransactionContext { rec.status = PeerPushDebitStatus.AbortingDeletePurse; break; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: case PeerPushDebitStatus.Done: case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: case PeerPushDebitStatus.Aborted: case PeerPushDebitStatus.Expired: case PeerPushDebitStatus.Failed: @@ -312,6 +319,9 @@ export class PeerPushDebitTransactionContext implements TransactionContext { case PeerPushDebitStatus.SuspendedAbortingDeletePurse: rec.status = PeerPushDebitStatus.AbortingDeletePurse; break; + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: + rec.status = PeerPushDebitStatus.ExpiredDeletePurse; + break; case PeerPushDebitStatus.SuspendedReady: rec.status = PeerPushDebitStatus.PendingReady; break; @@ -320,6 +330,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext { break; case PeerPushDebitStatus.PendingCreatePurse: case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.Done: case PeerPushDebitStatus.Aborted: @@ -360,6 +371,8 @@ export class PeerPushDebitTransactionContext implements TransactionContext { switch (rec.status) { case PeerPushDebitStatus.AbortingDeletePurse: case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: case PeerPushDebitStatus.SuspendedCreatePurse: @@ -621,6 +634,19 @@ async function handlePurseCreationConflict( return TaskRunResult.progress(); } +/** + * Pick the clean-up state for a purse that the exchange reports as gone. + * + * Both states delete the purse and reclaim the coins; they only differ in the + * terminal state they lead to, so that a purse that simply ran out of time is + * not reported as if somebody had called the payment off. + */ +function purseGoneStatus(rec: WalletPeerPushDebit): PeerPushDebitStatus { + return isPurseGoneByExpiration(timestampProtocolFromDb(rec.purseExpiration)) + ? PeerPushDebitStatus.ExpiredDeletePurse + : PeerPushDebitStatus.AbortingDeletePurse; +} + async function processPeerPushDebitCreateReserve( wex: WalletExecutionContext, peerPushInitiation: WalletPeerPushDebit, @@ -793,7 +819,6 @@ async function processPeerPushDebitCreateReserve( // Possibly on to the next batch. continue; case HttpStatusCode.Gone: - // FIXME we need PeerPushDebitStatus.ExpiredDeletePurse await wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { @@ -801,7 +826,7 @@ async function processPeerPushDebitCreateReserve( } switch (rec.status) { case PeerPushDebitStatus.PendingCreatePurse: - rec.status = PeerPushDebitStatus.AbortingDeletePurse; + rec.status = purseGoneStatus(rec); break; default: // Do nothing @@ -848,7 +873,6 @@ async function processPeerPushDebitCreateReserve( }); return TaskRunResult.progress(); case HttpStatusCode.Gone: - // FIXME we need PeerPushDebitStatus.ExpiredDeletePurse await wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { @@ -856,7 +880,7 @@ async function processPeerPushDebitCreateReserve( } switch (rec.status) { case PeerPushDebitStatus.PendingCreatePurse: - rec.status = PeerPushDebitStatus.AbortingDeletePurse; + rec.status = purseGoneStatus(rec); break; default: // Do nothing @@ -873,9 +897,18 @@ async function processPeerPushDebitCreateReserve( } } -async function processPeerPushDebitAbortingDeletePurse( +/** + * Delete the purse and reclaim the coins that went into it. + * + * Runs for both clean-up states: `fromSt` says which one, and with it whether + * the transaction ends up aborted or expired. + */ +async function processPeerPushDebitDeletePurse( wex: WalletExecutionContext, peerPushInitiation: WalletPeerPushDebit, + fromSt: + | PeerPushDebitStatus.AbortingDeletePurse + | PeerPushDebitStatus.ExpiredDeletePurse, ): Promise<TaskRunResult> { const { pursePub, pursePriv, exchangeBaseUrl } = peerPushInitiation; const ctx = new PeerPushDebitTransactionContext(wex, pursePub); @@ -905,7 +938,7 @@ async function processPeerPushDebitAbortingDeletePurse( if (!rec) { return; } - if (rec.status !== PeerPushDebitStatus.AbortingDeletePurse) { + if (rec.status !== fromSt) { return; } // The abort did not happen, so its reason must not be reported. @@ -930,7 +963,7 @@ async function processPeerPushDebitAbortingDeletePurse( return; } - if (rec.status !== PeerPushDebitStatus.AbortingDeletePurse) { + if (rec.status !== fromSt) { return; } @@ -956,9 +989,13 @@ async function processPeerPushDebitAbortingDeletePurse( rec.abortRefreshGroupId = refresh.refreshGroupId; } - rec.status = PeerPushDebitStatus.Aborted; - - await h.update(rec, "abort-purse-deleted"); + if (fromSt === PeerPushDebitStatus.ExpiredDeletePurse) { + rec.status = PeerPushDebitStatus.Expired; + await h.update(rec, "expire-purse-deleted"); + } else { + rec.status = PeerPushDebitStatus.Aborted; + await h.update(rec, "abort-purse-deleted"); + } }); return TaskRunResult.backoff(); @@ -1070,7 +1107,12 @@ export async function processPeerPushDebit( case PeerPushDebitStatus.PendingReady: return processPeerPushDebitReady(wex, peerPushInitiation); case PeerPushDebitStatus.AbortingDeletePurse: - return processPeerPushDebitAbortingDeletePurse(wex, peerPushInitiation); + case PeerPushDebitStatus.ExpiredDeletePurse: + return processPeerPushDebitDeletePurse( + wex, + peerPushInitiation, + peerPushInitiation.status, + ); default: { const txState = computePeerPushDebitTransactionState(peerPushInitiation); logger.warn( @@ -1272,12 +1314,14 @@ export function computePeerPushDebitTransactionActions( case PeerPushDebitStatus.Aborted: return [TransactionAction.Delete]; case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: return [ TransactionAction.Retry, TransactionAction.Suspend, TransactionAction.Fail, ]; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: return [TransactionAction.Resume, TransactionAction.Fail]; case PeerPushDebitStatus.SuspendedCreatePurse: return [TransactionAction.Resume, TransactionAction.Abort]; @@ -1312,12 +1356,14 @@ export function computePeerPushDebitTransactionState( major: TransactionMajorState.Aborted, }; case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.ExpiredDeletePurse: return { major: TransactionMajorState.Aborting, minor: TransactionMinorState.DeletePurse, working: true, }; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.SuspendedExpiredDeletePurse: return { major: TransactionMajorState.SuspendedAborting, minor: TransactionMinorState.DeletePurse,