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