aboutsummaryrefslogtreecommitdiffstats
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
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
-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); }