From 328f2db9af46cda473318bb3eada0f6e83bb42f1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 3 Oct 2026 16:00:39 -0700 Subject: A Lightning payment that can't be covered says why, not 'Payment failed' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user renewed all ten of their coins (55,611 sats) from the coins page, tried to pay a 55,300-sat invoice three minutes later, and was told only 'Payment failed'. Nothing had failed: the renewal's round ran at once, but its coins stay unspendable until the round's transaction confirms on-chain, and that block took 64 minutes. The quote found no spendable coin and gave up with the generic message; nothing reached the server. - The quote now tells apart (lnShortfall): money held by another payment (wait, as before — now by sum, not one coin covering it all), money a renewal holds ('Nothing was sent. N sats are being renewed…'), and a real shortfall ('needs X sats with its fee, Y available'). Coins leaving in an offboard or exit are never waited for. - The manager's 'insufficient ark balance' names a renewal too. - The coins page says before the tap that renewed coins can't be spent until the round confirms, and that renewing every coin leaves nothing to pay with meanwhile. tools/ark/ln-shortfall-test.js Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ULQZS9MQa6iAsXnrJUD9Du --- tools/ark/ln-shortfall-test.js | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tools/ark/ln-shortfall-test.js (limited to 'tools') diff --git a/tools/ark/ln-shortfall-test.js b/tools/ark/ln-shortfall-test.js new file mode 100644 index 0000000..f249ae1 --- /dev/null +++ b/tools/ark/ln-shortfall-test.js @@ -0,0 +1,52 @@ +// Why a Lightning payment can't be covered — so the wallet says "your coins +// are being renewed" or "not enough with the fee" instead of "Payment failed". +// The first case is the 2026-10-03 report: a user renewed all ten coins +// (55,611 sats) from the coins page, tried to pay 55,300 three minutes later, +// and the renewal's round took 64 minutes to confirm. +// Run: bun tools/ark/ln-shortfall-test.js +import { lnShortfall } from '../../src/ark/lightning.js'; +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const coin = (id, amountSat, state) => ({ id, amountSat, state }); + +const amounts = [3, 54, 54, 49500, 1000, 1000, 1000, 1000, 1000, 1000]; +const renewed = amounts.map((a, i) => coin('c' + i, a, 'pending')); +const refresh = { type: 'refresh', step: 'submitted', inputIds: renewed.map((v) => v.id) }; +let r = lnShortfall(renewed, [refresh], 55300); +check('every coin in a renewal: the payment is held by the renewal, by name', r.kind === 'renewing' && r.renewingSat === 55611 && r.spendableSat === 0, JSON.stringify(r)); + +r = lnShortfall(renewed, [{ ...refresh, step: 'done' }], 55300); +check('a finished renewal holds nothing', r.kind !== 'renewing' && r.renewingSat === 0, JSON.stringify(r)); + +r = lnShortfall([coin('a', 60000, 'pending')], [{ type: 'lnpay', step: 'htlc', inputIds: ['a'] }], 55300); +check('coins held by another payment: wait, they free themselves', r.kind === 'settling' && r.settlingSat === 60000, JSON.stringify(r)); + +r = lnShortfall([coin('a', 30000, 'pending'), coin('b', 30000, 'spendable')], [], 55300); +check('...counted together with what is spendable (it used to take one pending coin covering it all)', r.kind === 'settling', JSON.stringify(r)); + +r = lnShortfall([coin('a', 55400, 'spendable')], [], 55300); +check('enough for the amount but not its fee: short, not "failed"', r.kind === 'short' && r.spendableSat === 55400, JSON.stringify(r)); + +r = lnShortfall([coin('a', 100, 'spendable'), coin('b', 200, 'pending')], [{ type: 'refresh', step: 'created', inputIds: ['b'] }], 55300); +check('a renewal that wouldn\'t cover it anyway: short', r.kind === 'short' && r.renewingSat === 200, JSON.stringify(r)); + +r = lnShortfall([coin('a', 40000, 'pending'), coin('b', 20000, 'pending')], [{ type: 'refresh', step: 'issued', inputIds: ['a'] }, { type: 'lnpay', step: 'htlc', inputIds: ['b'] }], 55300); +check('part renewing, part settling, together enough: the renewal is what it waits on', r.kind === 'renewing', JSON.stringify(r)); + +r = lnShortfall([coin('a', 60000, 'pending')], [{ type: 'offboard', step: 'submitted', inputIds: ['a'] }], 55300); +check('coins leaving for Savings are not waited for', r.kind === 'short' && r.settlingSat === 0, JSON.stringify(r)); +r = lnShortfall([coin('a', 60000, 'pending')], [{ type: 'exit', step: 'chain', vtxoId: 'a' }], 55300); +check('...nor coins in a unilateral exit', r.kind === 'short' && r.settlingSat === 0, JSON.stringify(r)); + +// the manager's own error (ark sends, and the pay path behind the quote) names it too +const { ArkManager } = await import('../../src/ark/manager.js'); +const mgr = new ArkManager({ storage: { save() {} }, arkUrl: 'http://test.invalid' }); +mgr.state = { nextKeyIndex: 1, movements: [], actions: [refresh], vtxos: renewed.map((v) => ({ ...v, expiryHeight: 973921 })) }; +const msg = mgr._insufficientMsg(969751); +check('the manager\'s "insufficient" says a renewal holds the money', /55611 sat are being renewed/.test(msg), msg); +mgr.state.actions = []; +mgr.state.vtxos = []; +check('...and stays plain when nothing is renewing', mgr._insufficientMsg(969751) === 'insufficient ark balance'); + +console.log(ok ? '\n✅ a payment that can\'t be covered says why' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3