diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index bd673ec6..b8a974d7 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/lnd/payments.rs b/core/archipelago/src/api/rpc/lnd/payments.rs index eba3f182..5a7a63ef 100644 --- a/core/archipelago/src/api/rpc/lnd/payments.rs +++ b/core/archipelago/src/api/rpc/lnd/payments.rs @@ -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::().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::().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::().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, + ) -> Result { + 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( diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index ee4b553d..1e08b9d7 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -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', diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 7ea69e15..50f51ae0 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -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 diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index ca025fd1..3e3175d2 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -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 }>({ diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 01dc82a9..ee905c87 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -701,16 +701,17 @@ async function confirmSend() { error.value = '' try { if (action.value === 'pay-invoice') { - const params: Record = { 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 }>({ diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 7255c317..c7e82c82 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -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. diff --git a/neode-ui/src/views/web5/Web5SendReceiveModals.vue b/neode-ui/src/views/web5/Web5SendReceiveModals.vue index cc596c36..52fc3cf5 100644 --- a/neode-ui/src/views/web5/Web5SendReceiveModals.vue +++ b/neode-ui/src/views/web5/Web5SendReceiveModals.vue @@ -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()) {