aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/nwc.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 09:06:02 -0700
committerAdam Soltys <[email protected]>2026-09-14 09:06:02 -0700
commit671d2a96474f59ca9234c876ce6604d03de95727 (patch)
treed732896288985033c0531b70b7bcad0cc388cb00 /src/features/nwc.js
parent6394de43d4e596aaeddd2b262bb7a595679d8fbc (diff)
downloadcoinosv3-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.js29
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 });