taler-typescript-core

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

commit b4cb7fc3aefe898757adcee063e34d0b96c87377
parent 7215c15997c5da134d22cdca0188cccf4f672330
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 15:41:17 +0200

wallet-core: refresh every coin of a refunded pull purse

The exchange gives all deposits back when a purse expires or is
deleted, and an unpaid pull purse carries a merge timestamp from
creation, so neither the confirmed prefix nor that timestamp says the
payee got the money.

Diffstat:
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.test.ts | 31+++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 93 insertions(+), 15 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.test.ts @@ -19,6 +19,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { PeerPullPaymentCoinSelection } from "./db/records.js"; import { + getPeerPullDebitRecoverableCoins, getPeerPullDebitRemainder, getPeerPullDebitUnconfirmedCoins, markPeerPullDebitCoinsDeposited, @@ -111,3 +112,33 @@ test("race recovery excludes confirmed and externally broken coins", () => { [{ coinPub: "unsubmitted", amount: "TESTKUDOS:2" }], ); }); + +test("a refunded pull purse gives back the confirmed deposits as well", () => { + const selection: PeerPullPaymentCoinSelection = { + coinPubs: ["one", "two", "three"], + contributions: ["TESTKUDOS:1", "TESTKUDOS:2", "TESTKUDOS:3"], + totalCost: "TESTKUDOS:6", + }; + markPeerPullDebitCoinsDeposited(selection, 0, 2, "TESTKUDOS:3"); + + assert.deepStrictEqual( + getPeerPullDebitRecoverableCoins(selection, "deposited").map( + (c) => c.coinPub, + ), + ["three"], + ); + assert.deepStrictEqual( + getPeerPullDebitRecoverableCoins(selection, "refunded").map( + (c) => c.coinPub, + ), + ["one", "two", "three"], + ); + assert.deepStrictEqual( + getPeerPullDebitRecoverableCoins( + selection, + "refunded", + new Set(["two"]), + ).map((c) => c.coinPub), + ["one", "three"], + ); +}); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -196,6 +196,39 @@ export function getPeerPullDebitUnconfirmedCoins( return coins; } +/** + * How a pull purse ended from the payer's point of view: "deposited" when + * it was filled and the payee gets the money, "refunded" when the exchange + * gave every deposit back (the invoice lapsed or was withdrawn). + */ +export type PeerPullDebitPurseOutcome = "deposited" | "refunded"; + +/** + * The coins whose contribution the wallet can claim back once the purse is + * gone. After a refund that is every coin; after a deposit only the ones + * whose batch was never confirmed by the exchange. + */ +export function getPeerPullDebitRecoverableCoins( + selection: PeerPullPaymentCoinSelection, + outcome: PeerPullDebitPurseOutcome, + unrecoverableCoinPubs: ReadonlySet<string> = new Set(), +): CoinRefreshRequest[] { + if (outcome === "deposited") { + return getPeerPullDebitUnconfirmedCoins(selection, unrecoverableCoinPubs); + } + const coins: CoinRefreshRequest[] = []; + for (let i = 0; i < selection.coinPubs.length; i++) { + if (unrecoverableCoinPubs.has(selection.coinPubs[i])) { + continue; + } + coins.push({ + amount: selection.contributions[i], + coinPub: selection.coinPubs[i], + }); + } + return coins; +} + export function partitionPeerPullDebitRepair( selection: PeerPullPaymentCoinSelection, depositFees: AmountLike[], @@ -442,6 +475,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { */ async purseGoneTransaction( fromSt: PeerPullDebitRecordStatus, + outcome: PeerPullDebitPurseOutcome, unrecoverableCoinPubs: ReadonlySet<string> = new Set(), ): Promise<void> { const { wex } = this; @@ -451,11 +485,17 @@ export class PeerPullDebitTransactionContext implements TransactionContext { return; } if (rec.coinSel) { - const acceptedCount = getPeerPullDebitDepositedCoinCount(rec.coinSel); - // Only the suffix without a signed deposit confirmation can be - // recovered. Confirmed prefix coins did reach the purse. - const coinPubs = getPeerPullDebitUnconfirmedCoins( + // Coins with a signed deposit confirmation did reach the purse. + // If the purse was completed and merged, their value went to the + // payee; if the exchange refunded the purse, every deposit went back + // onto its coin and all of them can be refreshed. + const acceptedCount = + outcome === "deposited" + ? getPeerPullDebitDepositedCoinCount(rec.coinSel) + : 0; + const coinPubs = getPeerPullDebitRecoverableCoins( rec.coinSel, + outcome, unrecoverableCoinPubs, ); if (coinPubs.length > 0) { @@ -681,6 +721,7 @@ async function handlePurseCreationConflict( case HttpStatusCode.Gone: await ctx.purseGoneTransaction( peerPullInc.status, + "refunded", new Set([brokenCoinPub]), ); return TaskRunResult.finished(); @@ -707,6 +748,7 @@ async function handlePurseCreationConflict( if (isPurseDeposited(statusResp.body) || !instructedAmount) { await ctx.purseGoneTransaction( peerPullInc.status, + "deposited", new Set([brokenCoinPub]), ); return TaskRunResult.finished(); @@ -877,7 +919,10 @@ async function processPeerPullDebitDialogProposed( case HttpStatusCode.Gone: // The purse is gone: the invoice lapsed, the payee withdrew it, or // another wallet paid it. - await ctx.purseGoneTransaction(PeerPullDebitRecordStatus.DialogProposed); + await ctx.purseGoneTransaction( + PeerPullDebitRecordStatus.DialogProposed, + "refunded", + ); return TaskRunResult.finished(); case HttpStatusCode.NotFound: await ctx.failTransaction(pullIni.status, resp.detail); @@ -972,8 +1017,10 @@ async function processPeerPullDebitPendingDeposit( exchangeBaseUrl, statusResp.body, ); - if (isPurseMerged(statusResp.body)) { - await ctx.purseGoneTransaction(peerPullInc.status); + // A pull purse carries its merge timestamp from creation, so only + // the deposit timestamp says that the payee got the money. + if (isPurseDeposited(statusResp.body)) { + await ctx.purseGoneTransaction(peerPullInc.status, "deposited"); return TaskRunResult.finished(); } // The local clock reached the deadline first. Reconcile again after @@ -981,7 +1028,7 @@ async function processPeerPullDebitPendingDeposit( // select or submit any more coins. return TaskRunResult.longpollReturnedPending(); case HttpStatusCode.Gone: - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "refunded"); return TaskRunResult.finished(); case HttpStatusCode.NotFound: await ctx.failTransaction(peerPullInc.status, statusResp.detail); @@ -1004,7 +1051,7 @@ async function processPeerPullDebitPendingDeposit( case "ok": break; case HttpStatusCode.Gone: - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "refunded"); return TaskRunResult.finished(); case HttpStatusCode.NotFound: await ctx.failTransaction(peerPullInc.status, statusResp.detail); @@ -1022,7 +1069,7 @@ async function processPeerPullDebitPendingDeposit( statusResp.body.balance, ); if (isPurseDeposited(statusResp.body) || !instructedAmount) { - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "deposited"); return TaskRunResult.finished(); } const currency = instructedAmount.currency; @@ -1239,7 +1286,7 @@ async function processPeerPullDebitPendingDeposit( continue; } case HttpStatusCode.Gone: { - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "refunded"); return TaskRunResult.finished(); } case HttpStatusCode.Conflict: @@ -1358,7 +1405,7 @@ export async function processPeerPullDebit( isPurseGoneByExpiration(purseExpiration) ) { const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "refunded"); return TaskRunResult.finished(); } @@ -1430,7 +1477,7 @@ export async function confirmPeerPullDebit( ); const purseExpiration = contractTerms.contractTermsRaw.purse_expiration; if (isPurseGoneByExpiration(purseExpiration)) { - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "refunded"); return { transactionId: ctx.transactionId }; } @@ -1444,7 +1491,7 @@ export async function confirmPeerPullDebit( case "ok": break; case HttpStatusCode.Gone: - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "refunded"); return { transactionId: ctx.transactionId }; case HttpStatusCode.NotFound: await ctx.failTransaction(peerPullInc.status, statusResp.detail); @@ -1459,7 +1506,7 @@ export async function confirmPeerPullDebit( statusResp.body.balance, ); if (isPurseDeposited(statusResp.body) || !instructedAmount) { - await ctx.purseGoneTransaction(peerPullInc.status); + await ctx.purseGoneTransaction(peerPullInc.status, "deposited"); return { transactionId: ctx.transactionId }; } const currency = instructedAmount.currency;