taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/balance.ts | 2++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 412++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.test.ts | 13+++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 92+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Apackages/taler-wallet-core/src/purse-deposit-reconciliation.test.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/purse-deposit-reconciliation.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 }; +}