// 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 = `
`; 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/); assert.match(tx, /Allow coins to expire[\s\S]*no fees/); assert.match(tx, /keeps its own exit until it expires/); assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card'); assert.match(tx, /Minimize renewal cost[\s\S]*300 sats per renewal/); assert.match(tx, /Minimize exit cost[\s\S]*300 sats near expiry, 0\.2% \+ 300 sats when early/); assert.match(tx, /4 hops deep/); 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, /5,000 sats\s+expired/, 'the row never says "expired"'); assert.match(tx, /With “Allow coins to expire”, 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*2/); assert.match(tx, /Amount\s*20,800 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', '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.click('.ark-custody-opt[data-tier=exit] input'); await sleep(400); assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit'); 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.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); }