aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-23 08:35:22 -0700
committerAdam Soltys <[email protected]>2026-10-23 08:35:22 -0700
commit10a4841e1a80ae72b35638953add79489edce9b6 (patch)
treee43dfd72e1887c6090830e772a34e6dc324b6130 /tools
parent91dbee408ad59ef4eb85dbec3eff4d512c9b4792 (diff)
downloadcoinosv3-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')
-rw-r--r--tools/ark/offboard-split-test.js128
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); }