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 --- src/ark/lightning.js | 27 +++++++++++++++++++++++++++ src/ark/manager.js | 8 ++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) (limited to 'src/ark') diff --git a/src/ark/lightning.js b/src/ark/lightning.js index 568d0ab..0d23baa 100644 --- a/src/ark/lightning.js +++ b/src/ark/lightning.js @@ -111,6 +111,33 @@ export function lnSendFee(amountSat, fees, inputs, tip) { return Math.max(fees.minFeeSat || 0, (fees.baseFeeSat || 0) + ppmFee); } +// Why the spendable coins can't cover a payment, so the wallet can say so +// instead of "Payment failed". Coins a renewal took are 'pending' until its +// round's transaction confirms on-chain — ten minutes, or an hour — which is +// nothing like the seconds an in-flight payment or a revocation holds a coin +// for. (A user renewed their whole balance from the coins page, tried to pay +// three minutes later, and was told only that the payment failed.) +// 'settling' — money held by another payment frees itself shortly: wait +// 'renewing' — the balance would cover it, but a renewal holds it +// 'short' — there isn't enough, whatever is pending +export function lnShortfall(vtxos, actions, amountSat) { + const live = (actions || []).filter((a) => !['done', 'failed'].includes(a.step)); + const idsOf = (a) => [...(a.inputIds || []), ...(a.vtxoId ? [a.vtxoId] : [])]; + const renewIds = new Set(live.filter((a) => a.type === 'refresh').flatMap(idsOf)); + // coins on their way OUT (to Savings, or a unilateral exit) are pending + // too, and are never coming back to pay with + const leavingIds = new Set(live.filter((a) => a.type === 'offboard' || a.type === 'exit').flatMap(idsOf)); + const sum = (list) => list.reduce((n, v) => n + v.amountSat, 0); + const pending = (vtxos || []).filter((v) => v.state === 'pending' && !leavingIds.has(v.id)); + const spendableSat = sum((vtxos || []).filter((v) => v.state === 'spendable')); + const renewingSat = sum(pending.filter((v) => renewIds.has(v.id))); + const settlingSat = sum(pending.filter((v) => !renewIds.has(v.id))); + const kind = settlingSat > 0 && spendableSat + settlingSat >= amountSat ? 'settling' + : renewingSat > 0 && spendableSat + settlingSat + renewingSat >= amountSat ? 'renewing' + : 'short'; + return { kind, spendableSat, renewingSat, settlingSat }; +} + export const lnReceiveFee = (amountSat, fees) => (fees.baseFeeSat || 0) + Math.ceil((amountSat * (fees.ppm || 0)) / 1_000_000); diff --git a/src/ark/manager.js b/src/ark/manager.js index dc80ca3..a114702 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -33,6 +33,7 @@ import { requestLightningPayHtlcRevocation, startLightningReceive, checkLightningReceive, prepareLightningReceiveClaim, claimLightningReceive, cancelLightningReceive, lightningReceiveAttestation, + lnShortfall, } from './lightning.js'; import { boardFee, p2trAddress, buildBoard, requestBoardCosign, @@ -868,8 +869,11 @@ export class ArkManager { const expiredSat = this.state.vtxos .filter((v) => v.state === 'spendable' && this._expired(v, tip)) .reduce((n, v) => n + v.amountSat, 0); - return expiredSat - ? `insufficient ark balance (${expiredSat} sat expired and unusable)` + if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`; + // coins a renewal took stay unspendable until its round confirms on-chain + const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat; + return renewing + ? `insufficient ark balance (${renewing} sat are being renewed and can't be spent until the renewal confirms on-chain)` : 'insufficient ark balance'; } -- cgit v1.2.3