diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 12:32:28 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-16 12:32:28 -0700 |
| commit | 294a49598e43e3e6f9e34f0246bbea41837359f9 (patch) | |
| tree | 0fe376316626a9b27330ecbba15740d14ec4c825 /tools/nwc-test.js | |
| parent | 38ce0b441505d0bdd048d38c82ac95afe4d3b407 (diff) | |
| download | coinosv3-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-test.js')
| -rw-r--r-- | tools/nwc-test.js | 66 |
1 files changed, 65 insertions, 1 deletions
diff --git a/tools/nwc-test.js b/tools/nwc-test.js index 848b2c6..5b0dd10 100644 --- a/tools/nwc-test.js +++ b/tools/nwc-test.js @@ -13,12 +13,13 @@ const store = {}; globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>{store[k]=v;}, removeItem:k=>{delete store[k];} }; let subHandler = null; +const subFilters = []; // every filter subscribed, in order let priorReplies = []; let claimOk = true; const published = []; // fake relay transport: capture the subscription handler and any replies const nwcTransport = { - subscribe: (relays, filter, on) => { subHandler = on; return () => { subHandler = null; }; }, + subscribe: (relays, filter, on) => { subFilters.push(filter); subHandler = on; return () => { subHandler = null; }; }, publish: async (relays, evt) => { published.push(evt); return true; }, query: async () => priorReplies, // what other devices have answered (none, unless a test says so) errGraceMs: 20, // the sibling-first hold, shortened for the harness @@ -222,5 +223,68 @@ console.log('\n[device sync of connections]'); check('what we publish carries the connections', Array.isArray(ext.save().nwcConns)); } + +// --------------------------------------------------------------------------- +console.log('\n[period budgets + get_budget]'); +{ + const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js'); + const st = JSON.parse(store['fs:nwc']); + const c = st.conns.find((x) => x.id === 'test1'); + Object.assign(c, { period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1990 }); + store['fs:nwc'] = JSON.stringify(st); + f.stop(); f.init(); priorReplies = []; claimOk = true; + const wasPaid = paid.length; + r = await request('pay_invoice', { invoice: 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g' }); + check('21 sat over a monthly budget with 10 left → refused, naming the month', r?.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r?.error?.message || ''), JSON.stringify(r?.error)); + check('nothing was paid', paid.length === wasPaid); + r = await request('get_budget', {}); + check('get_budget reports the month', r?.result?.used_budget === 1990000 && r?.result?.total_budget === 2000000 && r?.result?.renewal_period === 'monthly' && r?.result?.renews_at === budgetRenewsAt('month'), JSON.stringify(r)); + r = await request('get_info', {}); + check('get_info lists get_budget', r?.result?.methods?.includes('get_budget')); + // last month's counter is not this month's + const st2 = JSON.parse(store['fs:nwc']); + const lm = new Date(); lm.setUTCMonth(lm.getUTCMonth() - 1); + Object.assign(st2.conns.find((x) => x.id === 'test1'), { spentDate: budgetWindow('month', lm.getTime()), spentToday: 1990 }); + store['fs:nwc'] = JSON.stringify(st2); + f.stop(); f.init(); + r = await request('get_budget', {}); + check('a counter from last month reads as nothing spent', r?.result?.used_budget === 0, JSON.stringify(r?.result)); + r = await request('pay_invoice', { invoice: INV21 }); // the mock seam pays 25 + 1 fee + check('…and a payment goes through, stamped with this month', !!r?.result?.preimage && JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentDate === budgetWindow('month') && JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentToday === 26, JSON.stringify(r?.error || r)); +} + +console.log('\n[revoked: refused out loud, for a while]'); +{ + const st = JSON.parse(store['fs:nwc']); + const c = st.conns.find((x) => x.id === 'test1'); + c.revoked = true; c.revokedAt = Date.now(); + store['fs:nwc'] = JSON.stringify(st); + subFilters.length = 0; + f.stop(); f.init(); + check('a lately revoked connection is still listened for', subFilters.some((x) => x.kinds?.[0] === 23194 && x['#p']?.[0] === conn.servicePk)); + const wasPaid = paid.length; + r = await request('pay_invoice', { invoice: INV21 }); + check('a pay is answered UNAUTHORIZED', r?.error?.code === 'UNAUTHORIZED' && r?.result_type === 'pay_invoice', JSON.stringify(r)); + check('nothing was paid', paid.length === wasPaid); + r = await request('get_balance', {}, 'nip04'); + check('a balance ask too, in nip04', r?.error?.code === 'UNAUTHORIZED' && r?.result_type === 'get_balance', JSON.stringify(r)); + const n = published.filter((e) => e.kind === 13194 && e.pubkey === conn.servicePk).length; + f.stop(); f.init(); await new Promise((r) => setTimeout(r, 100)); + check('no capability event is published for it', published.filter((e) => e.kind === 13194 && e.pubkey === conn.servicePk).length === n); + check('the revocation date travels in the snapshot', wallet.__ext.save().nwcConns.find((x) => x.id === 'test1').revokedAt === c.revokedAt); + // 31 days on: forgotten + const st2 = JSON.parse(store['fs:nwc']); + st2.conns.find((x) => x.id === 'test1').revokedAt = Date.now() - 31 * 86_400_000; + store['fs:nwc'] = JSON.stringify(st2); + subFilters.length = 0; + f.stop(); f.init(); + check('after the grace period it is no longer listened for', !subFilters.some((x) => x.kinds?.[0] === 23194 && x['#p']?.[0] === conn.servicePk)); + // a revocation dated on two devices keeps the earlier date + const ext = wallet.__ext; + const stale = { ...JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1'), revokedAt: Date.now() - 40 * 86_400_000 }; + ext.load({ nwcConns: [stale] }); + check('merge keeps the earlier revocation date', JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').revokedAt === stale.revokedAt); +} + console.log(ok ? '\n✅ NWC service behaves' : '\n❌ failed'); process.exit(ok?0:1); |
