diff options
Diffstat (limited to 'src/features/ark.js')
| -rw-r--r-- | src/features/ark.js | 89 |
1 files changed, 56 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; |
