taler-typescript-core

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

commit 68290f749950cba50d55b3def5ddea240ac4bb99
parent 68ad6cae6ce5496150171dd0ce05afaae2e81d42
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:49 +0200

wallet-core: keep payment token finalization crash-atomic

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 41++++++++++++++++++++++++++++-------------
2 files changed, 146 insertions(+), 31 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -20,6 +20,7 @@ import { SignedTokenEnvelope, TransactionAction, TransactionIdStr, + TalerPreciseTimestamp, } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; @@ -28,10 +29,12 @@ import { RefundGroupStatus, WalletPurchase, WalletRefundGroup, + WalletSlate, WalletToken, } from "./db-common.js"; import { WalletDbTransaction } from "./dbtx.js"; import { + applyFirstPaySuccessState, computePayMerchantTransactionActions, getCoinsToSpendForMerchantRepair, getPayMerchantAbortTransition, @@ -42,8 +45,10 @@ import { releasePaymentTokensInTx, setRefundGroupEffectiveAmount, splitPaymentOutputTokenSignatures, + storeFirstPaySuccess, validateClaimResponseBindings, } from "./pay-merchant.js"; +import { makeIdbRunner } from "./dbtx-runners.js"; function makeSelectedCoin( coinPub: string, @@ -59,10 +64,7 @@ function makeSelectedCoin( } test("merchant repair spends only newly selected coins", () => { - const retained = makeSelectedCoin( - "retained", - "TESTKUDOS:1" as AmountString, - ); + const retained = makeSelectedCoin("retained", "TESTKUDOS:1" as AmountString); const added = makeSelectedCoin("added", "TESTKUDOS:2" as AmountString); const toSpend = getCoinsToSpendForMerchantRepair( @@ -153,13 +155,7 @@ test("payment output signatures are mandatory and partitioned exactly", () => { test("cached output signatures recover an older partial finalization", () => { const cached = outputSig("cached"); assert.deepStrictEqual( - splitPaymentOutputTokenSignatures( - undefined, - [0], - undefined, - 0, - [cached], - ), + splitPaymentOutputTokenSignatures(undefined, [0], undefined, 0, [cached]), { slateTokenSigs: [cached], donauTokenSigs: [], @@ -203,9 +199,7 @@ function refundGroup( test("refund totals and effective amounts reflect completed items", () => { const first = refundGroup("first", "TESTKUDOS:3", "TESTKUDOS:0"); - setRefundGroupEffectiveAmount(first, [ - Amounts.parseOrThrow("TESTKUDOS:2.5"), - ]); + setRefundGroupEffectiveAmount(first, [Amounts.parseOrThrow("TESTKUDOS:2.5")]); const second = refundGroup("second", "TESTKUDOS:1", "TESTKUDOS:0.5"); assert.deepStrictEqual(getRefundTotals([first, second], "TESTKUDOS"), { @@ -256,13 +250,13 @@ test("payment replay waits for the requested session to finish", () => { "timestampFirstSuccessfulPay" | "lastSessionId" | "purchaseStatus" >; + assert.strictEqual(isPaymentSessionComplete(purchase, "new-session"), false); + purchase.purchaseStatus = PurchaseStatus.Done; + assert.strictEqual(isPaymentSessionComplete(purchase, "new-session"), true); assert.strictEqual( - isPaymentSessionComplete(purchase, "new-session"), + isPaymentSessionComplete(purchase, "other-session"), false, ); - purchase.purchaseStatus = PurchaseStatus.Done; - assert.strictEqual(isPaymentSessionComplete(purchase, "new-session"), true); - assert.strictEqual(isPaymentSessionComplete(purchase, "other-session"), false); }); test("every advertised merchant action has a state transition", () => { @@ -295,3 +289,109 @@ test("every advertised merchant action has a state transition", () => { } } }); + +test("a delayed payment success preserves a concurrent abort state", () => { + const purchase = { + purchaseStatus: PurchaseStatus.AbortingWithRefund, + } as WalletPurchase; + + const completed = applyFirstPaySuccessState( + purchase, + "session", + { sig: "merchant-payment-signature" } as any, + TalerPreciseTimestamp.fromSeconds(1_800_000_000), + ); + + assert.strictEqual(completed, false); + assert.strictEqual( + purchase.purchaseStatus, + PurchaseStatus.AbortingWithRefund, + ); + assert.strictEqual(purchase.merchantPaySig, "merchant-payment-signature"); + assert.strictEqual(purchase.lastSessionId, "session"); +}); + +test("payment token finalization rolls back with payment success", async () => { + const runner = await makeIdbRunner(); + const proposalId = "atomic-payment"; + const transactionId = `txn:payment:${proposalId}` as TransactionIdStr; + const inputToken = { + tokenUsePub: "input-token", + transactionId, + } as unknown as WalletToken; + const outputToken = { + tokenUsePub: "output-token", + } as unknown as WalletToken; + const slate = { tokenUsePub: "output-slate" } as unknown as WalletSlate; + const purchase = { + proposalId, + orderId: "order", + purchaseStatus: PurchaseStatus.PendingPaying, + timestampFirstSuccessfulPay: undefined, + payInfo: { + slateTokenSigs: undefined, + totalPayCost: "TESTKUDOS:1", + }, + download: { contractTermsHash: "contract-hash" }, + } as WalletPurchase; + try { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertPurchase(purchase); + await tx.upsertToken(inputToken); + await tx.upsertSlate(slate); + }); + const wex = { + async runWalletDbTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runner.runReadWriteTx(async (tx) => { + const failingTx = new Proxy(tx, { + get(target, property, receiver) { + if (property === "getContractTerms") { + return async () => { + throw Error("injected post-token failure"); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + return await f(failingTx); + }); + }, + }; + + await assert.rejects( + storeFirstPaySuccess( + wex as any, + proposalId, + "session", + { sig: "merchant-payment-signature" } as any, + { + inputTokenPubs: [inputToken.tokenUsePub], + outputSlates: [slate], + outputTokens: [outputToken], + slateTokenSigs: [outputSig("issued")], + }, + ), + /injected post-token failure/, + ); + + const state = await runner.runReadWriteTx(async (tx) => ({ + input: await tx.getToken(inputToken.tokenUsePub), + output: await tx.getToken(outputToken.tokenUsePub), + purchase: await tx.getPurchase(proposalId), + slates: await tx.listAllSlates(), + })); + assert.ok(state.input); + assert.strictEqual(state.output, undefined); + assert.strictEqual(state.slates.length, 1); + assert.strictEqual(state.purchase?.timestampFirstSuccessfulPay, undefined); + assert.strictEqual( + state.purchase?.purchaseStatus, + PurchaseStatus.PendingPaying, + ); + } finally { + await runner.close(); + } +}); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1724,7 +1724,28 @@ export async function createOrReusePurchase( }; } -async function storeFirstPaySuccess( +export function applyFirstPaySuccessState( + purchase: WalletPurchase, + sessionId: string | undefined, + payResponse: MerchantPayResponse, + timestamp: TalerPreciseTimestamp, +): boolean { + let completePaymentState = false; + switch (purchase.purchaseStatus) { + case PurchaseStatus.PendingPaying: + case PurchaseStatus.SuspendedPaying: + purchase.purchaseStatus = PurchaseStatus.Done; + completePaymentState = true; + break; + } + purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(timestamp); + purchase.lastSessionId = sessionId; + purchase.merchantPaySig = payResponse.sig; + purchase.posConfirmation = payResponse.pos_confirmation; + return completePaymentState; +} + +export async function storeFirstPaySuccess( wex: WalletExecutionContext, proposalId: string, sessionId: string | undefined, @@ -1771,18 +1792,12 @@ async function storeFirstPaySuccess( 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; - purchase.merchantPaySig = payResponse.sig; - purchase.posConfirmation = payResponse.pos_confirmation; + const completePaymentState = applyFirstPaySuccessState( + purchase, + sessionId, + payResponse, + now, + ); const dl = purchase.download; checkDbInvariant( !!dl,