fix(lightning): never report a slow in-flight payment as failed

Slow multi-hop payments (>15s routing) surfaced as "Payment failed"
while LND settled them in the background: the shared LND REST client's
15s total timeout aborted the synchronous /v1/channels/transactions
wait, and every UI path treated that abort as a definitive failure. The
payment then succeeded anyway and only appeared in history on the next
background poll.

Backend: lnd.payinvoice now decodes the invoice up front for its payment
hash, pays on a dedicated 120s client, and answers status:"pending" with
the hash (never an error) when the wait elapses after the payment was
handed to LND — only a pre-connect failure is still a hard error. New
lnd.paymentstatus RPC reports succeeded/failed/in_flight (with humanized
failure reasons) from /v1/payments.

Frontend: new rpcClient.payLightningInvoice() pays then polls
lnd.paymentstatus to a real terminal state (3s interval, up to 2 min);
all five call sites (send modal, scan modal, web5 unified send, peer-file
purchase, app-launcher payments) migrated. Failure is only shown when LND
itself declares FAILED; a still-settling payment shows an in-flight state
and success fires the transaction refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-27 05:33:02 -04:00
co-authored by Claude Fable 5
parent aa272bfcf4
commit 9cd507269c
8 changed files with 264 additions and 36 deletions
+11 -10
View File
@@ -1320,8 +1320,8 @@ async function payWithInvoice() {
/**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. No QR/polling: lnd.payinvoice only returns once the payment
* settles, so the payment_hash is immediately valid as the download gate token.
* release the file. payLightningInvoice resolves to a real terminal state, so
* on success the payment_hash is immediately valid as the download gate token.
*/
async function payWithLightning() {
const item = payItem.value
@@ -1341,14 +1341,15 @@ async function payWithLightning() {
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
return
}
// 2. Pay it from our own node. Returns only after settlement.
const pay = await rpcClient.call<{ payment_hash?: string; payment_error?: string }>({
method: 'lnd.payinvoice',
params: { payment_request: inv.bolt11 },
timeout: 120000,
})
if (pay?.payment_error) {
lnError.value = `Payment failed: ${pay.payment_error}`
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
// multi-hop route resolves via status polling instead of a false failure.
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
return
}
// 3. Settled — pull the file using the payment hash as the gate token.
@@ -291,10 +291,10 @@ async function unifiedSend() {
unifiedSendError.value = t('web5.pasteInvoice')
return
}
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: unifiedSendDest.value.trim() },
})
// Waits out slow multi-hop routing and only reports failure when LND
// itself declares the payment failed — never on a timeout.
const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
sendResultHash.value = res.payment_hash
} else {
if (!unifiedSendDest.value.trim()) {