diff options
| author | Adam Soltys <[email protected]> | 2026-09-14 09:16:23 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-14 09:16:23 -0700 |
| commit | dd1489303f59ccef6b322142f94e08d750c06e79 (patch) | |
| tree | eecccbeb577b873b855766f8e064622ce5c2f2dc /src/features | |
| parent | 671d2a96474f59ca9234c876ce6604d03de95727 (diff) | |
| download | coinosv3-dd1489303f59ccef6b322142f94e08d750c06e79.tar.gz coinosv3-dd1489303f59ccef6b322142f94e08d750c06e79.zip | |
A zap answers as fast as the route allows.
The wallet used to add two to four seconds of its own around a Lightning
send: a random pause plus a relay look-back before paying, and a
two-second poll for the result after. The notifier's claim now decides
who pays in one round trip, with the relay look-back kept only for
replays and for when the notifier gives no verdict; a phone answering
from behind another app waits a second so a desktop tab wins the claim
instead of paying at a throttled crawl. After the send, one held
CheckLightningPayment call brings the preimage the moment CLN has it —
for wallet-connect pays, the background worker, and the app's own Send.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 12 | ||||
| -rw-r--r-- | src/features/nwc.js | 48 |
2 files changed, 42 insertions, 18 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index e9c93b1..04444ff 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -763,6 +763,17 @@ export function arkFeature(ctx) { } function pollArkLn(id, onSettle) { stopArkLnPoll(id); + // One held call brings the settlement the moment the server has it; + // the interval below is the fallback pace if that call breaks. + if (ark) { + ark.awaitLnPay(id).then((a) => { + if (a && ['done', 'failed'].includes(a.step) && arkLnTimers.has(id)) { + stopArkLnPoll(id); + onSettle(a); + } + render(); + }).catch(() => {}); + } arkLnTimers.set(id, setInterval(async () => { if (!ark) return; const a = await ark.driveLn(id).catch(() => null); @@ -882,6 +893,7 @@ export function arkFeature(ctx) { for (let attempt = 0; attempt < 3; attempt++) { const routingFeeSat = attempt === 0 ? undefined : (last?.routingFeeSat ?? 0) + 1; const id = await mgr.payLnInvoice(invoice, { routingFeeSat }); + await mgr.awaitLnPay(id).catch(() => {}); for (let i = 0; i < 60; i++) { const a = mgr.lnAction(id); if (!a || ['done', 'failed'].includes(a.step)) break; diff --git a/src/features/nwc.js b/src/features/nwc.js index 0b6cf08..b72f206 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -66,6 +66,8 @@ export function nwcFeature(ctx) { // 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. + // Returns true (ours), false (someone else's) or null (no verdict: the + // notifier could not be reached). const claimAs = Math.random().toString(36).slice(2, 12); async function claimOn(id) { try { @@ -74,10 +76,14 @@ export function nwcFeature(ctx) { body: JSON.stringify({ event: id, by: claimAs }), signal: AbortSignal.timeout ? AbortSignal.timeout(4000) : undefined, }); - if (!r.ok) return true; + if (!r.ok) return null; return (await r.json()).ok !== false; - } catch { return true; } + } catch { return null; } } + // A phone answering from behind another app is throttled to a crawl and + // still tends to win a race it will then lose by ten seconds; let any + // desktop tab claim first. + const mobile = typeof navigator !== 'undefined' && /Mobi|Android/i.test(navigator.userAgent || ''); // ---- persisted connections ------------------------------------------- // { id, name, secret (client sk hex), clientPk, servicePk, serviceSk, @@ -335,17 +341,28 @@ export function nwcFeature(ctx) { if (!METHODS.includes(method)) { return reply(c, ev, scheme, { result_type: method, ...errRes('NOT_IMPLEMENTED', 'method not supported') }); } + // Leader election across the user's open devices: every tab sees every + // request. The notifier's claim decides in one round trip; only when it + // gives no verdict do we fall back to the old dance — defer a beat, then + // stay SILENT if any reply for this request already exists on the relays + // (a late tab, woken with the request still inside the replay window, + // once re-answered zaps another device had paid). A request older than a + // few seconds is a replay or a wake-up: check the relays first, since a + // reply very likely exists and the claim may have expired with it. + let claimed = null; // this device's verdict, resolved before any spend if (method === 'pay_invoice') { - // Loose leader election across the user's open devices: every tab sees - // every request, and a late tab (woken with the request still inside - // the replay window) once re-answered zaps another device had already - // paid — its error replies made successful zaps look failed. Defer a - // beat, then stay SILENT if any reply for this request already exists. const age = nowSec() - (ev.created_at || 0); - await new Promise((r) => setTimeout(r, age > 5 ? 0 : 300 + Math.random() * 900)); - const prior = await query(NWC_RELAYS, { kinds: [RES_KIND], '#e': [ev.id] }, 1500); - if (prior && prior.length) { - return console.log('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8)); + const lookBack = async () => { + const prior = await query(NWC_RELAYS, { kinds: [RES_KIND], '#e': [ev.id] }, 1500); + return !!(prior && prior.length); + }; + if (age > 5 && await lookBack()) return console.log('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8)); + if (mobile && age <= 5) await new Promise((r) => setTimeout(r, 1000)); + claimed = await claim(ev.id); + if (claimed === false) return console.log('nwc: standing down — another device claimed', ev.id.slice(0, 8)); + if (claimed === null && age <= 5) { + await new Promise((r) => setTimeout(r, 300 + Math.random() * 900)); + if (await lookBack()) return console.log('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8)); } } updateConn(c.id, { lastUsed: Date.now() }); @@ -370,8 +387,7 @@ export function nwcFeature(ctx) { await reply(c, ev, scheme, payload); }; try { - 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)); + const out = await handle(c, method, params); await sendChecked({ result_type: method, ...out }); } catch (e) { await sendChecked({ result_type: method, ...errRes('INTERNAL', e.message || 'failed') }); @@ -379,7 +395,7 @@ export function nwcFeature(ctx) { render(); } - async function handle(c, method, params, claimReq) { + async function handle(c, method, params) { if (!hook('arkReady')) return errRes('UNAUTHORIZED', 'Ark is not configured in this wallet'); if (method === 'get_info') { @@ -419,10 +435,6 @@ 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 }); |
