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:
parent
aa272bfcf4
commit
9cd507269c
@ -132,6 +132,7 @@ impl RpcHandler {
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||
|
||||
@ -36,6 +36,33 @@ impl RpcHandler {
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Decode the invoice up front (fast, local) so we know its payment
|
||||
// hash BEFORE handing it to LND. If the payment outlives our wait
|
||||
// below, the hash is what lets the UI keep tracking it instead of
|
||||
// declaring a false failure. Best-effort: a decode hiccup must not
|
||||
// block the payment itself.
|
||||
let (decoded_hash, decoded_amt) = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r.json::<serde_json::Value>().await {
|
||||
Ok(d) => (
|
||||
d.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
d.get("num_satoshis")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
Err(_) => (String::new(), 0),
|
||||
},
|
||||
Err(_) => (String::new(), 0),
|
||||
};
|
||||
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
@ -43,13 +70,46 @@ impl RpcHandler {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||
// payment settles or definitively fails, and multi-hop routing with
|
||||
// retries routinely takes longer than the shared client's 15s budget.
|
||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||
// paying in the background — the payment then succeeded and appeared
|
||||
// in history a minute later. Wait up to 120s on a dedicated client,
|
||||
// and treat a post-connect timeout as IN FLIGHT (status: pending),
|
||||
// never as failure — only LND may declare a payment failed.
|
||||
let pay_client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let resp = match pay_client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to pay invoice")?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_connect() => {
|
||||
// Never reached LND — nothing was sent; this IS a hard error.
|
||||
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
// Timed out (or lost the connection) AFTER the payment was
|
||||
// handed to LND — it may well still succeed. Report pending
|
||||
// with the hash so the caller can poll lnd.paymentstatus.
|
||||
info!("payinvoice wait elapsed; payment still in flight");
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": decoded_amt,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
@ -86,20 +146,105 @@ impl RpcHandler {
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(decoded_amt);
|
||||
|
||||
let payment_hash = body
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(decoded_hash);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "succeeded",
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
||||
/// "pending"`) to a real terminal state instead of guessing.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
|
||||
let hash_lower = payment_hash.to_lowercase();
|
||||
let found = body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|p| {
|
||||
p.get("payment_hash").and_then(|v| v.as_str())
|
||||
== Some(hash_lower.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let Some(p) = found else {
|
||||
// Not in the latest window — either very old or LND never saw it.
|
||||
return Ok(serde_json::json!({ "status": "unknown" }));
|
||||
};
|
||||
|
||||
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = match lnd_status {
|
||||
"SUCCEEDED" => "succeeded",
|
||||
"FAILED" => "failed",
|
||||
_ => "in_flight",
|
||||
};
|
||||
let failure_reason = match p
|
||||
.get("failure_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||
}
|
||||
"FAILURE_REASON_ERROR" => "Payment failed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
||||
p.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"failure_reason": failure_reason,
|
||||
"amount_sats": amt(p, "value_sat"),
|
||||
"fee_sats": amt(p, "fee_sat"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -525,10 +525,10 @@ async function approvePayment() {
|
||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||
} else if (method === 'lightning') {
|
||||
if (pay.invoice) {
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: pay.invoice },
|
||||
})
|
||||
// Tracked to a real terminal state — slow routing is not a failure.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
if (res.status === 'pending') throw new Error('Payment is still settling — check your wallet transactions before retrying.')
|
||||
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
|
||||
} else {
|
||||
// Create and immediately return an invoice for the requester to display
|
||||
|
||||
@ -620,11 +620,21 @@ async function send() {
|
||||
ecashToken.value = res.token
|
||||
} else if (method === 'lightning') {
|
||||
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
||||
const res = await rpcClient.call<{ payment_hash: string }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: dest.value.trim() },
|
||||
})
|
||||
successInfo.value = { amount: paidAmount, methodLabel: 'Paid over Lightning', hash: res.payment_hash }
|
||||
// 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: dest.value.trim() })
|
||||
if (res.status === 'failed') {
|
||||
error.value = res.failure_reason || t('web5.sendFailed')
|
||||
return
|
||||
}
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
|
||||
hash: res.payment_hash || undefined,
|
||||
...(res.status === 'pending'
|
||||
? { note: 'This payment is taking longer than usual to settle. It will appear in your transactions once it completes.' }
|
||||
: {}),
|
||||
}
|
||||
} else {
|
||||
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
|
||||
@ -701,16 +701,17 @@ async function confirmSend() {
|
||||
error.value = ''
|
||||
try {
|
||||
if (action.value === 'pay-invoice') {
|
||||
const params: Record<string, unknown> = { payment_request: dest.value }
|
||||
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
|
||||
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params,
|
||||
timeout: 60000,
|
||||
})
|
||||
// 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(params)
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
successAmount.value = res.amount_sats || effectiveAmount.value
|
||||
successVerb.value = 'PAID'
|
||||
successDetail.value = 'Lightning invoice paid'
|
||||
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
|
||||
successDetail.value = res.status === 'pending'
|
||||
? 'Payment in flight — it will appear in your transactions once it settles'
|
||||
: 'Lightning invoice paid'
|
||||
successRef.value = res.payment_hash
|
||||
} else if (action.value === 'send-onchain') {
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
|
||||
@ -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()) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user