Recover failed Lightning file attempts without blocking other payment methods

This commit is contained in:
archipelago
2026-10-06 19:59:47 -04:00
parent 0dda84e5c4
commit d94ff097d2
6 changed files with 322 additions and 61 deletions
+55 -27
View File
@@ -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<i64> {
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!(
+88
View File
@@ -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.
@@ -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()
})
})
+11 -2
View File
@@ -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
+75 -32
View File
@@ -396,9 +396,9 @@
accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3">
<button
v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
v-if="!hasBlockingLightningReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id"
:disabled="paymentActionBusy"
@click="prepareEcashPay"
>
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -413,22 +413,22 @@
<button
v-if="acceptsMethod(payItem.access, 'lightning')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying"
:disabled="paymentActionBusy"
@click="payWithLightning"
>
<svg class="w-6 h-6 text-yellow-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>
<span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span>
<span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
<span class="block text-base text-white">{{ lnPaying ? (hasBlockingLightningReceipt ? 'Checking payment…' : 'Paying…') : (hasBlockingLightningReceipt ? 'Check payment / retry download' : 'Pay with my Lightning node') }}</span>
<span class="block text-sm text-white/50">{{ hasBlockingLightningReceipt ? 'Checks the saved attempt; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
</span>
</button>
<button
v-if="acceptsMethod(payItem.access, 'lightning') || acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
:disabled="paymentActionBusy"
@click="openQrPay"
>
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -443,7 +443,7 @@
<button
v-if="acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
:disabled="paymentActionBusy"
@click="payOnchain"
>
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -845,21 +845,46 @@ const onchainError = ref('')
const onchainCopied = ref(false)
const lnPaying = ref(false)
const lnError = ref('')
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number; state?: 'pending' | 'succeeded' | 'failed'; failure_reason?: string }
const lnReceipt = ref<LightningReceipt | null>(null)
const lnReceiptReadError = ref(false)
const hasBlockingLightningReceipt = computed(() => lnReceiptReadError.value || Boolean(lnReceipt.value && lnReceipt.value.state !== 'failed'))
const paymentActionBusy = computed(() => lnPaying.value || onchainPaying.value || ecashPreparing.value || downloading.value !== null)
function activePaymentMatches(onion: string, id: string) {
return payItem.value?.id === id && (props.peerId || currentPeer.value?.onion) === onion
}
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
function readReceipt(onion: string, id: string): LightningReceipt | null {
const raw = localStorage.getItem(receiptKey(onion, id))
if (!raw) return null
const receipt = JSON.parse(raw) as LightningReceipt
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash) || (receipt.state !== undefined && !['pending', 'succeeded', 'failed'].includes(receipt.state))) throw new Error('Saved payment needs recovery. Do not pay again.')
return receipt
}
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
// Must succeed before handing an invoice to a payer. A failed transfer or
// navigation must never turn Retry into a second payment.
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
lnReceipt.value = receipt
if (activePaymentMatches(onion, id)) lnReceipt.value = receipt
}
function keepFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, reason: string) {
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason })
if (activePaymentMatches(onion, id)) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
}
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt): Promise<'failed' | 'pending' | 'other'> {
// Old backends throw for terminal failures. Only LND's matching payment status
// can distinguish that from a lost reply; never infer failure from error text.
try {
const result = await rpcClient.call<{ status?: string; failure_reason?: string }>({
method: 'lnd.paymentstatus', params: { payment_hash: receipt.payment_hash }, timeout: 15000,
})
if (result?.status === 'failed') {
keepFailedLightningAttempt(onion, id, receipt, result.failure_reason || 'Payment failed')
return 'failed'
}
if (result?.status === 'pending' || result?.status === 'in_flight') return 'pending'
} catch { /* unavailable/unknown is still recoverable, never permission to pay again */ }
return 'other'
}
const onchainPaying = ref(false)
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
@@ -1113,8 +1138,12 @@ function openPayModal(item: CatalogItem) {
onchainCopied.value = false
lnPaying.value = false
lnError.value = ''
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
lnReceipt.value = null
lnReceiptReadError.value = false
try {
lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id)
if (lnReceipt.value?.state === 'failed') lnError.value = `Previous Lightning attempt failed: ${lnReceipt.value.failure_reason || 'Payment failed'}. You can choose another method.`
} catch { lnReceiptReadError.value = true; lnError.value = 'Saved payment could not be read. Do not pay again.' }
onchainPaying.value = false
}
@@ -1137,7 +1166,7 @@ function closePayModal() {
*/
function openQrPay() {
payMode.value = 'qr'
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
if (hasBlockingLightningReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
payMode.value = 'qr'
invoiceData.value = null
invoiceQr.value = ''
@@ -1161,7 +1190,7 @@ function openQrPay() {
* forth doesn't silently stop watching for payment). */
function selectQrTab(tab: 'onchain' | 'lightning') {
if (qrTab.value === tab) return
if (tab === 'onchain' && lnReceipt.value) {
if (tab === 'onchain' && hasBlockingLightningReceipt.value) {
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
return
}
@@ -1243,7 +1272,8 @@ async function copyOnchain() {
async function payOnchain() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || onchainPaying.value) return
if (!item || !onion || paymentActionBusy.value) return
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
onchainPaying.value = true
lnError.value = ''
@@ -1262,7 +1292,7 @@ async function payOnchain() {
const send = await rpcClient.call<{ txid?: string; error?: string }>({
method: 'lnd.sendcoins',
params: { addr, amount: req.amount_sats },
timeout: 60000,
timeout: 60000, maxRetries: 1,
})
if (!send?.txid) {
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
@@ -1322,7 +1352,8 @@ function ecashBalanceOf(b: EcashBackend): number {
*/
async function prepareEcashPay() {
const item = payItem.value
if (!item) return
if (!item || paymentActionBusy.value) return
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
const price = getItemPrice(item.access)
ecashPreparing.value = true
purchaseError.value = null
@@ -1397,7 +1428,7 @@ async function confirmEcashPay() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
const method = ecashPlan.value?.chosen
if (!item || !onion || !method || downloading.value) return
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
const price = getItemPrice(item.access)
downloading.value = item.id
@@ -1406,7 +1437,7 @@ async function confirmEcashPay() {
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
timeout: 960000,
timeout: 960000, maxRetries: 1,
})
if (result?.data !== undefined || result?.owned === true) {
// The purchase is now cached + owned by this node (backend persisted it).
@@ -1432,7 +1463,8 @@ async function payWithInvoice() {
invoiceError.value = ''
invoiceWaiting.value = true
try {
const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
const saved = readReceipt(onion, item.id)
const res = (saved?.state === 'failed' ? null : saved) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 45000,
@@ -1443,7 +1475,7 @@ async function payWithInvoice() {
return
}
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
keepReceipt(onion, item.id, invoiceData.value)
keepReceipt(onion, item.id, { ...invoiceData.value, state: 'pending' })
try {
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
} catch {
@@ -1464,40 +1496,51 @@ async function payWithInvoice() {
async function payWithLightning() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || lnPaying.value) return
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
lnPaying.value = true
lnError.value = ''
let inv: LightningReceipt | null = null
try {
let inv = readReceipt(onion, item.id)
if (!inv) {
inv = readReceipt(onion, item.id)
if (inv && inv.state !== 'failed') {
const state = await recoverFailedLightningAttempt(onion, item.id, inv)
if (state === 'failed') return
if (state === 'pending') {
if (activePaymentMatches(onion, item.id)) lnError.value = 'Payment is still settling. You can close this window; check this saved attempt later without sending more sats.'
return
}
}
if (!inv || inv.state === 'failed') {
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
})
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access), state: 'pending' }
keepReceipt(onion, item.id, inv)
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
keepFailedLightningAttempt(onion, item.id, inv, pay.failure_reason || 'Payment failed')
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
if (pay.status !== 'succeeded') {
if (activePaymentMatches(onion, item.id)) lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
return
}
inv = { ...inv, state: 'succeeded' }
keepReceipt(onion, item.id, inv)
}
lnReceipt.value = inv
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000,
})
if (!activePaymentMatches(onion, item.id)) return
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
} catch (e: unknown) {
lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
} finally { lnPaying.value = false }
if (inv && inv.state !== 'failed' && inv.state !== 'succeeded' && await recoverFailedLightningAttempt(onion, item.id, inv) === 'failed') return
if (activePaymentMatches(onion, item.id)) lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
} finally { if (activePaymentMatches(onion, item.id)) lnPaying.value = false }
}
function scheduleInvoicePoll() {
@@ -56,6 +56,7 @@ describe('Lightning file delivery recovery', () => {
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].params).toMatchObject({ cache_only: true, method: 'cashu' })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].maxRetries).toBe(1)
wrapper.unmount()
})
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
@@ -133,4 +134,80 @@ describe('Lightning file delivery recovery', () => {
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
save.mockRestore(); wrapper.unmount()
})
it('reopens ecash after a returned terminal failure while retaining failed-attempt history', async () => {
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'failed', failure_reason: 'Insufficient channel balance' } as never)
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'wallet.ecash-balance' ? { cashu_sats: 10 } : original(args))
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 5, accepted: ['lightning', 'ecash'] } } })
await vm.payWithLightning(); await flushPromises()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'failed', payment_hash: hash })
expect(wrapper.text()).toContain('Pay from this node’s ecash wallet')
await vm.prepareEcashPay()
expect(vm.ecashPlan.chosen).toBe('cashu')
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(download).not.toHaveBeenCalled()
wrapper.unmount()
})
it('resolves an old-backend exception using definitive LND state', async () => {
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Payment failed: Insufficient channel balance'))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'failed', failure_reason: 'Insufficient channel balance' } : original(args))
const { wrapper, vm } = await open(); await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'failed' })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'lnd.paymentstatus')![0].params).toEqual({ payment_hash: hash })
expect(download).not.toHaveBeenCalled()
wrapper.unmount()
})
it('resolves an already stuck receipt without another payment or invoice', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'failed', failure_reason: 'No route' } : original(args))
const { wrapper, vm } = await open(); await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.request-invoice')).toBe(false)
wrapper.unmount()
})
it('keeps ambiguous attempts recoverable and prevents another payment method', async () => {
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'unknown' } : original(args))
const { wrapper, vm } = await open(); await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
await vm.prepareEcashPay()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
await vm.confirmEcashPay(); await vm.payOnchain()
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => ['content.download-peer-paid', 'content.request-onchain', 'lnd.sendcoins'].includes(v.method))).toBe(false)
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('returns known in-flight recovery promptly without a long delivery call or another payment', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'saved', payment_hash: hash, price_sats: 5, state: 'pending' }))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'pending' } : original(args))
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.lnPaying).toBe(false)
expect(vm.lnError).toContain('close this window')
expect(vi.mocked(rpcClient.call).mock.calls.some(([c]) => ['content.request-invoice', 'content.download-peer-invoice'].includes(c.method))).toBe(false)
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
vm.closePayModal()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
wrapper.unmount()
})
it('requests a fresh QR invoice after a confirmed failed local attempt', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'failed-old', payment_hash: hash, price_sats: 5, state: 'failed' }))
const { wrapper, vm } = await open()
await vm.payWithInvoice()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([c]) => c.method === 'content.request-invoice')).toHaveLength(1)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ bolt11: 'ln-test', state: 'pending' })
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
wrapper.unmount()
})
})