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