aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js3
-rw-r--r--src/nwc-bg.js25
-rw-r--r--src/nwc-respond.js83
3 files changed, 86 insertions, 25 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