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:
co-authored by
Claude Fable 5
parent
aa272bfcf4
commit
9cd507269c
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user