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
+70
View File
@@ -400,6 +400,76 @@ class RPCClient {
})
}
/** Pay a Lightning invoice and resolve it to a REAL terminal state.
*
* The backend waits up to 120s on LND's synchronous pay endpoint; if the
* payment is still routing after that it answers `status: "pending"` with
* the payment hash instead of an error. This helper then polls
* lnd.paymentstatus until LND itself reports succeeded/failed, so callers
* never show "failed" for a payment that is merely slow — the bug where a
* settling payment was declared failed and then appeared in history a
* minute later. Returns `pending` only if the payment is STILL in flight
* after the polling window (rare; caller should say "still settling",
* not "failed"). */
async payLightningInvoice(params: {
payment_request: string
amount_sats?: number
}): Promise<{
status: 'succeeded' | 'failed' | 'pending'
payment_hash: string
amount_sats: number
failure_reason?: string
}> {
const res = await this.call<{
status?: string
payment_hash?: string
amount_sats?: number
}>({
method: 'lnd.payinvoice',
params,
// Above the backend's 120s wait so the backend always answers first.
timeout: 130000,
})
const hash = res.payment_hash || ''
const amount = res.amount_sats || 0
// Older backends have no status field — a plain response was a success.
if (res.status !== 'pending') {
return { status: 'succeeded', payment_hash: hash, amount_sats: amount }
}
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
// Poll to a terminal state: every 3s for up to 2 minutes.
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 3000))
try {
const st = await this.call<{
status: string
failure_reason?: string
amount_sats?: number
}>({
method: 'lnd.paymentstatus',
params: { payment_hash: hash },
timeout: 15000,
})
if (st.status === 'succeeded') {
return { status: 'succeeded', payment_hash: hash, amount_sats: st.amount_sats || amount }
}
if (st.status === 'failed') {
return {
status: 'failed',
payment_hash: hash,
amount_sats: amount,
failure_reason: st.failure_reason || 'Payment failed',
}
}
} catch {
// Transient poll error — keep trying; only LND decides failure.
}
}
return { status: 'pending', payment_hash: hash, amount_sats: amount }
}
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
return this.call({
method: 'node.nostr-publish',