aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-ui-test.js
blob: 7ebc2ea16a0765243e11d89fbdf36e77aeec9501 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// 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/);
  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); }