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:
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,