aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/offboard-split-test.js
blob: 561ee2bef2605375410954af9d188d5a4d51dc10 (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
// 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); }