taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/deposits.test.ts | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/deposits.ts | 89++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
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: {