aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-22 09:10:10 -0700
committerAdam Soltys <[email protected]>2026-08-22 09:10:10 -0700
commit9963c4c45ccd33cc8588fd3c1cf816cf09a82c2b (patch)
treecdc5d6ca684ae7e6e521c575a386b8b7c04bce71
parent16a9e78080554774d8fec8a4f29d19f97fa3722a (diff)
downloadcoinosv3-9963c4c45ccd33cc8588fd3c1cf816cf09a82c2b.tar.gz
coinosv3-9963c4c45ccd33cc8588fd3c1cf816cf09a82c2b.zip
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 <[email protected]> Claude-Session: https://claude.ai/code/session_017Fc8SHJRvBBXgMCntjNCri
-rw-r--r--src/features/ark.js3
-rw-r--r--src/nwc-bg.js25
-rw-r--r--src/nwc-respond.js83
-rw-r--r--tools/nwc-sw-test.js70
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);