diff options
| author | Adam Soltys <[email protected]> | 2026-10-04 08:58:34 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-04 08:58:34 -0700 |
| commit | 7421218e987b0053f1ddbefaddc2c9ecfd664954 (patch) | |
| tree | 4d04e81afa32c230e7f2d8beb1f723662a4747c2 /src | |
| parent | 7c6ca4d70b91dc2d050cb18e7978b65bc1db2d59 (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 32 |
1 files changed, 29 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, |
