diff options
| author | Adam Soltys <[email protected]> | 2026-10-23 08:35:22 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-23 08:35:22 -0700 |
| commit | 10a4841e1a80ae72b35638953add79489edce9b6 (patch) | |
| tree | e43dfd72e1887c6090830e772a34e6dc324b6130 /tools/ark | |
| parent | 91dbee408ad59ef4eb85dbec3eff4d512c9b4792 (diff) | |
| download | coinosv3-10a4841e1a80ae72b35638953add79489edce9b6.tar.gz coinosv3-10a4841e1a80ae72b35638953add79489edce9b6.zip | |
A partial on-chain send from Spending takes whatever coins add up to the amount; a split that lands as several coins sends them all
An offboard moves whole coins, so a partial amount was first split off by
a payment to the wallet's own address, and the send then waited for ONE new
coin of exactly that size. A payment that needs more than one input arrives
as one coin per input, so for any balance held in two or more coins the
wait timed out ("The split is taking longer than expected") while the split
had gone through — every retry split again and nothing was ever sent
(Jarryd, 2026-10-23: three splits of 185,000 sats, each landing as
109,958 + 75,042).
Now the coins that leave are chosen as a set: a single coin of the exact
size, else the free coins that already add up to it (largest first, so the
halves an earlier split left behind go as they are, with no new split),
else the split's own output — every new coin on the receive key that adds
up to the amount. Change keeps its own key index and stays. Same carving
for the automatic withdrawal. tools/ark/offboard-split-test.js covers the
four cases against the real send form.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4
Diffstat (limited to 'tools/ark')
| -rw-r--r-- | tools/ark/offboard-split-test.js | 128 |
1 files changed, 128 insertions, 0 deletions
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); } |
