diff options
| -rw-r--r-- | src/features/ark.js | 3 | ||||
| -rw-r--r-- | src/nwc-bg.js | 25 | ||||
| -rw-r--r-- | src/nwc-respond.js | 83 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 70 |
4 files changed, 153 insertions, 28 deletions
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 diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 5a34c4e..acf3027 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -44,21 +44,24 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) { }, clientSk); } -function harness({ rec = baseRec(), answered = false } = {}) { +function harness({ rec = baseRec(), recs = null, answered = false } = {}) { const published = []; const fetchFn = async (url, opts) => { if (url.includes('/publish')) { published.push(JSON.parse(opts.body).event); return { ok: true, json: async () => ({ ok: true }) }; } - if (url.includes('/answered')) return { ok: true, json: async () => ({ answered }) }; + if (url.includes('/answered')) { + return { ok: true, json: async () => ({ answered: typeof answered === 'function' ? answered() : answered }) }; + } throw new Error('unexpected fetch ' + url); }; const deps = { notifier: 'http://notif.test', fetchFn, - recordsFn: async () => [{ walletKey: 'w1', rec }], + recordsFn: async () => recs || [{ walletKey: 'w1', rec }], saveFn: async () => {}, + errGraceMs: 5, }; const decryptReply = (ev) => { const scheme = ev.tags.find((t) => t[0] === 'encryption')?.[1]; @@ -142,5 +145,66 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z check('exhausted key window → wake the user', handled === false); } +console.log('\n[network fit]'); +{ + // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense + // error — it stands down (false → wake) and publishes nothing + const rec = baseRec(); + rec.ark.network = 'mutinynet'; + const { deps, published } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('wrong-network record stands down', handled === false && published.length === 0); +} +{ + // ...and stays silent when a sibling device already answered + const rec = baseRec(); + rec.ark.network = 'mutinynet'; + const { deps, published } = harness({ rec, answered: true }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('wrong-network + answered elsewhere → silent true', handled === true && published.length === 0); +} +{ + // two copies of the connection: the fresher mutinynet one must lose to the + // older mainnet one for a mainnet invoice. The mainnet copy has an empty + // mirror, so reaching it shows as a wake (false) with nothing published — + // the mutinynet copy would instead have published a network error. + const mut = baseRec(); mut.ark.network = 'mutinynet'; mut.updated = Date.now(); + const main = baseRec(); main.updated = Date.now() - 60_000; main.mgr.vtxos = []; + const { deps, published } = harness({ recs: [{ walletKey: 'wMut', rec: mut }, { walletKey: 'wMain', rec: main }] }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('invoice network picks the fitting record', handled === false && published.length === 0); +} +{ + // non-pay methods answer from the freshest copy + const stale = baseRec(); stale.updated = 1; stale.mgr.vtxos = []; + const fresh = baseRec(); fresh.updated = Date.now(); + const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'wOld', rec: stale }, { walletKey: 'wNew', rec: fresh }] }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance')) }, deps); + check('freshest record answers get_balance', decryptReply(published[0]).result?.balance === 5000000); +} + +console.log('\n[sibling record hygiene]'); +{ + const { disarmSiblingRecords } = await import('../src/nwc-bg.js'); + const live = baseRec(); + const staleShared = baseRec(); // same connection servicePk — must be disarmed + const unrelated = baseRec(); + unrelated.connections = [{ ...conn, id: 'c2', servicePk: 'f'.repeat(64) }]; + const legacy = { v: 2, connections: [conn] }; // pre-v3 pouch: left for its sweep + const saved = {}; + const n = await disarmSiblingRecords('wLive', live, { + loadAll: async () => [ + { walletKey: 'wLive', rec: live }, + { walletKey: 'wStale', rec: staleShared }, + { walletKey: 'wOther', rec: unrelated }, + { walletKey: 'wLegacy', rec: legacy }, + ], + save: async (k, r) => { saved[k] = r; }, + }); + check('shared connection stripped from the stale record', n === 1 && saved.wStale && saved.wStale.connections.length === 0); + check('own, unrelated and legacy records untouched', !saved.wLive && !saved.wOther && !saved.wLegacy); + check('stale record keeps its coin state', saved.wStale.mgr.vtxos.length === 1); +} + console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ background responder behaves'); process.exit(fails ? 1 : 0); |
