aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-foreign-test.js
blob: 148622d0d9462151bb866012b076297719f39e2d (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
// 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); }