aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/ark/custody-test.js98
-rw-r--r--tools/ark/custody-ui-test.js122
-rw-r--r--tools/nwc-sw-test.js19
3 files changed, 236 insertions, 3 deletions
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..da1079c
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,98 @@
+// The custody tiers, without a network: what each policy aims for, how the
+// manager treats a coin past its timelock on a server that honors it, how
+// backstop orders snap to a grid so wallets share rounds, and that the
+// receive-side validator can be told to accept such a coin.
+// Run: bun tools/ark/custody-test.js
+import assert from 'node:assert/strict';
+import { hex } from '@scure/base';
+import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js';
+import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js';
+
+// policies
+assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'early']);
+assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing');
+assert.equal(renewPolicy('nonsense', 'mainnet'), null);
+assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 });
+assert.deepEqual(renewPolicy('early', 'mainnet'), { window: 1008, margin: 1008, grid: 144 });
+assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 });
+// the snap never leaves the bracket: self stays under 96 blocks out, early under 2160
+for (let h = 100000; h < 100200; h++) {
+ const s = h - 36; assert.ok(h - (s - (s % 48)) < 96);
+ const e = h - 1008; assert.ok(h - (e - (e % 144)) < 2160 && h - (e - (e % 144)) >= 199);
+}
+console.log('✓ renewal policies: custodial renews nothing; self and early aim inside one fee bracket');
+
+const mk = (honorsExpired) => {
+ const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired });
+ mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] };
+ mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } };
+ mgr._tipH = 1000;
+ return mgr;
+};
+const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra });
+
+// a coin past its timelock: unusable on a server that refuses it, money on one that honors it
+{
+ const strict = mk(false), lenient = mk(true);
+ for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)];
+ assert.equal(strict._expired(strict.state.vtxos[0], 1000), true);
+ assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored');
+ assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 });
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false);
+ assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 });
+ assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…');
+ assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed');
+ lenient.state.vtxos.push(coin('d', 9000, 900));
+ assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package');
+ lenient.state.vtxos.pop();
+ assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']);
+ // the server's word still wins: a refusal naming the coin excludes it
+ lenient.state.vtxos[0].expiryRejected = true;
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true);
+ assert.equal(lenient.balance().spendableSat, 7100);
+ console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it');
+}
+
+// the send cap is wider now that nothing consolidates on its own
+{
+ const m = mk(true);
+ m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000));
+ assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine');
+ m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000));
+ assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/);
+ console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`);
+}
+
+// backstop orders snap down to the grid, and skip what the snap would put in the past
+{
+ const m = mk(true);
+ const orders = [];
+ m._tipMemo = async () => 1000;
+ m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); };
+ m.state.vtxos = [
+ coin('x', 10000, 1100), // 1100-36 = 1064 → 1056
+ coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it)
+ coin('z', 30000, 1200), // 1164 → 1152
+ coin('w', 40000, 1200, { state: 'pending' }),
+ ];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2);
+ assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48');
+ assert.ok(orders.every(([, h]) => h % 48 === 0));
+ orders.length = 0;
+ m.state.scheduled = [];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights');
+ assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]);
+ console.log('✓ backstop orders snap to the grid (and never into the past)');
+}
+
+// the validator: an expired coin is refused by default, accepted when told the server honors it
+{
+ const serverPub = new Uint8Array(33).fill(2);
+ const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }],
+ expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' };
+ const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null };
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message));
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor');
+ console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check');
+}
+console.log('\n✅ custody tiers behave');
diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js
new file mode 100644
index 0000000..d85cd36
--- /dev/null
+++ b/tools/ark/custody-ui-test.js
@@ -0,0 +1,122 @@
+// The custody card on the Spending settings page: three tiers, coinos holding
+// the coins by default, a coin past its timelock shown as held (not expired),
+// and switching tiers persists and reprices the page. Real app with a local
+// Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-ui-test.js
+import assert from 'node:assert/strict';
+import puppeteer from '../puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }];
+ window.coopManager = {
+ honorsExpired: true,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921 }, // 29 days left
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000 }, // past its timelock
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded() { throw new Error('no exit transaction fixture'); },
+ balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return 300 + Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const text = () => page.evaluate(() => document.body.innerText);
+
+ let tx = await text();
+ assert.match(tx, /Custody/);
+ assert.match(tx, /coinos holds my coins[\s\S]*no fees/);
+ assert.match(tx, /Keep my own exit[\s\S]*300 sats per renewal/);
+ assert.match(tx, /Renew a week early[\s\S]*0\.1% \+ 300 sats per renewal/);
+ const checked = await page.$$eval('.ark-custody input[type=radio]', (els) => els.filter((e) => e.checked).map((e) => e.value));
+ assert.deepEqual(checked, ['custodial'], 'coinos holds the coins by default');
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(tx, /held by coinos/, 'the coin past its timelock is held, not "expired"');
+ assert.doesNotMatch(tx, /\bexpired\b/);
+ assert.match(tx, /With “coinos holds my coins”, nothing renews on its own/);
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true });
+ console.log('✓ the custody card: three tiers with their prices, coinos holding by default, a past-timelock coin shown as held');
+
+ // the unilateral exit only counts the coin still inside its timelock
+ assert.match(tx, /Exit now/);
+ const exitBtn = await page.$$eval('button', (bs) => bs.filter((b) => b.textContent === 'Exit now').map((b) => b.disabled));
+ assert.deepEqual(exitBtn, [false]);
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Exit now').click()); await sleep(200);
+ tx = await text();
+ assert.match(tx, /Coins\s*1/); assert.match(tx, /Amount\s*20,000 sats/);
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200);
+ console.log('✓ a unilateral exit counts only the coin still inside its timelock');
+
+ // opt into keeping the exit: persisted, the page reprices, renewal starts covering
+ await page.click('.ark-custody-opt[data-tier=self] input'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet');
+ tx = await text();
+ assert.match(tx, /Custody choice saved/);
+ assert.doesNotMatch(tx, /nothing renews on its own/);
+ assert.match(tx, /renews coins automatically once they are close to expiring/);
+ const calls = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin past its timelock is renewed now; the healthy one is left alone');
+ console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing');
+
+ await page.click('.ark-custody-opt[data-tier=early] input'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'early');
+ await page.click('.ark-custody-opt[data-tier=custodial] input'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await text(), /nothing renews on its own/);
+ console.log('✓ the other tiers switch the same way, and back');
+ assert.deepEqual(errors, [], 'no page errors');
+ console.log('\n✅ custody is a choice, made on the Spending page');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index e3bdb7b..2d07963 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -332,16 +332,17 @@ console.log('\n[tending renewals while closed]');
if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash });
};
ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; };
- ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; };
+ ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; };
try {
const rec = baseRec();
rec.key4 = 'ff'.repeat(32);
+ rec.custody = 'self'; // a renewing tier: the worker covers bare coins
rec.mgr.nextKeyIndex = 5;
rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window
rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }];
const saved = [];
const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} });
- check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls));
+ check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls));
check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1'));
check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done');
// a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next
@@ -351,8 +352,20 @@ console.log('\n[tending renewals while closed]');
const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} });
check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls));
+ // the default tier — coinos holds the coins — registers no order at all,
+ // however many keys the window holds (the claim of an order that already
+ // ran still happens: it was priced and promised when registered)
calls.length = 0;
- const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
+ const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} });
+ check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+ calls.length = 0;
+ const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'early'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} });
+ check('early tier → a week ahead on the daily grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 1008, 1, 144]]), JSON.stringify(calls));
+
+ calls.length = 0;
+ const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} });
check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls));