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 /src/sw-nwc.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 'src/sw-nwc.js')
| -rw-r--r-- | src/sw-nwc.js | 11 |
1 files changed, 9 insertions, 2 deletions
diff --git a/src/sw-nwc.js b/src/sw-nwc.js index ddcdbb5..3dac246 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -4,7 +4,7 @@ // nwc-respond.js), and only falls back to the wake-the-user notification // when it can't. -import { respondFromBg } from './nwc-respond.js'; +import { respondFromBg, tendRenewals } from './nwc-respond.js'; import { withRequestLock } from './nwc-lock.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js'; @@ -70,6 +70,7 @@ self.addEventListener('push', (e) => { return; } if (data.type !== 'nwc') return; + const startedAt = Date.now(); e.waitUntil((async () => { // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. @@ -78,6 +79,11 @@ self.addEventListener('push', (e) => { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); return; } + // A wake is the only moment a closed wallet gets to look after its coins: + // claim renewals whose round has run, cover coins without one. After the + // answer, inside what is left of the push budget. + const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) }) + .catch((err) => console.warn('[sw-renew] failed:', err && err.message)); // an NWC wake-up may also have moved money in; check the rule after setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0); let handled = false; @@ -97,9 +103,10 @@ self.addEventListener('push', (e) => { tag: handled.notify.tag || 'nwc-incoming', renotify: true, data: { url: './' }, }); + await tend(); return; } - if (handled) return; + if (handled) { await tend(); return; } // A kind-21001 offer request is money coming IN (someone asking us to // mint an invoice) — "an app wants to pay" would read as the opposite. const incoming = data.event && data.event.kind === 21001; |
