aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 12:32:28 -0700
committerAdam Soltys <[email protected]>2026-10-16 12:32:28 -0700
commit294a49598e43e3e6f9e34f0246bbea41837359f9 (patch)
tree0fe376316626a9b27330ecbba15740d14ec4c825 /src/nwc-respond.js
parent38ce0b441505d0bdd048d38c82ac95afe4d3b407 (diff)
downloadcoinosv3-294a49598e43e3e6f9e34f0246bbea41837359f9.tar.gz
coinosv3-294a49598e43e3e6f9e34f0246bbea41837359f9.zip
NWC: budgets reset by the day, week or month; a revoked connection says so; a closed wallet keeps its coins renewed. Three asks from a subscription service builder (PullCap, by DM, 2026-10-16) testing a 1-sat-an-hour pull against a locked phone. (1) "For a monthly subscription a monthly cap would be tighter": a connection's budget now resets every day, week (Monday) or month, in UTC (nwc-budget.js: the window key stamps the spent counter; both the app and the worker count by it), chosen on the form, read on the card ("21,000 a month · 0 spent this month"); the worker now also counts what the APP spent this window (the counter rides the mirror) — it only ever counted its own log before, so a daily budget could be exceeded across the two; get_budget answers used/total/renews_at/renewal_period (NIP-47), in get_info's method list and the 13194 event. (2) "Does V3 reply with an NWC error when a connection is revoked, or just stop answering?" — it stopped: the open app dropped the request, the mirror forgot the key, the device even de-registered it from the push notifier, so a revocation looked exactly like a phone that is off. Now a revoked connection is kept for 30 days (revokedAt; the earlier date wins in a merge): still subscribed, still registered for pushes, still in the mirror flagged, and every request on it — from the app or the worker, in the asker's scheme — is answered UNAUTHORIZED "this connection was revoked"; no capability event for it; after the grace it is forgotten. (3) "If a user is only woken once a month by an NWC request, will V3 renew the coins on its own?" — only for one cycle: the app registers a standing order per coin, but a coin the worker SPENDS voids its order and the change had none until the app next opened, and a renewed coin sat unclaimed until then too. Now every NWC wake, after the answer and inside what is left of the push budget (tendRenewals, sw-nwc.js): claim orders whose round has run — and drive a claim left in flight on, since one takes several round trips and confirmations — then register an order for every coin without one (backstopMargin shared with the app; limited to the output keys the mirror carries; the mirror now also carries the output key a standing order or a claim in flight burned, which was below the window). Standing orders merge by unlock hash when the app returns (mergeScheduled, in mergeArkStates), so it adopts the worker's. tools/ark/bg-renewal-test.js on regtest, end to end: the worker registers from the mirror alone, a worker spend kills the order and the next wake covers the change, the round runs with nobody online and the next wake claims the renewed coin — and covers it in the same wake — then the app merges it all (fresh coin, right balance, the order adopted, not busy, one history row). tools/nwc-budget-test.js (windows, renews_at, counting with the worker's unabsorbed spends); nwc-sw-test.js (monthly budget over app+worker spends, last month's not counted, get_budget, UNAUTHORIZED in both schemes, the mirror's keys and flags, tending: claim/drive/register and their limits); nwc-test.js (monthly refusal naming the period, get_budget, a counter from last month reads as nothing, the revoked connection answered UNAUTHORIZED, no 13194, revokedAt in the snapshot, forgotten after 31 days, the earlier date wins); nwc-settings-ui-test.js (the form's period choice, the card's wording, Revoke dates it); ark-renewal-sync, sync-blob, sync-outbox and spending-restore tests pass.
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'src/nwc-respond.js')
-rw-r--r--src/nwc-respond.js85
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;
+}