aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-ui-test.js
blob: 9c238f5a67105a3ade5d1c4055e2d2a9ca57a8b8 (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
132
133
134
135
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); }