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