From d94ff097d207c84856180c057e1690aac465fffd Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 19:59:47 -0400 Subject: [PATCH] Recover failed Lightning file attempts without blocking other payment methods --- core/archipelago/src/api/rpc/lnd/payments.rs | 82 +++++++++----- docs/paid-content-recovery-followup.md | 88 ++++++++++++++ neode-ui/src/api/__tests__/rpc-client.test.ts | 16 +++ neode-ui/src/api/rpc-client.ts | 13 ++- neode-ui/src/views/PeerFiles.vue | 107 ++++++++++++------ .../__tests__/PeerFilesLightning.test.ts | 77 +++++++++++++ 6 files changed, 322 insertions(+), 61 deletions(-) diff --git a/core/archipelago/src/api/rpc/lnd/payments.rs b/core/archipelago/src/api/rpc/lnd/payments.rs index 23c5aecb..3a23765e 100644 --- a/core/archipelago/src/api/rpc/lnd/payments.rs +++ b/core/archipelago/src/api/rpc/lnd/payments.rs @@ -34,6 +34,33 @@ fn payment_failure_reason(reason: &str) -> &'static str { } } +/// Preserve terminal LND state as structured data. An RPC exception is an +/// ambiguous outcome to callers and must not hide a verified unpaid failure. +fn router_payment_outcome( + payment: &serde_json::Value, + hash: &str, + decoded_amt: i64, +) -> serde_json::Value { + let status = match payment.get("status").and_then(|value| value.as_str()) { + Some("SUCCEEDED") => "succeeded", + Some("FAILED") => "failed", + _ => "pending", + }; + let mut result = serde_json::json!({ + "status": status, "payment_hash": hash, + "amount_sats": json_i64(payment, "value_sat").unwrap_or(decoded_amt), + }); + if status == "failed" { + result["failure_reason"] = serde_json::json!(payment_failure_reason( + payment + .get("failure_reason") + .and_then(|value| value.as_str()) + .unwrap_or("") + )); + } + result +} + fn json_i64(value: &serde_json::Value, key: &str) -> Option { value.get(key).and_then(|v| { v.as_str() @@ -190,33 +217,7 @@ impl RpcHandler { return Err(payment_error(msg)); } let payment = body.get("result").unwrap_or(&body); - match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") { - "SUCCEEDED" => {} - "FAILED" => { - let reason = payment - .get("failure_reason") - .and_then(|v| v.as_str()) - .map(payment_failure_reason) - .unwrap_or("Payment failed"); - return Err(anyhow::anyhow!("Payment failed: {reason}")); - } - _ => { - return Ok(serde_json::json!({ - "status": "pending", - "payment_hash": decoded_hash, - "amount_sats": decoded_amt, - })); - } - } - - let amount_sat = json_i64(payment, "value_sat").unwrap_or(decoded_amt); - Ok(serde_json::json!({ - "status": "succeeded", - // The decode endpoint returns the canonical hex hash used by our - // polling/list APIs. Router's bytes field is base64 in REST JSON. - "payment_hash": decoded_hash, - "amount_sats": amount_sat, - })) + Ok(router_payment_outcome(payment, &decoded_hash, decoded_amt)) } /// Status of an outgoing Lightning payment by hex payment hash. Lets the @@ -244,6 +245,10 @@ impl RpcHandler { .send() .await .context("LND REST connection failed")?; + anyhow::ensure!( + resp.status().is_success(), + "LND payment status is unavailable" + ); let body: serde_json::Value = resp .json() .await @@ -565,6 +570,29 @@ mod tests { assert!(payment_error(msg).to_string().contains("fresh invoice")); } + #[test] + fn terminal_router_failures_remain_distinct_from_ambiguous_payment_outcomes() { + let failed = router_payment_outcome( + &serde_json::json!({"status":"FAILED", "failure_reason":"FAILURE_REASON_INSUFFICIENT_BALANCE", "value_sat":"2"}), + &"ab".repeat(32), + 0, + ); + assert_eq!(failed["status"], "failed"); + assert_eq!(failed["failure_reason"], "Insufficient channel balance"); + assert_eq!(failed["amount_sats"], 2); + assert_eq!(failed["payment_hash"], "ab".repeat(32)); + for status in ["IN_FLIGHT", "INITIATED", "UNKNOWN", ""] { + assert_eq!( + router_payment_outcome(&serde_json::json!({"status":status}), "", 2)["status"], + "pending" + ); + } + assert_eq!( + router_payment_outcome(&serde_json::json!({"status":"SUCCEEDED"}), "", 2)["status"], + "succeeded" + ); + } + #[test] fn router_failure_reasons_are_actionable() { assert_eq!( diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 086ccdcb..9bbe138a 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -459,3 +459,91 @@ authenticated seller offer/receipt transport, or serving capability. Scratch acceptance/deadline/executor work in `/tmp/archy-purchase-executor-next` is separate and is NOT included in these test results. Explicit seller acceptance before buyer spending and retained late-settlement eligibility are the next batch. + +### New live report: failed Lightning blocks the other payment methods + +The 6 October operator test reproduced a distinct payment-choice dead end. Yaya's +outgoing LND record at 23:23:41 UTC is a 2-sat terminal `FAILED` payment with +`FAILURE_REASON_INSUFFICIENT_BALANCE`. The old backend turned that state into an +RPC exception; PeerFiles retained its invoice as unresolved and hid ecash forever. +The generic frontend Lightning helper also treated unknown returned statuses as +success. Both source paths now distinguish failed, pending and succeeded. + +The frontend keeps the failed attempt's receipt/history, but permits another +method only after an explicit returned failure or read-only `lnd.paymentstatus` +confirmation for that saved hash. It supports old deployed backends that throw +for terminal failures. An existing saved attempt can be checked without asking +for another invoice or sending another payment. Unknown, unavailable, possibly +settled and corrupt saved attempts remain blocked from a second payment; delivery +recovery remains available. This does not cancel the seller's invoice or claim +that an externally displayed invoice cannot subsequently be paid. + +Focused qualification passed 108 tests across PeerFilesLightning and rpc-client: +`/tmp/archy-payment-switch-ui-tests.log`. Coverage includes returned terminal +failure, old-backend exceptions, persisted failed-attempt recovery, ambiguous +status retaining its block, and unknown status never becoming success. The new +Rust LND status test is awaiting the coordinated combined isolated backend run. +These changes are not yet claimed deployed or accepted on the three real nodes. + +Separate live evidence must remain open: Yaya's 100-sat ecash request to Archi +Dev Box at 23:25:16 UTC encountered an unavailable FIPS route/connection timeout; +the existing backend logged reclaim at 23:25:33. Investigation did not initiate +that reclaim or any payment. Dev's FIPS/backend services and listeners were active, +but the journals show repeated control-socket/seed/peer connection timeouts. +Framework access was restored and actual hostname `framework-pt` verified. At +23:24:58 its seller catalog pruned `Photos/web54321-balanced-2.mp4` because its +backing file was missing; neither supported source path exists. This is separate +from the closed historical Framework LND-startup incident. Raw node journals and +sanitized payment summaries are retained privately under +`/tmp/archy-payment-switch-incident/` (mode 0600). + +Acceptance still requires real-node no-charge checks of failed-Lightning method +switching, unknown/settled delivery recovery, reconnect and cross-method state, +seller missing-file/changed-catalog feedback, and FIPS delivery between Yaya, +Framework and dev. No new real payment, proof mutation, data deletion, or pending +state reset was performed during this investigation. + +Further review separated three remaining cases from that targeted fix. An old +failed local receipt must not be reused automatically for a newly displayed QR; +request a fresh seller invoice. Known in-flight Lightning recovery should return +promptly with retained receipt and a check-later action, instead of locking the +UI behind a long delivery request. Spending RPCs must use a single network +attempt: automatic timeout/502 retries of legacy ecash purchase or on-chain send +can otherwise repeat a mutation. These frontend refinements and two additional +regressions are prepared; their focused rerun is queued behind backend isolation. + +The larger payment-flow followup remains required: persist per-item on-chain +address/amount before broadcast, preserve txid and uncertain send state on close +and reload, and resume seller/status/cache lookup rather than issue another send. +Persist ecash dispatch intent and recover through authoritative backend purchase +state rather than call a potentially spending legacy endpoint as a status check. +Unpaid/failed/ambiguous/settled states must stay distinct across method changes; +externally exposed QR invoices/addresses remain payable until their actual expiry +or cancellation, and local attempt failure is not evidence of their cancellation. +Browser storage is supplemental only: server-owned pending lookup must survive +lost client state and another browser. This cannot be called fully fixed by the +current Lightning-only recovery improvements. + +Current no-payment route check: Yaya's FIPS request to dev's `/health` timed out +before TCP connection after four seconds; the same FIPS endpoint on dev returns +200 locally. The target matches dev's live fips0 address, backend is listening, +and nft accepts TCP 5679 on fips0. Both mesh daemons report connected common peers, +but no direct link between those two nodes. Further mesh routing diagnosis is +required; no firewall or daemon state was changed. Framework's live FileBrowser +bind mounts point to `filebrowser` and `filebrowser-data`; both were searched for +the stale filename, with no match. The persistent ext4 mount is present and its +Photos/Videos directories exist. This establishes absence from the current Cloud +roots, not deletion everywhere or a justification to rebuild any buyer receipt. + +The final focused frontend rerun passed **110 tests across two files** in 8.52s +(`/tmp/archy-payment-switch-ui-tests-final.log`), including the fresh-QR and prompt +pending-status cases. The combined isolated backend batch passed the LND status +regression and all purchase-executor tests, but was **not an overall pass**: +1,821 passed, one failed, five ignored. Its failure is in the separately owned +FIPS duplicate-identity transport assertion; follow-up is underway. The urgent +frontend source is frozen for the coordinated production build. + +Subsequent read-only route checks by the coordinating agent returned Yaya→dev +health 200 twice (2.57s and 0.49s total), with no restart or configuration change. +The earlier four-second connect timeout remains valid evidence of an intermittent +mesh route problem, not a claim of permanent loss of connectivity. diff --git a/neode-ui/src/api/__tests__/rpc-client.test.ts b/neode-ui/src/api/__tests__/rpc-client.test.ts index 206ad870..8d0b935d 100644 --- a/neode-ui/src/api/__tests__/rpc-client.test.ts +++ b/neode-ui/src/api/__tests__/rpc-client.test.ts @@ -637,4 +637,20 @@ describe('RPCClient convenience methods', () => { await rpcClient.diskCleanup() expect(getLastMethod()).toBe('system.disk-cleanup') }) + it.each([ + [{ status: 'failed', payment_hash: 'a'.repeat(64), failure_reason: 'No route' }, 'failed'], + [{ status: 'unknown', payment_hash: 'a'.repeat(64) }, 'pending'], + [{ status: 'new-status', payment_hash: 'a'.repeat(64) }, 'pending'], + [{ status: '', payment_hash: 'a'.repeat(64) }, 'pending'], + [{}, 'pending'], + [{ status: 'succeeded', payment_hash: 'a'.repeat(64) }, 'succeeded'], + [{ payment_hash: 'a'.repeat(64), amount_sats: 5 }, 'succeeded'], + ])('keeps terminal, uncertain and legacy payment states distinct: %j', async (response, expected) => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: response })) + const result = await rpcClient.payLightningInvoice({ payment_request: 'ln-test' }) + expect(result.status).toBe(expected) + if (expected === 'failed') expect(result.failure_reason).toBe('No route') + expect(mockFetch).toHaveBeenCalledOnce() + }) + }) diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index ac6316cc..a18f46f9 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -490,19 +490,28 @@ class RPCClient { status?: string payment_hash?: string amount_sats?: number + failure_reason?: string }>({ method: 'lnd.payinvoice', params, // Above the backend's 120s wait so the backend always answers first. timeout: 130000, + maxRetries: 1, }) 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') { + if (res.status === 'failed') { + return { status: 'failed', payment_hash: hash, amount_sats: amount, failure_reason: res.failure_reason || 'Payment failed' } + } + // Legacy success must include the canonical payment hash. An unknown, + // malformed or newly introduced status must never become invented success. + if (res.status === 'succeeded' || (res.status === undefined && /^[a-f0-9]{64}$/i.test(hash))) { return { status: 'succeeded', payment_hash: hash, amount_sats: amount } } + if (res.status !== 'pending' && res.status !== 'in_flight') { + return { status: 'pending', payment_hash: hash, amount_sats: amount } + } if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount } // Let the caller unblock its UI right now ("settling…") — the backend diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index d131137a..748843eb 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -396,9 +396,9 @@ accepts for this item are offered -->