diff options
| -rw-r--r-- | src/features/ark.js | 22 | ||||
| -rw-r--r-- | src/nwc-bg.js | 13 | ||||
| -rw-r--r-- | src/nwc-respond.js | 40 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 32 |
4 files changed, 86 insertions, 21 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 0a529c5..b8e83a7 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -995,6 +995,26 @@ export function arkFeature(ctx) { // connections/offer belong to the nwc feature; when it isn't the caller // (an auto-withdraw setting changed, say) carry forward what's mirrored // rather than blanking someone's wallet connections. + // The mirror carries hundreds of pre-derived keys (BG_KEY_LOOKAHEAD) and + // is rewritten after every state change: derive each index once per + // account, not once per write. + const bgKeyMemo = new WeakMap(); // account node -> Map('chain:i' -> hex) + function bgKeyFor(account) { + let m = bgKeyMemo.get(account); + if (!m) { m = new Map(); bgKeyMemo.set(account, m); } + const chains = new Map(); + return (chain, i) => { + const k = chain + ':' + i; + let v = m.get(k); + if (!v) { + let node = chains.get(chain); + if (!node) { node = account.deriveChild(chain); chains.set(chain, node); } + v = hex.encode(node.deriveChild(i).privateKey); + m.set(k, v); + } + return v; + }; + } async function writeBgMirror(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; @@ -1006,7 +1026,7 @@ export function arkFeature(ctx) { ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired }, mgrState: ark.state, custody: getCustody(), - keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), + keyFor: bgKeyFor(ark.account), connections, offer, autowithdraw: await awMirror(prev), diff --git a/src/nwc-bg.js b/src/nwc-bg.js index ab083d9..9e7a303 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -133,8 +133,15 @@ export async function allBgs() { } // How many upcoming change/refund indices travel with the record: the worker -// cannot derive keys, so its allocations must land inside this window. -export const BG_KEY_LOOKAHEAD = 25; +// cannot derive keys, so its allocations must land inside this window. Each +// Lightning send burns a change and an HTLC index, so 25 keys lasted PullCap +// about 19 hourly payments (2026-10-26, "key window exhausted" after a day +// with the app closed). 250 keys are 16 KB in the record and cover weeks of +// hourly payments, or years of monthly ones; the app tops the window up +// every time it writes the mirror. +export const BG_KEY_LOOKAHEAD = 250; +// ...and the receive preimages a closed wallet can mint invoices with +export const BG_RECV_LOOKAHEAD = 60; // { ark } endpoints, the manager state to mirror, and a keyFor(chain, index) // derive function (the caller owns the account node — it never enters this @@ -153,7 +160,7 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra idx.add(0); // the receive-address key: mailbox/refund defaults land on it for (const i of idx) keys[String(i)] = keyFor(3, i); const keys5 = {}; - for (let i = 0; i < 15; i++) { + for (let i = 0; i < BG_RECV_LOOKAHEAD; i++) { const n = (mgrState.nextLnRecvIndex || 0) + i; keys5[String(n)] = keyFor(5, n); } diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 0778813..6ea23ab 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -24,6 +24,13 @@ import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js'; import { renewPolicy } from './ark/manager.js'; const MAX_AGE_SEC = 120; +// A request the worker can't act on (keys or coins short in the mirror, an +// old mirror) wakes the user — and, unless someone answers first, tells the +// other app so (PullCap read the silence as "no answer" and suspended the +// subscription; with an error it can tell the payer their wallet is waiting +// for them). The notifier holds the reply this long before sending it. +const DECLINE_AFTER_MS = 20_000; +const WAIT_MSG = 'Coinos is waiting for its owner to open the app and approve. Look this request up again later.'; const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin const nowSec = () => Math.floor(Date.now() / 1000); const errRes = (t, code, message) => ({ result_type: t, error: { code, message } }); @@ -206,7 +213,10 @@ export async function respondFromBg(data, { } = {}) { const ev = data && data.event; if (!ev || !ev.id || !ev.pubkey) return false; - if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false; + // Older than the request window (a push that sat at APNs while the phone + // was out of reach): the other app has moved on — nothing to answer, and + // no "Payment request" to show for it. The wake still tends what's in flight. + if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) { log(`request ${String(ev.id).slice(0, 8)} is ${nowSec() - ev.created_at} s old — ignored`); return true; } const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1]; if (!target) return false; @@ -250,6 +260,20 @@ export async function respondFromBg(data, { try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; } const method = req?.method; const params = req?.params || {}; + // Wake the user, out loud: the wait reply is left with the notifier, which + // sends it only if nobody (a sibling device, the woken user) answers in + // time — so it can never land on top of a real payment. + const declineLater = async (why) => { + log(why + ' — waking the user; the app is told to wait'); + try { + const evt = await buildReply(conn, ev, scheme, errRes(method || 'error', 'OTHER', WAIT_MSG)); + await fetchFn(`${notifier}/decline`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt, afterMs: DECLINE_AFTER_MS }), + }); + } catch (e) { log('could not leave the wait reply: ' + (e && e.message)); } + return false; + }; // the push's budget (on iOS about ten seconds from the push, not thirty) const deadline = until || Date.now() + PAY_WAIT_MS; let lookupNotice = null; @@ -301,9 +325,9 @@ export async function respondFromBg(data, { if (method === 'make_invoice') { const sat = Math.floor((params.amount || 0) / 1000); if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; } - if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user - if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted - if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted + if (!rec.keys5 || !rec.key4 || !rec.mgr) return declineLater('old mirror, no receive keys'); + if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return declineLater('preimage window exhausted'); + if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted'); let a; try { const mgr = await bgManager(rec, walletKey, saveFn); @@ -340,7 +364,7 @@ export async function respondFromBg(data, { tx = view.lnLookup(q); if (tx) { ({ walletKey, rec, conn } = m); break; } } - if (!tx && !rec.mgr) return false; + if (!tx && !rec.mgr) return declineLater('old mirror, no state to look up'); // a payment of ours still on its way (incoming: claim it; outgoing: a // send an earlier wake began — drive it on, so the lookup itself can // answer 'settled' with the preimage instead of a second pay request) @@ -512,13 +536,13 @@ export async function respondFromBg(data, { const withNotices = (out) => (notices.length && out ? { ...(out === true ? {} : out), notices } : out); const spendable = spendableSat(rec); - if (spendable < dec.amountSat) { log(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked — waking the user`); return false; } + if (spendable < dec.amountSat) return declineLater(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked`); // The ASP's own Lightning send, over the mirrored state. Change and HTLC // outputs allocate from nextKeyIndex, which must stay inside the key // window the app pre-derived; if it ran out, wake the user instead. - if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; } - try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; } + if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted'); + try { await ready(); } catch (e) { return declineLater('ark init failed: ' + e.message); } if (!(await claim())) { log('another device claimed this request — standing down'); return true; } // whose send this is, for the wake that finishes it should this one not diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 5a15d48..376a12e 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -47,11 +47,16 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) { function harness({ rec = baseRec(), recs = null, answered = false } = {}) { const published = []; + const declined = []; // wait replies left with the notifier (sent only if nobody answers) 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('/decline')) { + declined.push(JSON.parse(opts.body).event); + return { ok: true, json: async () => ({ ok: true, scheduled: true }) }; + } if (url.includes('/claim')) { return { ok: true, json: async () => ({ ok: true }) }; } @@ -73,7 +78,7 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) { ? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk)) : nip04.decrypt(clientSk, conn.servicePk, ev.content)); }; - return { deps, published, decryptReply }; + return { deps, published, declined, decryptReply }; } const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would @@ -137,15 +142,19 @@ console.log('\n[merchant methods (BTCPay)]'); check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`); } { - const { deps } = harness(); // a mirror without receive keys - check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false); + const { deps, declined, decryptReply } = harness(); // a mirror without receive keys + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps); + const r = declined.length ? decryptReply(declined[0]) : null; + check('make_invoice on an old mirror → wake the user, wait reply left behind', handled === false && declined.length === 1 && r.result_type === 'make_invoice' && r.error?.code === 'OTHER', JSON.stringify(r)); } console.log('\n[guards]'); { - const { deps } = harness(); + const { deps, published, declined } = harness(); const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300)); - check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false); + // a push that sat at APNs past the request window: nothing to answer, and + // no "Payment request" for it either (true = handled quietly) + check('stale request ignored quietly', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === true && !published.length && !declined.length); } { const { deps } = harness(); @@ -185,16 +194,21 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z { const rec = baseRec(); rec.mgr.vtxos = []; // nothing spendable in the mirror - const { deps } = harness({ rec }); + const { deps, published, declined, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); - check('empty mirror → wake the user', handled === false); + const r = declined.length ? decryptReply(declined[0]) : null; + // the wait reply is NOT published by the worker: the notifier holds it and + // sends it only if nobody answers — a sibling's payment can't be shouted over + check('empty mirror → wake the user, wait reply left with the notifier', handled === false && published.length === 0 && declined.length === 1 + && r.result_type === 'pay_invoice' && r.error?.code === 'OTHER' && /open the app/.test(r.error.message), JSON.stringify(r)); + check('…addressed to the request', declined[0].kind === 23195 && declined[0].tags.some((t) => t[0] === 'e')); } { const rec = baseRec(); delete rec.keys['5']; // change index outside the mirrored window - const { deps } = harness({ rec }); + const { deps, declined } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); - check('exhausted key window → wake the user', handled === false); + check('exhausted key window → wake the user, wait reply left behind', handled === false && declined.length === 1); } console.log('\n[pay outcome]'); |
