commit 68ad6cae6ce5496150171dd0ce05afaae2e81d42
parent 33b1ea8d0e5159d4d9b2ffac016d72e2daa417fc
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:49 +0200
wallet-core: reconstruct refund recovery handoffs
Diffstat:
2 files changed, 106 insertions(+), 31 deletions(-)
diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts
@@ -17,7 +17,11 @@
import { HttpStatusCode } from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
-import { depositRefundStatusIsRetryable } from "./deposits.js";
+import { DepositElementStatus, WalletDepositGroup } from "./db-common.js";
+import {
+ depositRefundStatusIsRetryable,
+ reconstructDepositRefundRequests,
+} from "./deposits.js";
test("deposit abort retries transient refund responses", () => {
assert.strictEqual(
@@ -41,3 +45,45 @@ test("deposit abort retries transient refund responses", () => {
false,
);
});
+
+test("deposit abort reconstructs 404 refund handoffs after restart", async () => {
+ const signed: unknown[] = [];
+ const wex = {
+ cryptoApi: {
+ async signRefund(req: unknown) {
+ signed.push(req);
+ return { sig: "merchant-refund-signature" };
+ },
+ },
+ };
+ const depositGroup = {
+ contractTermsHash: "contract-hash",
+ merchantPriv: "merchant-private-key",
+ merchantPub: "merchant-public-key",
+ payCoinSelection: {
+ coinContributions: ["TESTKUDOS:2", "TESTKUDOS:3"],
+ coinPubs: ["coin-with-404", "coin-already-refunded"],
+ },
+ statusPerCoin: [
+ DepositElementStatus.RefundNotFound,
+ DepositElementStatus.RefundSuccess,
+ ],
+ } as unknown as WalletDepositGroup;
+
+ const requests = await reconstructDepositRefundRequests(
+ wex as any,
+ depositGroup,
+ );
+
+ assert.deepStrictEqual(requests, [
+ {
+ h_contract_terms: "contract-hash",
+ merchant_pub: "merchant-public-key",
+ merchant_sig: "merchant-refund-signature",
+ refund_amount: "TESTKUDOS:2",
+ rtransaction_id: 1,
+ },
+ undefined,
+ ]);
+ assert.strictEqual(signed.length, 1);
+});
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -96,7 +96,6 @@ import {
prepareTransferOptionsRaw,
spendCoins,
} from "./common.js";
-import { requireValidDirectExchangeRefundConfirmation } from "./exchange-signatures.js";
import {
DepositElementStatus,
DepositOperationStatus,
@@ -125,6 +124,7 @@ import {
markExchangeUsed,
} from "./exchanges.js";
import {
+ requireValidDirectExchangeRefundConfirmation,
requireValidExchangeDepositConfirmation,
requireValidExchangeWireConfirmation,
} from "./exchange-signatures.js";
@@ -882,6 +882,56 @@ export function computeDepositTransactionActions(
}
}
+async function makeDepositRefundRequest(
+ wex: WalletExecutionContext,
+ depositGroup: WalletDepositGroup,
+ coinPub: string,
+ refundAmount: AmountString,
+): Promise<ExchangeRefundRequest> {
+ // We use a constant refund transaction ID, since there can only be one
+ // refund for this contract.
+ const rtransactionId = 1;
+ const sig = await wex.cryptoApi.signRefund({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ refundAmount,
+ rtransactionId,
+ });
+ return {
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_pub: depositGroup.merchantPub,
+ merchant_sig: sig.sig,
+ refund_amount: refundAmount,
+ rtransaction_id: rtransactionId,
+ };
+}
+
+export async function reconstructDepositRefundRequests(
+ wex: WalletExecutionContext,
+ depositGroup: WalletDepositGroup,
+): Promise<Array<ExchangeRefundRequest | undefined>> {
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin || !payCoinSelection) {
+ return [];
+ }
+ return await Promise.all(
+ statusPerCoin.map((status, coinIndex) => {
+ if (status !== DepositElementStatus.RefundNotFound) {
+ return undefined;
+ }
+ return makeDepositRefundRequest(
+ wex,
+ depositGroup,
+ payCoinSelection.coinPubs[coinIndex],
+ payCoinSelection.coinContributions[coinIndex],
+ );
+ }),
+ );
+}
+
async function refundDepositGroup(
wex: WalletExecutionContext,
depositGroup: WalletDepositGroup,
@@ -905,8 +955,12 @@ async function refundDepositGroup(
let newTxPerCoin = [...statusPerCoin];
// Refunds that might need to be handed off to the refresh,
// as we don't know if deposit request will still arrive
- // before doing the refresh.
- const refundReqPerCoin: ExchangeRefundRequest[] = Array(newTxPerCoin.length);
+ // before doing the refresh. Reconstruct already-persisted 404 results so
+ // the handoff survives task retries and wallet restarts.
+ const refundReqPerCoin = await reconstructDepositRefundRequests(
+ wex,
+ depositGroup,
+ );
const coins = await wex.runWalletDbTx((tx) =>
tx.getCoinsByPubs(payCoinSelection.coinPubs),
);
@@ -915,26 +969,8 @@ async function refundDepositGroup(
const makeRefundRequest = async (
coinPub: string,
refundAmount: AmountString,
- ): Promise<ExchangeRefundRequest> => {
- // We use a constant refund transaction ID, since there can only be one
- // refund for this contract.
- const rtransactionId = 1;
- const sig = await wex.cryptoApi.signRefund({
- coinPub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- refundAmount,
- rtransactionId,
- });
- return {
- h_contract_terms: depositGroup.contractTermsHash,
- merchant_pub: depositGroup.merchantPub,
- merchant_sig: sig.sig,
- refund_amount: refundAmount,
- rtransaction_id: rtransactionId,
- };
- };
+ ): Promise<ExchangeRefundRequest> =>
+ makeDepositRefundRequest(wex, depositGroup, coinPub, refundAmount);
for (let i = 0; i < statusPerCoin.length; i++) {
const st = statusPerCoin[i];
@@ -943,13 +979,6 @@ async function refundDepositGroup(
case DepositElementStatus.RefundSuccess:
break;
case DepositElementStatus.RefundNotFound: {
- // This request is needed if refresh later discovers that the deposit
- // raced with the 404. Reconstruct it after retries and restarts
- // instead of relying on the transient in-memory array.
- refundReqPerCoin[i] = await makeRefundRequest(
- payCoinSelection.coinPubs[i],
- payCoinSelection.coinContributions[i],
- );
break;
}
default: {