aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/ark.js89
-rw-r--r--tools/ark/offboard-split-test.js128
2 files changed, 184 insertions, 33 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index b155d17..eccc3b8 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -2224,23 +2224,8 @@ export function arkFeature(ctx) {
const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address));
// Offboarding moves whole vtxos, so carve out the exact amount first
// when we're not sending everything.
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
- let ids;
- if (amountSat < total) {
- let exact = spendables().find((v) => v.amountSat === amountSat);
- if (!exact) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !exact; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!exact) throw new Error(t('arkSplitTimeout'));
- }
- ids = [exact.id];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ const ids = amountSat < total ? await carveCoins(mgr, amountSat) : undefined;
await mgr.startOffboard(spk, target.address, ids);
return;
}
@@ -3316,6 +3301,58 @@ export function arkFeature(ctx) {
})());
}
+ // A set of coins adding up to exactly `amountSat`: one coin of that size,
+ // else the smallest subset of the free coins that sums to it (largest coins
+ // first, so an offboard takes as few inputs as it can). null when no subset
+ // fits. Reachable sums are built up coin by coin, capped so a balance in
+ // hundreds of dust coins can't stall the tap.
+ function exactCoins(coins, amountSat) {
+ const one = coins.find((v) => v.amountSat === amountSat);
+ if (one) return [one];
+ const sorted = [...coins].filter((v) => v.amountSat > 0 && v.amountSat < amountSat).sort((a, b) => b.amountSat - a.amountSat);
+ const reach = new Map([[0, null]]); // sum -> { coin, prev } that first reached it
+ for (const v of sorted) {
+ const add = [];
+ for (const sum of reach.keys()) {
+ const next = sum + v.amountSat;
+ if (next <= amountSat && !reach.has(next)) add.push([next, { coin: v, prev: sum }]);
+ }
+ for (const [sum, via] of add) reach.set(sum, via);
+ if (reach.has(amountSat)) break;
+ if (reach.size > 50_000) return null;
+ }
+ if (!reach.has(amountSat)) return null;
+ const picked = [];
+ for (let sum = amountSat; sum > 0;) { const via = reach.get(sum); picked.push(via.coin); sum = via.prev; }
+ return picked;
+ }
+ // The coins that leave Spending for an on-chain send of `amountSat`. An
+ // offboard moves whole vtxos, so the amount has to be covered exactly:
+ // by coins already in the wallet when they fit (a balance that happens to
+ // add up, or the halves an earlier split left behind), else an exact amount
+ // is split off by paying it to this wallet's own address. A payment spread
+ // over several inputs arrives as several coins, one per input with the last
+ // carrying the remainder, so what the split yields is every coin that lands
+ // on the receive key and adds up to the amount, not a single coin of that
+ // size: looking for one made every partial on-chain send from a balance
+ // in two or more coins time out (Jarryd, 2026-10-23, three splits and no
+ // send). Change from the split carries its own key index and stays.
+ async function carveCoins(mgr, amountSat) {
+ const free = () => freeCoins(mgr.state);
+ const have = exactCoins(free(), amountSat);
+ if (have) return have.map((v) => v.id);
+ const before = new Set(free().map((v) => v.id));
+ await mgr.send(mgr.address(), amountSat);
+ for (let i = 0; i < 30; i++) {
+ await mgr.sync().catch(() => {});
+ const landed = free().filter((v) => !before.has(v.id) && !v.keyIndex);
+ const hit = exactCoins(landed, amountSat);
+ if (hit) return hit.map((v) => v.id);
+ await new Promise((r) => setTimeout(r, 2000));
+ }
+ throw new Error(t('arkSplitTimeout'));
+ }
+
// vtxoIds: exactly these coins go (the coins page's ticked subset);
// otherwise an amount is split off the balance, or everything goes.
async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) {
@@ -3324,23 +3361,9 @@ export function arkFeature(ctx) {
const address = destAddress || wallet.freshReceive().address;
const spk = addrScript(address);
const mgr = await connectArk();
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
let ids = vtxoIds; // undefined = all spendable vtxos
- if (!ids && amountSat && amountSat < total) {
- let target = spendables().find((v) => v.amountSat === amountSat);
- if (!target) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !target; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!target) throw new Error(t('arkSplitTimeout'));
- }
- ids = [target.id];
- }
+ if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat);
const action = await mgr.startOffboard(spk, address, ids);
ui.arkOffboardAmt = '';
ui.arkMoveOpen = false;
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); }