aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark
diff options
context:
space:
mode:
Diffstat (limited to 'tools/ark')
-rw-r--r--tools/ark/cooperative-exit-ui-test.js25
-rw-r--r--tools/ark/custody-foreign-test.js102
-rw-r--r--tools/ark/custody-test.js130
-rw-r--r--tools/ark/custody-ui-test.js136
-rw-r--r--tools/ark/offboard-split-test.js128
5 files changed, 516 insertions, 5 deletions
diff --git a/tools/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js
index d8fa0d8..03e65d5 100644
--- a/tools/ark/cooperative-exit-ui-test.js
+++ b/tools/ark/cooperative-exit-ui-test.js
@@ -66,10 +66,9 @@ try {
});
await page.waitForSelector('.coin-table');
};
- const coopButton = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').click());
+ const coopButton = () => page.evaluate(() => document.querySelector('.ark-coop-go').click());
const send = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click());
+ .find((c) => c.querySelector('h3')?.textContent === 'Withdraw').querySelector('.btn-primary').click());
const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value);
// The balance card's gear is the way in: Spending's settings lead with
// its coins and carry what a unilateral exit costs; the home screen no
@@ -114,13 +113,16 @@ try {
await page.evaluate(() => { window.forceArkReady = false; coopApp.wallet.saveFeatureState('arkDepth', null); coopApp.ui.screen = 'wallet'; coopApp.render(); });
await manage();
- assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']);
+ // the three actions sit right under the table, named for the ticked total; no explainer cards
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew now', 'Withdraw 20,000 sats', 'Exit 20,000 sats']);
+ assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), []);
await coopButton();
await page.waitForSelector('input.mono-input');
const savings = await page.evaluate(() => coopApp.wallet.freshReceive().address);
assert.equal(await page.$eval('input.mono-input', (e) => e.value), savings);
assert.equal(await page.$eval('input[type="number"]', (e) => e.value), '20000');
assert.equal(await page.$eval('.card .btn-primary', (e) => e.textContent), 'Send');
+ assert.match(await page.evaluate(() => document.body.innerText), /one transaction into Savings, ready after a single confirmation/, 'the withdraw explainer is on the form');
assert.deepEqual(await page.evaluate(() => coopCalls), [], 'opening the form never starts an exit');
await page.evaluate(() => [...document.querySelectorAll('.card button')].find((b) => b.textContent.includes('Back')).click());
await page.waitForSelector('.coin-table');
@@ -162,12 +164,25 @@ try {
assert.deepEqual(await page.evaluate(() => coopCalls[1].script), await page.evaluate(() => [...coopApp.wallet.derive(0, 1).script]));
console.log('✓ Editing the destination and amount offboards only the requested amount to the chosen on-chain address');
+ // ticking a subset on Manage: exactly those coins go, no split, the amount is theirs and not a field
+ await manage();
+ await page.evaluate(() => { coopApp.ui.arkCoinsSel = new Set(['coin-b']); coopApp.render(); });
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew 1 selected for free', 'Withdraw 8,000 sats', 'Exit 8,000 sats'], 'the buttons name the ticked total');
+ await coopButton();
+ await page.waitForSelector('.ark-coop-chosen');
+ assert.match(await page.$eval('.ark-coop-chosen', (e) => e.textContent), /1 selected coin · 8,000 sats/);
+ assert.equal(await page.$('input[type="number"]'), null, 'no amount field for chosen coins');
+ await send();
+ await page.waitForFunction(() => coopCalls.length === 3 && !coopApp.ui.arkBusy);
+ assert.deepEqual(await page.evaluate(() => ({ type: coopCalls[2].type, ids: coopCalls[2].ids, address: coopCalls[2].address })), { type: 'offboard', ids: ['coin-b'], address: savings });
+ console.log('✓ Ticking a subset on Manage offboards exactly those coins, without splitting');
+
await manage(); await coopButton();
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Wallet"]').click());
assert.equal(await page.evaluate(() => coopApp.ui.arkOffboardSend), null);
await manage();
await page.evaluate(() => { coopManager.state.vtxos.forEach((v) => v.state = 'pending'); coopManager.state.actions = [{ type: 'refresh', step: 'submitted', inputIds: ['coin-a', 'coin-b'], inAmountSat: 20000 }]; coopApp.render(); });
- assert(await page.evaluate(() => [...document.querySelectorAll('.card')].find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').disabled));
+ assert(await page.evaluate(() => document.querySelector('.ark-coop-go').disabled));
await page.evaluate(() => {
const { ui, render } = coopApp;
ui.arkCoinsPage = null; ui.tab = 'send';
diff --git a/tools/ark/custody-foreign-test.js b/tools/ark/custody-foreign-test.js
new file mode 100644
index 0000000..148622d
--- /dev/null
+++ b/tools/ark/custody-foreign-test.js
@@ -0,0 +1,102 @@
+// On a server that does not honor a coin past its timelock (Second, a custom
+// captaind) "coinos holds my coins" is not a tier: whatever is stored, the
+// tier in force is "self" (or "exit" when chosen), the card offers only those
+// two, and the renewal pass registers backstops and renews what is due.
+// Real app with a local Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-foreign-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-foreign-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, applyCustody, maybeAutoRefresh, 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');
+ // a custom server: no honorsExpired on its record, like Second's preset
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'custom');
+ localStorage.setItem('btc-wallet-ark-custom:regtest', JSON.stringify({ ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }));
+ });
+ 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: 288, ppm: 2000 }, { thresholdBlocks: 1008, ppm: 4000 }, { thresholdBlocks: 2016, ppm: 5000 }];
+ window.coopManager = {
+ honorsExpired: false,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 2 }, // weeks left
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 969760, depth: 1 }, // 9 blocks from its timelock
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 4032, roundIntervalSecs: 3600, refreshFees: { baseFeeSat: 0, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ 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 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');
+
+ // nothing stored: the tier in force is "self", and the card offers no custodial option
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), null, 'nothing was written to the record');
+ const opts = await page.$$eval('#ark-custody-select option', (os) => os.map((o) => [o.value, o.selected]));
+ assert.deepEqual(opts, [['self', true], ['exit', false]]);
+ console.log('✓ a server that does not honor expired coins has no "coinos holds my coins" tier; "self" is in force');
+
+ // the stored default from a coinos wallet does not switch renewals off here
+ await page.evaluate(() => coopApp.wallet.saveFeatureState('arkCustody', 'custodial'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ console.log('✓ a stored "custodial" choice reads as "self" on such a server');
+
+ // the renewal pass runs: backstop orders are registered and the coin at its timelock renews now
+ const calls = await page.evaluate(async () => { coopFeature.autoRefreshCalls.length = 0; await coopFeature.maybeAutoRefresh(coopManager); return coopFeature.autoRefreshCalls; });
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin close to its timelock is renewed now');
+ console.log('✓ automatic renewal runs on such a server with nothing opted into');
+
+ // the exit tier can still be chosen, and is kept
+ await page.evaluate(() => coopFeature.applyCustody('exit'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), 'exit');
+ console.log('✓ "exit" can be chosen there and is saved');
+ assert.deepEqual(errors, [], 'no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..214ab45
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,130 @@
+// 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', 'exit']);
+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('exit', 'mainnet'), { window: 96, margin: 36, grid: 48, maxDepth: 4 }, 'exit = self + a depth trigger');
+assert.deepEqual(renewPolicy('early', 'mainnet'), renewPolicy('exit', 'mainnet'), 'the old name still means the exit tier');
+assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 });
+// the snap never leaves the free bracket: under 96 blocks out
+for (let h = 100000; h < 100200; h++) { const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); }
+console.log('✓ renewal policies: custodial renews nothing; self and exit aim inside the free 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');
+}
+
+// deep coins: the server's depth cap and its base-fee waiver, mirrored
+{
+ const m = mk(true);
+ m.info.refreshFees.deepWaiverDepth = 60;
+ m.info.maxVtxoExitDepth = 100;
+ m.exitDepth = (v) => v.depth || 0;
+ m._tipMemo = async () => 1000;
+ const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 });
+ const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 });
+ const expiredDeep = coin('e', 6000, 900, { depth: 70 });
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee');
+ assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0);
+ assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays');
+ assert.equal(m.refreshFee([shallow], 1000), 300);
+ assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not');
+ assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)');
+ m.state.vtxos = [capped];
+ assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why');
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ // the worker's orders: one per deep coin still inside its timelock, two blocks out
+ const orders = [];
+ m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); };
+ assert.equal(await m.ensureDeepRefreshes(3), 3);
+ assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app');
+ assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice');
+ // no waiver and no cap advertised: nothing is deep
+ const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)];
+ assert.deepEqual(plain.deepCoins(), []);
+ assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep');
+ console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened');
+}
+
+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..9c238f5
--- /dev/null
+++ b/tools/ark/custody-ui-test.js
@@ -0,0 +1,136 @@
+// 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, depth: 5 }, // 29 days left, five hops deep
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000, depth: 1 }, // past its timelock
+ { id: 'coin-2', amountSat: 800, state: 'spendable', expiryHeight: 973921, depth: 6 }, // deep but not worth 300 sats
+ ], 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(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25800, 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/);
+ const opts = await page.$$eval('.ark-custody option', (es) => es.map((e) => e.value + '=' + e.textContent));
+ assert.deepEqual(opts, ['custodial=Allow coins to expire', 'self=Minimize renewal cost', 'exit=Minimize exit cost']);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), 'no fees');
+ assert.equal(await page.$eval('#ark-custody-select', (e) => e.value), 'custodial', 'coins expire by default');
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /keeps its own exit until it expires/);
+ assert.doesNotMatch(tx, /Coins renew in the last hours|Every transfer adds a hop/, 'only the chosen strategy is described');
+ assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card');
+ const order = await page.$$eval('.card h4, .card h3', (es) => es.map((e) => e.textContent).filter((x) => /Spending coins|Custody strategy|Renewal/.test(x)));
+ assert.deepEqual(order, ['Spending coins', 'Custody strategy'], 'coins first, then the strategy; no explainer cards');
+ assert.match(tx, /held by Coinos/, 'the coin past its timelock is held, not "expired"');
+ assert.doesNotMatch(tx, /5,000 sats\s+expired/, 'the row never says "expired"');
+ assert.doesNotMatch(tx, /nothing renews on its own/, 'the renewal explainer is not on the page…');
+ const renewSheet = async () => { await page.click('.ark-renew-ask'); await sleep(200); const t = await text(); await page.evaluate(() => [...document.querySelectorAll('.ark-renew-confirm button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); return t; };
+ assert.match(await renewSheet(), /With “Allow coins to expire”, nothing renews on its own[\s\S]*more than 15 days left/, '…it is on the Renew confirmation, with the fee schedule');
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true });
+ console.log('✓ the custody card: a drop-down of three strategies with their prices, coins expire by default, one description, a past-timelock coin shown as held');
+
+ // the unilateral exit only counts the coin still inside its timelock
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.textContent), 'Exit 20,800 sats', 'the exit button names the ticked coins still inside their timelock');
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.disabled), false);
+ await page.click('.ark-exit-ask'); await sleep(200);
+ tx = await text();
+ assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/);
+ assert.match(tx, /If the server vanished today/, 'the exit explainer is on the confirmation');
+ 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.select('#ark-custody-select', 'self'); 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.match(await renewSheet(), /renews coins automatically once they are close to expiring/);
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /Coins renew in the last hours/);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats per renewal');
+ 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', 'coin-2']], 'the coin past its timelock is renewed now with the small change; the deep healthy one is left alone');
+ console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing');
+
+ // the exit tier also flattens the deep coin — but not the deep 800-sat one on its own merits
+ await page.evaluate(() => { coopFeature.autoRefreshCalls.length = 0; });
+ await page.select('#ark-custody-select', 'exit'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats near expiry, 0.2% + 300 sats when early');
+ const calls2 = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls2[1], ['refresh', ['coin-1', 'coin-0', 'coin-2']], 'expired + deep (worth it) + small change ride one round');
+ console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it');
+ await page.select('#ark-custody-select', 'custodial'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await renewSheet(), /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/ark/offboard-split-test.js b/tools/ark/offboard-split-test.js
new file mode 100644
index 0000000..561ee2b
--- /dev/null
+++ b/tools/ark/offboard-split-test.js
@@ -0,0 +1,128 @@
+// A partial on-chain send from Spending moves whole coins: the amount is
+// covered by coins that already add up to it, else split off via a self-send
+// that lands as one coin per input — all of them go, not one of the exact
+// size (Jarryd, 2026-10-23: three splits, no send). Local manager stub.
+// Run: bun tools/ark/offboard-split-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: 'offboard-split-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, get ready() { return !_bootDeciding; } };
+` }));
+ 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; }, offboard: doArkOffboard, exactCoins };
+ 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', 'off');
+ });
+ 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 () => {};
+ window.coopCalls = [];
+ window.coopManager = {
+ state: { vtxos: [], actions: [], movements: [], nextKeyIndex: 5 },
+ async offboardQuote() { return { chainSat: 736, serviceSat: 204, totalSat: 940, minSat: 1270 }; },
+ info: { network: 'regtest' }, _tipH: 100,
+ vtxos() { return this.state.vtxos; },
+ refreshFee() { return 0; }, unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ // the real thing: largest inputs first until covered, one destination
+ // coin per input (the last carries the remainder), change on a new key;
+ // the destination coins land on the receive key at the next sync
+ async send(address, amount) {
+ coopCalls.push({ type: 'split', address, amount });
+ const free = this.state.vtxos.filter((v) => v.state === 'spendable').sort((a, b) => b.amountSat - a.amountSat);
+ const inputs = []; let sum = 0;
+ for (const v of free) { inputs.push(v); sum += v.amountSat; if (sum >= amount) break; }
+ let remaining = amount; const landing = [];
+ for (const v of inputs) {
+ const dest = Math.min(remaining, v.amountSat); remaining -= dest;
+ landing.push({ id: `dest-${v.id}`, amountSat: dest, state: 'spendable', keyIndex: 0 });
+ if (v.amountSat > dest) this.state.vtxos.push({ id: `change-${v.id}`, amountSat: v.amountSat - dest, state: 'spendable', keyIndex: this.state.nextKeyIndex++ });
+ v.state = 'spent';
+ }
+ this.pendingLanding = landing;
+ return 'send-1';
+ },
+ async sync() { if (this.pendingLanding) { this.state.vtxos.push(...this.pendingLanding); this.pendingLanding = null; } },
+ async startOffboard(spk, address, ids) {
+ coopCalls.push({ type: 'offboard', address, ids: ids || null });
+ return { txid: 'ab'.repeat(32), netSat: 1, feeSat: 1 };
+ },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.ui.screen = 'wallet'; coopApp.ui.tab = 'send'; coopApp.render();
+ }, generateMnemonic(wordlist));
+ const run = (coins, amount) => page.evaluate(async (coins, amount) => {
+ coopManager.state.vtxos = coins.map((c) => ({ state: 'spendable', keyIndex: 0, ...c }));
+ coopCalls.length = 0;
+ coopApp.ui.arkError = ''; coopApp.ui.arkOffboarded = null;
+ await coopFeature.offboard(amount, coopApp.wallet.derive(0, 1).address);
+ return { calls: coopCalls, err: coopApp.ui.arkError, done: !!coopApp.ui.arkOffboarded };
+ }, coins, amount);
+
+ // Jarryd's balance after one split: the halves add up, so nothing is split again
+ let r = await run([{ id: 'a', amountSat: 109958 }, { id: 'b', amountSat: 75042 }, { id: 'c', amountSat: 24958, keyIndex: 2 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['offboard']);
+ assert.deepEqual([...r.calls[0].ids].sort(), ['a', 'b']);
+ console.log('✓ coins that already add up to the amount go as they are, no split');
+
+ // one coin of the exact size
+ r = await run([{ id: 'a', amountSat: 109958 }, { id: 'c', amountSat: 24958 }], 24958);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: ['c'] }]);
+ console.log('✓ a single coin of the exact size is the one that goes');
+
+ // Jarryd's first attempt: 185,000 out of 100,000 + 109,958 — nothing adds
+ // up, so a self-send splits it off and lands as two coins; both go
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['split', 'offboard']);
+ assert.equal(r.calls[0].amount, 185000);
+ assert.deepEqual([...r.calls[1].ids].sort(), ['dest-a', 'dest-b']);
+ const left = await page.evaluate(() => coopManager.state.vtxos.filter((v) => v.state === 'spendable').map((v) => [v.id, v.amountSat, v.keyIndex]));
+ assert.deepEqual(left.filter(([id]) => id.startsWith('change')), [['change-a', 24958, 5]]);
+ console.log('✓ a split that lands as two coins sends both; the change stays behind');
+
+ // everything: no carving at all
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 0);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: null }]);
+ console.log('✓ the whole balance goes without a split');
+
+ // the subset search: prefers few large coins, gives up on no fit
+ const sub = await page.evaluate(() => {
+ const coins = (a) => a.map((amountSat, i) => ({ id: 'c' + i, amountSat }));
+ const pick = (a, n) => { const r = coopFeature.exactCoins(coins(a), n); return r && r.map((v) => v.amountSat).sort((x, y) => y - x); };
+ return [pick([5000, 3000, 2000, 1000, 1000], 6000), pick([5000, 3000], 7000), pick([5000, 3000, 2000], 5000), pick([7000, 3000], 3000)];
+ });
+ assert.deepEqual(sub, [[5000, 1000], null, [5000], [3000]]);
+ console.log('✓ exactCoins finds a subset, the single coin, or nothing');
+ assert.deepEqual(errors, []);
+} finally { await browser.close(); server.stop(true); }