aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-budget.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-budget.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-budget.js')
-rw-r--r--src/nwc-budget.js54
1 files changed, 54 insertions, 0 deletions
diff --git a/src/nwc-budget.js b/src/nwc-budget.js
new file mode 100644
index 0000000..a16e068
--- /dev/null
+++ b/src/nwc-budget.js
@@ -0,0 +1,54 @@
+// An NWC connection's spending budget resets on a calendar boundary — a day,
+// a week or a month — in UTC, so every device of a wallet (and the service
+// worker answering while the app is closed) agrees on which window a spend
+// fell in without a timezone in the picture. The window key is what the
+// connection's spent counter is stamped with: a counter stamped with another
+// key is from a window that has passed.
+
+export const PERIODS = ['day', 'week', 'month'];
+
+const pad = (n) => String(n).padStart(2, '0');
+
+// The week runs Monday to Sunday (ISO), keyed by its Monday.
+function mondayOf(d) {
+ const m = new Date(d);
+ const dow = (m.getUTCDay() + 6) % 7; // Monday = 0
+ m.setUTCDate(m.getUTCDate() - dow);
+ m.setUTCHours(0, 0, 0, 0);
+ return m;
+}
+
+export function budgetWindow(period, ts = Date.now()) {
+ const d = new Date(ts);
+ if (period === 'month') return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`;
+ if (period === 'week') { const m = mondayOf(d); return `W${m.getUTCFullYear()}-${pad(m.getUTCMonth() + 1)}-${pad(m.getUTCDate())}`; }
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
+}
+
+// When the current window ends, in unix seconds (NIP-47's renews_at).
+export function budgetRenewsAt(period, ts = Date.now()) {
+ const d = new Date(ts);
+ let end;
+ if (period === 'month') end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1);
+ else if (period === 'week') { const m = mondayOf(d); end = m.getTime() + 7 * 86_400_000; }
+ else end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
+ return Math.floor(end / 1000);
+}
+
+// NIP-47 names the period as an adverb.
+export const renewalPeriod = (period) => (period === 'month' ? 'monthly' : period === 'week' ? 'weekly' : 'daily');
+
+// What a connection has spent in the window that is on now: the counter
+// carried on the connection (stamped with its window) plus, for the worker,
+// its own spends the app has not yet folded into that counter.
+export function spentInWindow(conn, extraSpends = [], ts = Date.now()) {
+ const period = conn.period || 'day';
+ const win = budgetWindow(period, ts);
+ let spent = conn.spentDate === win ? (conn.spentToday || 0) : 0;
+ for (const s of extraSpends) {
+ if (s.absorbed || s.connId !== conn.id) continue;
+ if (budgetWindow(period, s.ts) !== win) continue;
+ spent += (s.amountSat || 0) + (s.feeSat || 0);
+ }
+ return { window: win, period, spent, left: Math.max(0, (conn.dailySat || 0) - spent) };
+}