From 9963c4c45ccd33cc8588fd3c1cf816cf09a82c2b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 22 Aug 2026 09:10:10 -0700 Subject: One record answers, and failure learns patience: a rewritten mirror disarms stale sibling copies of its connections, the responder pays from the record whose network the invoice names (standing down when none fits), a ghost coin reconciles and retries instead of dying, and error replies wait out a grace period so a sibling's success reaches the client first Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Fc8SHJRvBBXgMCntjNCri --- src/features/ark.js | 3 +- src/nwc-bg.js | 25 ++++++++++++++++ src/nwc-respond.js | 83 +++++++++++++++++++++++++++++++++++++---------------- 3 files changed, 86 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/features/ark.js b/src/features/ark.js index 30b167a..9aa43c2 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -7,7 +7,7 @@ import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; -import { loadBg, saveBg, buildBg } from '../nwc-bg.js'; +import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee } from '../ark/board.js'; import { maybeBolt11, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; @@ -533,6 +533,7 @@ export function arkFeature(ctx) { if (prev) rec.spends = prev.spends; // the worker's log survives rewrites if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); + await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {}); } // On connect, fold whatever the worker did while we were closed back into diff --git a/src/nwc-bg.js b/src/nwc-bg.js index b22c36e..92786c0 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -118,6 +118,31 @@ export const bgSpendableSat = (rec) => ((rec && rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable') .reduce((n, v) => n + (v.amountSat || 0), 0); +// After (re)writing a wallet's record: strip its connections/offer from any +// OTHER record that still carries them — an old network's mirror, say — so +// exactly one record ever answers for a connection. The sibling's coin state +// stays put for a later merge; only its answering role is removed. A stale +// copy that races the live one answers from the wrong wallet (and a wrong- +// network mirror fails instantly, beating the real payer's reply). +export async function disarmSiblingRecords(walletKey, rec, { loadAll = allBgs, save = saveBg } = {}) { + const pks = new Set((rec.connections || []).map((c) => c.servicePk)); + if (rec.offer?.pk) pks.add(rec.offer.pk); + if (!pks.size) return 0; + let disarmed = 0; + for (const r of await loadAll()) { + if (r.walletKey === walletKey || !r.rec || r.rec.v < 3) continue; + const conns = r.rec.connections || []; + const keep = conns.filter((c) => !pks.has(c.servicePk)); + const offerHit = !!(r.rec.offer?.pk && pks.has(r.rec.offer.pk)); + if (keep.length === conns.length && !offerHit) continue; + r.rec.connections = keep; + if (offerHit) r.rec.offer = null; + await save(r.walletKey, r.rec); + disarmed++; + } + return disarmed; +} + // The worker records what it spent so the app can absorb it into budgets and // history on next open. export async function noteBgSpend(walletKey, spend) { diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 232ae3e..532b13e 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -125,6 +125,7 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log // left to another device); false means "wake the user instead". export async function respondFromBg(data, { notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {}, + errGraceMs = 3000, } = {}) { const ev = data && data.event; if (!ev || !ev.id || !ev.pubkey) return false; @@ -133,23 +134,29 @@ export async function respondFromBg(data, { const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1]; if (!target) return false; - // CLINK offer requests route by the offer service pubkey. + // CLINK offer requests route by the offer service pubkey — freshest record + // wins if stale copies linger. if (ev.kind === 21001) { - for (const r of await recordsFn()) { - if (r.rec.v >= 3 && r.rec.offer?.pk === target) { - return respondOffer(ev, r.rec, r.walletKey, { notifier, fetchFn, saveFn, log }); - } - } - return false; + const offers = (await recordsFn()) + .filter((r) => r.rec.v >= 3 && r.rec.offer?.pk === target) + .sort((a, b) => (b.rec.updated || 0) - (a.rec.updated || 0)); + if (!offers.length) return false; + return respondOffer(ev, offers[0].rec, offers[0].walletKey, { notifier, fetchFn, saveFn, log }); } if (ev.kind !== 23194) return false; - let walletKey = null, rec = null, conn = null; + // The same connection can survive under several wallet records (an old + // network's mirror beside the live one — they share the service keys). + // Gather every copy, freshest first; pay_invoice narrows further to the + // record whose wallet is on the invoice's network. + const matches = []; for (const r of await recordsFn()) { const c = (r.rec.connections || []).find((x) => x.servicePk === target); - if (c && r.rec.v >= 3) { walletKey = r.walletKey; rec = r.rec; conn = c; break; } + if (c && r.rec.v >= 3) matches.push({ walletKey: r.walletKey, rec: r.rec, conn: c }); } - if (!conn) return false; + matches.sort((a, b) => (b.rec.updated || 0) - (a.rec.updated || 0)); + if (!matches.length) return false; + let { walletKey, rec, conn } = matches[0]; if (ev.pubkey !== conn.clientPk) return false; const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04'; @@ -216,6 +223,18 @@ export async function respondFromBg(data, { const dec = invoice && maybeBolt11(invoice); if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; } if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; } + // The bolt11 names its network; only a record on that network can pay it. + // A mismatched copy fails in milliseconds with a nonsense error that beats + // a sibling device's real payment to the client — so stand down instead, + // and only wake the user if nobody else answered. + const netOf = (n) => (n === 'mutinynet' ? 'signet' : n); + const fit = matches.find((m) => netOf(m.rec.ark && m.rec.ark.network) === dec.network); + if (!fit) { + log(`no ${dec.network} record for this connection — standing down`); + await new Promise((r) => setTimeout(r, errGraceMs)); + return answered(); + } + ({ walletKey, rec, conn } = fit); if (dec.amountSat > conn.maxSat) { await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`)); return true; @@ -239,27 +258,43 @@ export async function respondFromBg(data, { mgr = await bgManager(rec, walletKey, saveFn); } catch (e) { log('ark init failed: ' + e.message); return false; } - let id; - try { - id = await mgr.payLnInvoice(invoice); - } catch (e) { + // A local failure carries no urgency — wait out the grace period and + // re-check before publishing, so a sibling device's slower SUCCESS reply + // wins the client's eyes instead of being shouted over. + const failQuietly = async (message) => { + await new Promise((r) => setTimeout(r, errGraceMs)); if (!(await answered())) { - await publish(errRes('pay_invoice', 'INTERNAL', e.message)).catch(() => {}); + await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {}); } return true; - } + }; const deadline = Date.now() + PAY_WAIT_MS; - let a = mgr.lnAction(id); - while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 1500)); - await mgr.driveLn(id).catch(() => {}); + let a = null; + for (let attempt = 0; ; attempt++) { + let id; + try { + id = await mgr.payLnInvoice(invoice); + } catch (e) { + return failQuietly(e.message); + } a = mgr.lnAction(id); + while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 1500)); + await mgr.driveLn(id).catch(() => {}); + a = mgr.lnAction(id); + } + // A ghost coin (spent elsewhere, this mirror never learned) dies on the + // launchpad in milliseconds. Reconcile against the server — flipping the + // ghost — and retry once from live coins; the budget has barely started. + if (attempt === 0 && a && a.step === 'failed' && /not spendable|already spent/i.test(a.error || '')) { + log('a mirrored coin was already spent — reconciling and retrying'); + await mgr.reconcile().catch(() => {}); + continue; + } + break; } if (!a || a.step === 'failed') { - if (!(await answered())) { - await publish(errRes('pay_invoice', 'INTERNAL', (a && a.error) || 'payment failed')).catch(() => {}); - } - return true; + return failQuietly((a && a.error) || 'payment failed'); } if (a.step !== 'done') { // still in flight as our budget runs out: the app resumes it from the -- cgit v1.2.3