aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-04 08:58:34 -0700
committerAdam Soltys <[email protected]>2026-10-04 08:58:34 -0700
commit7421218e987b0053f1ddbefaddc2c9ecfd664954 (patch)
tree4d04e81afa32c230e7f2d8beb1f723662a4747c2
parent7c6ca4d70b91dc2d050cb18e7978b65bc1db2d59 (diff)
downloadcoinosv3-7421218e987b0053f1ddbefaddc2c9ecfd664954.tar.gz
coinosv3-7421218e987b0053f1ddbefaddc2c9ecfd664954.zip
Ark: a double-tapped zap no longer spends one coin twice
A reconcile pass that began before the first zap took its coin handed it back as spendable (the server still said so) while the zap was in flight; the second zap picked it and the server refused: 'vtxo … already spent'. Coins whose state changed during the pass are left alone. And a payment refused for an already-spent coin now reconciles and pays once more instead of failing with the raw error. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR
-rw-r--r--src/ark/manager.js32
-rw-r--r--tools/ark-reconcile-race.test.js22
2 files changed, 51 insertions, 3 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 1cf1d7a..0031da5 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -137,6 +137,7 @@ export class ArkManager {
this.state = null;
this.info = null;
this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push
+ this._spentRetried = new Set(); // payment hashes already given their one already-spent retry
this._refreshDriving = new Set(); // same, for round claims: sync loop vs mailbox breadcrumb
this._schedPollAt = new Map(); // last status poll per standing-order unlock hash
// A derived key for an index never changes for this wallet, but computing
@@ -626,6 +627,7 @@ export class ArkManager {
}
for (const e of this.state.scheduled || []) for (const id of e.inputIds || []) held.add(id);
const candidates = this.state.vtxos.filter((v) => v.state === 'spendable' || (v.state === 'pending' && !held.has(v.id) && v.bytes));
+ const was = candidates.map((v) => v.state); // what each was when this pass began
// Each status query signs a challenge (schnorr) and may decode the vtxo —
// real secp256k1 work. Building them all in one synchronous map was a
// ~100ms+ EC burst on boot (dozens of coins), the stall behind a carousel
@@ -640,6 +642,12 @@ export class ArkManager {
const states = await Promise.all(pending); // unreachable/erroring server changes nothing
let changed = false;
candidates.forEach((v, i) => {
+ // A coin a payment took while the server was being asked is that
+ // payment's now: the answer predates it. A double-tapped zap lost its
+ // second half this way — the first zap's coin, 'spendable' when this
+ // pass began, was handed back as spendable mid-payment, and the second
+ // zap spent it again ("vtxo … already spent").
+ if (v.state !== was[i]) return;
if (v.state === 'pending') {
// an orphan the server still counts is simply spendable again; one it
// holds as spent is gone, and the pay that took it was recorded (or
@@ -967,9 +975,14 @@ export class ArkManager {
// stale input but not which: optimistically free them all and let
// reconcile() flip the truly-gone one back to spent.
for (const r of inputRecs) r.state = 'spendable';
- if (spent) this.reconcile().catch(() => {});
action.step = 'failed';
action.error = e.message;
+ // the first time, payLnInvoice reconciles and pays again — no
+ // failed row for a payment that's about to go through
+ if (spent && !this._spentRetried.has(action.paymentHash)) {
+ action.spentRetry = true; this._save(); return;
+ }
+ if (spent) this.reconcile().catch(() => {});
if (this._recordExpiryRejection(action)) { action.expiryRetry = true; this._save(); return; }
this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message, to: action.destAddress });
this._save();
@@ -1173,7 +1186,15 @@ export class ArkManager {
// Pay a bolt11 or bolt12 invoice with ark funds. Returns the action id;
// drive to a terminal step ('done' | 'failed') via driveLn()/sync.
async payLnInvoice(invoice, opts = {}) {
- return (await this._retryExpired(() => this._payLnInvoiceOnce(invoice, opts))).id;
+ let action = await this._retryExpired(() => this._payLnInvoiceOnce(invoice, opts));
+ // one of its coins was already spent (another payment got there first):
+ // settle which with the server and pay once more from what's left
+ if (action.step === 'failed' && action.spentRetry) {
+ this._spentRetried.add(action.paymentHash);
+ await this.reconcile().catch(() => {});
+ action = await this._retryExpired(() => this._payLnInvoiceOnce(invoice, opts));
+ }
+ return action.id;
}
async _payLnInvoiceOnce(invoice, { amountSat: userAmountSat, routingFeeSat } = {}) {
@@ -1299,9 +1320,14 @@ export class ArkManager {
const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message);
if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) {
for (const r of inputRecs) r.state = 'spendable';
- if (spent) this.reconcile().catch(() => {});
action.step = 'failed';
action.error = e.message;
+ // the first time, payLnInvoice reconciles and pays again — no
+ // failed row for a payment that's about to go through
+ if (spent && !this._spentRetried.has(action.paymentHash)) {
+ action.spentRetry = true; this._save(); return;
+ }
+ if (spent) this.reconcile().catch(() => {});
if (this._recordExpiryRejection(action)) { action.expiryRetry = true; this._save(); return; }
// "already in progress" means another device of this wallet is
// paying this very invoice — that payment's row is the history,
diff --git a/tools/ark-reconcile-race.test.js b/tools/ark-reconcile-race.test.js
new file mode 100644
index 0000000..165eb58
--- /dev/null
+++ b/tools/ark-reconcile-race.test.js
@@ -0,0 +1,22 @@
+// bun test tools/ark-reconcile-race.test.js — a reconcile pass must not hand a
+// coin back to spendable after a payment took it mid-pass (double-tapped zap).
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+let release;
+const gate = new Promise((r) => { release = r; });
+mock.module('../src/ark/proto.js', () => ({ ...proto, getVtxoStatus: async () => { await gate; return proto.VTXO_STATE_SPENDABLE; } }));
+const { ArkManager } = await import('../src/ark/manager.js');
+test('a coin taken mid-reconcile stays taken', async () => {
+ const m = Object.create(ArkManager.prototype);
+ m.state = { vtxos: [{ id: 'a', state: 'spendable', amountSat: 85, bytes: 'x' }, { id: 'b', state: 'pending', amountSat: 5, bytes: 'x' }], actions: [], scheduled: [] };
+ m.arkUrl = 'x'; m._decoded = () => ({ point: { raw: new Uint8Array(36) } }); m._keyForVtxo = () => ({ privkey: new Uint8Array(32) });
+ m._save = () => {}; m._movement = () => {};
+ const p = m.reconcile();
+ await new Promise((r) => setTimeout(r, 10));
+ m.state.vtxos[0].state = 'pending'; // the zap takes coin a
+ m.state.actions.push({ type: 'ln-pay', step: 'created', parts: [{ inputId: 'a' }] });
+ release();
+ await p;
+ expect(m.state.vtxos[0].state).toBe('pending');
+ expect(m.state.vtxos[1].state).toBe('spendable'); // a real orphan is still freed
+});