aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/ln-shortfall-test.js
blob: f249ae19581df8578e67e32b84594457f746fcf6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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);