From 8bea3707cad78068e673b06b1fef0f927296cbdd Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 29 Jul 2026 10:47:39 -0400 Subject: [PATCH] feat(lightning): instant pay feedback, balances never vanish mid-payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework-pt report: a paid invoice stalled the UI with no success shown, and lightning/total balances disappeared until it settled. Three compounding causes, three fixes: - Backend payinvoice's synchronous wait drops 120s → 8s. Fast payments (the majority) still settle in one round trip; slow multi-hop routes return pending + payment_hash quickly and the caller's 3s poll takes over — instead of the modal freezing for up to two minutes. - payLightningInvoice gains an onPending hook: SendBitcoinModal and the scan modal now flip to a visible "Settling…" success pane the moment the payment goes pending (safe to close), and the ongoing poll upgrades it to Paid — or replaces it with LND's real failure. - One slow lnd.getinfo poll (5s budget) flipped the Home wallet card to "disconnected", hiding balances the user already knew. Three consecutive failures are now required (~30s) before the card gives up; last-known balances keep rendering throughout. rpc-client tests 75/75. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/lnd/payments.rs | 14 +++++++----- neode-ui/src/api/rpc-client.ts | 7 +++++- neode-ui/src/components/SendBitcoinModal.vue | 23 ++++++++++++++++++-- neode-ui/src/components/WalletScanModal.vue | 14 ++++++++++-- neode-ui/src/views/Home.vue | 13 +++++++++-- 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/core/archipelago/src/api/rpc/lnd/payments.rs b/core/archipelago/src/api/rpc/lnd/payments.rs index f4a52bd3..af4654dc 100644 --- a/core/archipelago/src/api/rpc/lnd/payments.rs +++ b/core/archipelago/src/api/rpc/lnd/payments.rs @@ -74,14 +74,18 @@ impl RpcHandler { // 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. + // paying in the background — only LND may declare a payment failed, + // so a post-connect timeout is IN FLIGHT (status: pending), never + // failure. The window is deliberately SHORT: most payments settle in + // a couple of seconds and still get their answer in one round trip, + // while a slow multi-hop route flips the UI into its "settling…" + // polling state (lnd.paymentstatus every 3s) after ~8s instead of + // freezing the modal for two minutes with no feedback (framework-pt + // user report, 2026-07-29). let pay_client = reqwest::Client::builder() .no_proxy() .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(120)) + .timeout(std::time::Duration::from_secs(8)) .danger_accept_invalid_certs(true) .build() .context("Failed to create HTTP client")?; diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 7619f957..9e062f6b 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -445,7 +445,7 @@ class RPCClient { async payLightningInvoice(params: { payment_request: string amount_sats?: number - }): Promise<{ + }, onPending?: (payment_hash: string) => void): Promise<{ status: 'succeeded' | 'failed' | 'pending' payment_hash: string amount_sats: number @@ -470,6 +470,11 @@ class RPCClient { } if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount } + // Let the caller unblock its UI right now ("settling…") — the backend + // answers pending after ~8s, and freezing a modal through the poll loop + // below was the "paid but the interface stalled" report. + try { onPending?.(hash) } catch { /* caller UI must not break polling */ } + // 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)) diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 3e3175d2..7d8d433f 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -621,9 +621,28 @@ async function send() { } else if (method === 'lightning') { if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return } // 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() }) + // itself declares the payment failed — never on a timeout. The moment + // the payment goes pending (~8s), the success pane appears in + // "settling…" mode instead of freezing the modal through the poll — + // the poll keeps running and upgrades the pane when LND settles. + const res = await rpcClient.payLightningInvoice( + { payment_request: dest.value.trim() }, + (hash) => { + successInfo.value = { + amount: paidAmount, + methodLabel: 'Settling…', + hash, + note: 'Payment is on its way through the network. This pane updates the moment it settles — safe to close.', + } + confirming.value = false + processing.value = false + emit('sent') + }, + ) if (res.status === 'failed') { + // If the settling pane is up, replace it with the failure — LND + // declared this payment failed for real. + successInfo.value = null error.value = res.failure_reason || t('web5.sendFailed') return } diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index ee905c87..73c4c403 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -704,8 +704,18 @@ async function confirmSend() { const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value } if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value // 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) + // itself declares the payment failed — never on a timeout. Pending + // (~8s) jumps straight to the success pane in SETTLING mode instead of + // spinning through the whole poll; the poll upgrades it to PAID. + const res = await rpcClient.payLightningInvoice(params, (hash) => { + successAmount.value = effectiveAmount.value + successVerb.value = 'SETTLING' + successDetail.value = 'Payment is on its way — this updates the moment it settles. Safe to close.' + successRef.value = hash + processing.value = false + goTo('success') + emit('sent') + }) if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed') successAmount.value = res.amount_sats || effectiveAmount.value successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID' diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index b1e68109..2c51aca2 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -547,6 +547,7 @@ const showScanModal = ref(false); const showSendModal = ref(false); const showRe async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } } const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0) +let walletInfoFailures = 0 const walletArk = ref(0) const walletTransactions = ref([]) @@ -630,8 +631,16 @@ async function loadWeb5Status() { // call, which is what makes the card feel like an app launch. const balances = Promise.allSettled([ rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }) - .then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true }) - .catch(() => { walletConnected.value = false }), + .then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 }) + .catch(() => { + // A single slow poll must NOT flip the card to "disconnected" and + // hide balances the user already knows — busy nodes routinely blow + // the 5s budget mid-payment or during IO storms (framework-pt user + // report: balances vanished while a payment settled). Only call it + // disconnected after three consecutive failures (~30s of silence). + walletInfoFailures += 1 + if (walletInfoFailures >= 3) walletConnected.value = false + }), rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }) .then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }), rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })