Recover failed Lightning file attempts without blocking other payment methods
This commit is contained in:
@@ -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> {
|
fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
|
||||||
value.get(key).and_then(|v| {
|
value.get(key).and_then(|v| {
|
||||||
v.as_str()
|
v.as_str()
|
||||||
@@ -190,33 +217,7 @@ impl RpcHandler {
|
|||||||
return Err(payment_error(msg));
|
return Err(payment_error(msg));
|
||||||
}
|
}
|
||||||
let payment = body.get("result").unwrap_or(&body);
|
let payment = body.get("result").unwrap_or(&body);
|
||||||
match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") {
|
Ok(router_payment_outcome(payment, &decoded_hash, decoded_amt))
|
||||||
"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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||||
@@ -244,6 +245,10 @@ impl RpcHandler {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("LND REST connection failed")?;
|
.context("LND REST connection failed")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"LND payment status is unavailable"
|
||||||
|
);
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
@@ -565,6 +570,29 @@ mod tests {
|
|||||||
assert!(payment_error(msg).to_string().contains("fresh invoice"));
|
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]
|
#[test]
|
||||||
fn router_failure_reasons_are_actionable() {
|
fn router_failure_reasons_are_actionable() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -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
|
acceptance/deadline/executor work in `/tmp/archy-purchase-executor-next` is separate
|
||||||
and is NOT included in these test results. Explicit seller acceptance before
|
and is NOT included in these test results. Explicit seller acceptance before
|
||||||
buyer spending and retained late-settlement eligibility are the next batch.
|
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()
|
await rpcClient.diskCleanup()
|
||||||
expect(getLastMethod()).toBe('system.disk-cleanup')
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -490,19 +490,28 @@ class RPCClient {
|
|||||||
status?: string
|
status?: string
|
||||||
payment_hash?: string
|
payment_hash?: string
|
||||||
amount_sats?: number
|
amount_sats?: number
|
||||||
|
failure_reason?: string
|
||||||
}>({
|
}>({
|
||||||
method: 'lnd.payinvoice',
|
method: 'lnd.payinvoice',
|
||||||
params,
|
params,
|
||||||
// Above the backend's 120s wait so the backend always answers first.
|
// Above the backend's 120s wait so the backend always answers first.
|
||||||
timeout: 130000,
|
timeout: 130000,
|
||||||
|
maxRetries: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hash = res.payment_hash || ''
|
const hash = res.payment_hash || ''
|
||||||
const amount = res.amount_sats || 0
|
const amount = res.amount_sats || 0
|
||||||
// Older backends have no status field — a plain response was a success.
|
if (res.status === 'failed') {
|
||||||
if (res.status !== 'pending') {
|
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 }
|
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 }
|
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
|
||||||
|
|
||||||
// Let the caller unblock its UI right now ("settling…") — the backend
|
// Let the caller unblock its UI right now ("settling…") — the backend
|
||||||
|
|||||||
@@ -396,9 +396,9 @@
|
|||||||
accepts for this item are offered -->
|
accepts for this item are offered -->
|
||||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||||
<button
|
<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"
|
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"
|
@click="prepareEcashPay"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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
|
<button
|
||||||
v-if="acceptsMethod(payItem.access, 'lightning')"
|
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"
|
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"
|
@click="payWithLightning"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 text-yellow-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<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-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">{{ 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-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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="acceptsMethod(payItem.access, 'lightning') || acceptsMethod(payItem.access, 'onchain')"
|
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"
|
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"
|
@click="openQrPay"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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
|
<button
|
||||||
v-if="acceptsMethod(payItem.access, 'onchain')"
|
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"
|
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"
|
@click="payOnchain"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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 onchainCopied = ref(false)
|
||||||
const lnPaying = ref(false)
|
const lnPaying = ref(false)
|
||||||
const lnError = ref('')
|
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 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 receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
|
||||||
function readReceipt(onion: string, id: string): LightningReceipt | null {
|
function readReceipt(onion: string, id: string): LightningReceipt | null {
|
||||||
const raw = localStorage.getItem(receiptKey(onion, id))
|
const raw = localStorage.getItem(receiptKey(onion, id))
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const receipt = JSON.parse(raw) as LightningReceipt
|
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
|
return receipt
|
||||||
}
|
}
|
||||||
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
|
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
|
||||||
// Must succeed before handing an invoice to a payer. A failed transfer or
|
// Must succeed before handing an invoice to a payer. A failed transfer or
|
||||||
// navigation must never turn Retry into a second payment.
|
// navigation must never turn Retry into a second payment.
|
||||||
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
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)
|
const onchainPaying = ref(false)
|
||||||
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -1113,8 +1138,12 @@ function openPayModal(item: CatalogItem) {
|
|||||||
onchainCopied.value = false
|
onchainCopied.value = false
|
||||||
lnPaying.value = false
|
lnPaying.value = false
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
|
lnReceipt.value = null
|
||||||
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
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
|
onchainPaying.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,7 +1166,7 @@ function closePayModal() {
|
|||||||
*/
|
*/
|
||||||
function openQrPay() {
|
function openQrPay() {
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
|
if (hasBlockingLightningReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
invoiceData.value = null
|
invoiceData.value = null
|
||||||
invoiceQr.value = ''
|
invoiceQr.value = ''
|
||||||
@@ -1161,7 +1190,7 @@ function openQrPay() {
|
|||||||
* forth doesn't silently stop watching for payment). */
|
* forth doesn't silently stop watching for payment). */
|
||||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||||
if (qrTab.value === tab) return
|
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.'
|
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1243,7 +1272,8 @@ async function copyOnchain() {
|
|||||||
async function payOnchain() {
|
async function payOnchain() {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
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
|
onchainPaying.value = true
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
@@ -1262,7 +1292,7 @@ async function payOnchain() {
|
|||||||
const send = await rpcClient.call<{ txid?: string; error?: string }>({
|
const send = await rpcClient.call<{ txid?: string; error?: string }>({
|
||||||
method: 'lnd.sendcoins',
|
method: 'lnd.sendcoins',
|
||||||
params: { addr, amount: req.amount_sats },
|
params: { addr, amount: req.amount_sats },
|
||||||
timeout: 60000,
|
timeout: 60000, maxRetries: 1,
|
||||||
})
|
})
|
||||||
if (!send?.txid) {
|
if (!send?.txid) {
|
||||||
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
|
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
|
||||||
@@ -1322,7 +1352,8 @@ function ecashBalanceOf(b: EcashBackend): number {
|
|||||||
*/
|
*/
|
||||||
async function prepareEcashPay() {
|
async function prepareEcashPay() {
|
||||||
const item = payItem.value
|
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)
|
const price = getItemPrice(item.access)
|
||||||
ecashPreparing.value = true
|
ecashPreparing.value = true
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
@@ -1397,7 +1428,7 @@ async function confirmEcashPay() {
|
|||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
const method = ecashPlan.value?.chosen
|
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)
|
const price = getItemPrice(item.access)
|
||||||
|
|
||||||
downloading.value = item.id
|
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 }>({
|
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',
|
method: 'content.download-peer-paid',
|
||||||
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
|
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) {
|
if (result?.data !== undefined || result?.owned === true) {
|
||||||
// The purchase is now cached + owned by this node (backend persisted it).
|
// The purchase is now cached + owned by this node (backend persisted it).
|
||||||
@@ -1432,7 +1463,8 @@ async function payWithInvoice() {
|
|||||||
invoiceError.value = ''
|
invoiceError.value = ''
|
||||||
invoiceWaiting.value = true
|
invoiceWaiting.value = true
|
||||||
try {
|
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',
|
method: 'content.request-invoice',
|
||||||
params: { onion, content_id: item.id },
|
params: { onion, content_id: item.id },
|
||||||
timeout: 45000,
|
timeout: 45000,
|
||||||
@@ -1443,7 +1475,7 @@ async function payWithInvoice() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
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 {
|
try {
|
||||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1464,40 +1496,51 @@ async function payWithInvoice() {
|
|||||||
async function payWithLightning() {
|
async function payWithLightning() {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion || lnPaying.value) return
|
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
|
||||||
lnPaying.value = true
|
lnPaying.value = true
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
|
let inv: LightningReceipt | null = null
|
||||||
try {
|
try {
|
||||||
let inv = readReceipt(onion, item.id)
|
inv = readReceipt(onion, item.id)
|
||||||
if (!inv) {
|
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 }>({
|
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
|
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.')
|
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)
|
keepReceipt(onion, item.id, inv)
|
||||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||||
if (pay.status === 'failed') {
|
if (pay.status === 'failed') {
|
||||||
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
|
keepFailedLightningAttempt(onion, item.id, inv, pay.failure_reason || 'Payment failed')
|
||||||
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pay.status === 'pending') {
|
if (pay.status !== 'succeeded') {
|
||||||
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
if (activePaymentMatches(onion, item.id)) lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
||||||
return
|
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 }>({
|
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-invoice',
|
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 },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||||
timeout: 960000,
|
timeout: 960000,
|
||||||
})
|
})
|
||||||
|
if (!activePaymentMatches(onion, item.id)) return
|
||||||
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
|
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.'
|
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
|
||||||
} catch (e: unknown) {
|
} 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.'
|
if (inv && inv.state !== 'failed' && inv.state !== 'succeeded' && await recoverFailedLightningAttempt(onion, item.id, inv) === 'failed') return
|
||||||
} finally { lnPaying.value = false }
|
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() {
|
function scheduleInvoicePoll() {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('Lightning file delivery recovery', () => {
|
|||||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||||
expect(vm.viewerMime).toBe('video/mp4')
|
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].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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
|
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()
|
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||||
save.mockRestore(); wrapper.unmount()
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user