taler-typescript-core

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

commit 9abeece6cf73ac6e8baaf5c39f009fcfba0da448
parent d9401613fd72554b2c16ef1b116595fcb8b5789f
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:49 +0200

wallet-core: pay authenticated pull-purse remainder

Diffstat:
Mpackages/taler-wallet-core/src/db-common.ts | 6++++++
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 7+++++++
Apackages/taler-wallet-core/src/pay-peer-pull-debit.test.ts | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 664++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
4 files changed, 717 insertions(+), 73 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -2378,6 +2378,12 @@ export interface PeerPullPaymentCoinSelection { contributions: AmountString[]; coinPubs: string[]; totalCost: AmountString | undefined; + + /** Number of leading entries confirmed by a signed exchange response. */ + depositedCoinCount?: number; + + /** Latest purse balance covered by a verified deposit confirmation. */ + confirmedPurseBalance?: AmountString; } /** diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -3795,6 +3795,13 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const rec = makePeerPullDebit("ppld-1"); rec.contractPriv = ck("cpriv-pull"); + rec.coinSel = { + coinPubs: [ck("pull-coin-1"), ck("pull-coin-2")], + contributions: [amt("TESTKUDOS:1"), amt("TESTKUDOS:2")], + totalCost: amt("TESTKUDOS:3.1"), + depositedCoinCount: 1, + confirmedPurseBalance: amt("TESTKUDOS:1"), + }; await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(rec)); const got = await runner.runReadWriteTx((tx) => tx.getPeerPullDebit("ppld-1"), 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 @@ -0,0 +1,113 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + 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 } from "@gnu-taler/taler-util"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { PeerPullPaymentCoinSelection } from "./db-common.js"; +import { + getPeerPullDebitRemainder, + getPeerPullDebitUnconfirmedCoins, + markPeerPullDebitCoinsDeposited, + partitionPeerPullDebitRepair, +} from "./pay-peer-pull-debit.js"; + +test("peer pull debit selects only the authenticated purse remainder", () => { + assert.strictEqual( + Amounts.stringify( + getPeerPullDebitRemainder("TESTKUDOS:10", "TESTKUDOS:1")!, + ), + "TESTKUDOS:9", + ); + assert.strictEqual( + Amounts.stringify( + getPeerPullDebitRemainder("TESTKUDOS:10", "TESTKUDOS:0")!, + ), + "TESTKUDOS:10", + ); +}); + +test("a full or overfunded pull purse has no payable remainder", () => { + assert.strictEqual( + getPeerPullDebitRemainder("TESTKUDOS:10", "TESTKUDOS:10"), + undefined, + ); + assert.strictEqual( + getPeerPullDebitRemainder("TESTKUDOS:10", "TESTKUDOS:11"), + undefined, + ); +}); + +test("pull purse remainder rejects a currency mismatch", () => { + assert.throws( + () => getPeerPullDebitRemainder("TESTKUDOS:10", "EUR:1"), + /currency/i, + ); +}); + +test("signed batch progress advances only in prefix order", () => { + const selection: PeerPullPaymentCoinSelection = { + coinPubs: ["one", "two", "three"], + contributions: ["TESTKUDOS:1", "TESTKUDOS:2", "TESTKUDOS:3"], + totalCost: "TESTKUDOS:6", + }; + markPeerPullDebitCoinsDeposited(selection, 0, 2, "TESTKUDOS:3"); + assert.strictEqual(selection.depositedCoinCount, 2); + assert.strictEqual(selection.confirmedPurseBalance, "TESTKUDOS:3"); + assert.throws( + () => markPeerPullDebitCoinsDeposited(selection, 0, 1, "TESTKUDOS:4"), + /sequence/, + ); +}); + +test("conflict repair never carries coins beyond the latest remainder", () => { + const selection: PeerPullPaymentCoinSelection = { + coinPubs: ["accepted", "too-large", "fits", "broken"], + contributions: ["TESTKUDOS:2", "TESTKUDOS:6", "TESTKUDOS:3", "TESTKUDOS:2"], + totalCost: "TESTKUDOS:13", + depositedCoinCount: 1, + }; + const partition = partitionPeerPullDebitRepair( + selection, + ["TESTKUDOS:1", "TESTKUDOS:1", "TESTKUDOS:1", "TESTKUDOS:1"], + "broken", + "TESTKUDOS:4", + ); + + assert.deepStrictEqual(partition.repair, [ + { + coinPub: "fits", + contribution: Amounts.parseOrThrow("TESTKUDOS:3"), + }, + ]); + assert.deepStrictEqual(partition.recover, [ + { coinPub: "too-large", amount: "TESTKUDOS:6" }, + ]); +}); + +test("race recovery excludes confirmed and externally broken coins", () => { + const selection: PeerPullPaymentCoinSelection = { + coinPubs: ["accepted", "unsubmitted", "broken"], + contributions: ["TESTKUDOS:1", "TESTKUDOS:2", "TESTKUDOS:3"], + totalCost: "TESTKUDOS:6", + depositedCoinCount: 1, + }; + + assert.deepStrictEqual( + getPeerPullDebitUnconfirmedCoins(selection, new Set(["broken"])), + [{ coinPub: "unsubmitted", amount: "TESTKUDOS:2" }], + ); +}); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -22,6 +22,8 @@ import { AcceptPeerPullPaymentResponse, + AmountJson, + AmountLike, Amounts, CoinRefreshRequest, ConfirmPeerPullDebitRequest, @@ -80,6 +82,7 @@ import { } from "./common.js"; import { PeerPullDebitRecordStatus, + PeerPullPaymentCoinSelection, RefreshOperationStatus, WalletPeerPullDebit, timestampPreciseFromDb, @@ -92,6 +95,7 @@ import { } from "./progress.js"; import { getTotalPeerPaymentCost, + getTotalPeerPaymentCostInTx, isPurseDeposited, isPurseGoneByExpiration, queryCoinInfosForSelection, @@ -115,6 +119,178 @@ import { const logger = new Logger("pay-peer-pull-debit.ts"); /** + * Amount this payer still needs to contribute to the purse. + * + * A missing result means that the authenticated purse balance already meets + * (or exceeds) the contract target. The caller must then distinguish a + * locally proven replay from a purse completed by another wallet. + */ +export function getPeerPullDebitRemainder( + target: AmountLike, + balance: AmountLike, +): AmountJson | undefined { + const targetAmount = Amounts.parseOrThrow(target); + const balanceAmount = Amounts.parseOrThrow(balance); + if (Amounts.cmp(balanceAmount, targetAmount) >= 0) { + return undefined; + } + return Amounts.sub(targetAmount, balanceAmount).amount; +} + +export function getPeerPullDebitDepositedCoinCount( + selection: PeerPullPaymentCoinSelection, +): number { + const count = selection.depositedCoinCount ?? 0; + if ( + !Number.isSafeInteger(count) || + count < 0 || + count > selection.coinPubs.length || + selection.coinPubs.length !== selection.contributions.length + ) { + throw Error("invalid persisted peer pull-debit deposit progress"); + } + return count; +} + +export function markPeerPullDebitCoinsDeposited( + selection: PeerPullPaymentCoinSelection, + batchStart: number, + batchSize: number, + confirmedPurseBalance: AmountLike, +): void { + const oldCount = getPeerPullDebitDepositedCoinCount(selection); + if ( + batchStart !== oldCount || + !Number.isSafeInteger(batchSize) || + batchSize <= 0 || + batchStart + batchSize > selection.coinPubs.length + ) { + throw Error("invalid peer pull-debit deposit confirmation sequence"); + } + selection.depositedCoinCount = batchStart + batchSize; + selection.confirmedPurseBalance = Amounts.stringify(confirmedPurseBalance); +} + +export function getPeerPullDebitUnconfirmedCoins( + selection: PeerPullPaymentCoinSelection, + unrecoverableCoinPubs: ReadonlySet<string> = new Set(), +): CoinRefreshRequest[] { + const acceptedCount = getPeerPullDebitDepositedCoinCount(selection); + const coins: CoinRefreshRequest[] = []; + for (let i = acceptedCount; 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[], + brokenCoinPub: string, + remainder: AmountLike, +): { + repair: PreviousPayCoins; + recover: CoinRefreshRequest[]; +} { + if (depositFees.length !== selection.coinPubs.length) { + throw Error("missing deposit fee for peer pull-debit repair"); + } + let remaining = Amounts.parseOrThrow(remainder); + const repair: PreviousPayCoins = []; + const recover: CoinRefreshRequest[] = []; + const acceptedCount = getPeerPullDebitDepositedCoinCount(selection); + for (let i = acceptedCount; i < selection.coinPubs.length; i++) { + const coinPub = selection.coinPubs[i]; + if (coinPub === brokenCoinPub) { + continue; + } + const contribution = Amounts.parseOrThrow(selection.contributions[i]); + const netContribution = Amounts.sub(contribution, depositFees[i]); + if ( + !netContribution.saturated && + Amounts.isNonZero(netContribution.amount) && + Amounts.cmp(netContribution.amount, remaining) <= 0 + ) { + repair.push({ coinPub, contribution }); + remaining = Amounts.sub(remaining, netContribution.amount).amount; + } else { + recover.push({ + coinPub, + amount: Amounts.stringify(contribution), + }); + } + } + return { repair, recover }; +} + +async function getStoredPeerPullDebitSelectionCostInTx( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + selection: PeerPullPaymentCoinSelection, + end: number, + currency: string, +): Promise<AmountJson> { + if (end === 0) { + return Amounts.zeroOfCurrency(currency); + } + const pubs = selection.coinPubs.slice(0, end); + const coins = await tx.getCoinsByPubs(pubs); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const prospective = pubs.map((coinPub, i) => { + const coin = coinsByPub.get(coinPub); + checkDbInvariant(!!coin, `coin ${coinPub} missing from peer pull debit`); + return { + coinPub, + contribution: selection.contributions[i], + denomPubHash: coin.denomPubHash, + exchangeBaseUrl: coin.exchangeBaseUrl, + exchangeMasterPub: coin.exchangeMasterPub, + }; + }); + return getTotalPeerPaymentCostInTx(wex, tx, prospective); +} + +async function retainPeerPullDebitAcceptedSelectionInTx( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + rec: WalletPeerPullDebit, +): Promise<number> { + if (!rec.coinSel) { + return 0; + } + const acceptedCount = getPeerPullDebitDepositedCoinCount(rec.coinSel); + if (acceptedCount === 0) { + return 0; + } + rec.coinSel.totalCost = Amounts.stringify( + await getStoredPeerPullDebitSelectionCostInTx( + wex, + tx, + rec.coinSel, + acceptedCount, + Amounts.currencyOf(rec.amount), + ), + ); + rec.coinSel.coinPubs = rec.coinSel.coinPubs.slice(0, acceptedCount); + rec.coinSel.contributions = rec.coinSel.contributions.slice(0, acceptedCount); + return acceptedCount; +} + +function makePeerPullDebitPartialDepositError(): TalerErrorDetail { + return makeErrorDetail( + TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE, + { purseExpired: false }, + "only part of this wallet's payment was accepted by the purse", + ); +} + +/** * Common context for a peer-pull-debit transaction. */ export class PeerPullDebitTransactionContext implements TransactionContext { @@ -216,6 +392,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { async failTransaction( fromSt: PeerPullDebitRecordStatus, reason?: TalerErrorDetail, + unrecoverableCoinPubs: ReadonlySet<string> = new Set(), ): Promise<void> { const { wex } = this; await wex.runWalletDbTx(async (tx) => { @@ -223,6 +400,23 @@ export class PeerPullDebitTransactionContext implements TransactionContext { if (rec?.status != fromSt) { return; } + if (rec.coinSel) { + const recover = getPeerPullDebitUnconfirmedCoins( + rec.coinSel, + unrecoverableCoinPubs, + ); + if (recover.length > 0) { + await createRefreshGroup( + wex, + tx, + Amounts.currencyOf(rec.amount), + recover, + RefreshReason.AbortPeerPullDebit, + this.transactionId, + ); + } + await retainPeerPullDebitAcceptedSelectionInTx(wex, tx, rec); + } rec.status = PeerPullDebitRecordStatus.Failed; rec.failReason = reason; await h.update(rec, "fail"); @@ -234,7 +428,10 @@ export class PeerPullDebitTransactionContext implements TransactionContext { * the invoice anymore, which happens when the invoice lapsed, when the * payee withdrew it and when another wallet paid it first. */ - async purseGoneTransaction(fromSt: PeerPullDebitRecordStatus): Promise<void> { + async purseGoneTransaction( + fromSt: PeerPullDebitRecordStatus, + unrecoverableCoinPubs: ReadonlySet<string> = new Set(), + ): Promise<void> { const { wex } = this; await wex.runWalletDbTx(async (tx) => { const [rec, h] = await this.getRecordHandle(tx); @@ -242,25 +439,31 @@ export class PeerPullDebitTransactionContext implements TransactionContext { return; } if (rec.coinSel) { - // The purse is gone, so the coins we allocated for it never reached - // anyone. Without the refresh they would stay allocated to this dead - // transaction and the money would be lost. - const coinPubs: CoinRefreshRequest[] = []; - for (let i = 0; i < rec.coinSel.coinPubs.length; i++) { - coinPubs.push({ - amount: rec.coinSel.contributions[i], - coinPub: rec.coinSel.coinPubs[i], - }); - } - const refresh = await createRefreshGroup( - wex, - tx, - Amounts.currencyOf(rec.totalCostEstimated), - coinPubs, - RefreshReason.AbortPeerPullDebit, - this.transactionId, + 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( + rec.coinSel, + unrecoverableCoinPubs, ); - rec.abortRefreshGroupId = refresh.refreshGroupId; + if (coinPubs.length > 0) { + const refresh = await createRefreshGroup( + wex, + tx, + Amounts.currencyOf(rec.totalCostEstimated), + coinPubs, + RefreshReason.AbortPeerPullDebit, + this.transactionId, + ); + rec.abortRefreshGroupId = refresh.refreshGroupId; + } + if (acceptedCount > 0) { + await retainPeerPullDebitAcceptedSelectionInTx(wex, tx, rec); + rec.status = PeerPullDebitRecordStatus.Failed; + rec.failReason = makePeerPullDebitPartialDepositError(); + await h.update(rec, "purse-gone-after-partial-deposit"); + return; + } } const ct = await tx.getContractTerms(rec.contractTermsHash); checkDbInvariant(!!ct, "contract terms for P2P payment not found"); @@ -398,11 +601,20 @@ export class PeerPullDebitTransactionContext implements TransactionContext { pi.status = PeerPullDebitRecordStatus.Aborted; pi.abortReason = reason; } else { - for (let i = 0; i < pi.coinSel.coinPubs.length; i++) { - coinPubs.push({ - amount: pi.coinSel.contributions[i], - coinPub: pi.coinSel.coinPubs[i], - }); + const acceptedCount = getPeerPullDebitDepositedCoinCount(pi.coinSel); + coinPubs.push(...getPeerPullDebitUnconfirmedCoins(pi.coinSel)); + if (coinPubs.length === 0) { + if (acceptedCount > 0) { + await retainPeerPullDebitAcceptedSelectionInTx(this.wex, tx, pi); + pi.status = PeerPullDebitRecordStatus.Failed; + pi.failReason = makePeerPullDebitPartialDepositError(); + pi.abortReason = reason; + } else { + pi.status = PeerPullDebitRecordStatus.Aborted; + pi.abortReason = reason; + } + await h.update(pi, "abort-without-refresh"); + return; } const refresh = await createRefreshGroup( this.wex, @@ -435,9 +647,8 @@ async function handlePurseCreationConflict( const brokenCoinPub = conflict.coin_pub; logger.trace(`excluded broken coin pub=${brokenCoinPub}`); - const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); - const currency = instructedAmount.currency; const exchangeBaseUrl = peerPullInc.exchangeBaseUrl; + const currency = Amounts.currencyOf(peerPullInc.amount); const sel = peerPullInc.coinSel; checkDbInvariant( @@ -445,17 +656,55 @@ async function handlePurseCreationConflict( `no coin selected for peer pull deposit ${peerPullInc.pursePub}`, ); - const repair: PreviousPayCoins = []; + const statusResp = await walletExchangeClient( + exchangeBaseUrl, + ctx.wex, + ).getPurseStatusAtMerge(peerPullInc.pursePub); + switch (statusResp.case) { + case "ok": + break; + case HttpStatusCode.Gone: + await ctx.purseGoneTransaction( + peerPullInc.status, + new Set([brokenCoinPub]), + ); + return TaskRunResult.finished(); + case HttpStatusCode.NotFound: + await ctx.failTransaction( + peerPullInc.status, + statusResp.detail, + new Set([brokenCoinPub]), + ); + return TaskRunResult.finished(); + default: + assertUnreachable(statusResp); + } + await requireValidExchangePurseStatus( + ctx.wex, + exchangeBaseUrl, + statusResp.body, + ); - for (let i = 0; i < sel.coinPubs.length; i++) { - if (sel.coinPubs[i] != brokenCoinPub) { - repair.push({ - coinPub: sel.coinPubs[i], - contribution: Amounts.parseOrThrow(sel.contributions[i]), - }); - } + const instructedAmount = getPeerPullDebitRemainder( + peerPullInc.amount, + statusResp.body.balance, + ); + if (isPurseDeposited(statusResp.body) || !instructedAmount) { + await ctx.purseGoneTransaction( + peerPullInc.status, + new Set([brokenCoinPub]), + ); + return TaskRunResult.finished(); } + const coinDetails = await queryCoinInfosForSelection(ctx.wex, sel); + const { repair, recover } = partitionPeerPullDebitRepair( + sel, + coinDetails.map((x) => x.feeDeposit), + brokenCoinPub, + instructedAmount, + ); + const coinSelRes = await selectPeerCoins(ctx.wex, { instructedAmount, repair, @@ -467,18 +716,66 @@ async function handlePurseCreationConflict( }); switch (coinSelRes.type) { - case "failure": - // FIXME: Details! - throw Error( - "insufficient balance to re-select coins to repair double spending", - ); + case "failure": { + // Recover every unsubmitted, non-broken allocation. Once those + // refreshes produce fresh coins, the normal pending path re-selects + // against a newly authenticated remainder. + const recoverAfterFailure = [ + ...recover, + ...repair.map((x) => ({ + coinPub: x.coinPub, + amount: Amounts.stringify(x.contribution), + })), + ]; + await ctx.wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if ( + rec?.status !== PeerPullDebitRecordStatus.PendingDeposit || + !rec.coinSel + ) { + return; + } + const acceptedCount = getPeerPullDebitDepositedCoinCount(rec.coinSel); + if (recoverAfterFailure.length > 0) { + await createRefreshGroup( + ctx.wex, + tx, + currency, + recoverAfterFailure, + RefreshReason.AbortPeerPullDebit, + ctx.transactionId, + ); + } + if (acceptedCount === 0) { + rec.coinSel = undefined; + } else { + rec.coinSel.totalCost = Amounts.stringify( + await getStoredPeerPullDebitSelectionCostInTx( + ctx.wex, + tx, + rec.coinSel, + acceptedCount, + currency, + ), + ); + rec.coinSel.coinPubs = rec.coinSel.coinPubs.slice(0, acceptedCount); + rec.coinSel.contributions = rec.coinSel.contributions.slice( + 0, + acceptedCount, + ); + rec.coinSel.confirmedPurseBalance = statusResp.body.balance; + } + await h.update(rec, "purse-conflict-await-refresh"); + }); + return TaskRunResult.backoff(); + } case "success": break; default: assertUnreachable(coinSelRes); } - const totalAmount = await getTotalPeerPaymentCost( + const replacementCost = await getTotalPeerPaymentCost( ctx.wex, coinSelRes.result.coins, ); @@ -491,15 +788,47 @@ async function handlePurseCreationConflict( switch (rec.status) { case PeerPullDebitRecordStatus.PendingDeposit: case PeerPullDebitRecordStatus.SuspendedDeposit: { - const sel = coinSelRes.result; + if (!rec.coinSel) { + return; + } + const acceptedCount = getPeerPullDebitDepositedCoinCount(rec.coinSel); + const oldSelection = rec.coinSel; + const acceptedCost = await getStoredPeerPullDebitSelectionCostInTx( + ctx.wex, + tx, + oldSelection, + acceptedCount, + currency, + ); + if (recover.length > 0) { + await createRefreshGroup( + ctx.wex, + tx, + currency, + recover, + RefreshReason.AbortPeerPullDebit, + ctx.transactionId, + ); + } + const replacement = coinSelRes.result; rec.coinSel = { - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => x.contribution), - totalCost: Amounts.stringify(totalAmount), + coinPubs: [ + ...oldSelection.coinPubs.slice(0, acceptedCount), + ...replacement.coins.map((x) => x.coinPub), + ], + contributions: [ + ...oldSelection.contributions.slice(0, acceptedCount), + ...replacement.coins.map((x) => x.contribution), + ], + totalCost: Amounts.stringify( + Amounts.add(acceptedCost, replacementCost).amount, + ), + depositedCoinCount: acceptedCount, + confirmedPurseBalance: statusResp.body.balance, }; // The carried-over coins were spent when they were first selected, // the ones that replace the double-spent coin still have to be. - const added = coinsAddedByRepair(repair, sel.coins); + const added = coinsAddedByRepair(repair, replacement.coins); await spendCoins(ctx.wex, tx, { transactionId: ctx.transactionId, coinPubs: added.map((x) => x.coinPub), @@ -548,7 +877,10 @@ async function processPeerPullDebitDialogProposed( resp.body, ); - if (isPurseDeposited(resp.body)) { + if ( + isPurseDeposited(resp.body) || + !getPeerPullDebitRemainder(pullIni.amount, resp.body.balance) + ) { logger.info("purse completed by another wallet"); await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); @@ -579,9 +911,63 @@ async function processPeerPullDebitPendingDeposit( const exchangeBaseUrl = peerPullInc.exchangeBaseUrl; - // This can happen when there was a prospective coin selection. - if (coinSel == null) { - const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + if (coinSel) { + const depositedCount = getPeerPullDebitDepositedCoinCount(coinSel); + if ( + depositedCount > 0 && + depositedCount === coinSel.coinPubs.length && + coinSel.confirmedPurseBalance && + !getPeerPullDebitRemainder( + peerPullInc.amount, + coinSel.confirmedPurseBalance, + ) + ) { + await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (rec?.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return; + } + rec.status = PeerPullDebitRecordStatus.Done; + await h.update(rec, "deposit-replay-confirmed"); + }); + return TaskRunResult.finished(); + } + } + + // This can happen when there was a prospective coin selection, or after a + // conflict where the already-confirmed prefix was retained and every + // unsubmitted coin was sent to recovery. + if ( + coinSel == null || + getPeerPullDebitDepositedCoinCount(coinSel) === coinSel.coinPubs.length + ) { + const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex); + const statusResp = await exchangeClient.getPurseStatusAtMerge(pursePub); + switch (statusResp.case) { + case "ok": + break; + case HttpStatusCode.Gone: + await ctx.purseGoneTransaction(peerPullInc.status); + return TaskRunResult.finished(); + case HttpStatusCode.NotFound: + await ctx.failTransaction(peerPullInc.status, statusResp.detail); + return TaskRunResult.finished(); + default: + assertUnreachable(statusResp); + } + await requireValidExchangePurseStatus( + wex, + exchangeBaseUrl, + statusResp.body, + ); + const instructedAmount = getPeerPullDebitRemainder( + peerPullInc.amount, + statusResp.body.balance, + ); + if (isPurseDeposited(statusResp.body) || !instructedAmount) { + await ctx.purseGoneTransaction(peerPullInc.status); + return TaskRunResult.finished(); + } const currency = instructedAmount.currency; const coinSelRes = await selectPeerCoins(wex, { @@ -619,9 +1005,18 @@ async function processPeerPullDebitPendingDeposit( if (!rec) { return TaskRunResult.finished(); } + if (rec.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return TaskRunResult.backoff(); + } + const acceptedCount = rec.coinSel + ? getPeerPullDebitDepositedCoinCount(rec.coinSel) + : 0; if ( - rec.status !== PeerPullDebitRecordStatus.PendingDeposit || - rec.coinSel != null + (coinSel == null && rec.coinSel != null) || + (coinSel != null && + (!rec.coinSel || + acceptedCount !== rec.coinSel.coinPubs.length || + acceptedCount !== coinSel.coinPubs.length)) ) { return TaskRunResult.backoff(); } @@ -633,10 +1028,25 @@ async function processPeerPullDebitPendingDeposit( ), refreshReason: RefreshReason.PayPeerPull, }); + const acceptedCoinPubs = rec.coinSel?.coinPubs ?? []; + const acceptedContributions = rec.coinSel?.contributions ?? []; + const acceptedCost = rec.coinSel?.totalCost + ? Amounts.parseOrThrow(rec.coinSel.totalCost) + : Amounts.zeroOfCurrency(currency); rec.coinSel = { - coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), - contributions: coinSelRes.result.coins.map((x) => x.contribution), - totalCost: Amounts.stringify(totalAmount), + coinPubs: [ + ...acceptedCoinPubs, + ...coinSelRes.result.coins.map((x) => x.coinPub), + ], + contributions: [ + ...acceptedContributions, + ...coinSelRes.result.coins.map((x) => x.contribution), + ], + totalCost: Amounts.stringify( + Amounts.add(acceptedCost, totalAmount).amount, + ), + depositedCoinCount: acceptedCount, + confirmedPurseBalance: statusResp.body.balance, }; await h.update(rec, "select-coins"); return TaskRunResult.progress(); @@ -657,8 +1067,9 @@ async function processPeerPullDebitPendingDeposit( const coins = await queryCoinInfosForSelection(wex, coinSel); const maxBatchSize = 64; + const depositedCoinCount = getPeerPullDebitDepositedCoinCount(coinSel); - for (let i = 0; i < coins.length; i += maxBatchSize) { + for (let i = depositedCoinCount; i < coins.length; i += maxBatchSize) { const batchSize = Math.min(maxBatchSize, coins.length - i); wex.oc.observe({ @@ -694,6 +1105,77 @@ async function processPeerPullDebitPendingDeposit( purseExpiration: contractTerms.contractTermsRaw.purse_expiration, response: resp.body, }); + const purseComplete = + getPeerPullDebitRemainder( + peerPullInc.amount, + resp.body.total_deposited, + ) === undefined; + const confirmationStored = await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if ( + rec?.status !== PeerPullDebitRecordStatus.PendingDeposit || + !rec.coinSel + ) { + return false; + } + const currentCount = getPeerPullDebitDepositedCoinCount(rec.coinSel); + if (currentCount !== i) { + return false; + } + markPeerPullDebitCoinsDeposited( + rec.coinSel, + i, + batchSize, + resp.body.total_deposited, + ); + if (purseComplete) { + const acceptedCount = i + batchSize; + const unsubmitted: CoinRefreshRequest[] = []; + for ( + let coinIndex = acceptedCount; + coinIndex < rec.coinSel.coinPubs.length; + coinIndex++ + ) { + unsubmitted.push({ + coinPub: rec.coinSel.coinPubs[coinIndex], + amount: rec.coinSel.contributions[coinIndex], + }); + } + if (unsubmitted.length > 0) { + await createRefreshGroup( + wex, + tx, + Amounts.currencyOf(peerPullInc.amount), + unsubmitted, + RefreshReason.AbortPeerPullDebit, + ctx.transactionId, + ); + } + rec.coinSel.totalCost = Amounts.stringify( + await getStoredPeerPullDebitSelectionCostInTx( + wex, + tx, + rec.coinSel, + acceptedCount, + Amounts.currencyOf(peerPullInc.amount), + ), + ); + rec.coinSel.coinPubs = rec.coinSel.coinPubs.slice(0, acceptedCount); + rec.coinSel.contributions = rec.coinSel.contributions.slice( + 0, + acceptedCount, + ); + rec.status = PeerPullDebitRecordStatus.Done; + } + await h.update(rec, "deposit-confirmed"); + return true; + }); + if (!confirmationStored) { + return TaskRunResult.progress(); + } + if (purseComplete) { + return TaskRunResult.finished(); + } continue; case HttpStatusCode.Gone: { await ctx.purseGoneTransaction(peerPullInc.status); @@ -709,17 +1191,9 @@ async function processPeerPullDebitPendingDeposit( assertUnreachable(resp); } } - // All batches succeeded, we can transition! - await ctx.wex.runWalletDbTx(async (tx) => { - const [rec, h] = await ctx.getRecordHandle(tx); - switch (rec?.status) { - case PeerPullDebitRecordStatus.PendingDeposit: - rec.status = PeerPullDebitRecordStatus.Done; - break; - default: - return; - } - await h.update(rec, "deposit-done"); + await ctx.failTransaction(peerPullInc.status, { + code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + hint: "exchange accepted every selected coin but reported an incomplete purse", }); return TaskRunResult.finished(); } @@ -732,6 +1206,7 @@ async function processPeerPullDebitAbortingRefresh( const abortRefreshGroupId = peerPullInc.abortRefreshGroupId; checkLogicInvariant(!!abortRefreshGroupId); const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); + let terminal = false; await wex.runWalletDbTx(async (tx) => { const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId); const [rec, h] = await ctx.getRecordHandle(tx); @@ -743,13 +1218,27 @@ async function processPeerPullDebitAbortingRefresh( // just go into failed. logger.warn("no aborting refresh group found for deposit group"); rec.status = PeerPullDebitRecordStatus.Failed; + terminal = true; } else { switch (refreshGroup.operationStatus) { - case RefreshOperationStatus.Finished: - rec.status = PeerPullDebitRecordStatus.Aborted; + case RefreshOperationStatus.Finished: { + const acceptedCount = await retainPeerPullDebitAcceptedSelectionInTx( + wex, + tx, + rec, + ); + if (acceptedCount > 0) { + rec.status = PeerPullDebitRecordStatus.Failed; + rec.failReason = makePeerPullDebitPartialDepositError(); + } else { + rec.status = PeerPullDebitRecordStatus.Aborted; + } + terminal = true; break; + } case RefreshOperationStatus.Failed: { rec.status = PeerPullDebitRecordStatus.Failed; + terminal = true; break; } default: @@ -758,8 +1247,7 @@ async function processPeerPullDebitAbortingRefresh( } await h.update(rec, "aborting-refresh-failed"); }); - // FIXME: Shouldn't this be finished in some cases?! - return TaskRunResult.backoff(); + return terminal ? TaskRunResult.finished() : TaskRunResult.backoff(); } export async function processPeerPullDebit( @@ -825,7 +1313,32 @@ export async function confirmPeerPullDebit( const exchangeBaseUrl = peerPullInc.exchangeBaseUrl; - const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + const statusResp = await walletExchangeClient( + exchangeBaseUrl, + wex, + ).getPurseStatusAtMerge(peerPullInc.pursePub); + switch (statusResp.case) { + case "ok": + break; + case HttpStatusCode.Gone: + await ctx.purseGoneTransaction(peerPullInc.status); + return { transactionId: ctx.transactionId }; + case HttpStatusCode.NotFound: + await ctx.failTransaction(peerPullInc.status, statusResp.detail); + return { transactionId: ctx.transactionId }; + default: + assertUnreachable(statusResp); + } + await requireValidExchangePurseStatus(wex, exchangeBaseUrl, statusResp.body); + + const instructedAmount = getPeerPullDebitRemainder( + peerPullInc.amount, + statusResp.body.balance, + ); + if (isPurseDeposited(statusResp.body) || !instructedAmount) { + await ctx.purseGoneTransaction(peerPullInc.status); + return { transactionId: ctx.transactionId }; + } const currency = instructedAmount.currency; const coinSelRes = await selectPeerCoins(wex, { @@ -880,6 +1393,8 @@ export async function confirmPeerPullDebit( coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), contributions: coinSelRes.result.coins.map((x) => x.contribution), totalCost: Amounts.stringify(totalAmount), + depositedCoinCount: 0, + confirmedPurseBalance: statusResp.body.balance, }; } rec.status = PeerPullDebitRecordStatus.PendingDeposit; @@ -1079,7 +1594,11 @@ async function internalPreparePeerPullDebit( await requireValidExchangePurseStatus(wex, exchangeBaseUrl, resp.body); - if (isPurseDeposited(resp.body)) { + const instructedAmount = getPeerPullDebitRemainder( + contractTerms.amount, + resp.body.balance, + ); + if (isPurseDeposited(resp.body) || !instructedAmount) { logger.info("purse completed by another wallet"); throw TalerError.fromDetail( TalerErrorCode.WALLET_PEER_PULL_DEBIT_ALREADY_PAID, @@ -1094,7 +1613,6 @@ async function internalPreparePeerPullDebit( // FIXME: Why don't we compute the totalCost here?! - const instructedAmount = Amounts.parseOrThrow(contractTerms.amount); const currency = Amounts.currencyOf(instructedAmount); const coinSelRes = await selectPeerCoins(wex, {