diff options
Diffstat (limited to 'tools/ark')
| -rw-r--r-- | tools/ark/custody-foreign-test.js | 102 | ||||
| -rw-r--r-- | tools/ark/custody-test.js | 34 | ||||
| -rw-r--r-- | tools/ark/offboard-split-test.js | 128 |
3 files changed, 264 insertions, 0 deletions
diff --git a/tools/ark/custody-foreign-test.js b/tools/ark/custody-foreign-test.js new file mode 100644 index 0000000..148622d --- /dev/null +++ b/tools/ark/custody-foreign-test.js @@ -0,0 +1,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); } diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js index f8106b7..214ab45 100644 --- a/tools/ark/custody-test.js +++ b/tools/ark/custody-test.js @@ -93,4 +93,38 @@ const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expi await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor'); console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check'); } + +// deep coins: the server's depth cap and its base-fee waiver, mirrored +{ + const m = mk(true); + m.info.refreshFees.deepWaiverDepth = 60; + m.info.maxVtxoExitDepth = 100; + m.exitDepth = (v) => v.depth || 0; + m._tipMemo = async () => 1000; + const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 }); + const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 }); + const expiredDeep = coin('e', 6000, 900, { depth: 70 }); + m.state.vtxos = [shallow, deep, capped, near, expiredDeep]; + assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee'); + assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0); + assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays'); + assert.equal(m.refreshFee([shallow], 1000), 300); + assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not'); + assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)'); + m.state.vtxos = [capped]; + assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why'); + m.state.vtxos = [shallow, deep, capped, near, expiredDeep]; + // the worker's orders: one per deep coin still inside its timelock, two blocks out + const orders = []; + m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); }; + assert.equal(await m.ensureDeepRefreshes(3), 3); + assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app'); + assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice'); + // no waiver and no cap advertised: nothing is deep + const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)]; + assert.deepEqual(plain.deepCoins(), []); + assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep'); + console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened'); +} + console.log('\n✅ custody tiers behave'); diff --git a/tools/ark/offboard-split-test.js b/tools/ark/offboard-split-test.js new file mode 100644 index 0000000..561ee2b --- /dev/null +++ b/tools/ark/offboard-split-test.js @@ -0,0 +1,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); } |
