diff options
| author | Adam Soltys <[email protected]> | 2026-09-14 09:06:02 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-14 09:06:02 -0700 |
| commit | 671d2a96474f59ca9234c876ce6604d03de95727 (patch) | |
| tree | d732896288985033c0531b70b7bcad0cc388cb00 /src/features/nwc.js | |
| parent | 6394de43d4e596aaeddd2b262bb7a595679d8fbc (diff) | |
| download | coinosv3-671d2a96474f59ca9234c876ce6604d03de95727.tar.gz coinosv3-671d2a96474f59ca9234c876ce6604d03de95727.zip | |
One device pays a zap, whichever of them heard it first.
Every awake device of a wallet hears a wallet-connect pay request within
the same second, and the per-tab lock only reaches one browser profile:
two profiles on one laptop each locked an HTLC for the same 21-sat zap.
The push notifier now hands each pay request to the first device that
claims it; a device that loses the claim stands down silently, and a
device that cannot reach the notifier answers as before so nobody goes
quiet over a hiccup. The worker path claims the same way.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
Diffstat (limited to 'src/features/nwc.js')
| -rw-r--r-- | src/features/nwc.js | 29 |
1 files changed, 27 insertions, 2 deletions
diff --git a/src/features/nwc.js b/src/features/nwc.js index 73ab781..0b6cf08 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -57,8 +57,28 @@ export function nwcFeature(ctx) { // how long a pay_invoice error (or a sibling's success) waits for the // device that actually paid to speak first errGraceMs = 3000, + claim = claimOn, } = ctx.nwcTransport || {}; + // Cross-device leadership for a pay request. Every awake device of the + // wallet hears every request within the same second; the tab lock only + // covers one browser profile, and two profiles (or two machines) each + // locked an HTLC for the same zap. The notifier hands each request to the + // first device that asks. Unreachable notifier → answer anyway: a lone + // device must never go silent over a hiccup here. + const claimAs = Math.random().toString(36).slice(2, 12); + async function claimOn(id) { + try { + const r = await fetch(`${NOTIFIER}/claim`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: id, by: claimAs }), + signal: AbortSignal.timeout ? AbortSignal.timeout(4000) : undefined, + }); + if (!r.ok) return true; + return (await r.json()).ok !== false; + } catch { return true; } + } + // ---- persisted connections ------------------------------------------- // { id, name, secret (client sk hex), clientPk, servicePk, serviceSk, // maxSat, dailySat, spentToday, spentDate, created, lastUsed, revoked } @@ -350,7 +370,8 @@ export function nwcFeature(ctx) { await reply(c, ev, scheme, payload); }; try { - const out = await handle(c, method, params); + const out = await handle(c, method, params, () => claim(ev.id)); + if (out && out.silent) return console.log('nwc: standing down — another device claimed', ev.id.slice(0, 8)); await sendChecked({ result_type: method, ...out }); } catch (e) { await sendChecked({ result_type: method, ...errRes('INTERNAL', e.message || 'failed') }); @@ -358,7 +379,7 @@ export function nwcFeature(ctx) { render(); } - async function handle(c, method, params) { + async function handle(c, method, params, claimReq) { if (!hook('arkReady')) return errRes('UNAUTHORIZED', 'Ark is not configured in this wallet'); if (method === 'get_info') { @@ -398,6 +419,10 @@ export function nwcFeature(ctx) { if (dec.amountSat > left) { return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`); } + // Everything local has passed; now make sure this device is the one + // that pays. Claiming before the checks would block a sibling behind + // a refusal it would not itself have given. + if (claimReq && !(await claimReq())) return { silent: true }; let res; try { res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat }); |
