diff options
Diffstat (limited to 'src/nwc-respond.js')
| -rw-r--r-- | src/nwc-respond.js | 85 |
1 files changed, 73 insertions, 12 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 27699e7..ec19923 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -20,6 +20,8 @@ import { finalizeEvent } from 'nostr-tools/pure'; import { ArkManager } from './ark/manager.js'; import { maybeBolt11 } from './ark/lightning.js'; import { allBgs, saveBg } from './nwc-bg.js'; +import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js'; +import { backstopMargin } from './ark/manager.js'; const MAX_AGE_SEC = 120; const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin @@ -160,11 +162,6 @@ export async function respondFromBg(data, { if (ev.pubkey !== conn.clientPk) return false; const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04'; - let req; - try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; } - const method = req?.method; - const params = req?.params || {}; - const publish = async (payload) => { const evt = await buildReply(conn, ev, scheme, payload); const r = await fetchFn(`${notifier}/publish`, { @@ -174,6 +171,17 @@ export async function respondFromBg(data, { if (!r.ok) throw new Error(`publish refused: ${r.status}`); return true; }; + let req; + try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; } + const method = req?.method; + const params = req?.params || {}; + // Revoked, lately: refuse out loud — a silent worker looks like a phone + // that is off, which is the one thing the other app cannot tell apart. + if (conn.revoked) { + await publish(errRes(method || 'error', 'UNAUTHORIZED', 'this connection was revoked')); + log('refused a request on a revoked connection'); + return true; + } // Another device (an open tab elsewhere) may have answered already — a // paid zap must never be shouted over with a late error, and paying twice // wastes funds-in-flight. The notifier tracks recent reply e-tags. @@ -204,7 +212,7 @@ export async function respondFromBg(data, { alias: 'Coinos', color: '#15171a', network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network, block_height: 0, block_hash: '', - methods: ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'], + methods: ['get_info', 'get_balance', 'get_budget', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'], }, }); return true; @@ -265,6 +273,14 @@ export async function respondFromBg(data, { await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } }); return true; } + if (method === 'get_budget') { + const { spent } = spentInWindow(conn, rec.spends || []); + await publish({ result_type: 'get_budget', result: { + used_budget: spent * 1000, total_budget: (conn.dailySat || 0) * 1000, + renews_at: budgetRenewsAt(conn.period || 'day'), renewal_period: renewalPeriod(conn.period), + } }); + return true; + } if (method === 'list_transactions') { // the mirrored wallet's Lightning history, both ways, as the open app reports it const txs = ((rec.mgr && rec.mgr.movements) || []) @@ -309,12 +325,11 @@ export async function respondFromBg(data, { await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`)); return true; } - const today = new Date().toISOString().slice(0, 10); - const spentToday = (rec.spends || []) - .filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today) - .reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0); - if (spentToday + dec.amountSat > conn.dailySat) { - await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`)); + // What the app spent this window rides on the mirrored connection; the + // worker's own spends the app has not yet folded in are counted from its log. + const { left } = spentInWindow(conn, rec.spends || []); + if (dec.amountSat > left) { + await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`)); return true; } if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user @@ -411,3 +426,49 @@ export async function respondFromBg(data, { }, }; } + +// Renewals while the app is closed. The app registers a standing order with +// the ASP for every coin (a round it runs on its own, margin blocks before +// expiry) — but a coin spent by this worker voids its order, and the change +// left behind has none until the app next opens. A wallet only ever woken by +// NWC requests would lose cover one spend after its last open. So each wake +// tends the mirror's coins the way the app does: claim any order whose round +// has run (the output waits 'unclaimed' until someone forfeits the inputs), +// and register an order for every coin without one. Both need only what the +// mirror carries — the coins' keys and a window of upcoming indices. +// Bounded: the push handler has a budget, and anything left over waits for +// the next wake or the app. +export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000, margin = null } = {}) { + let n = 0; + for (const { walletKey, rec } of await recordsFn()) { + if (!rec || rec.v < 3 || !rec.mgr || !rec.ark || !rec.key4) continue; + if (Date.now() > deadline - 1500) break; + const timeLeft = () => Math.max(0, deadline - Date.now()); + const bounded = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), timeLeft()))]); + try { + const mgr = await bounded(bgManager(rec, walletKey, saveFn)); + await bounded(mgr._tipMemo()); + // orders whose round has run: claim them (the output becomes spendable + // here, and merges into the app's state on its next open). A claim is + // several round trips — the funding tx must confirm, then leaf cosign, + // forfeit, adopt — so one that ran out of time (or confirmations) last + // wake sits as a refresh action in flight: drive it on, every wake. + await bounded(mgr._driveScheduled()).catch((e) => log('renewal claim: ' + e.message)); + for (const a of mgr.state.actions || []) { + if (a.type !== 'refresh' || ['done', 'failed'].includes(a.step)) continue; + await bounded(mgr._driveRefresh(a)).catch((e) => log('renewal claim: ' + e.message)); + if (a.step === 'done') { n++; log('claimed a renewal while closed'); } + } + // coins without an order: register one, as long as the output key the + // order burns is inside the mirrored window (the worker can't derive) + let room = 0; + while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++; + if (room) { + const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? backstopMargin(rec.ark.network), room)).catch((e) => { log('renewal order: ' + e.message); return 0; }); + if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); } + } + await saveFn(walletKey, rec); + } catch (e) { log('renewals: ' + e.message); } + } + return n; +} |
