taler-typescript-core

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

commit f182fcceb124d47ff86e13801137085d0092182e
parent 73a08edbc2e2fe9b2f5ef4aaaff3d545656ccb49
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:44 +0200

wallet-core: finalize merchant payment tokens atomically

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 38++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
2 files changed, 153 insertions(+), 76 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -17,6 +17,7 @@ import { Amounts, AmountString, SelectedCoin, + SignedTokenEnvelope, TransactionIdStr, } from "@gnu-taler/taler-util"; import assert from "node:assert"; @@ -26,6 +27,7 @@ import { WalletDbTransaction } from "./dbtx.js"; import { getCoinsToSpendForMerchantRepair, releasePaymentTokensInTx, + splitPaymentOutputTokenSignatures, } from "./pay-merchant.js"; function makeSelectedCoin( @@ -101,3 +103,39 @@ test("payment token release is ownership-checked and idempotent", async () => { assert.strictEqual(tokens.get("other")?.transactionId, "payment:other"); assert.deepStrictEqual(updates, ["owned"]); }); + +function outputSig(id: string): SignedTokenEnvelope { + return { blind_sig: { id } } as unknown as SignedTokenEnvelope; +} + +test("payment output signatures are mandatory and partitioned exactly", () => { + const slateSig = outputSig("slate"); + const donauSig = outputSig("donau"); + + assert.deepStrictEqual( + splitPaymentOutputTokenSignatures([slateSig, donauSig], 1, 1), + { + slateTokenSigs: [slateSig], + donauTokenSigs: [donauSig], + }, + ); + assert.throws( + () => splitPaymentOutputTokenSignatures(undefined, 1, 0), + /returned 0 token signatures, expected 1/, + ); + assert.throws( + () => splitPaymentOutputTokenSignatures([slateSig, donauSig], 1, 0), + /returned 2 token signatures, expected 1/, + ); +}); + +test("cached output signatures recover an older partial finalization", () => { + const cached = outputSig("cached"); + assert.deepStrictEqual( + splitPaymentOutputTokenSignatures(undefined, 1, 0, [cached]), + { + slateTokenSigs: [cached], + donauTokenSigs: [], + }, + ); +}); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1708,6 +1708,12 @@ async function storeFirstPaySuccess( proposalId: string, sessionId: string | undefined, payResponse: MerchantPayResponse, + tokenFinalization?: { + inputTokenPubs: string[]; + outputTokens: WalletToken[]; + outputSlates: WalletSlate[]; + slateTokenSigs: SignedTokenEnvelope[]; + }, ): Promise<void> { const ctx = new PayMerchantTransactionContext(wex, proposalId); const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); @@ -1721,8 +1727,33 @@ async function storeFirstPaySuccess( logger.warn("payment success already stored"); return; } - if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) { - purchase.purchaseStatus = PurchaseStatus.Done; + + if (tokenFinalization) { + for (let i = 0; i < tokenFinalization.outputTokens.length; i++) { + await tx.upsertToken(tokenFinalization.outputTokens[i]); + await tx.deleteSlate(tokenFinalization.outputSlates[i].tokenUsePub); + } + for (const tokenPub of tokenFinalization.inputTokenPubs) { + const token = await tx.getToken(tokenPub); + if (token && token.transactionId !== ctx.transactionId) { + throw Error( + `payment input token ${tokenPub} is reserved by another transaction`, + ); + } + await tx.deleteToken(tokenPub); + } + const payInfo = purchase.payInfo; + checkDbInvariant(!!payInfo, `purchase ${purchase.orderId} without payInfo`); + payInfo.slateTokenSigs = tokenFinalization.slateTokenSigs; + } + + let completePaymentState = false; + switch (purchase.purchaseStatus) { + case PurchaseStatus.PendingPaying: + case PurchaseStatus.SuspendedPaying: + purchase.purchaseStatus = PurchaseStatus.Done; + completePaymentState = true; + break; } purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now); purchase.lastSessionId = sessionId; @@ -1742,7 +1773,7 @@ async function storeFirstPaySuccess( contractTermsRecord.contractTermsRaw, ); const protoAr = contractTerms.auto_refund; - if (protoAr && protoAr.d_us != 0) { + if (completePaymentState && protoAr && protoAr.d_us != 0) { const ar = Duration.fromTalerProtocolDuration(protoAr); logger.info("auto_refund present"); purchase.purchaseStatus = PurchaseStatus.FinalizingQueryingAutoRefund; @@ -1756,6 +1787,45 @@ async function storeFirstPaySuccess( }); } +export function splitPaymentOutputTokenSignatures( + responseTokenSigs: SignedTokenEnvelope[] | undefined, + slateCount: number, + donauCount: number, + cachedSlateSigs?: SignedTokenEnvelope[], +): { + slateTokenSigs: SignedTokenEnvelope[]; + donauTokenSigs: SignedTokenEnvelope[]; +} { + const tokenSigs = responseTokenSigs ?? []; + const expectedCount = slateCount + donauCount; + + if ( + cachedSlateSigs && + cachedSlateSigs.length === slateCount && + tokenSigs.length === 0 && + donauCount === 0 + ) { + // Recover databases written by older wallets that committed the slate + // signatures before committing payment success. + return { + slateTokenSigs: cachedSlateSigs, + donauTokenSigs: [], + }; + } + + if (tokenSigs.length !== expectedCount) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + `merchant returned ${tokenSigs.length} token signatures, expected ${expectedCount}`, + ); + } + return { + slateTokenSigs: tokenSigs.slice(0, slateCount), + donauTokenSigs: tokenSigs.slice(slateCount), + }; +} + async function storePayReplaySuccess( wex: WalletExecutionContext, proposalId: string, @@ -3342,68 +3412,28 @@ async function processPurchasePay( throw Error("merchant payment signature invalid"); } - // Here we make the assumption that slate signatures always - // come before donau signatures. - // But the merchant API should be improved - // to separate them. - - /** Start index of processed outpok tokens. */ - let outTokOffset = 0; - logger.trace(`have slates: ${slates?.length}`); + const slateList = slates ?? []; + const donauPlanchetList = donauPlanchets ?? []; + const { slateTokenSigs, donauTokenSigs } = + splitPaymentOutputTokenSignatures( + merchantResp.token_sigs, + slateList.length, + donauPlanchetList.length, + payInfo.slateTokenSigs, + ); - let tokenSigs: SignedTokenEnvelope[] | undefined; - if (payInfo.slateTokenSigs) { - tokenSigs = payInfo.slateTokenSigs; - outTokOffset = payInfo.slateTokenSigs.length; - } else if ( - slates && - slates.length > 0 && - merchantResp.token_sigs && - merchantResp.token_sigs.length > 0 - ) { - const slatesLen = slates.length; - const sigsLen = merchantResp.token_sigs.length; - logger.trace(`received ${sigsLen} token signatures from merchant`); - if (slatesLen > sigsLen) { - throw Error( - `merchant returned mismatching number of token signatures (${slatesLen} vs ${sigsLen})`, - ); - } - tokenSigs = merchantResp.token_sigs.slice(0, slatesLen); - outTokOffset = slatesLen; - } - - if (tokenSigs) { - await wex.runWalletDbTx(async (tx) => { - if (!purchase.payInfo) { - return; - } - purchase.payInfo.slateTokenSigs = tokenSigs; - tx.upsertPurchase(purchase); - }); - } - - // store token outputs - if (slates && tokenSigs) { - for (let i = 0; i < slates.length; i++) { - const slate = slates[i]; - const sigEv = tokenSigs[i]; - await validateAndStoreToken(wex, slate, sigEv); - } - } + // Verify every token output before making any of the payment's token + // state durable. The verified records, consumed inputs and payment + // success are committed together below. + const outputTokens = await Promise.all( + slateList.map((slate, i) => + validateToken(wex, slate, slateTokenSigs[i]), + ), + ); - if (donauPlanchets && merchantResp.token_sigs) { - const donauBlindedSigs = merchantResp.token_sigs.slice( - outTokOffset, - outTokOffset + donauPlanchets.length, - ); - if (donauPlanchets.length != donauBlindedSigs.length) { - throw Error( - `wrong number of donau signatures (planchets ${donauPlanchets.length} vs sigs ${donauBlindedSigs.length}`, - ); - } - logger.info(`got ${donauPlanchets.length} donau sigs`); + if (donauPlanchetList.length > 0) { + logger.info(`got ${donauPlanchetList.length} donau sigs`); const donauUrl = purchase.donauBaseUrl; if (!donauUrl) { throw Error("bad db: no donau URL"); @@ -3411,17 +3441,17 @@ async function processPurchasePay( await acceptDonauBlindSigs( wex, donauUrl, - donauPlanchets, - donauBlindedSigs, + donauPlanchetList, + donauTokenSigs, ); } - // cleanup token inputs - if (payInfo.payTokenSelection?.tokenPubs) { - await cleanupUsedTokens(wex, payInfo.payTokenSelection.tokenPubs); - } - - await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp); + await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp, { + inputTokenPubs: payInfo.payTokenSelection?.tokenPubs ?? [], + outputTokens, + outputSlates: slateList, + slateTokenSigs, + }); } else if (!sessionId) { // There is no session to bind the payment to, and /paid only makes // sense for an actual session (the merchant rejects an empty session @@ -3484,6 +3514,20 @@ export async function validateAndStoreToken( slate: WalletSlate, blindedEv: SignedTokenEnvelope, ): Promise<void> { + const token = await validateToken(wex, slate, blindedEv); + + // insert token and delete slate + await wex.runWalletDbTx(async (tx) => { + await tx.upsertToken(token); + await tx.deleteSlate(slate.tokenUsePub); + }); +} + +export async function validateToken( + wex: WalletExecutionContext, + slate: WalletSlate, + blindedEv: SignedTokenEnvelope, +): Promise<WalletToken> { const { tokenIssuePub, tokenIssuePubHash, tokenUsePub, blindingKey } = slate; logger.trace( `validating token ${tokenIssuePubHash} for purchase ${slate.purchaseId}`, @@ -3524,12 +3568,7 @@ export async function validateAndStoreToken( tokenIssueSig, ...slate, }; - - // insert token and delete slate - await wex.runWalletDbTx(async (tx) => { - await tx.upsertToken(token); - await tx.deleteSlate(slate.tokenUsePub); - }); + return token; } export async function generateTokenSigs(