commit c95600fe0487e07caae0181eb73fd0fb94939970
parent 7060c6b2c79c7104a1c6b9b8fe72b400ba52cb7f
Author: Florian Dold <dold@taler.net>
Date: Fri, 4 Sep 2026 01:01:53 +0200
wallet-core: recover peer debit deposits before deletion
Verify pull-payment targets and purse-deposit signatures. Keep abort and
expiry pending until each contribution is spent or refreshed.
Diffstat:
6 files changed, 642 insertions(+), 116 deletions(-)
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -872,7 +872,9 @@ export async function getBalancesInsideTransaction(
switch (rec.status) {
case PeerPullDebitRecordStatus.PendingDeposit:
case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.AbortingReconcile:
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingReconcile:
case PeerPullDebitRecordStatus.SuspendedDeposit: {
const currency = Amounts.currencyOf(rec.amount);
const amount = rec.coinSel?.totalCost ?? rec.amount;
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -104,7 +104,6 @@ import {
getTotalPeerPaymentCostInTx,
isPurseDeposited,
isPurseGoneByExpiration,
- isPurseMerged,
queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { createRefreshGroup } from "./refresh.js";
@@ -119,9 +118,14 @@ import {
import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";
import { WalletDbTransaction } from "./db/transaction.js";
import {
+ requireValidExchangePurseCreateConfirmation,
requireValidExchangePurseDepositConfirmation,
requireValidExchangePurseStatus,
} from "./exchange-signatures.js";
+import {
+ PurseDepositReconciliation,
+ reconcilePurgedPurseDeposits,
+} from "./purse-deposit-reconciliation.js";
const logger = new Logger("pay-peer-pull-debit.ts");
@@ -322,6 +326,52 @@ async function retainPeerPullDebitAcceptedSelectionInTx(
return acceptedCount;
}
+async function retainPeerPullDebitCommittedSelectionInTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbTransaction,
+ rec: WalletPeerPullDebit,
+ committedCoinPubs: ReadonlySet<string>,
+): Promise<number> {
+ if (!rec.coinSel) {
+ return 0;
+ }
+ const oldSelection = rec.coinSel;
+ const kept: PeerPullPaymentCoinSelection = {
+ coinPubs: [],
+ contributions: [],
+ totalCost: undefined,
+ confirmedPurseBalance: oldSelection.confirmedPurseBalance,
+ };
+ for (let i = 0; i < oldSelection.coinPubs.length; i++) {
+ if (!committedCoinPubs.has(oldSelection.coinPubs[i])) {
+ continue;
+ }
+ kept.coinPubs.push(oldSelection.coinPubs[i]);
+ kept.contributions.push(oldSelection.contributions[i]);
+ }
+ kept.depositedCoinCount = kept.coinPubs.length;
+ if (kept.coinPubs.length === 0) {
+ rec.coinSel = undefined;
+ return 0;
+ }
+ kept.totalCost = Amounts.stringify(
+ await getStoredPeerPullDebitSelectionCostInTx(
+ wex,
+ tx,
+ kept,
+ kept.coinPubs.length,
+ Amounts.currencyOf(rec.amount),
+ ),
+ );
+ rec.coinSel = kept;
+ return kept.coinPubs.length;
+}
+
+type PeerPullDebitCleanupStatus =
+ | PeerPullDebitRecordStatus.Aborted
+ | PeerPullDebitRecordStatus.Expired
+ | PeerPullDebitRecordStatus.Failed;
+
function makePeerPullDebitPartialDepositError(): TalerErrorDetail {
return makeErrorDetail(
TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
@@ -468,6 +518,90 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
});
}
+ async beginPurseReconciliation(
+ fromSt: PeerPullDebitRecordStatus,
+ finalStatus: PeerPullDebitCleanupStatus,
+ reason?: TalerErrorDetail,
+ ): Promise<void> {
+ await this.wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await this.getRecordHandle(tx);
+ if (rec?.status !== fromSt) {
+ return;
+ }
+ if (!rec.coinSel) {
+ rec.status = finalStatus;
+ } else {
+ rec.status = PeerPullDebitRecordStatus.AbortingReconcile;
+ rec.cleanupFinalStatus = finalStatus;
+ }
+ if (finalStatus === PeerPullDebitRecordStatus.Failed) {
+ rec.failReason = reason;
+ } else if (finalStatus === PeerPullDebitRecordStatus.Aborted) {
+ rec.abortReason = reason;
+ }
+ await h.update(rec, "begin-purse-reconciliation");
+ });
+ }
+
+ async applyPurseReconciliation(
+ reconciliation: PurseDepositReconciliation,
+ purseFinal: boolean,
+ ): Promise<{ waitingForPurse: boolean; terminal: boolean }> {
+ return await this.wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await this.getRecordHandle(tx);
+ if (rec?.status !== PeerPullDebitRecordStatus.AbortingReconcile) {
+ return { waitingForPurse: false, terminal: true };
+ }
+ const finalStatus = rec.cleanupFinalStatus;
+ checkDbInvariant(
+ finalStatus !== undefined,
+ "peer pull debit reconciliation has no final status",
+ );
+ if (reconciliation.recoverable.length > 0) {
+ const refresh = await createRefreshGroup(
+ this.wex,
+ tx,
+ Amounts.currencyOf(rec.amount),
+ reconciliation.recoverable,
+ RefreshReason.AbortPeerPullDebit,
+ this.transactionId,
+ );
+ rec.abortRefreshGroupId = refresh.refreshGroupId;
+ }
+ const committedCount = await retainPeerPullDebitCommittedSelectionInTx(
+ this.wex,
+ tx,
+ rec,
+ reconciliation.committedCoinPubs,
+ );
+ if (committedCount > 0) {
+ if (purseFinal) {
+ // An unrefunded deposit in the history of a final/purged purse is
+ // proof that the recipient obtained the value.
+ delete rec.abortReason;
+ delete rec.failReason;
+ delete rec.cleanupFinalStatus;
+ rec.status = PeerPullDebitRecordStatus.Done;
+ await h.update(rec, "purse-reconciliation-committed");
+ return { waitingForPurse: false, terminal: true };
+ }
+ // The recipient may still complete the purse. Do not submit more
+ // deposits or refresh the committed coins; reconcile at the deadline.
+ await h.update(rec, "purse-reconciliation-wait");
+ return { waitingForPurse: true, terminal: false };
+ }
+ if (rec.abortRefreshGroupId) {
+ rec.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ await h.update(rec, "purse-reconciliation-refresh");
+ return { waitingForPurse: false, terminal: false };
+ }
+ delete rec.cleanupFinalStatus;
+ rec.status = finalStatus;
+ await h.update(rec, "purse-reconciliation-finished");
+ return { waitingForPurse: false, terminal: true };
+ });
+ }
+
/**
* Terminate the transaction because the exchange does not have the purse of
* the invoice anymore, which happens when the invoice lapsed, when the
@@ -562,6 +696,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
case PeerPullDebitRecordStatus.Failed:
case PeerPullDebitRecordStatus.Expired:
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingReconcile:
return;
case PeerPullDebitRecordStatus.PendingDeposit:
rec.status = PeerPullDebitRecordStatus.SuspendedDeposit;
@@ -569,6 +704,9 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
case PeerPullDebitRecordStatus.AbortingRefresh:
rec.status = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
break;
+ case PeerPullDebitRecordStatus.AbortingReconcile:
+ rec.status = PeerPullDebitRecordStatus.SuspendedAbortingReconcile;
+ break;
default:
assertUnreachable(rec.status);
}
@@ -590,8 +728,12 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
rec.status = PeerPullDebitRecordStatus.AbortingRefresh;
break;
+ case PeerPullDebitRecordStatus.SuspendedAbortingReconcile:
+ rec.status = PeerPullDebitRecordStatus.AbortingReconcile;
+ break;
case PeerPullDebitRecordStatus.Aborted:
case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.AbortingReconcile:
case PeerPullDebitRecordStatus.Failed:
case PeerPullDebitRecordStatus.DialogProposed:
case PeerPullDebitRecordStatus.Done:
@@ -646,9 +788,6 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
default:
return;
}
- const currency = Amounts.currencyOf(pi.totalCostEstimated);
- const coinPubs: CoinRefreshRequest[] = [];
-
if (!pi.coinSel) {
// We didn't even select coins yet, abort immediately.
// Can happen for DBs that still have a prospective
@@ -656,32 +795,11 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
pi.status = PeerPullDebitRecordStatus.Aborted;
pi.abortReason = reason;
} else {
- const acceptedCount = getPeerPullDebitDepositedCoinCount(pi.coinSel);
- coinPubs.push(...getPeerPullDebitUnconfirmedCoins(pi.coinSel));
- if (coinPubs.length === 0) {
- if (acceptedCount > 0) {
- await retainPeerPullDebitAcceptedSelectionInTx(this.wex, tx, pi);
- pi.status = PeerPullDebitRecordStatus.Failed;
- pi.failReason = makePeerPullDebitPartialDepositError();
- pi.abortReason = reason;
- } else {
- pi.status = PeerPullDebitRecordStatus.Aborted;
- pi.abortReason = reason;
- }
- await h.update(pi, "abort-without-refresh");
- return;
- }
- const refresh = await createRefreshGroup(
- this.wex,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPullDebit,
- this.transactionId,
- );
-
- pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
- pi.abortRefreshGroupId = refresh.refreshGroupId;
+ // A deposit request may have reached the exchange even if the wallet
+ // never received its response. Reconcile authenticated coin history
+ // before creating any refresh request.
+ pi.status = PeerPullDebitRecordStatus.AbortingReconcile;
+ pi.cleanupFinalStatus = PeerPullDebitRecordStatus.Aborted;
pi.abortReason = reason;
}
await h.update(pi, "abort");
@@ -696,8 +814,12 @@ async function handlePurseCreationConflict(
conflict: PurseConflict,
): Promise<TaskRunResult> {
if (conflict.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.failTransaction(peerPullInc.status, { ...conflict });
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Failed,
+ { ...conflict },
+ );
+ return TaskRunResult.progress();
}
const brokenCoinPub = conflict.coin_pub;
@@ -719,19 +841,18 @@ async function handlePurseCreationConflict(
case "ok":
break;
case HttpStatusCode.Gone:
- await ctx.purseGoneTransaction(
+ await ctx.beginPurseReconciliation(
peerPullInc.status,
- "refunded",
- new Set([brokenCoinPub]),
+ PeerPullDebitRecordStatus.Aborted,
);
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
case HttpStatusCode.NotFound:
- await ctx.failTransaction(
+ await ctx.beginPurseReconciliation(
peerPullInc.status,
+ PeerPullDebitRecordStatus.Failed,
statusResp.detail,
- new Set([brokenCoinPub]),
);
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
default:
assertUnreachable(statusResp);
}
@@ -746,12 +867,11 @@ async function handlePurseCreationConflict(
statusResp.body.balance,
);
if (isPurseDeposited(statusResp.body) || !instructedAmount) {
- await ctx.purseGoneTransaction(
+ await ctx.beginPurseReconciliation(
peerPullInc.status,
- "deposited",
- new Set([brokenCoinPub]),
+ PeerPullDebitRecordStatus.Aborted,
);
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
}
const coinDetails = await queryCoinInfosForSelection(ctx.wex, sel);
@@ -1020,19 +1140,29 @@ async function processPeerPullDebitPendingDeposit(
// A pull purse carries its merge timestamp from creation, so only
// the deposit timestamp says that the payee got the money.
if (isPurseDeposited(statusResp.body)) {
- await ctx.purseGoneTransaction(peerPullInc.status, "deposited");
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Expired,
+ );
+ return TaskRunResult.progress();
}
// The local clock reached the deadline first. Reconcile again after
// the exchange has either expired or merged the purse, but do not
// select or submit any more coins.
return TaskRunResult.longpollReturnedPending();
case HttpStatusCode.Gone:
- await ctx.purseGoneTransaction(peerPullInc.status, "refunded");
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Expired,
+ );
+ return TaskRunResult.progress();
case HttpStatusCode.NotFound:
- await ctx.failTransaction(peerPullInc.status, statusResp.detail);
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Expired,
+ statusResp.detail,
+ );
+ return TaskRunResult.progress();
default:
assertUnreachable(statusResp);
}
@@ -1051,11 +1181,18 @@ async function processPeerPullDebitPendingDeposit(
case "ok":
break;
case HttpStatusCode.Gone:
- await ctx.purseGoneTransaction(peerPullInc.status, "refunded");
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Aborted,
+ );
+ return TaskRunResult.progress();
case HttpStatusCode.NotFound:
- await ctx.failTransaction(peerPullInc.status, statusResp.detail);
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Failed,
+ statusResp.detail,
+ );
+ return TaskRunResult.progress();
default:
assertUnreachable(statusResp);
}
@@ -1069,8 +1206,11 @@ async function processPeerPullDebitPendingDeposit(
statusResp.body.balance,
);
if (isPurseDeposited(statusResp.body) || !instructedAmount) {
- await ctx.purseGoneTransaction(peerPullInc.status, "deposited");
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Aborted,
+ );
+ return TaskRunResult.progress();
}
const currency = instructedAmount.currency;
@@ -1286,24 +1426,45 @@ async function processPeerPullDebitPendingDeposit(
continue;
}
case HttpStatusCode.Gone: {
- await ctx.purseGoneTransaction(peerPullInc.status, "refunded");
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ isPurseGoneByExpiration(
+ contractTerms.contractTermsRaw.purse_expiration,
+ )
+ ? PeerPullDebitRecordStatus.Expired
+ : PeerPullDebitRecordStatus.Aborted,
+ );
+ return TaskRunResult.progress();
}
case HttpStatusCode.Conflict:
return handlePurseCreationConflict(ctx, peerPullInc, resp.body);
case HttpStatusCode.Forbidden:
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Failed,
+ resp.detail,
+ );
+ return TaskRunResult.progress();
case HttpStatusCode.NotFound:
- await ctx.failTransaction(peerPullInc.status, resp.detail);
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Failed,
+ resp.detail,
+ );
+ return TaskRunResult.progress();
default:
assertUnreachable(resp);
}
}
- await ctx.failTransaction(peerPullInc.status, {
- code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
- hint: "exchange accepted every selected coin but reported an incomplete purse",
- });
- return TaskRunResult.finished();
+ await ctx.beginPurseReconciliation(
+ peerPullInc.status,
+ PeerPullDebitRecordStatus.Failed,
+ {
+ code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ hint: "exchange accepted every selected coin but reported an incomplete purse",
+ },
+ );
+ return TaskRunResult.progress();
}
async function processPeerPullDebitAbortingRefresh(
@@ -1330,17 +1491,9 @@ async function processPeerPullDebitAbortingRefresh(
} else {
switch (refreshGroup.operationStatus) {
case RefreshOperationStatus.Finished: {
- const acceptedCount = await retainPeerPullDebitAcceptedSelectionInTx(
- wex,
- tx,
- rec,
- );
- if (acceptedCount > 0) {
- rec.status = PeerPullDebitRecordStatus.Failed;
- rec.failReason = makePeerPullDebitPartialDepositError();
- } else {
- rec.status = PeerPullDebitRecordStatus.Aborted;
- }
+ rec.status =
+ rec.cleanupFinalStatus ?? PeerPullDebitRecordStatus.Aborted;
+ delete rec.cleanupFinalStatus;
terminal = true;
break;
}
@@ -1358,6 +1511,71 @@ async function processPeerPullDebitAbortingRefresh(
return terminal ? TaskRunResult.finished() : TaskRunResult.backoff();
}
+async function processPeerPullDebitAbortingReconcile(
+ wex: WalletExecutionContext,
+ peerPullDebit: WalletPeerPullDebit,
+): Promise<TaskRunResult> {
+ const ctx = new PeerPullDebitTransactionContext(
+ wex,
+ peerPullDebit.peerPullDebitId,
+ );
+ const exchangeClient = walletExchangeClient(
+ peerPullDebit.exchangeBaseUrl,
+ wex,
+ );
+ const statusResp = await exchangeClient.getPurseStatusAtMerge(
+ peerPullDebit.pursePub,
+ );
+ let purseFinal: boolean;
+ switch (statusResp.case) {
+ case "ok":
+ await requireValidExchangePurseStatus(
+ wex,
+ peerPullDebit.exchangeBaseUrl,
+ statusResp.body,
+ );
+ purseFinal = isPurseDeposited(statusResp.body);
+ break;
+ case HttpStatusCode.Gone:
+ case HttpStatusCode.NotFound:
+ // Coin histories, unlike a purged purse, remain available and say
+ // whether each deposit was refunded or ultimately committed.
+ purseFinal = true;
+ break;
+ default:
+ assertUnreachable(statusResp);
+ }
+
+ const selection = peerPullDebit.coinSel;
+ checkDbInvariant(
+ !!selection,
+ "peer pull debit reconciliation has no coin selection",
+ );
+ const reconciliation = await reconcilePurgedPurseDeposits(wex, {
+ exchangeBaseUrl: peerPullDebit.exchangeBaseUrl,
+ pursePub: peerPullDebit.pursePub,
+ selection,
+ });
+ const result = await ctx.applyPurseReconciliation(reconciliation, purseFinal);
+ if (result.terminal) {
+ return TaskRunResult.finished();
+ }
+ if (!result.waitingForPurse) {
+ return TaskRunResult.progress();
+ }
+ const purseExpiration = await wex.runWalletDbTx(async (tx) => {
+ const ct = await tx.getContractTerms(peerPullDebit.contractTermsHash);
+ checkDbInvariant(!!ct, "peer pull debit contract terms are missing");
+ return ct.contractTermsRaw.purse_expiration;
+ });
+ if (!isPurseGoneByExpiration(purseExpiration)) {
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.fromProtocolTimestamp(purseExpiration),
+ );
+ }
+ return TaskRunResult.backoff();
+}
+
export async function processPeerPullDebit(
wex: WalletExecutionContext,
peerPullDebitId: string,
@@ -1394,6 +1612,7 @@ export async function processPeerPullDebit(
case PeerPullDebitRecordStatus.Failed:
case PeerPullDebitRecordStatus.Expired:
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingReconcile:
case PeerPullDebitRecordStatus.SuspendedDeposit:
return TaskRunResult.finished();
default:
@@ -1413,6 +1632,7 @@ export async function processPeerPullDebit(
switch (peerPullInc.status) {
case PeerPullDebitRecordStatus.DialogProposed:
case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.AbortingReconcile:
if (!isPurseGoneByExpiration(purseExpiration)) {
return TaskRunResult.runAgainAt(
AbsoluteTime.fromProtocolTimestamp(purseExpiration),
@@ -1432,6 +1652,8 @@ export async function processPeerPullDebit(
return processPeerPullDebitPendingDeposit(wex, peerPullInc);
case PeerPullDebitRecordStatus.AbortingRefresh:
return processPeerPullDebitAbortingRefresh(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingReconcile:
+ return processPeerPullDebitAbortingReconcile(wex, peerPullInc);
default:
assertUnreachable(peerPullInc.status);
}
@@ -1747,6 +1969,29 @@ async function internalPreparePeerPullDebit(
);
}
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
+ const purseCreateProof = uri.purseCreateProof;
+ if (!purseCreateProof) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_UNSUPPORTED,
+ {},
+ "the pull-payment invoice lacks an exchange-authenticated purse commitment",
+ );
+ }
+ await requireValidExchangePurseCreateConfirmation(wex, {
+ exchangeBaseUrl,
+ pursePub,
+ contractTermsHash,
+ purseValueAfterFees: contractTerms.amount,
+ purseExpiration: contractTerms.purse_expiration,
+ response: {
+ total_deposited: purseCreateProof.totalDeposited,
+ exchange_timestamp: purseCreateProof.exchangeTimestamp,
+ exchange_sig: purseCreateProof.exchangeSig,
+ exchange_pub: purseCreateProof.exchangePub,
+ },
+ });
+
const resp = await runWithProgressRetries(wex, () =>
exchangeClient.getPurseStatusAtMerge(pursePub),
);
@@ -1792,8 +2037,6 @@ async function internalPreparePeerPullDebit(
const peerPullDebitId = encodeCrock(getRandomBytes(32));
- const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
-
// FIXME: Why don't we compute the totalCost here?!
const currency = Amounts.currencyOf(instructedAmount);
@@ -1908,6 +2151,12 @@ export function computePeerPullDebitTransactionState(
minor: TransactionMinorState.Refresh,
working: true,
};
+ case PeerPullDebitRecordStatus.AbortingReconcile:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Deposit,
+ working: true,
+ };
case PeerPullDebitRecordStatus.Failed:
return {
major: TransactionMajorState.Failed,
@@ -1921,6 +2170,11 @@ export function computePeerPullDebitTransactionState(
major: TransactionMajorState.SuspendedAborting,
minor: TransactionMinorState.Refresh,
};
+ case PeerPullDebitRecordStatus.SuspendedAbortingReconcile:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Deposit,
+ };
}
}
@@ -1940,11 +2194,15 @@ export function computePeerPullDebitTransactionActions(
return [TransactionAction.Delete];
case PeerPullDebitRecordStatus.AbortingRefresh:
return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.AbortingReconcile:
+ return [TransactionAction.Suspend];
case PeerPullDebitRecordStatus.Expired:
return [TransactionAction.Delete];
case PeerPullDebitRecordStatus.Failed:
return [TransactionAction.Delete];
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullDebitRecordStatus.SuspendedAbortingReconcile:
+ return [TransactionAction.Resume];
}
}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
+ Amounts,
ScopeType,
+ TalerError,
+ TalerErrorCode,
TalerProtocolTimestamp,
setGlobalLogLevelFromString,
} from "@gnu-taler/taler-util";
@@ -17,6 +20,7 @@ import {
decodePeerPushDebitQuote,
encodePeerPushDebitQuote,
processPeerPushDebit,
+ requirePositivePeerPushDebitAmount,
} from "./pay-peer-push-debit.js";
import { WalletExecutionContext } from "./wallet.js";
@@ -65,6 +69,15 @@ test("peer push debit quote rejects malformed and unknown versions", () => {
);
});
+test("peer push debit rejects a zero amount before quoting it", () => {
+ assert.throws(
+ () => requirePositivePeerPushDebitAmount(Amounts.parseOrThrow("CHF:0")),
+ (e: unknown) =>
+ e instanceof TalerError &&
+ e.errorDetail.code === TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ );
+});
+
test("peer push debit metadata logging excludes private capabilities", async () => {
const privateValues = [
"contract-private-capability",
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -16,6 +16,7 @@
import {
AbsoluteTime,
+ AmountJson,
Amounts,
CheckPeerPushDebitOkResponse,
CheckPeerPushDebitRequest,
@@ -98,7 +99,6 @@ import {
fetchFreshExchange,
getExchangeDetailsInTx,
getLegacyScopesForTransaction,
- getPreferredExchangeForCurrency,
getScopeForAllExchanges,
requireExchangeCoinUseConfirmedOrThrow,
} from "./exchanges.js";
@@ -117,6 +117,7 @@ import {
} from "./transactions.js";
import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";
import { updateWithdrawalDenomsForCurrency } from "./withdraw.js";
+import { reconcilePurgedPurseDeposits } from "./purse-deposit-reconciliation.js";
const logger = new Logger("pay-peer-push-debit.ts");
@@ -597,6 +598,7 @@ async function internalCheckPeerPushDebit(
req: CheckPeerPushDebitRequest,
): Promise<CheckPeerPushDebitResponse> {
const instructedAmount = Amounts.parseOrThrow(req.amount);
+ requirePositivePeerPushDebitAmount(instructedAmount);
const currency = instructedAmount.currency;
logger.trace(
`checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
@@ -611,40 +613,6 @@ async function internalCheckPeerPushDebit(
url: req.exchangeBaseUrl,
};
}
- if (Amounts.isZero(req.amount)) {
- const exchangeBaseUrl = await getPreferredExchangeForCurrency(
- wex,
- currency,
- restrictScope,
- );
- if (!exchangeBaseUrl) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_NO_SUITABLE_EXCHANGE,
- { currency },
- "no exchange in the wallet can be used for this payment",
- );
- }
-
- return {
- type: "ok",
- amountEffective: req.amount,
- amountRaw: req.amount,
- exchangeBaseUrl,
- maxExpirationDate: TalerProtocolTimestamp.never(),
- defaultExpiration: await getDefaultPeerPushExpiration(
- wex,
- exchangeBaseUrl,
- ),
- peerPushDebitQuote: encodePeerPushDebitQuote({
- version: 1,
- amount: req.amount,
- amountEffective: req.amount,
- exchangeBaseUrl,
- restrictScope: req.restrictScope,
- maxExpirationDate: TalerProtocolTimestamp.never(),
- }),
- };
- }
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
restrictScope,
@@ -701,6 +669,16 @@ async function internalCheckPeerPushDebit(
};
}
+export function requirePositivePeerPushDebitAmount(amount: AmountJson): void {
+ if (Amounts.isZero(amount)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "amount" },
+ "peer push payment amount must be positive",
+ );
+ }
+}
+
async function handlePurseCreationConflict(
wex: WalletExecutionContext,
peerPushInitiation: WalletPeerPushDebit,
@@ -1159,14 +1137,26 @@ async function processPeerPushDebitDeletePurse(
pursePriv,
});
const resp = await exchangeClient.deletePurse(pursePub, sigResp.sig);
+ let purgedOutcome:
+ | { recoverable: CoinRefreshRequest[]; hasCommitted: boolean }
+ | undefined;
switch (resp.case) {
case "ok":
// Successfully deleted the purse, we now refresh.
break;
case HttpStatusCode.NotFound:
- // Not found => Previous deletion succeeded.
- // FIXME: Look at response error code more closely
+ if (peerPushInitiation.coinSel) {
+ const reconciliation = await reconcilePurgedPurseDeposits(wex, {
+ exchangeBaseUrl,
+ pursePub,
+ selection: peerPushInitiation.coinSel,
+ });
+ purgedOutcome = {
+ recoverable: reconciliation.recoverable,
+ hasCommitted: reconciliation.committedCoinPubs.size > 0,
+ };
+ }
break;
case HttpStatusCode.Conflict: {
// The purse was already decided, which the exchange reports the same way
@@ -1197,6 +1187,19 @@ async function processPeerPushDebitDeletePurse(
});
return TaskRunResult.finished();
}
+ if (statusResp.case === HttpStatusCode.NotFound) {
+ if (peerPushInitiation.coinSel) {
+ const reconciliation = await reconcilePurgedPurseDeposits(wex, {
+ exchangeBaseUrl,
+ pursePub,
+ selection: peerPushInitiation.coinSel,
+ });
+ purgedOutcome = {
+ recoverable: reconciliation.recoverable,
+ hasCommitted: reconciliation.committedCoinPubs.size > 0,
+ };
+ }
+ }
break;
}
case HttpStatusCode.Forbidden:
@@ -1219,14 +1222,17 @@ async function processPeerPushDebitDeletePurse(
const currency = Amounts.currencyOf(rec.amount);
const coinPubs: CoinRefreshRequest[] = [];
- if (rec.coinSel) {
+ if (purgedOutcome) {
+ coinPubs.push(...purgedOutcome.recoverable);
+ } else if (rec.coinSel) {
for (let i = 0; i < rec.coinSel.coinPubs.length; i++) {
coinPubs.push({
amount: rec.coinSel.contributions[i],
coinPub: rec.coinSel.coinPubs[i],
});
}
-
+ }
+ if (coinPubs.length > 0) {
const refresh = await createRefreshGroup(
wex,
tx,
@@ -1238,6 +1244,14 @@ async function processPeerPushDebitDeletePurse(
rec.abortRefreshGroupId = refresh.refreshGroupId;
}
+ if (purgedOutcome?.hasCommitted) {
+ delete rec.abortReason;
+ delete rec.failReason;
+ rec.status = PeerPushDebitStatus.Done;
+ await h.update(rec, "purged-purse-coin-history-committed");
+ return;
+ }
+
if (fromSt === PeerPushDebitStatus.ExpiredDeletePurse) {
rec.status = PeerPushDebitStatus.Expired;
await h.update(rec, "expire-purse-deleted");
diff --git a/packages/taler-wallet-core/src/purse-deposit-reconciliation.test.ts b/packages/taler-wallet-core/src/purse-deposit-reconciliation.test.ts
@@ -0,0 +1,77 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountString,
+ CoinPurseDepositTransaction,
+} from "@gnu-taler/taler-util";
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { classifyPurseDepositCoinHistory } from "./purse-deposit-reconciliation.js";
+
+const args: Parameters<typeof classifyPurseDepositCoinHistory>[1] = {
+ coinPub: "coin",
+ contribution: "TESTKUDOS:2" as AmountString,
+ exchangeBaseUrl: "https://exchange.example/",
+ pursePub: "purse",
+};
+
+function deposit(
+ overrides: Partial<CoinPurseDepositTransaction> = {},
+): CoinPurseDepositTransaction {
+ return {
+ type: "PURSE-DEPOSIT",
+ amount: "TESTKUDOS:2",
+ deposit_fee: "TESTKUDOS:0.1",
+ exchange_base_url: args.exchangeBaseUrl,
+ purse_pub: args.pursePub,
+ refunded: false,
+ coin_sig: "sig",
+ h_denom_pub: "denom",
+ history_offset: 0,
+ ...overrides,
+ };
+}
+
+test("missing and refunded purse deposits are recoverable", () => {
+ assert.strictEqual(classifyPurseDepositCoinHistory([], args), "recoverable");
+ assert.strictEqual(
+ classifyPurseDepositCoinHistory([deposit({ refunded: true })], args),
+ "recoverable",
+ );
+});
+
+test("an authenticated unrefunded purse deposit remains committed", () => {
+ assert.strictEqual(
+ classifyPurseDepositCoinHistory([deposit()], args),
+ "committed",
+ );
+});
+
+test("purse deposit history rejects mismatches and duplicates", () => {
+ assert.throws(
+ () =>
+ classifyPurseDepositCoinHistory(
+ [deposit({ amount: "TESTKUDOS:3" })],
+ args,
+ ),
+ /does not match/,
+ );
+ assert.throws(
+ () => classifyPurseDepositCoinHistory([deposit(), deposit()], args),
+ /duplicate/,
+ );
+});
diff --git a/packages/taler-wallet-core/src/purse-deposit-reconciliation.ts b/packages/taler-wallet-core/src/purse-deposit-reconciliation.ts
@@ -0,0 +1,162 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountString,
+ Amounts,
+ CoinPurseDepositTransaction,
+ CoinRefreshRequest,
+ TalerError,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
+import { checkDbInvariant } from "@gnu-taler/taler-util";
+import { requireValidExchangeCoinHistory } from "./exchange-signatures.js";
+import { validateAndRecomputeCoinHistoryBalance } from "./refresh.js";
+import {
+ getDenomInfo,
+ WalletExecutionContext,
+ walletExchangeClient,
+} from "./wallet.js";
+
+export interface PurseDepositSelection {
+ coinPubs: string[];
+ contributions: AmountString[];
+}
+
+export type PurseDepositCoinOutcome = "recoverable" | "committed";
+
+export interface PurseDepositReconciliation {
+ recoverable: CoinRefreshRequest[];
+ committedCoinPubs: Set<string>;
+}
+
+export function classifyPurseDepositCoinHistory(
+ history: CoinPurseDepositTransaction[],
+ args: {
+ coinPub: string;
+ contribution: AmountString;
+ exchangeBaseUrl: string;
+ pursePub: string;
+ },
+): PurseDepositCoinOutcome {
+ const purseDeposits = history.filter(
+ (item) => item.purse_pub === args.pursePub,
+ );
+ if (purseDeposits.length > 1) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ "coin history contains duplicate deposits for the purse",
+ );
+ }
+ const purseDeposit = purseDeposits[0];
+ if (!purseDeposit) {
+ return "recoverable";
+ }
+ if (
+ purseDeposit.exchange_base_url !== args.exchangeBaseUrl ||
+ Amounts.cmp(purseDeposit.amount, args.contribution) !== 0
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ `coin ${args.coinPub} history does not match the wallet's purse deposit`,
+ );
+ }
+ return purseDeposit.refunded ? "recoverable" : "committed";
+}
+
+/**
+ * Determine whether each selected coin is reusable after the purse itself is
+ * no longer available. The caller must only use this after a signed purse
+ * outcome or an authenticated 404/410: a live, unmerged purse can still
+ * refund a currently committed deposit later.
+ */
+export async function reconcilePurgedPurseDeposits(
+ wex: WalletExecutionContext,
+ args: {
+ exchangeBaseUrl: string;
+ pursePub: string;
+ selection: PurseDepositSelection;
+ },
+): Promise<PurseDepositReconciliation> {
+ if (args.selection.coinPubs.length !== args.selection.contributions.length) {
+ throw Error("invalid persisted purse deposit coin selection");
+ }
+ const loaded = await wex.runWalletDbTx(async (tx) => {
+ const result = [];
+ for (const coinPub of args.selection.coinPubs) {
+ const coin = await tx.getCoin(coinPub);
+ checkDbInvariant(!!coin, `selected purse coin ${coinPub} is missing`);
+ const denomination = await getDenomInfo(wex, tx, coin);
+ checkDbInvariant(
+ !!denomination,
+ `denomination for selected purse coin ${coinPub} is missing`,
+ );
+ result.push({ coin, denomination });
+ }
+ return result;
+ });
+
+ const recoverable: CoinRefreshRequest[] = [];
+ const committedCoinPubs = new Set<string>();
+ const exchangeClient = walletExchangeClient(args.exchangeBaseUrl, wex);
+ for (let i = 0; i < loaded.length; i++) {
+ const { coin, denomination } = loaded[i];
+ const historySig = await wex.cryptoApi.signCoinHistoryRequest({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ startOffset: 0,
+ });
+ const historyResp = await exchangeClient.getCoinHistory(
+ coin.coinPub,
+ historySig.sig,
+ );
+ await requireValidExchangeCoinHistory(wex, {
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ coinPub: coin.coinPub,
+ denomination,
+ response: historyResp.body,
+ });
+ validateAndRecomputeCoinHistoryBalance(
+ coin.denomPubHash,
+ denomination.value,
+ historyResp.body,
+ );
+
+ const outcome = classifyPurseDepositCoinHistory(
+ historyResp.body.history.filter(
+ (item): item is CoinPurseDepositTransaction =>
+ item.type === "PURSE-DEPOSIT",
+ ),
+ {
+ coinPub: coin.coinPub,
+ contribution: args.selection.contributions[i],
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ pursePub: args.pursePub,
+ },
+ );
+ if (outcome === "recoverable") {
+ recoverable.push({
+ coinPub: coin.coinPub,
+ amount: args.selection.contributions[i],
+ });
+ } else {
+ committedCoinPubs.add(coin.coinPub);
+ }
+ }
+ return { recoverable, committedCoinPubs };
+}