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