feat(lightning): instant pay feedback, balances never vanish mid-payment
Some checks failed
Demo images / Build & push demo images (push) Failing after 4m20s

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 10:47:39 -04:00
parent b7310ecfaf
commit 8bea3707ca
5 changed files with 59 additions and 12 deletions

View File

@ -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")?;

View File

@ -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))

View File

@ -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
}

View File

@ -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'

View File

@ -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<WalletTransaction[]>([])
@ -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 })