taler-typescript-core

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

commit f15a63c8861dc164c39b723658e4e5df3b6e1e8d
parent c91b5eae8adc0a42cfa81a7e6c442174e0ad8fb2
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 15:56:11 +0200

wallet-core: keep a withdrawal batch whole across retries

The exchange identifies a withdrawal by the hash over the whole batch.
Storing coins one transaction at a time and re-sending only the
missing planchets could turn a retry into a second reserve debit or an
unfulfillable request.

Diffstat:
Mpackages/taler-wallet-core/src/withdraw.ts | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
1 file changed, 81 insertions(+), 40 deletions(-)

diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -1815,6 +1815,7 @@ async function processPlanchetExchangeBatchRequest( const coinEvs: CoinEnvelope[] = []; const denomHashes: HashCode[] = []; let csCoinIdx: number | undefined; + let numPendingPlanchets = 0; checkDbInvariant( !!withdrawalGroup.instructedAmount, "missing instructed amount in withdrawal group", @@ -1835,10 +1836,6 @@ async function processPlanchetExchangeBatchRequest( if (!planchet) { continue; } - if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { - logger.warn("processPlanchet: planchet already withdrawn"); - continue; - } if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) { continue; } @@ -1851,6 +1848,14 @@ async function processPlanchetExchangeBatchRequest( logger.error("db inconsistent: denom for planchet not found"); continue; } + // A planchet that was already withdrawn stays in the batch: the + // exchange identifies a withdrawal by the hash over the whole batch, + // so leaving one out would turn a replay into a new withdrawal that + // debits the reserve again or fails for lack of funds. Its signature + // is simply not stored a second time. + if (planchet.planchetStatus !== PlanchetStatus.WithdrawalDone) { + numPendingPlanchets++; + } accAmount = Amounts.add(accAmount, denom.value).amount; accFee = Amounts.add(accFee, denom.feeWithdraw).amount; requestCoinIdxs.push(coinIdx); @@ -1874,6 +1879,13 @@ async function processPlanchetExchangeBatchRequest( coinIdxs: [], }; } + if (numPendingPlanchets == 0) { + logger.trace("withdrawal batch already fully withdrawn"); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } async function storeCoinError( errDetail: TalerErrorDetail, @@ -1989,12 +2001,22 @@ async function processPlanchetExchangeBatchRequest( } } -async function processPlanchetVerifyAndStoreCoin( +type PlanchetVerification = + | { type: "skipped"; coinIdx: number } + | { type: "invalid"; coinIdx: number } + | { type: "verified"; coinIdx: number; coin: WalletCoin }; + +/** + * Unblind and verify the exchange's signature for one planchet. + * + * Only reads the database; storing happens for the whole batch at once. + */ +async function verifyPlanchetSignature( wex: WalletExecutionContext, wgContext: WithdrawalGroupStatusInfo, coinIdx: number, resp: BlindedDenominationSignature, -): Promise<void> { +): Promise<PlanchetVerification> { const withdrawalGroup = wgContext.wgRecord; checkDbInvariant( withdrawalGroup.exchangeBaseUrl !== undefined, @@ -2002,7 +2024,7 @@ async function processPlanchetVerifyAndStoreCoin( ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; - logger.trace(`checking and storing planchet idx=${coinIdx}`); + logger.trace(`checking planchet idx=${coinIdx}`); const d = await wex.runWalletDbTx(async (tx) => { const planchet = await tx.getPlanchetByGroupAndIndex( withdrawalGroup.withdrawalGroupId, @@ -2012,7 +2034,6 @@ async function processPlanchetVerifyAndStoreCoin( return; } if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { - logger.warn("processPlanchet: planchet already withdrawn"); return; } const denomInfo = await getDenomInfo(wex, tx, { @@ -2030,7 +2051,7 @@ async function processPlanchetVerifyAndStoreCoin( }); if (!d) { - return; + return { type: "skipped", coinIdx }; } const transactionId = constructTransactionIdentifier({ @@ -2062,22 +2083,7 @@ async function processPlanchetVerifyAndStoreCoin( }); if (!verifyResp.valid) { - await wex.runWalletDbTx(async (tx) => { - const planchet = await tx.getPlanchetByGroupAndIndex( - withdrawalGroup.withdrawalGroupId, - coinIdx, - ); - if (!planchet) { - return; - } - planchet.lastError = makeErrorDetail( - TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID, - {}, - "invalid signature from the exchange after unblinding", - ); - await tx.upsertPlanchet(planchet); - }); - return; + return { type: "invalid", coinIdx }; } const coin: WalletCoin = { @@ -2101,21 +2107,57 @@ async function processPlanchetVerifyAndStoreCoin( maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, ageCommitmentProof: planchet.ageCommitmentProof, }; + return { type: "verified", coinIdx, coin }; +} - const planchetCoinPub = planchet.coinPub; - - wgContext.planchetsFinished.add(planchet.coinPub); - +/** + * Store the outcome of one withdrawal batch. + * + * All coins of the batch are committed together: the exchange replays a + * batch only as a whole, so a partially stored batch could not be completed + * by re-sending the rest. + */ +async function storeVerifiedPlanchets( + wex: WalletExecutionContext, + wgContext: WithdrawalGroupStatusInfo, + results: PlanchetVerification[], +): Promise<void> { + const withdrawalGroup = wgContext.wgRecord; + const storedCoinPubs: string[] = []; await wex.runWalletDbTx(async (tx) => { - const p = await tx.getPlanchet(planchetCoinPub); - if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) { - return; + for (const r of results) { + if (r.type === "skipped") { + continue; + } + const planchet = await tx.getPlanchetByGroupAndIndex( + withdrawalGroup.withdrawalGroupId, + r.coinIdx, + ); + if (!planchet) { + continue; + } + if (r.type === "invalid") { + planchet.lastError = makeErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID, + {}, + "invalid signature from the exchange after unblinding", + ); + await tx.upsertPlanchet(planchet); + continue; + } + if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { + continue; + } + planchet.planchetStatus = PlanchetStatus.WithdrawalDone; + planchet.lastError = undefined; + await tx.upsertPlanchet(planchet); + await makeCoinAvailable(wex, tx, r.coin); + storedCoinPubs.push(r.coin.coinPub); } - p.planchetStatus = PlanchetStatus.WithdrawalDone; - p.lastError = undefined; - await tx.upsertPlanchet(p); - await makeCoinAvailable(wex, tx, coin); }); + for (const coinPub of storedCoinPubs) { + wgContext.planchetsFinished.add(coinPub); + } } /** @@ -3004,15 +3046,14 @@ async function processWithdrawalGroupPendingReady( i += maxBatchSize; } - let work: Promise<void>[] = []; - work = []; + const work: Promise<PlanchetVerification>[] = []; for (let j = 0; j < resp.coinIdxs.length; j++) { if (!resp.batchResp.ev_sigs[j]) { // response may not be available when there is kyc needed continue; } work.push( - processPlanchetVerifyAndStoreCoin( + verifyPlanchetSignature( wex, wgContext, resp.coinIdxs[j], @@ -3020,7 +3061,7 @@ async function processWithdrawalGroupPendingReady( ), ); } - await Promise.all(work); + await storeVerifiedPlanchets(wex, wgContext, await Promise.all(work)); } let redenomRequired = false;