aboutsummaryrefslogtreecommitdiffstats
path: root/tools/nwc-budget-test.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 /tools/nwc-budget-test.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 'tools/nwc-budget-test.js')
-rw-r--r--tools/nwc-budget-test.js52
1 files changed, 52 insertions, 0 deletions
diff --git a/tools/nwc-budget-test.js b/tools/nwc-budget-test.js
new file mode 100644
index 0000000..780f5ea
--- /dev/null
+++ b/tools/nwc-budget-test.js
@@ -0,0 +1,52 @@
+// The NWC budget window: a UTC day, week (Monday) or month; what a
+// connection spent in the window on now, counting the app's counter and the
+// worker's unabsorbed spends; when it renews. Run: bun tools/nwc-budget-test.js
+import { budgetWindow, budgetRenewsAt, renewalPeriod, spentInWindow } from '../src/nwc-budget.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!ok) fails++; };
+
+// Wednesday 2026-10-14 23:30 UTC
+const T = Date.UTC(2026, 9, 14, 23, 30);
+check('day window is the UTC date', budgetWindow('day', T) === '2026-10-14', budgetWindow('day', T));
+check('day: half an hour later is the next day', budgetWindow('day', T + 3600_000) === '2026-10-15');
+check('week window is keyed by its Monday', budgetWindow('week', T) === 'W2026-10-12', budgetWindow('week', T));
+check('week: Sunday night is still that week', budgetWindow('week', Date.UTC(2026, 9, 18, 23, 59)) === 'W2026-10-12');
+check('week: Monday 00:00 starts the next', budgetWindow('week', Date.UTC(2026, 9, 19, 0, 0)) === 'W2026-10-19');
+check('month window is the UTC month', budgetWindow('month', T) === '2026-10');
+check('month: Nov 1 00:00 is the next', budgetWindow('month', Date.UTC(2026, 10, 1)) === '2026-11');
+check('unknown period counts as a day', budgetWindow(undefined, T) === '2026-10-14');
+
+check('day renews at next midnight', budgetRenewsAt('day', T) === Date.UTC(2026, 9, 15) / 1000);
+check('week renews next Monday', budgetRenewsAt('week', T) === Date.UTC(2026, 9, 19) / 1000);
+check('month renews on the 1st', budgetRenewsAt('month', T) === Date.UTC(2026, 10, 1) / 1000);
+check('December rolls into January', budgetRenewsAt('month', Date.UTC(2026, 11, 20)) === Date.UTC(2027, 0, 1) / 1000);
+check('NIP-47 period words', renewalPeriod('day') === 'daily' && renewalPeriod('week') === 'weekly' && renewalPeriod('month') === 'monthly' && renewalPeriod(undefined) === 'daily');
+
+console.log('\n[spent in the window]');
+const conn = { id: 'c1', dailySat: 10000, period: 'month', spentDate: '2026-10', spentToday: 3000 };
+{
+ const r = spentInWindow(conn, [], T);
+ check('the app counter counts when stamped with this window', r.spent === 3000 && r.left === 7000, JSON.stringify(r));
+}
+{
+ const r = spentInWindow({ ...conn, spentDate: '2026-09' }, [], T);
+ check('a counter from last month does not', r.spent === 0 && r.left === 10000);
+}
+{
+ const spends = [
+ { connId: 'c1', amountSat: 500, feeSat: 2, ts: Date.UTC(2026, 9, 2) }, // this month, unabsorbed
+ { connId: 'c1', amountSat: 400, feeSat: 0, ts: Date.UTC(2026, 9, 3), absorbed: true }, // already in the counter
+ { connId: 'c1', amountSat: 900, feeSat: 0, ts: Date.UTC(2026, 8, 30) }, // last month
+ { connId: 'c2', amountSat: 800, feeSat: 0, ts: Date.UTC(2026, 9, 5) }, // another connection
+ ];
+ const r = spentInWindow(conn, spends, T);
+ check('the worker adds only its own, unabsorbed spends from this window', r.spent === 3502, String(r.spent));
+}
+{
+ const r = spentInWindow({ id: 'c1', dailySat: 100, spentDate: '2026-10-14', spentToday: 80 }, [{ connId: 'c1', amountSat: 30, ts: T - 60_000 }], T);
+ check('a day budget, over: left is 0 not negative', r.spent === 110 && r.left === 0);
+}
+
+console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ budget windows behave');
+process.exit(fails ? 1 : 0);