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