aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark
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/ark
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/ark')
-rw-r--r--tools/ark/bg-renewal-test.js135
1 files changed, 135 insertions, 0 deletions
diff --git a/tools/ark/bg-renewal-test.js b/tools/ark/bg-renewal-test.js
new file mode 100644
index 0000000..0a85816
--- /dev/null
+++ b/tools/ark/bg-renewal-test.js
@@ -0,0 +1,135 @@
+// The service worker tends a closed wallet's renewals, on regtest, end to end:
+//
+// the app receives a coin and writes the background mirror -> "closes" ->
+// the worker (tendRenewals over the mirror, keys from the mirror alone)
+// registers a standing order for the coin -> the worker SPENDS from the
+// mirror (an NWC pay) which voids that order; the next wake covers the
+// change coin -> the scheduled height passes, the server runs the round
+// with nobody online -> the next wake claims the renewed coin into the
+// mirror -> the app returns and merges the worker's state: fresh coin,
+// old one spent, the order gone.
+//
+// Usage: bun tools/ark/bg-renewal-test.js
+// Needs the ark-regtest stack (bc, ark-electrs, captaind on :3535).
+
+import { execSync } from 'node:child_process';
+import { hex } from '@scure/base';
+import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { HDKey } from '@scure/bip32';
+
+import { ArkManager } from '../../src/ark/manager.js';
+import { buildBg } from '../../src/nwc-bg.js';
+import { tendRenewals, bgManager } from '../../src/nwc-respond.js';
+import { mergeArkStates } from '../../src/features/ark.js';
+import { ALICE, aliceReady } from './alice.js';
+
+const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
+const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002';
+const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
+const tipHeight = () => Number(sh(`${BCLI} getblockcount`));
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// --- the app's wallet ---
+const store = { raw: null };
+const storage = { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } };
+const mnemonic = generateMnemonic(wordlist);
+const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'");
+const app = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
+await aliceReady();
+
+const receive = async (sat) => {
+ const before = app.balance().spendableSat;
+ sh(`${ALICE} -q send ${app.address()} "${sat} sat"`);
+ for (let i = 0; i < 15 && app.balance().spendableSat < before + sat; i++) { await app.sync(); await sleep(1000); }
+ return app.balance().spendableSat - before;
+};
+
+// --- the mirror the worker answers from (what writeBgMirror writes) ---
+let rec = null;
+const writeMirror = () => {
+ rec = buildBg({
+ ark: { arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest', serverPubkey: app.info.serverPubkey },
+ mgrState: JSON.parse(JSON.stringify(app.state)),
+ keyFor: (chain, i) => hex.encode(account.deriveChild(chain).deriveChild(i).privateKey),
+ connections: [],
+ });
+};
+const recs = async () => [{ walletKey: 'w1', rec }];
+const saveFn = async (k, r) => { rec = r; };
+const log = (m) => console.log(' [sw-renew] ' + m);
+const tend = (margin) => tendRenewals({ recordsFn: recs, saveFn, log, deadline: Date.now() + 60_000, margin });
+const mirrorCoins = () => (rec.mgr.vtxos || []).filter((v) => v.state === 'spendable');
+
+console.log('\n[1] the app receives a coin, mirrors it, and closes');
+check('received 20000 sat', (await receive(20000)) === 20000, JSON.stringify(app.balance()));
+writeMirror();
+const coin = mirrorCoins()[0];
+check('the mirror holds the coin and its key', !!coin && !!rec.keys[String(coin.keyIndex)] && !!rec.key4);
+check('no standing order yet, on either side', !(app.state.scheduled || []).length && !(rec.mgr.scheduled || []).length);
+
+console.log('\n[2] a wake: the worker registers a standing order from the mirror alone');
+// aim the order a few blocks out so the test needn't mine a month
+const n1 = await tend(coin.expiryHeight - (tipHeight() + 3));
+const order1 = (rec.mgr.scheduled || [])[0];
+check('one order registered', n1 === 1 && (rec.mgr.scheduled || []).length === 1);
+check('with the server\'s unlock hash', !!order1?.unlockHash, order1?.unlockHash?.slice(0, 16));
+check('the coin stays spendable in the mirror', mirrorCoins().some((v) => v.id === coin.id));
+check('the output key it burned is in the mirror', !!rec.keys[String(order1?.outKeyIndex)]);
+check('the app knows nothing of it yet', !(app.state.scheduled || []).length);
+check('a second wake registers nothing more', (await tend(coin.expiryHeight - (tipHeight() + 3))) === 0 && rec.mgr.scheduled.length === 1);
+
+console.log('\n[3] the worker spends from the mirror (an NWC pay): the order dies, the change gets its own');
+const worker = await bgManager(rec, 'w1', saveFn);
+await worker.send(sh(`${ALICE} -q address`), 5000);
+await worker.sync();
+const change = mirrorCoins().find((v) => v.id !== coin.id);
+check('change coin in the mirror', !!change, change && `${change.amountSat} sat`);
+check('the spent coin\'s order is gone', !(rec.mgr.scheduled || []).some((s) => s.inputIds.includes(coin.id)));
+const n2 = await tend(change.expiryHeight - (tipHeight() + 3));
+const order2 = (rec.mgr.scheduled || []).find((s) => s.inputIds.includes(change.id));
+check('the next wake covers the change coin', n2 === 1 && !!order2, JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds)));
+check('scheduled a few blocks out', order2?.scheduledHeight === tipHeight() + 3, `${order2?.scheduledHeight} vs ${tipHeight() + 3}`);
+
+console.log('\n[4] the round runs with nobody online; the next wake claims it into the mirror');
+mine(3 + 2);
+await sleep(15_000);
+const claimed = () => (rec.mgr.actions || []).some((a) => a.type === 'refresh' && a.step === 'done');
+for (let i = 0; i < 25 && !claimed(); i++) { await tend(change.expiryHeight - (tipHeight() + 3)); mine(1); await sleep(1500); }
+if (!claimed()) console.log(' actions:', JSON.stringify((rec.mgr.actions || []).map((a) => [a.type, a.step, a.lastError])));
+const renewed = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id);
+const renewedId = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id)?.id;
+check('the consumed order is replaced by one for the fresh coin, in the same wake', (rec.mgr.scheduled || []).length === 1 && !!renewedId && rec.mgr.scheduled[0].inputIds.includes(renewedId), JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds)));
+check('the change coin is spent', rec.mgr.vtxos.find((v) => v.id === change.id)?.state === 'spent');
+check('a fresh coin sits in the mirror', !!renewed, renewed && `${renewed.amountSat} sat`);
+check('worth the change minus the scheduled fee', !!renewed && renewed.amountSat === change.amountSat - order2.feeSat, `${renewed?.amountSat} vs ${change?.amountSat} - ${order2?.feeSat}`);
+check('with its expiry clock restarted', !!renewed && renewed.expiryHeight > change.expiryHeight, renewed && `${change.expiryHeight} -> ${renewed.expiryHeight}`);
+check('the renewal is in the mirror\'s history', (rec.mgr.movements || []).some((m) => m.type === 'refresh' && m.status === 'complete'));
+
+console.log('\n[5] the app returns and merges the worker\'s state');
+// what mergeBgWorkerState does on connect: additive merge, the worker's spent flips win, reconcile
+const merged = mergeArkStates(app.state, rec.mgr);
+const workerState = new Map(rec.mgr.vtxos.map((v) => [v.id, v.state]));
+for (const v of merged.vtxos) if (workerState.get(v.id) === 'spent') v.state = 'spent';
+merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1);
+app.state = merged; app._save();
+await app.reconcile().catch((e) => console.log(' reconcile:', e.message));
+await app.sync();
+const appCoins = app.vtxos().filter((v) => v.state === 'spendable');
+check('the app holds the fresh coin and nothing stale', appCoins.length === 1 && appCoins[0].id === renewed?.id, JSON.stringify(appCoins.map((v) => [v.id.slice(0, 8), v.amountSat, v.state])));
+check('balance is the change minus the renewal fee', app.balance().spendableSat === change.amountSat - order2.feeSat, JSON.stringify(app.balance()));
+check('the app adopts the worker\'s order for the fresh coin', (app.state.scheduled || []).length === 1 && app.state.scheduled[0].inputIds.includes(renewed?.id) && app.state.scheduled[0].unlockHash === rec.mgr.scheduled[0].unlockHash, JSON.stringify((app.state.scheduled || []).map((s) => s.inputIds)));
+check('the app is not busy', app.pendingActions().length === 0, JSON.stringify(app.pendingActions().map((a) => [a.type, a.step])));
+check('the renewal shows in the app\'s history once', app.movements().filter((m) => m.type === 'refresh' && m.status === 'complete').length === 1);
+
+console.log(ok ? '\nALL GREEN\n' : '\nFAILURES\n');
+process.exit(ok ? 0 : 1);