taler-typescript-core

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

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

wallet-core: release merchant token reservations safely

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/pay-merchant.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/tokenSelection.test.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/tokenSelection.ts | 38+++++++++++++++++++++++++++++++++++---
4 files changed, 186 insertions(+), 5 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -13,10 +13,20 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, AmountString, SelectedCoin } from "@gnu-taler/taler-util"; +import { + Amounts, + AmountString, + SelectedCoin, + TransactionIdStr, +} from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { getCoinsToSpendForMerchantRepair } from "./pay-merchant.js"; +import { WalletToken } from "./db-common.js"; +import { WalletDbTransaction } from "./dbtx.js"; +import { + getCoinsToSpendForMerchantRepair, + releasePaymentTokensInTx, +} from "./pay-merchant.js"; function makeSelectedCoin( coinPub: string, @@ -50,3 +60,44 @@ test("merchant repair spends only newly selected coins", () => { assert.deepStrictEqual(toSpend, [added]); }); + +test("payment token release is ownership-checked and idempotent", async () => { + const currentTransaction = "payment:current" as TransactionIdStr; + const tokens = new Map<string, WalletToken>([ + [ + "owned", + { + tokenUsePub: "owned", + transactionId: currentTransaction, + tokenUseSig: { token_pub: "owned" }, + } as unknown as WalletToken, + ], + [ + "other", + { + tokenUsePub: "other", + transactionId: "payment:other", + } as unknown as WalletToken, + ], + ]); + const updates: string[] = []; + const tx = { + getToken: async (pub: string) => tokens.get(pub), + upsertToken: async (token: WalletToken) => { + updates.push(token.tokenUsePub); + tokens.set(token.tokenUsePub, token); + }, + } as WalletDbTransaction; + + await releasePaymentTokensInTx(tx, currentTransaction, [ + "owned", + "other", + "missing", + ]); + await releasePaymentTokensInTx(tx, currentTransaction, ["owned"]); + + assert.strictEqual(tokens.get("owned")?.transactionId, undefined); + assert.strictEqual(tokens.get("owned")?.tokenUseSig, undefined); + assert.strictEqual(tokens.get("other")?.transactionId, "payment:other"); + assert.deepStrictEqual(updates, ["owned"]); +}); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -464,6 +464,11 @@ export class PayMerchantTransactionContext implements TransactionContext { ); await otherCtx.deleteTransactionInTx(tx, { keepRelated: true }); } + await releasePaymentTokensInTx( + tx, + this.transactionId, + rec.payInfo?.payTokenSelection?.tokenPubs ?? [], + ); await h.update( undefined, opts.causeHint ?? "delete", @@ -1890,6 +1895,10 @@ async function reselectCoinsTx( proposalId: p.proposalId, choiceIndex: p.choiceIndex, contractTerms: contractData.contractTerms, + existingReservation: { + transactionId: ctx.transactionId, + tokenPubs: prevTokensPubs, + }, }); switch (res.type) { @@ -1906,6 +1915,12 @@ async function reselectCoinsTx( p.payInfo.payTokenSelection = { tokenPubs: res.tokens.map((t) => t.tokenUsePub), }; + const selectedTokenPubs = new Set(p.payInfo.payTokenSelection.tokenPubs); + await releasePaymentTokensInTx( + tx, + ctx.transactionId, + prevTokensPubs.filter((x) => !selectedTokenPubs.has(x)), + ); } await tx.upsertPurchase(p); @@ -4307,6 +4322,11 @@ async function processPurchaseAbortingRefund( if (purchase.payInfo?.payCoinSelection != null) { return; } + await releasePaymentTokensInTx( + tx, + ctx.transactionId, + rec.payInfo?.payTokenSelection?.tokenPubs ?? [], + ); rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; await h.update(rec, "abort-unpaid"); }); @@ -4505,6 +4525,11 @@ async function waitForRefreshOnAbortedPayment( if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) { return false; } + await releasePaymentTokensInTx( + tx, + ctx.transactionId, + rec.payInfo?.payTokenSelection?.tokenPubs ?? [], + ); rec.purchaseStatus = finalStatus; await h.update(rec, "abort-refresh-done"); return true; @@ -4513,6 +4538,29 @@ async function waitForRefreshOnAbortedPayment( return didTransition ? TaskRunResult.progress() : TaskRunResult.backoff(); } +/** + * Release token reservations owned by one payment. + * + * The ownership check keeps retries and stale cleanup calls from making a + * token allocated by another transaction available. A token-use signature + * is contract-specific and must not survive release. + */ +export async function releasePaymentTokensInTx( + tx: WalletDbTransaction, + transactionId: TransactionIdStr, + tokenPubs: string[], +): Promise<void> { + for (const tokenPub of tokenPubs) { + const token = await tx.getToken(tokenPub); + if (token?.transactionId !== transactionId) { + continue; + } + delete token.transactionId; + delete token.tokenUseSig; + await tx.upsertToken(token); + } +} + async function processPurchaseQueryRefund( wex: WalletExecutionContext, purchase: WalletPurchase, diff --git a/packages/taler-wallet-core/src/tokenSelection.test.ts b/packages/taler-wallet-core/src/tokenSelection.test.ts @@ -16,12 +16,15 @@ import { MerchantContractTokenKind, TalerProtocolTimestamp, + TransactionIdStr, } from "@gnu-taler/taler-util"; import { test } from "node:test"; import assert from "node:assert"; import { WalletToken } from "./db-common.js"; +import { timestampProtocolToDb } from "./db-common.js"; import { isTokenValidBetween, + selectTokenCandidates, TokenMerchantVerificationResult, verifyTokenMerchant, } from "./tokenSelection.js"; @@ -199,3 +202,50 @@ test("a token is valid between two points only if it covers both", (t) => { // The validity window is strictly inside the requested interval. assert.strictEqual(isTokenValidBetween(tok, at(900), at(2100)), false); }); + +function selectableToken( + tokenUsePub: string, + transactionId?: string, +): WalletToken { + return { + tokenUsePub, + transactionId, + merchantBaseUrl: "https://merchant.example/", + extraData: { + class: MerchantContractTokenKind.Discount, + expected_domains: [], + }, + validAfter: timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(0)), + validBefore: timestampProtocolToDb(TalerProtocolTimestamp.never()), + } as unknown as WalletToken; +} + +test("payment repair retains its tokens and excludes other reservations", () => { + const retained = selectableToken("retained", "payment:current"); + const free = selectableToken("free"); + const reservedElsewhere = selectableToken("other", "payment:other"); + + const result = selectTokenCandidates( + { + family: { + records: [free, reservedElsewhere, retained], + requested: 1, + }, + }, + 1, + "https://merchant.example/", + { + transactionId: "payment:current" as TransactionIdStr, + tokenPubs: [retained.tokenUsePub, reservedElsewhere.tokenUsePub], + }, + ); + + assert.strictEqual(result.type, "success"); + if (result.type === "success") { + assert.deepStrictEqual( + result.tokens.map((x) => x.tokenUsePub), + [retained.tokenUsePub], + ); + assert.strictEqual(result.details.tokensAvailable, 2); + } +}); diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -29,6 +29,7 @@ import { TalerErrorCode, TalerProtocolTimestamp, TokenAvailabilityHint, + TransactionIdStr, } from "@gnu-taler/taler-util"; import { timestampProtocolFromDb, WalletToken } from "./db-common.js"; import { WalletDbTransaction } from "./dbtx.js"; @@ -40,6 +41,16 @@ export interface SelectPayTokensRequest { proposalId: string; choiceIndex: number; contractTerms: MerchantContractTermsV1; + + /** + * Tokens already reserved by this payment. Repair selection may retain + * these tokens even though tokens reserved by other transactions remain + * unavailable. + */ + existingReservation?: { + transactionId: TransactionIdStr; + tokenPubs: string[]; + }; } export interface SelectPayTokensAllChoicesRequest { @@ -232,6 +243,7 @@ export async function selectPayTokensInTx( inputTokens, tokensRequested, proposal.merchantBaseUrl, + req.existingReservation, ); } @@ -253,7 +265,15 @@ export function selectTokenCandidates( }, tokensRequested: number, merchantBaseUrl: string, + existingReservation?: { + transactionId: TransactionIdStr; + tokenPubs: string[]; + }, ): SelectPayTokensResult { + const previousTokenPubSet = new Set(existingReservation?.tokenPubs ?? []); + const isPreviousToken = (tok: WalletToken): boolean => + tok.transactionId === existingReservation?.transactionId && + previousTokenPubSet.has(tok.tokenUsePub); const details: PaymentTokenAvailabilityDetails = { tokensRequested, tokensAvailable: 0, @@ -276,13 +296,16 @@ export function selectTokenCandidates( }; // Selection algorithm: - // - filter out spent tokens (i.e. no transactionId) + // - filter out tokens reserved by another transaction // - filter out expired/not-yet-valid tokens // - filter out tokens with errors // - sort ascending by expiration date // - choose the first n tokens in the list const usable = records - .filter((tok) => !isTokenInUse(tok)) + .filter( + (tok) => + !isTokenInUse(tok) || isPreviousToken(tok), + ) .filter((tok) => isTokenValid(tok)) .filter((tok) => { const res = verifyTokenMerchant( @@ -303,7 +326,16 @@ export function selectTokenCandidates( assertUnreachable(res); } }) - .sort((a, b) => a.validBefore - b.validBefore); + .sort((a, b) => { + // Keep an existing reservation stable during payment repair. Using a + // different free token would otherwise strand the old reservation. + const aPrevious = isPreviousToken(a); + const bPrevious = isPreviousToken(b); + if (aPrevious !== bPrevious) { + return aPrevious ? -1 : 1; + } + return a.validBefore - b.validBefore; + }); details.perTokenFamily[slug].available = usable.length; details.tokensAvailable += details.perTokenFamily[slug].available;