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