taler-typescript-core

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

commit 4ddf87d438a480f6fef6ed373bea58f97ef19f2b
parent f59667fc2f3de51493c783f848867bb7c598efbf
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:46 +0200

wallet-core: resolve pull-credit abort races

Diffstat:
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.test.ts | 19++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
2 files changed, 73 insertions(+), 20 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts @@ -14,7 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TransactionAction } from "@gnu-taler/taler-util"; +import { + TalerProtocolTimestamp, + TransactionAction, +} from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; import { @@ -25,6 +28,7 @@ import { WalletDbTransaction } from "./dbtx.js"; import { computePeerPullCreditTransactionActions, PeerPullCreditTransactionContext, + statusAfterPullCreditDeleteConflict, } from "./pay-peer-pull-credit.js"; import { WalletExecutionContext } from "./wallet.js"; @@ -104,3 +108,16 @@ test("aborting a suspended pre-withdrawal pull credit starts cleanup", async () ); assert.strictEqual(reset, true); }); + +test("delete conflict continues a pull credit whose purse was merged", () => { + assert.strictEqual( + statusAfterPullCreditDeleteConflict({ + merge_timestamp: TalerProtocolTimestamp.fromSeconds(1), + } as any), + PeerPullPaymentCreditStatus.PendingReady, + ); + assert.strictEqual( + statusAfterPullCreditDeleteConflict({} as any), + PeerPullPaymentCreditStatus.Aborted, + ); +}); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -21,6 +21,7 @@ import { ContractTermsUtil, Duration, ExchangeReservePurseRequest, + ExchangePurseStatus, ExchangeWalletKycStatus, HostPortPath, HttpStatusCode, @@ -92,7 +93,11 @@ import { isKycOperationDue, runKycCheckAlgo, } from "./kyc.js"; -import { getMergeReserveInfo, isPurseDeposited } from "./pay-peer-common.js"; +import { + getMergeReserveInfo, + isPurseDeposited, + isPurseMerged, +} from "./pay-peer-common.js"; import { runWithMaybeProgressContext } from "./progress.js"; import { constructTransactionIdentifier, @@ -671,33 +676,64 @@ async function processPeerPullCreditAbortingDeletePurse( pursePriv, }); const resp = await exchangeClient.deletePurse(pursePub, sigResp.sig); + let completionStatus = PeerPullPaymentCreditStatus.Aborted; switch (resp.case) { case "ok": case HttpStatusCode.NotFound: - await wex.runWalletDbTx(async (tx) => { - const [rec, h] = await ctx.getRecordHandle(tx); - if (!rec) { - return; - } - switch (rec.status) { - case PeerPullPaymentCreditStatus.AbortingDeletePurse: - rec.status = PeerPullPaymentCreditStatus.Aborted; - break; - default: - return; - } - await h.update(rec, "aborting-delete-purse"); - }); - return TaskRunResult.finished(); + break; case HttpStatusCode.Forbidden: await ctx.failTransaction(peerPullIni.status, resp.detail); return TaskRunResult.finished(); - case HttpStatusCode.Conflict: - // FIXME check if done ? - throw Error(`cannot be deleted`); + case HttpStatusCode.Conflict: { + const statusResp = await exchangeClient.getPurseStatusAtMerge(pursePub); + switch (statusResp.case) { + case "ok": + // If the payer won the race with our abort, continue through the + // normal reserve-withdrawal path instead of retrying DELETE. + completionStatus = statusAfterPullCreditDeleteConflict( + statusResp.body, + ); + break; + case HttpStatusCode.Gone: + case HttpStatusCode.NotFound: + // No merged purse remains, so there is nothing left to receive. + break; + default: + assertUnreachable(statusResp); + } + break; + } default: assertUnreachable(resp); } + + await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (rec?.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) { + return; + } + rec.status = completionStatus; + if (completionStatus === PeerPullPaymentCreditStatus.PendingReady) { + delete rec.abortReason; + } + await h.update( + rec, + completionStatus === PeerPullPaymentCreditStatus.PendingReady + ? "abort-purse-merged" + : "aborting-delete-purse", + ); + }); + return completionStatus === PeerPullPaymentCreditStatus.PendingReady + ? TaskRunResult.progress() + : TaskRunResult.finished(); +} + +export function statusAfterPullCreditDeleteConflict( + purse: ExchangePurseStatus, +): PeerPullPaymentCreditStatus.PendingReady | PeerPullPaymentCreditStatus.Aborted { + return isPurseMerged(purse) + ? PeerPullPaymentCreditStatus.PendingReady + : PeerPullPaymentCreditStatus.Aborted; } async function processPeerPullCreditWithdrawing(