From fba3273c6797e0a8d4c6a3e3e8fa068955f8f14c Mon Sep 17 00:00:00 2001
From: archipelago
Date: Wed, 7 Oct 2026 00:31:49 -0400
Subject: [PATCH] Add durable buyer-bound Lightning recovery and explicit
native retry
Preserve original invoice preimages, private snapshots and exposure provenance; serialize rail admission and retire native-only failures before explicit replacement. Qualify 55 focused UI tests and vue-tsc. Expanded 17 engine cases and combined backend acceptance remain pending; six earlier engine cases passed in isolation. No live payment or publication.
---
core/Cargo.lock | 1 +
core/archipelago/Cargo.toml | 1 +
.../src/api/handler/lightning_purchase.rs | 241 ++++
core/archipelago/src/api/handler/mod.rs | 4 +
core/archipelago/src/api/rpc/dispatcher.rs | 9 +
.../src/api/rpc/lightning_purchase.rs | 356 +++++
.../src/api/rpc/lnd/external_invoice.rs | 201 +++
core/archipelago/src/api/rpc/lnd/mod.rs | 1 +
core/archipelago/src/api/rpc/lnd/payments.rs | 2 +-
core/archipelago/src/api/rpc/mod.rs | 8 +
core/archipelago/src/api/rpc/purchase.rs | 10 +
core/archipelago/src/content_lightning.rs | 1233 +++++++++++++++++
.../src/content_payment_admission.rs | 44 +
.../src/content_purchase_download.rs | 2 +-
core/archipelago/src/content_server.rs | 38 +
core/archipelago/src/main.rs | 2 +
neode-ui/src/views/PeerFiles.vue | 188 ++-
.../__tests__/PeerFilesLightning.test.ts | 293 +++-
18 files changed, 2525 insertions(+), 109 deletions(-)
create mode 100644 core/archipelago/src/api/handler/lightning_purchase.rs
create mode 100644 core/archipelago/src/api/rpc/lightning_purchase.rs
create mode 100644 core/archipelago/src/api/rpc/lnd/external_invoice.rs
create mode 100644 core/archipelago/src/content_lightning.rs
create mode 100644 core/archipelago/src/content_payment_admission.rs
diff --git a/core/Cargo.lock b/core/Cargo.lock
index 845eba7c..b0d6aa53 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -141,6 +141,7 @@ dependencies = [
"iroh",
"iroh-blobs",
"libc",
+ "lightning-invoice",
"lofty",
"mainline",
"mdns-sd",
diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml
index 4a2e974a..af13a0cc 100644
--- a/core/archipelago/Cargo.toml
+++ b/core/archipelago/Cargo.toml
@@ -73,6 +73,7 @@ chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "2.1", features = ["rand"] }
+lightning-invoice = "=0.34.1"
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration
diff --git a/core/archipelago/src/api/handler/lightning_purchase.rs b/core/archipelago/src/api/handler/lightning_purchase.rs
new file mode 100644
index 00000000..36d55e0a
--- /dev/null
+++ b/core/archipelago/src/api/handler/lightning_purchase.rs
@@ -0,0 +1,241 @@
+use super::{build_response, ApiHandler};
+use crate::content_lightning::{Binding, Journal, Phase};
+use anyhow::{Context, Result};
+use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
+use serde::{Deserialize, Serialize};
+use tokio::io::AsyncReadExt;
+pub(crate) const ROUTE: &str = "/content/lightning/v1/operation";
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Operation {
+ pub binding: Binding,
+ pub action: String,
+}
+impl ApiHandler {
+ pub(super) async fn handle_lightning_purchase(
+ &self,
+ mut request: Request,
+ ) -> Result> {
+ anyhow::ensure!(
+ request.method() == Method::POST && request.uri().path() == ROUTE,
+ "Invalid invoice route"
+ );
+ let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
+ let mut bytes = Vec::new();
+ while let Some(chunk) = request.body_mut().data().await {
+ let chunk = chunk?;
+ anyhow::ensure!(
+ bytes.len() + chunk.len() <= 16384,
+ "Invoice request too large"
+ );
+ bytes.extend_from_slice(&chunk)
+ }
+ Ok::<_, anyhow::Error>(bytes)
+ })
+ .await
+ .context("Invoice request timed out")??;
+ let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
+ let buyer = crate::content_auth::authenticate_request(
+ request.headers(),
+ &seller,
+ &Method::POST,
+ ROUTE,
+ &bytes,
+ chrono::Utc::now().timestamp(),
+ )?;
+ let operation: Operation = serde_json::from_slice(&bytes)?;
+ anyhow::ensure!(
+ operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
+ "Invoice peer identity mismatch"
+ );
+ anyhow::ensure!(
+ matches!(
+ operation.action.as_str(),
+ "create" | "status" | "cancel" | "download"
+ ),
+ "Invalid invoice action"
+ );
+ let binding = &operation.binding;
+ let journal = Journal::open(&self.config.data_dir).await?;
+ let mut saved = journal.seller(binding)?;
+ if saved.is_none() {
+ anyhow::ensure!(
+ operation.action == "create",
+ "Unknown original invoice operation"
+ );
+ anyhow::ensure!(
+ !binding.content_id.starts_with("registered_"),
+ "Registered rentals use their native purchase contract"
+ );
+ let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
+ let item = catalog
+ .items
+ .iter()
+ .find(|v| v.id == binding.content_id)
+ .context("Shared item unavailable")?;
+ let visible = match &item.availability {
+ crate::content_server::Availability::Nobody => false,
+ crate::content_server::Availability::AllPeers => true,
+ crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
+ };
+ anyhow::ensure!(visible, "Item is not shared with this buyer");
+ anyhow::ensure!(
+ matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
+ && crate::content_server::method_accepted(&item.access, "lightning"),
+ "Invoice price or accepted method changed"
+ );
+ crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
+ .await?;
+ let source = crate::content_server::content_file_path(&self.config.data_dir, item);
+ let roots = [
+ self.config.data_dir.join("content/files"),
+ self.config.data_dir.join("filebrowser"),
+ ];
+ let (root, relative) = roots
+ .iter()
+ .find_map(|root| {
+ source
+ .strip_prefix(root)
+ .ok()
+ .map(|p| (root.clone(), p.to_path_buf()))
+ })
+ .context("Unsupported invoice source root")?;
+ let data = self.config.data_dir.clone();
+ let id = binding.content_id.clone();
+ struct CancelCopy(std::sync::Arc);
+ impl Drop for CancelCopy {
+ fn drop(&mut self) {
+ self.0.store(true, std::sync::atomic::Ordering::SeqCst);
+ }
+ }
+ let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
+ false,
+ )));
+ let cancelled = cancel_copy.0.clone();
+ let snapshot = tokio::task::spawn_blocking(move || {
+ crate::content_snapshot::prepare(
+ &data,
+ &root,
+ &id,
+ &relative,
+ &crate::media_registration::Limits {
+ max_bytes: 64 * 1024 * 1024 * 1024,
+ cancelled: &cancelled,
+ },
+ 64 * 1024 * 1024 * 1024,
+ 512 * 1024 * 1024,
+ |_| Ok(()),
+ )
+ })
+ .await??;
+ anyhow::ensure!(
+ snapshot.size == item.size_bytes,
+ "Shared file changed before invoice"
+ );
+ // Source metadata is private and committed before AddInvoice dispatch.
+ let record = crate::content_server::publish_snapshot_invoice(
+ &self.config.data_dir,
+ item,
+ &journal,
+ binding.clone(),
+ crate::content_lightning::RetainedFile {
+ sha256: snapshot.sha256,
+ size: snapshot.size,
+ filename: item.filename.clone(),
+ mime_type: item.mime_type.clone(),
+ },
+ )
+ .await?;
+ saved = Some(record);
+ }
+ let mut saved = saved.context("Missing invoice operation")?;
+ anyhow::ensure!(
+ saved.source.is_some(),
+ "Original invoice source is not prepared; no new invoice dispatched"
+ );
+ let status = if operation.action == "cancel" && saved.phase == Phase::Prepared {
+ saved.phase = Phase::CanceledUnpaid;
+ journal.save_seller(&saved)?;
+ saved.status()
+ } else if operation.action != "create"
+ && operation.action != "cancel"
+ && saved.phase == Phase::Prepared
+ {
+ saved.status()
+ } else {
+ self.rpc_handler
+ .drive_external_invoice(&journal, binding, operation.action == "cancel")
+ .await?
+ };
+ // The original legacy delivery mechanism remains usable by its hash.
+ if status.bolt11.is_some() {
+ crate::content_invoice::record_pending(
+ &self.config.data_dir,
+ &status.payment_hash,
+ &binding.content_id,
+ binding.price_sats,
+ )
+ .await?;
+ if status.state == Phase::Settled {
+ crate::content_invoice::mark_paid(&self.config.data_dir, &status.payment_hash)
+ .await?;
+ }
+ }
+ if operation.action == "download" {
+ anyhow::ensure!(
+ status.state == Phase::Settled,
+ "Original invoice has not settled"
+ );
+ let source = status
+ .source
+ .as_ref()
+ .context("Original invoice snapshot is missing")?;
+ let data = self.config.data_dir.clone();
+ let id = binding.content_id.clone();
+ let retained = source.clone();
+ struct CancelCopy(std::sync::Arc);
+ impl Drop for CancelCopy {
+ fn drop(&mut self) {
+ self.0.store(true, std::sync::atomic::Ordering::SeqCst);
+ }
+ }
+ let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
+ false,
+ )));
+ let cancelled = cancel_copy.0.clone();
+ let snapshot = tokio::task::spawn_blocking(move || {
+ crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
+ })
+ .await??;
+ let stream = futures_util::stream::try_unfold(
+ (tokio::fs::File::from_std(snapshot.file), source.size),
+ |(mut file, left)| async move {
+ if left == 0 {
+ return Ok::<_, std::io::Error>(None);
+ }
+ let mut bytes = vec![0; left.min(65536) as usize];
+ let count = file.read(&mut bytes).await?;
+ if count == 0 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::UnexpectedEof,
+ "Original invoice snapshot ended early",
+ ));
+ }
+ bytes.truncate(count);
+ Ok(Some((bytes, (file, left - count as u64))))
+ },
+ );
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", &source.mime_type)
+ .header("Content-Length", source.size)
+ .header("Cache-Control", "private, no-store")
+ .body(Body::wrap_stream(stream))?);
+ }
+ Ok(build_response(
+ StatusCode::OK,
+ "application/json",
+ Body::from(serde_json::to_vec(&status)?),
+ ))
+ }
+}
diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs
index 77d7b8c1..cda073f5 100644
--- a/core/archipelago/src/api/handler/mod.rs
+++ b/core/archipelago/src/api/handler/mod.rs
@@ -3,6 +3,7 @@ mod cdp;
mod cloud_purchase;
mod content;
mod dwn;
+pub(crate) mod lightning_purchase;
mod model_proxy;
mod node_message;
mod proxy;
@@ -454,6 +455,9 @@ impl ApiHandler {
.await;
}
+ if method == Method::POST && path == lightning_purchase::ROUTE {
+ return self.handle_lightning_purchase(req).await;
+ }
// Purchase routes bound the original body before the generic buffer.
if method == Method::POST
&& matches!(
diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs
index f7f227f5..1cb6de68 100644
--- a/core/archipelago/src/api/rpc/dispatcher.rs
+++ b/core/archipelago/src/api/rpc/dispatcher.rs
@@ -337,6 +337,15 @@ impl RpcHandler {
"content.playback-start" => self.handle_playback_start(params, session_token).await,
"content.playback-status" => self.handle_playback_status(params, session_token).await,
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
+ "content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
+ "content.invoice-download" => self.handle_lightning_operation(params, "download").await,
+ "content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
+ "content.invoice-retry-native" => {
+ self.handle_lightning_operation(params, "retry").await
+ }
+ "content.invoice-create" => self.handle_lightning_operation(params, "create").await,
+ "content.invoice-recover" => self.handle_lightning_operation(params, "status").await,
+ "content.invoice-cancel" => self.handle_lightning_operation(params, "cancel").await,
"content.purchase" => self.handle_content_purchase(params).await,
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
"content.payment-status" => self.handle_content_payment_status(params).await,
diff --git a/core/archipelago/src/api/rpc/lightning_purchase.rs b/core/archipelago/src/api/rpc/lightning_purchase.rs
new file mode 100644
index 00000000..6d768478
--- /dev/null
+++ b/core/archipelago/src/api/rpc/lightning_purchase.rs
@@ -0,0 +1,356 @@
+use super::RpcHandler;
+use crate::{
+ api::handler::lightning_purchase::{Operation, ROUTE},
+ content_lightning::{Binding, BuyerRecord, Journal, Phase, Status},
+};
+use anyhow::{Context, Result};
+use serde::Deserialize;
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Params {
+ onion: String,
+ content_id: String,
+ price_sats: Option,
+ operation_id: Option,
+ #[serde(default)]
+ external_exposure: bool,
+}
+impl RpcHandler {
+ pub(super) async fn handle_lightning_operation(
+ &self,
+ params: Option,
+ action: &str,
+ ) -> Result {
+ let params: Params = serde_json::from_value(params.context("Missing invoice operation")?)?;
+ let peer =
+ crate::federation::load_unique_payment_peer(&self.config.data_dir, ¶ms.onion)
+ .await?;
+ let fips = peer
+ .fips_npub
+ .context("Seller has no authenticated mesh connection")?;
+ let buyer =
+ crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
+ .await?
+ .did_key()?;
+ anyhow::ensure!(buyer != peer.did, "Cannot buy a file from this same node");
+ let _admission = crate::content_payment_admission::lock(
+ &self.config.data_dir,
+ &buyer,
+ &peer.did,
+ ¶ms.content_id,
+ )
+ .await?;
+ if matches!(action, "create" | "pay" | "retry") || params.external_exposure {
+ let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
+ anyhow::ensure!(
+ cashu
+ .find_buyers(&buyer, &peer.did, ¶ms.content_id)
+ .await?
+ .iter()
+ .all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
+ "Recover or cancel the original Cashu purchase before exposing a Lightning invoice"
+ );
+ }
+ let journal = Journal::open(&self.config.data_dir).await?;
+ let original = if let Some(id) = ¶ms.operation_id {
+ journal.buyer(id)?
+ } else {
+ journal.buyer_for(&buyer, &peer.did, ¶ms.content_id)?
+ };
+ if let Some(record) = &original {
+ anyhow::ensure!(
+ record.binding.buyer_did == buyer
+ && record.binding.seller_did == peer.did
+ && record.binding.content_id == params.content_id
+ && record.seller_onion == params.onion,
+ "Original invoice belongs to another purchase"
+ );
+ }
+ if action == "lookup" {
+ return Ok(match original {
+ None => serde_json::json!({"attempt":null}),
+ Some(mut record) => {
+ let mut native_result = record.native_result.clone();
+ if native_result.is_none() && record.native_dispatched {
+ if let Some(status) = &record.last {
+ if let Ok(payment) = self
+ .handle_lnd_paymentstatus(Some(
+ serde_json::json!({"payment_hash":status.payment_hash}),
+ ))
+ .await
+ {
+ if let Some(result @ ("failed" | "succeeded")) =
+ payment["status"].as_str()
+ {
+ native_result = Some(result.to_owned());
+ record.native_result = native_result.clone();
+ journal.save_buyer(&record)?;
+ }
+ }
+ }
+ }
+ let native_failed =
+ !record.external_exposure && native_result.as_deref() == Some("failed");
+ let native_succeeded = native_result.as_deref() == Some("succeeded");
+ if !record.external_exposure {
+ if let Some(status) = record.last.as_mut() {
+ status.bolt11 = None;
+ }
+ }
+ serde_json::json!({"attempt":{"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"external_exposure":record.external_exposure,"native_failed":native_failed,"native_succeeded":native_succeeded,"status":record.last}})
+ }
+ });
+ }
+ let mut record = if let Some(record) = original {
+ record
+ } else {
+ anyhow::ensure!(
+ action == "create" && params.operation_id.is_none(),
+ "Original invoice operation is unavailable"
+ );
+ BuyerRecord {
+ binding: Binding {
+ id: uuid::Uuid::new_v4().to_string(),
+ buyer_did: buyer.clone(),
+ seller_did: peer.did.clone(),
+ content_id: params.content_id.clone(),
+ price_sats: params
+ .price_sats
+ .context("Expected invoice price is required")?,
+ },
+ seller_onion: params.onion.clone(),
+ external_exposure: false,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: false,
+ native_result: None,
+ last: None,
+ }
+ };
+ anyhow::ensure!(
+ record.binding.buyer_did == buyer
+ && record.binding.seller_did == peer.did
+ && record.binding.content_id == params.content_id
+ && record.seller_onion == params.onion
+ && params
+ .operation_id
+ .as_ref()
+ .is_none_or(|id| id == &record.binding.id),
+ "Original invoice operation changed"
+ );
+ if action == "retry" {
+ anyhow::ensure!(
+ params.operation_id.is_some()
+ && !params.external_exposure
+ && params.price_sats == Some(record.binding.price_sats),
+ "Explicit original native retry and original price required"
+ );
+ if record.native_result.is_none()
+ && record.native_dispatched
+ && !record.external_exposure
+ {
+ let hash = &record
+ .last
+ .as_ref()
+ .context("Original invoice metadata missing")?
+ .payment_hash;
+ let payment = self
+ .handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
+ .await?;
+ if matches!(payment["status"].as_str(), Some("failed" | "succeeded")) {
+ record.native_result = payment["status"].as_str().map(str::to_owned);
+ journal.save_buyer(&record)?;
+ }
+ }
+ record = journal.retry_native(&record.binding.id)?;
+ }
+ anyhow::ensure!((!record.native_retired || matches!(action,"status"|"cancel"|"download")) && (!record.native_retired || !params.external_exposure),"This native invoice was retired before changing payment method; recover the replacement purchase");
+ if action == "pay" {
+ anyhow::ensure!(
+ params.operation_id.is_some(),
+ "Original invoice operation required for native payment"
+ );
+ return crate::content_lightning::drive_native(
+ &self.config.data_dir,
+ journal,
+ &record.binding.id,
+ &super::lnd::external_invoice::NativeNode(self),
+ )
+ .await;
+ }
+ record.external_exposure |= params.external_exposure;
+ journal.save_buyer(&record)?;
+ drop(journal);
+ // Native-only local FAILED never cancels an externally exposed invoice.
+ // The seller terminal state is authoritative regardless of UI receipt loss.
+ let operation = Operation {
+ binding: record.binding.clone(),
+ action: if action == "retry" {
+ "create".into()
+ } else {
+ action.into()
+ },
+ };
+ let response = crate::fips::dial::PeerRequest::new(Some(&fips), ¶ms.onion, ROUTE)
+ .require_fips()
+ .single_delivery()
+ .timeout(std::time::Duration::from_secs(if action == "download" {
+ 900
+ } else {
+ 45
+ }))
+ .send_content_json(&self.config.data_dir, &peer.did, &operation)
+ .await;
+ let mut response = match response {
+ Ok((r, _)) => r,
+ Err(_) => {
+ return Ok(
+ serde_json::json!({"state":"unknown","operation_id":record.binding.id,"recovery_required":true,"error":"The original invoice request is saved on this node. Recover it; no replacement invoice was requested."}),
+ )
+ }
+ };
+ anyhow::ensure!(
+ response.status().is_success(),
+ "Seller could not resolve original invoice; recover operation {}",
+ record.binding.id
+ );
+ let journal = Journal::open(&self.config.data_dir).await?;
+ if action == "download" {
+ let mut paid = record
+ .last
+ .clone()
+ .context("Original invoice metadata missing; recover it first")?;
+ let source = paid
+ .source
+ .clone()
+ .context("Original invoice snapshot missing")?;
+ anyhow::ensure!(
+ response.content_length() == Some(source.size),
+ "Original invoice file length changed"
+ );
+ paid.state = Phase::Settled;
+ paid.can_switch_method = false;
+ record.last = Some(paid);
+ journal.save_buyer(&record)?;
+ drop(journal);
+ let stream = crate::content_purchase_download::verified_stream(
+ response.bytes_stream(),
+ source.sha256,
+ source.size,
+ );
+ let owned = crate::content_owned::record_purchase_stream(
+ &self.config.data_dir,
+ crate::content_owned::OwnedItem {
+ onion: params.onion,
+ content_id: params.content_id,
+ filename: source.filename,
+ mime_type: source.mime_type,
+ size_bytes: source.size,
+ paid_sats: record.binding.price_sats,
+ ecash_backend: "lightning".into(),
+ purchased_at: chrono::Utc::now().to_rfc3339(),
+ download_complete: false,
+ },
+ Box::pin(stream),
+ Some(source.size),
+ )
+ .await?;
+ return Ok(
+ serde_json::json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
+ );
+ }
+ let mut bytes = Vec::new();
+ while let Some(chunk) = response.chunk().await? {
+ anyhow::ensure!(
+ bytes.len() + chunk.len() <= 16384,
+ "Invoice response too large"
+ );
+ bytes.extend_from_slice(&chunk)
+ }
+ let status: Status = serde_json::from_slice(&bytes)?;
+ anyhow::ensure!(
+ status.binding == record.binding
+ && status.source.is_some()
+ && status.payment_hash.len() == 64
+ && status.payment_hash.bytes().all(|b| b.is_ascii_hexdigit())
+ && status.can_switch_method == (status.state == Phase::CanceledUnpaid),
+ "Seller invoice binding changed"
+ );
+ if let Some(bolt11) = &status.bolt11 {
+ let invoice: lightning_invoice::Bolt11Invoice =
+ bolt11.parse().context("Seller invoice is invalid")?;
+ invoice.check_signature()?;
+ anyhow::ensure!(
+ invoice.payment_hash().to_string() == status.payment_hash
+ && invoice.amount_milli_satoshis()
+ == record.binding.price_sats.checked_mul(1000),
+ "Invoice hash or amount differs from saved purchase"
+ );
+ }
+ if let Some(previous) = &record.last {
+ anyhow::ensure!(
+ previous.payment_hash == status.payment_hash
+ && previous.source == status.source
+ && previous
+ .bolt11
+ .as_ref()
+ .is_none_or(|v| status.bolt11.as_ref() == Some(v)),
+ "Original invoice replaced"
+ );
+ }
+ record.last = Some(status.clone());
+ journal.save_buyer(&record)?;
+ Ok(
+ serde_json::json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"payment_hash":status.payment_hash,"bolt11":if record.external_exposure{status.bolt11}else{None},"state":match status.state{Phase::Settled=>"settled",Phase::CanceledUnpaid=>"canceled",Phase::Issued=>"open",Phase::Prepared=>"prepared",Phase::Dispatched=>"unknown",Phase::CancelRequested=>"cancel_requested"},"paid":status.state==Phase::Settled,"can_switch_method":status.can_switch_method,"cancel_supported":true,"external_exposure":record.external_exposure}),
+ )
+ }
+}
+
+impl RpcHandler {
+ /// Caller holds content_payment_admission before entering any rail journal.
+ pub(super) async fn ensure_invoice_allows_other_rail(
+ &self,
+ buyer: &str,
+ seller: &str,
+ content: &str,
+ ) -> Result<()> {
+ let journal = Journal::open(&self.config.data_dir).await?;
+ if let Some(mut record) = journal.buyer_for(buyer, seller, content)? {
+ anyhow::ensure!(record.native_replacement.is_none(),
+ "An explicit native retry is being recovered; recover its replacement operation first");
+ anyhow::ensure!(
+ !record.external_exposure,
+ "An externally payable invoice remains unresolved; cancel or recover it first"
+ );
+ let status = record
+ .last
+ .clone()
+ .context("Original invoice creation is unresolved; recover it first")?;
+ anyhow::ensure!(
+ status.state != Phase::Settled,
+ "Original Lightning purchase is paid; recover its file"
+ );
+ // A local terminal failure can release only a never-exposed native
+ // attempt. This check runs under the same outer lock as QR exposure.
+ anyhow::ensure!(
+ record.native_dispatched,
+ "Original invoice has not been canceled; cancel it before replacing the method"
+ );
+ if record.native_result.as_deref() != Some("failed") {
+ let payment = self
+ .handle_lnd_paymentstatus(Some(
+ serde_json::json!({"payment_hash":status.payment_hash}),
+ ))
+ .await?;
+ anyhow::ensure!(
+ payment["status"] == "failed",
+ "Original native Lightning attempt remains unresolved"
+ );
+ }
+ record.native_result = Some("failed".into());
+ record.native_retired = true;
+ journal.save_buyer(&record)?;
+ }
+ Ok(())
+ }
+}
diff --git a/core/archipelago/src/api/rpc/lnd/external_invoice.rs b/core/archipelago/src/api/rpc/lnd/external_invoice.rs
new file mode 100644
index 00000000..b142a19a
--- /dev/null
+++ b/core/archipelago/src/api/rpc/lnd/external_invoice.rs
@@ -0,0 +1,201 @@
+use super::LND_REST_BASE_URL;
+use crate::{
+ api::rpc::RpcHandler,
+ content_lightning::{Binding, Invoice, InvoiceNode, Journal, Status},
+};
+use anyhow::{Context, Result};
+use base64::Engine;
+struct Node {
+ client: reqwest::Client,
+ macaroon: String,
+}
+fn number(v: &serde_json::Value) -> Option {
+ v.as_u64().or_else(|| v.as_str()?.parse().ok())
+}
+impl InvoiceNode for Node {
+ async fn prepare_creation(&self) -> Result<()> {
+ let info: serde_json::Value = self
+ .client
+ .get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
+ .header("Grpc-Metadata-macaroon", &self.macaroon)
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ anyhow::ensure!(
+ info["identity_pubkey"]
+ .as_str()
+ .is_some_and(|key| !key.is_empty()),
+ "LND invoice service is not ready; original preparation retained"
+ );
+ Ok(())
+ }
+ async fn lookup(&self, hash: &str) -> Result> {
+ let response = self
+ .client
+ .get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
+ .header("Grpc-Metadata-macaroon", &self.macaroon)
+ .send()
+ .await?;
+ if response.status() == reqwest::StatusCode::NOT_FOUND {
+ return Ok(None);
+ }
+ let body: serde_json::Value = response.error_for_status()?.json().await?;
+ let raw = body["r_hash"].as_str().context("Invoice hash omitted")?;
+ let payment_hash = hex::encode(base64::engine::general_purpose::STANDARD.decode(raw)?);
+ Ok(Some(Invoice {
+ payment_hash,
+ bolt11: body["payment_request"]
+ .as_str()
+ .context("Invoice payment request omitted")?
+ .into(),
+ price_sats: number(&body["value"]).context("Invoice amount omitted")?,
+ state: body["state"]
+ .as_str()
+ .context("Invoice state omitted")?
+ .into(),
+ paid_sats: number(&body["amt_paid_sat"]),
+ paid_msats: number(&body["amt_paid_msat"]),
+ }))
+ }
+ async fn add(&self, binding: &Binding, preimage_hex: &str) -> Result<()> {
+ let preimage = base64::engine::general_purpose::STANDARD.encode(hex::decode(preimage_hex)?);
+ self.client.post(format!("{LND_REST_BASE_URL}/v1/invoices")).header("Grpc-Metadata-macaroon",&self.macaroon)
+ .json(&serde_json::json!({"memo":format!("Archipelago peer file {}",binding.content_id),"value":binding.price_sats.to_string(),"r_preimage":preimage,"private":true,"expiry":"3600"})).send().await?.error_for_status()?;
+ Ok(())
+ }
+ async fn cancel(&self, hash: &str) -> Result<()> {
+ self.client.post(format!("{LND_REST_BASE_URL}/v2/invoices/cancel")).header("Grpc-Metadata-macaroon",&self.macaroon)
+ .json(&serde_json::json!({"payment_hash":base64::engine::general_purpose::STANDARD.encode(hex::decode(hash)?)})).send().await?.error_for_status()?;
+ Ok(())
+ }
+}
+impl RpcHandler {
+ pub(crate) async fn drive_external_invoice(
+ &self,
+ journal: &Journal,
+ binding: &Binding,
+ cancel: bool,
+ ) -> Result {
+ // Configuration/auth preflight before the engine persists dispatch.
+ let (client, macaroon) = self.lnd_client().await?;
+ crate::content_lightning::drive(journal, binding, &Node { client, macaroon }, cancel).await
+ }
+}
+
+/// Prepared before the durable native-dispatch marker. Once execute is called,
+/// every transport error is ambiguous and only original-hash lookup may follow.
+pub(crate) struct PreparedNativePayment {
+ client: reqwest::Client,
+ request: reqwest::Request,
+ hash: String,
+ amount: u64,
+}
+impl PreparedNativePayment {
+ pub(crate) async fn execute(self) -> Result {
+ let response = self
+ .client
+ .execute(self.request)
+ .await
+ .context("Native payment response is unknown; recover the original operation")?;
+ let status = response.status();
+ let body: serde_json::Value = response
+ .json()
+ .await
+ .context("Native payment response is unknown")?;
+ anyhow::ensure!(
+ status.is_success(),
+ "LND did not confirm the original payment; recover its status"
+ );
+ let payment = body.get("result").unwrap_or(&body);
+ anyhow::ensure!(
+ payment
+ .get("payment_hash")
+ .and_then(|v| v.as_str())
+ .is_none_or(|hash| hash == self.hash),
+ "LND payment hash changed"
+ );
+ Ok(super::payments::router_payment_outcome(
+ payment,
+ &self.hash,
+ self.amount as i64,
+ ))
+ }
+}
+impl RpcHandler {
+ pub(crate) async fn prepare_bound_invoice_payment(
+ &self,
+ bolt11: &str,
+ hash: &str,
+ amount: u64,
+ ) -> Result {
+ let invoice: lightning_invoice::Bolt11Invoice =
+ bolt11.parse().context("Invalid original invoice")?;
+ invoice.check_signature()?;
+ anyhow::ensure!(
+ invoice.payment_hash().to_string() == hash
+ && invoice.amount_milli_satoshis() == amount.checked_mul(1000),
+ "Original invoice amount/hash changed"
+ );
+ anyhow::ensure!(
+ !invoice.is_expired(),
+ "Original invoice expired; cancel or recover it before choosing another method"
+ );
+ let (client, macaroon) = self.lnd_client().await?;
+ let info: serde_json::Value = client
+ .get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
+ .header("Grpc-Metadata-macaroon", &macaroon)
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ let network = match invoice.currency() {
+ lightning_invoice::Currency::Bitcoin => "mainnet",
+ lightning_invoice::Currency::BitcoinTestnet => "testnet",
+ lightning_invoice::Currency::Regtest => "regtest",
+ lightning_invoice::Currency::Signet => "signet",
+ lightning_invoice::Currency::Simnet => "simnet",
+ };
+ anyhow::ensure!(
+ info["chains"].as_array().is_some_and(|chains| chains
+ .iter()
+ .any(|chain| chain["chain"] == "bitcoin" && chain["network"] == network)),
+ "Original invoice belongs to another Bitcoin network"
+ );
+ let client = reqwest::Client::builder()
+ .no_proxy()
+ .connect_timeout(std::time::Duration::from_secs(10))
+ .timeout(std::time::Duration::from_secs(8))
+ .danger_accept_invalid_certs(true)
+ .build()?;
+ let request=client.post(format!("{LND_REST_BASE_URL}/v2/router/send")).header("Grpc-Metadata-macaroon",macaroon).json(&serde_json::json!({"payment_request":bolt11,"no_inflight_updates":true,"timeout_seconds":120,"fee_limit_sat":amount})).build()?;
+ Ok(PreparedNativePayment {
+ client,
+ request,
+ hash: hash.into(),
+ amount,
+ })
+ }
+}
+
+impl crate::content_lightning::PreparedPayment for PreparedNativePayment {
+ async fn execute(self) -> Result {
+ PreparedNativePayment::execute(self).await
+ }
+}
+pub(crate) struct NativeNode<'a>(pub &'a RpcHandler);
+impl crate::content_lightning::NativeInvoiceNode for NativeNode<'_> {
+ type Prepared = PreparedNativePayment;
+ async fn prepare(&self, invoice: &str, hash: &str, amount: u64) -> Result {
+ self.0
+ .prepare_bound_invoice_payment(invoice, hash, amount)
+ .await
+ }
+ async fn lookup_payment(&self, hash: &str) -> Result {
+ self.0
+ .handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
+ .await
+ }
+}
diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs
index 9e26822b..a7c6ac21 100644
--- a/core/archipelago/src/api/rpc/lnd/mod.rs
+++ b/core/archipelago/src/api/rpc/lnd/mod.rs
@@ -1,4 +1,5 @@
mod channels;
+pub(super) mod external_invoice;
mod fee_bump;
mod fee_policy;
mod info;
diff --git a/core/archipelago/src/api/rpc/lnd/payments.rs b/core/archipelago/src/api/rpc/lnd/payments.rs
index 3a23765e..c3768a73 100644
--- a/core/archipelago/src/api/rpc/lnd/payments.rs
+++ b/core/archipelago/src/api/rpc/lnd/payments.rs
@@ -36,7 +36,7 @@ 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(
+pub(super) fn router_payment_outcome(
payment: &serde_json::Value,
hash: &str,
decoded_amt: i64,
diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs
index e5c04f17..c8b735a1 100644
--- a/core/archipelago/src/api/rpc/mod.rs
+++ b/core/archipelago/src/api/rpc/mod.rs
@@ -17,6 +17,7 @@ mod fips;
mod handshake;
mod identity;
mod interfaces;
+mod lightning_purchase;
pub(crate) mod lnd;
mod marketplace;
mod media_registration;
@@ -109,6 +110,13 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
| "media.registration.context"
| "media.registration.resolve"
| "content.rental-purchase"
+ | "content.invoice-pay"
+ | "content.invoice-download"
+ | "content.invoice-attempt"
+ | "content.invoice-retry-native"
+ | "content.invoice-create"
+ | "content.invoice-recover"
+ | "content.invoice-cancel"
| "content.purchase"
| "content.cancel-purchase"
| "content.playback-handle"
diff --git a/core/archipelago/src/api/rpc/purchase.rs b/core/archipelago/src/api/rpc/purchase.rs
index da51aea9..83585d88 100644
--- a/core/archipelago/src/api/rpc/purchase.rs
+++ b/core/archipelago/src/api/rpc/purchase.rs
@@ -40,6 +40,16 @@ impl RpcHandler {
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let buyer = identity.did_key()?;
+ let _rail = crate::content_payment_admission::lock(
+ &self.config.data_dir,
+ &buyer,
+ transport.seller_did(),
+ ¶ms.content_id,
+ )
+ .await?;
+ self.ensure_invoice_allows_other_rail(&buyer, transport.seller_did(), ¶ms.content_id)
+ .await?;
+
let result = caller::purchase(
&self.config.data_dir,
&buyer,
diff --git a/core/archipelago/src/content_lightning.rs b/core/archipelago/src/content_lightning.rs
new file mode 100644
index 00000000..2e41938e
--- /dev/null
+++ b/core/archipelago/src/content_lightning.rs
@@ -0,0 +1,1233 @@
+//! Durable external-invoice operations. Browser storage is supplemental only.
+//! An ambiguous AddInvoice is never replayed: lookup the saved hash instead.
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::{
+ fs,
+ io::{Read, Write},
+ path::{Path, PathBuf},
+};
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Binding {
+ pub id: String,
+ pub buyer_did: String,
+ pub seller_did: String,
+ pub content_id: String,
+ pub price_sats: u64,
+}
+impl Binding {
+ fn validate(&self) -> Result<()> {
+ anyhow::ensure!(
+ uuid::Uuid::parse_str(&self.id)?.to_string() == self.id,
+ "Invalid invoice operation"
+ );
+ crate::identity::pubkey_bytes_from_did_key(&self.buyer_did)?;
+ crate::identity::pubkey_bytes_from_did_key(&self.seller_did)?;
+ anyhow::ensure!(
+ !self.content_id.is_empty()
+ && self.content_id.len() <= 128
+ && self
+ .content_id
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'),
+ "Invalid invoice content"
+ );
+ anyhow::ensure!(
+ self.price_sats > 0 && self.price_sats <= i64::MAX as u64,
+ "Invalid invoice price"
+ );
+ Ok(())
+ }
+}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum Phase {
+ Prepared,
+ Dispatched,
+ Issued,
+ CancelRequested,
+ CanceledUnpaid,
+ Settled,
+}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct RetainedFile {
+ pub sha256: String,
+ pub size: u64,
+ pub filename: String,
+ pub mime_type: String,
+}
+impl RetainedFile {
+ fn validate(&self) -> Result<()> {
+ anyhow::ensure!(
+ self.sha256.len() == 64
+ && self.sha256.bytes().all(|b| b.is_ascii_hexdigit())
+ && self.size > 0
+ && !self.filename.is_empty()
+ && self.filename.len() <= 4096
+ && !self.filename.chars().any(char::is_control)
+ && !self.mime_type.is_empty()
+ && self.mime_type.len() <= 256,
+ "Invalid retained invoice source metadata"
+ );
+ hyper::header::HeaderValue::from_str(&self.mime_type)?;
+ Ok(())
+ }
+}
+#[derive(Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct SellerRecord {
+ pub binding: Binding,
+ preimage: String,
+ pub payment_hash: String,
+ pub phase: Phase,
+ pub source: Option,
+ pub bolt11: Option,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct BuyerRecord {
+ pub binding: Binding,
+ pub seller_onion: String,
+ pub external_exposure: bool,
+ #[serde(default)]
+ pub native_retired: bool,
+ #[serde(default)]
+ pub native_replacement: Option,
+ #[serde(default)]
+ pub native_dispatched: bool,
+ #[serde(default)]
+ pub native_result: Option,
+ pub last: Option,
+}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Status {
+ pub binding: Binding,
+ pub payment_hash: String,
+ pub bolt11: Option,
+ pub state: Phase,
+ pub can_switch_method: bool,
+ pub source: Option,
+}
+#[derive(Clone)]
+pub(crate) struct Invoice {
+ pub payment_hash: String,
+ pub bolt11: String,
+ pub price_sats: u64,
+ pub state: String,
+ pub paid_sats: Option,
+ pub paid_msats: Option,
+}
+pub(crate) trait InvoiceNode {
+ async fn prepare_creation(&self) -> Result<()>;
+ async fn lookup(&self, hash: &str) -> Result>;
+ async fn add(&self, binding: &Binding, preimage_hex: &str) -> Result<()>;
+ async fn cancel(&self, hash: &str) -> Result<()>;
+}
+#[derive(Serialize, Deserialize)]
+struct Envelope {
+ payload: String,
+ checksum: String,
+}
+pub(crate) struct Journal {
+ directory: PathBuf,
+ _lock: fs::File,
+}
+impl Journal {
+ pub async fn open(data_dir: &Path) -> Result {
+ let data = data_dir.to_path_buf();
+ tokio::task::spawn_blocking(move || {
+ use std::os::{
+ fd::AsRawFd,
+ unix::fs::{OpenOptionsExt, PermissionsExt},
+ };
+ fs::create_dir_all(&data)?;
+ let data = fs::canonicalize(data)?;
+ let directory = data.join("content-lightning");
+ fs::create_dir_all(&directory)?;
+ anyhow::ensure!(
+ fs::symlink_metadata(&directory)?.is_dir(),
+ "Invoice journal is not a directory"
+ );
+ fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
+ fs::File::open(&data)?.sync_all()?;
+ let lock = fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .mode(0o600)
+ .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+ .open(directory.join(".lock"))?;
+ anyhow::ensure!(lock.metadata()?.is_file(), "Invalid invoice lock");
+ loop {
+ if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == 0 {
+ break;
+ }
+ let e = std::io::Error::last_os_error();
+ if e.kind() != std::io::ErrorKind::Interrupted {
+ return Err(e.into());
+ }
+ }
+ Ok(Self {
+ directory,
+ _lock: lock,
+ })
+ })
+ .await?
+ }
+ fn path(&self, role: &str, id: &str) -> Result {
+ anyhow::ensure!(
+ matches!(role, "buyer" | "seller") && uuid::Uuid::parse_str(id)?.to_string() == id,
+ "Invalid invoice journal key"
+ );
+ Ok(self.directory.join(format!("{role}-{id}.json")))
+ }
+ fn read(&self, role: &str, id: &str) -> Result> {
+ use std::os::unix::fs::OpenOptionsExt;
+ let file = match fs::OpenOptions::new()
+ .read(true)
+ .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+ .open(self.path(role, id)?)
+ {
+ Ok(v) => v,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(e) => return Err(e.into()),
+ };
+ anyhow::ensure!(file.metadata()?.is_file(), "Invalid invoice record");
+ let mut bytes = Vec::new();
+ file.take(65537).read_to_end(&mut bytes)?;
+ anyhow::ensure!(bytes.len() <= 65536, "Invoice record too large");
+ let envelope: Envelope =
+ serde_json::from_slice(&bytes).context("Invoice recovery damaged; do not pay again")?;
+ anyhow::ensure!(
+ hex::encode(Sha256::digest(envelope.payload.as_bytes())) == envelope.checksum,
+ "Invoice recovery checksum changed"
+ );
+ Ok(Some(serde_json::from_str(&envelope.payload)?))
+ }
+ fn write(&self, role: &str, id: &str, value: &T) -> Result<()> {
+ use std::os::unix::fs::OpenOptionsExt;
+ let payload = serde_json::to_string(value)?;
+ let bytes = serde_json::to_vec(&Envelope {
+ checksum: hex::encode(Sha256::digest(payload.as_bytes())),
+ payload,
+ })?;
+ anyhow::ensure!(bytes.len() <= 65536, "Invoice record too large");
+ let temporary = self
+ .directory
+ .join(format!(".{}.tmp", uuid::Uuid::new_v4()));
+ let result = (|| -> Result<()> {
+ let mut f = fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(&temporary)?;
+ f.write_all(&bytes)?;
+ f.sync_all()?;
+ fs::rename(&temporary, self.path(role, id)?)?;
+ fs::File::open(&self.directory)?.sync_all()?;
+ Ok(())
+ })();
+ if result.is_err() {
+ let _ = fs::remove_file(temporary);
+ }
+ result
+ }
+ pub fn seller(&self, binding: &Binding) -> Result> {
+ binding.validate()?;
+ let record: Option = self.read("seller", &binding.id)?;
+ if let Some(v) = &record {
+ anyhow::ensure!(&v.binding == binding, "Invoice operation binding changed");
+ let secret = hex::decode(&v.preimage)?;
+ anyhow::ensure!(
+ secret.len() == 32 && hex::encode(Sha256::digest(secret)) == v.payment_hash,
+ "Invoice preimage binding damaged"
+ );
+ }
+ Ok(record)
+ }
+ pub fn prepare_seller(&self, binding: Binding) -> Result {
+ self.prepare_seller_source(binding, None)
+ }
+ pub fn prepare_seller_source(
+ &self,
+ binding: Binding,
+ source: Option,
+ ) -> Result {
+ if let Some(source) = &source {
+ source.validate()?;
+ }
+ if let Some(saved) = self.seller(&binding)? {
+ anyhow::ensure!(saved.source == source, "Original invoice source changed");
+ return Ok(saved);
+ }
+ use rand::RngCore;
+ let mut secret = [0u8; 32];
+ rand::rngs::OsRng.fill_bytes(&mut secret);
+ let record = SellerRecord {
+ payment_hash: hex::encode(Sha256::digest(secret)),
+ preimage: hex::encode(secret),
+ binding,
+ phase: Phase::Prepared,
+ source,
+ bolt11: None,
+ };
+ self.save_seller(&record)?;
+ Ok(record)
+ }
+ pub fn save_seller(&self, record: &SellerRecord) -> Result<()> {
+ self.write("seller", &record.binding.id, record)
+ }
+ pub fn buyer(&self, id: &str) -> Result> {
+ let record: Option = self.read("buyer", id)?;
+ if let Some(v) = &record {
+ v.binding.validate()?;
+ anyhow::ensure!(v.binding.id == id, "Invoice operation changed");
+ }
+ Ok(record)
+ }
+ pub fn buyer_for(
+ &self,
+ buyer: &str,
+ seller: &str,
+ content: &str,
+ ) -> Result> {
+ let mut found = None;
+ for entry in fs::read_dir(&self.directory)? {
+ let name = entry?
+ .file_name()
+ .into_string()
+ .map_err(|_| anyhow::anyhow!("Invalid invoice record name"))?;
+ let Some(id) = name
+ .strip_prefix("buyer-")
+ .and_then(|s| s.strip_suffix(".json"))
+ else {
+ continue;
+ };
+ let record: BuyerRecord = self
+ .read("buyer", id)?
+ .context("Invoice record disappeared")?;
+ record.binding.validate()?;
+ let unfinished_replacement = if record.native_retired {
+ if let Some(id) = &record.native_replacement {
+ self.buyer(id)?.is_none()
+ } else {
+ false
+ }
+ } else {
+ false
+ };
+ if record.binding.buyer_did == buyer
+ && record.binding.seller_did == seller
+ && record.binding.content_id == content
+ && (!record.native_retired || unfinished_replacement)
+ && (unfinished_replacement
+ || record.last.as_ref().is_none_or(|s| !s.can_switch_method))
+ {
+ anyhow::ensure!(
+ found.is_none(),
+ "Multiple unresolved invoice operations; recover them first"
+ );
+ found = Some(record)
+ }
+ }
+ Ok(found)
+ }
+ /// Explicit retry only: retire a proven native-only failure and retain its
+ /// replacement UUID before creating anything. Recovery reuses that UUID.
+ pub fn retry_native(&self, id: &str) -> Result {
+ let mut old = self.buyer(id)?.context("Original native invoice missing")?;
+ anyhow::ensure!(
+ !old.external_exposure
+ && old.native_dispatched
+ && old.native_result.as_deref() == Some("failed")
+ && old.last.as_ref().is_some_and(|s| s.state != Phase::Settled),
+ "Only a confirmed native-only failure can be retried"
+ );
+ anyhow::ensure!(
+ !old.native_retired || old.native_replacement.is_some(),
+ "Original invoice was retired for another payment method"
+ );
+ let replacement = old
+ .native_replacement
+ .clone()
+ .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
+ let mut binding = old.binding.clone();
+ binding.id = replacement.clone();
+ if let Some(saved) = self.buyer(&replacement)? {
+ anyhow::ensure!(
+ saved.binding == binding && saved.seller_onion == old.seller_onion,
+ "Native replacement binding changed"
+ );
+ return Ok(saved);
+ }
+ let current = self
+ .buyer_for(
+ &old.binding.buyer_did,
+ &old.binding.seller_did,
+ &old.binding.content_id,
+ )?
+ .context("Original native operation no longer owns this purchase")?;
+ anyhow::ensure!(
+ current.binding.id == old.binding.id,
+ "Another operation owns this purchase"
+ );
+ old.native_retired = true;
+ old.native_replacement = Some(replacement);
+ self.save_buyer(&old)?;
+ let new = BuyerRecord {
+ binding,
+ seller_onion: old.seller_onion,
+ external_exposure: false,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: false,
+ native_result: None,
+ last: None,
+ };
+ self.save_buyer(&new)?;
+ Ok(new)
+ }
+ pub fn save_buyer(&self, record: &BuyerRecord) -> Result<()> {
+ record.binding.validate()?;
+ anyhow::ensure!(
+ matches!(
+ record.native_result.as_deref(),
+ None | Some("failed" | "succeeded")
+ ),
+ "Invalid native invoice outcome"
+ );
+ anyhow::ensure!(
+ !record.native_retired
+ || (!record.external_exposure && record.native_result.as_deref() == Some("failed")),
+ "Retired native invoice cannot be exposed"
+ );
+ if let Some(id) = &record.native_replacement {
+ anyhow::ensure!(
+ record.native_retired
+ && uuid::Uuid::parse_str(id)?.to_string() == *id
+ && *id != record.binding.id,
+ "Invalid native replacement identity"
+ );
+ }
+ if let Some(old) = self.read::("buyer", &record.binding.id)? {
+ anyhow::ensure!(
+ old.binding == record.binding
+ && old.seller_onion == record.seller_onion
+ && (!old.external_exposure || record.external_exposure)
+ && (!old.native_retired || record.native_retired)
+ && old
+ .native_replacement
+ .as_ref()
+ .is_none_or(|id| record.native_replacement.as_ref() == Some(id))
+ && (!old.native_dispatched || record.native_dispatched)
+ && (old.native_result.as_deref() != Some("succeeded")
+ || record.native_result.as_deref() == Some("succeeded")),
+ "Invoice buyer binding changed"
+ );
+ if old.last.as_ref().is_some_and(|s| s.state == Phase::Settled) {
+ anyhow::ensure!(
+ record
+ .last
+ .as_ref()
+ .is_some_and(|s| s.state == Phase::Settled),
+ "Settled invoice cannot regress"
+ );
+ }
+ }
+ if let Some(status) = &record.last {
+ if let Some(source) = &status.source {
+ source.validate()?;
+ }
+ anyhow::ensure!(
+ status.binding == record.binding
+ && !(record.native_result.as_deref() == Some("succeeded")
+ && status.can_switch_method)
+ && status.can_switch_method == (status.state == Phase::CanceledUnpaid),
+ "Invoice status binding changed"
+ );
+ }
+ self.write("buyer", &record.binding.id, record)
+ }
+}
+impl SellerRecord {
+ pub fn status(&self) -> Status {
+ Status {
+ binding: self.binding.clone(),
+ payment_hash: self.payment_hash.clone(),
+ bolt11: self.bolt11.clone(),
+ state: self.phase.clone(),
+ can_switch_method: self.phase == Phase::CanceledUnpaid,
+ source: self.source.clone(),
+ }
+ }
+ fn observe(&mut self, invoice: Invoice) -> Result<()> {
+ anyhow::ensure!(
+ invoice.payment_hash == self.payment_hash
+ && invoice.price_sats == self.binding.price_sats
+ && !invoice.bolt11.is_empty(),
+ "LND invoice binding changed"
+ );
+ if let Some(original) = &self.bolt11 {
+ anyhow::ensure!(original == &invoice.bolt11, "Original invoice changed");
+ }
+ self.bolt11 = Some(invoice.bolt11);
+ let settled = invoice.state == "SETTLED"
+ && invoice
+ .paid_sats
+ .is_some_and(|v| v >= self.binding.price_sats)
+ && invoice.paid_msats.is_none_or(|v| {
+ self.binding
+ .price_sats
+ .checked_mul(1000)
+ .is_some_and(|required| v >= required)
+ });
+ if settled {
+ self.phase = Phase::Settled
+ } else if self.phase != Phase::Settled
+ && invoice.state == "CANCELED"
+ && invoice.paid_sats == Some(0)
+ && invoice.paid_msats.is_none_or(|v| v == 0)
+ {
+ self.phase = Phase::CanceledUnpaid
+ } else if self.phase != Phase::Settled
+ && self.phase != Phase::CanceledUnpaid
+ && self.phase != Phase::CancelRequested
+ {
+ self.phase = Phase::Issued
+ }
+ Ok(())
+ }
+}
+/// Journal lock is retained through network calls. Persist dispatch BEFORE await.
+/// Cancellation of this future leaves a recoverable record, never permission to
+/// issue another invoice. A prepared operation can be canceled before dispatch.
+pub(crate) async fn drive(
+ journal: &Journal,
+ binding: &Binding,
+ node: &N,
+ cancel: bool,
+) -> Result {
+ let mut record = journal
+ .seller(binding)?
+ .context("Unknown invoice operation")?;
+ if matches!(record.phase, Phase::Settled | Phase::CanceledUnpaid) {
+ return Ok(record.status());
+ }
+ if cancel && record.phase == Phase::Prepared {
+ record.phase = Phase::CanceledUnpaid;
+ journal.save_seller(&record)?;
+ return Ok(record.status());
+ }
+ if cancel {
+ record.phase = Phase::CancelRequested;
+ journal.save_seller(&record)?;
+ }
+ if record.phase == Phase::Prepared {
+ node.prepare_creation().await?;
+ record.phase = Phase::Dispatched;
+ journal.save_seller(&record)?;
+ // Exactly one AddInvoice attempt. A failed response may still have created
+ // it; subsequent operations only look up the saved hash.
+ let _ = node.add(binding, &record.preimage).await;
+ }
+ let Some(invoice) = node.lookup(&record.payment_hash).await? else {
+ return Ok(record.status());
+ };
+ record.observe(invoice)?;
+ journal.save_seller(&record)?;
+ if cancel && record.phase != Phase::Settled && record.phase != Phase::CanceledUnpaid {
+ let _ = node.cancel(&record.payment_hash).await;
+ if let Some(invoice) = node.lookup(&record.payment_hash).await? {
+ record.observe(invoice)?;
+ journal.save_seller(&record)?;
+ }
+ }
+ Ok(record.status())
+}
+
+/// Runtime adapters prepare a request without dispatching it, then consume it once.
+pub(crate) trait PreparedPayment {
+ async fn execute(self) -> Result;
+}
+pub(crate) trait NativeInvoiceNode {
+ type Prepared: PreparedPayment;
+ async fn prepare(&self, invoice: &str, hash: &str, amount: u64) -> Result;
+ async fn lookup_payment(&self, hash: &str) -> Result;
+}
+/// Caller retains the per-buyer/seller/item admission lock across this operation.
+/// The journal lock is released during actual payment, so unrelated invoices can recover.
+pub(crate) async fn drive_native(
+ data_dir: &Path,
+ journal: Journal,
+ id: &str,
+ node: &N,
+) -> Result {
+ let mut record = journal
+ .buyer(id)?
+ .context("Original invoice operation missing")?;
+ anyhow::ensure!(
+ !record.native_retired,
+ "Original native invoice was retired before changing methods"
+ );
+ let status = record
+ .last
+ .as_ref()
+ .context("Original invoice has not been created")?;
+ anyhow::ensure!(!status.can_switch_method, "Original invoice was canceled");
+ if status.state == Phase::Settled || record.native_result.as_deref() == Some("succeeded") {
+ return Ok(serde_json::json!({"status":"succeeded","payment_hash":status.payment_hash}));
+ }
+ anyhow::ensure!(
+ status.source.is_some(),
+ "Original invoice snapshot is not confirmed; no payment dispatched"
+ );
+ if record.native_dispatched {
+ if record.native_result.as_deref() == Some("failed") {
+ return Ok(serde_json::json!({"status":"failed","payment_hash":status.payment_hash}));
+ }
+ let payment = node.lookup_payment(&status.payment_hash).await?;
+ if matches!(payment["status"].as_str(), Some("succeeded" | "failed")) {
+ record.native_result = payment["status"].as_str().map(str::to_owned);
+ journal.save_buyer(&record)?;
+ }
+ return Ok(payment);
+ }
+ let invoice = status
+ .bolt11
+ .as_ref()
+ .context("Original invoice is unavailable")?;
+ // Preparation validates identity/amount/network/expiry and builds the request;
+ // deterministic preparation failures leave this same operation undispatched.
+ let prepared = node
+ .prepare(invoice, &status.payment_hash, record.binding.price_sats)
+ .await?;
+ record.native_dispatched = true;
+ journal.save_buyer(&record)?;
+ drop(journal);
+ let payment = prepared.execute().await?;
+ if matches!(payment["status"].as_str(), Some("succeeded" | "failed")) {
+ record.native_result = payment["status"].as_str().map(str::to_owned);
+ Journal::open(data_dir).await?.save_buyer(&record)?;
+ }
+ Ok(payment)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::{
+ atomic::{AtomicUsize, Ordering},
+ Mutex,
+ };
+ struct Node {
+ invoice: Mutex>,
+ adds: AtomicUsize,
+ lost_reply: bool,
+ settle_on_cancel: bool,
+ reject_preflight: std::sync::atomic::AtomicBool,
+ }
+ impl Node {
+ fn new() -> Self {
+ Self {
+ invoice: Mutex::new(None),
+ adds: AtomicUsize::new(0),
+ lost_reply: true,
+ settle_on_cancel: false,
+ reject_preflight: std::sync::atomic::AtomicBool::new(false),
+ }
+ }
+ }
+ impl InvoiceNode for Node {
+ async fn prepare_creation(&self) -> Result<()> {
+ anyhow::ensure!(
+ !self.reject_preflight.load(Ordering::SeqCst),
+ "LND unavailable before invoice dispatch"
+ );
+ Ok(())
+ }
+ async fn lookup(&self, _: &str) -> Result > {
+ Ok(self.invoice.lock().unwrap().clone())
+ }
+ async fn add(&self, b: &Binding, p: &str) -> Result<()> {
+ self.adds.fetch_add(1, Ordering::SeqCst);
+ *self.invoice.lock().unwrap() = Some(Invoice {
+ payment_hash: hex::encode(Sha256::digest(hex::decode(p)?)),
+ bolt11: "ln-original".into(),
+ price_sats: b.price_sats,
+ state: "OPEN".into(),
+ paid_sats: Some(0),
+ paid_msats: Some(0),
+ });
+ if self.lost_reply {
+ anyhow::bail!("reply lost after LND stored invoice")
+ }
+ Ok(())
+ }
+ async fn cancel(&self, _: &str) -> Result<()> {
+ let mut guard = self.invoice.lock().unwrap();
+ let v = guard.as_mut().unwrap();
+ if self.settle_on_cancel {
+ v.state = "SETTLED".into();
+ v.paid_sats = Some(v.price_sats);
+ v.paid_msats = Some(v.price_sats * 1000)
+ } else {
+ v.state = "CANCELED".into()
+ };
+ anyhow::bail!("cancel reply lost")
+ }
+ }
+ fn binding() -> Binding {
+ Binding {
+ id: uuid::Uuid::new_v4().to_string(),
+ buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
+ seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap(),
+ content_id: "file".into(),
+ price_sats: 8,
+ }
+ }
+ #[tokio::test]
+ async fn lost_add_reply_and_process_restart_recover_original_invoice_once() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Node::new();
+ let j = Journal::open(root.path()).await.unwrap();
+ j.prepare_seller(b.clone()).unwrap();
+ let first = drive(&j, &b, &node, false).await.unwrap();
+ drop(j);
+ let j = Journal::open(root.path()).await.unwrap();
+ let second = drive(&j, &b, &node, false).await.unwrap();
+ assert_eq!(first, second);
+ assert_eq!(node.adds.load(Ordering::SeqCst), 1);
+ assert_eq!(second.state, Phase::Issued);
+ }
+ #[tokio::test]
+ async fn ambiguous_dispatch_missing_lookup_cannot_reissue_or_cancel_as_unpaid() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Node::new();
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut r = j.prepare_seller(b.clone()).unwrap();
+ r.phase = Phase::Dispatched;
+ j.save_seller(&r).unwrap();
+ drop(j);
+ let j = Journal::open(root.path()).await.unwrap();
+ let unknown = drive(&j, &b, &node, true).await.unwrap();
+ assert_eq!(unknown.state, Phase::CancelRequested);
+ assert!(!unknown.can_switch_method);
+ assert_eq!(node.adds.load(Ordering::SeqCst), 0);
+ // Original delayed AddInvoice arrives after the first cancel lookup.
+ node.add(&b, &r.preimage).await.unwrap_err();
+ let resolved = drive(&j, &b, &node, true).await.unwrap();
+ assert_eq!(resolved.state, Phase::CanceledUnpaid);
+ assert!(resolved.can_switch_method);
+ assert_eq!(node.adds.load(Ordering::SeqCst), 1);
+ }
+ #[tokio::test]
+ async fn prepared_cancel_has_no_remote_creation_and_cannot_be_reopened() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Node::new();
+ let j = Journal::open(root.path()).await.unwrap();
+ j.prepare_seller(b.clone()).unwrap();
+ assert!(drive(&j, &b, &node, true).await.unwrap().can_switch_method);
+ assert!(drive(&j, &b, &node, false).await.unwrap().can_switch_method);
+ assert_eq!(node.adds.load(Ordering::SeqCst), 0);
+ }
+ #[tokio::test]
+ async fn settlement_wins_lost_cancel_response_and_survives_missing_lnd_record() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let mut node = Node::new();
+ node.settle_on_cancel = true;
+ let j = Journal::open(root.path()).await.unwrap();
+ j.prepare_seller(b.clone()).unwrap();
+ drive(&j, &b, &node, false).await.unwrap();
+ let paid = drive(&j, &b, &node, true).await.unwrap();
+ assert_eq!(paid.state, Phase::Settled);
+ assert!(!paid.can_switch_method);
+ *node.invoice.lock().unwrap() = None;
+ assert_eq!(drive(&j, &b, &node, true).await.unwrap(), paid);
+ }
+ #[tokio::test]
+ async fn browser_loss_finds_original_buyer_operation_and_exposure_is_monotonic() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut record = BuyerRecord {
+ binding: b.clone(),
+ seller_onion: "original.onion".into(),
+ external_exposure: true,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: false,
+ native_result: None,
+ last: None,
+ };
+ j.save_buyer(&record).unwrap();
+ drop(j);
+ let j = Journal::open(root.path()).await.unwrap();
+ assert_eq!(
+ j.buyer_for(&b.buyer_did, &b.seller_did, &b.content_id)
+ .unwrap()
+ .unwrap()
+ .binding,
+ b
+ );
+ record.external_exposure = false;
+ assert!(j.save_buyer(&record).is_err());
+ let mut changed = b.clone();
+ changed.price_sats += 1;
+ j.prepare_seller(b).unwrap();
+ assert!(j.prepare_seller(changed).is_err());
+ }
+ #[tokio::test]
+ async fn checksum_damage_blocks_replacement() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let j = Journal::open(root.path()).await.unwrap();
+ j.prepare_seller(b.clone()).unwrap();
+ let p = j.path("seller", &b.id).unwrap();
+ fs::write(p, b"{}").unwrap();
+ assert!(j.prepare_seller(b).is_err());
+ }
+ #[tokio::test]
+ async fn native_success_cannot_be_replaced_by_contradictory_canceled_status() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut last = j.prepare_seller(b.clone()).unwrap().status();
+ last.state = Phase::Issued;
+ let mut record = BuyerRecord {
+ binding: b.clone(),
+ seller_onion: "original.onion".into(),
+ external_exposure: false,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: true,
+ native_result: Some("succeeded".into()),
+ last: Some(last),
+ };
+ j.save_buyer(&record).unwrap();
+ record.last.as_mut().unwrap().state = Phase::CanceledUnpaid;
+ record.last.as_mut().unwrap().can_switch_method = true;
+ assert!(j.save_buyer(&record).is_err());
+ drop(j);
+ let j = Journal::open(root.path()).await.unwrap();
+ assert_eq!(
+ j.buyer_for(&b.buyer_did, &b.seller_did, &b.content_id)
+ .unwrap()
+ .unwrap()
+ .native_result
+ .as_deref(),
+ Some("succeeded")
+ );
+ }
+ #[tokio::test]
+ async fn retired_native_failure_stays_retired_and_cannot_later_expose_invoice() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut record = BuyerRecord {
+ binding: b.clone(),
+ seller_onion: "original.onion".into(),
+ external_exposure: false,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: true,
+ native_result: Some("failed".into()),
+ last: None,
+ };
+ j.save_buyer(&record).unwrap();
+ record.native_retired = true;
+ j.save_buyer(&record).unwrap();
+ drop(j);
+ let j = Journal::open(root.path()).await.unwrap();
+ assert!(j
+ .buyer_for(&b.buyer_did, &b.seller_did, &b.content_id)
+ .unwrap()
+ .is_none());
+ assert!(j.buyer(&b.id).unwrap().unwrap().native_retired);
+ record.external_exposure = true;
+ assert!(j.save_buyer(&record).is_err());
+ record.external_exposure = false;
+ record.native_retired = false;
+ assert!(j.save_buyer(&record).is_err());
+ }
+ #[tokio::test]
+ async fn inconsistent_paid_units_cannot_create_settlement_or_cancellation() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Node::new();
+ let j = Journal::open(root.path()).await.unwrap();
+ j.prepare_seller(b.clone()).unwrap();
+ drive(&j, &b, &node, false).await.unwrap();
+ {
+ let mut held = node.invoice.lock().unwrap();
+ let invoice = held.as_mut().unwrap();
+ invoice.state = "SETTLED".into();
+ invoice.paid_sats = Some(b.price_sats);
+ invoice.paid_msats = Some(0);
+ }
+ let result = drive(&j, &b, &node, false).await.unwrap();
+ assert_ne!(result.state, Phase::Settled);
+ assert!(!result.can_switch_method);
+ {
+ let mut held = node.invoice.lock().unwrap();
+ let invoice = held.as_mut().unwrap();
+ invoice.state = "CANCELED".into();
+ invoice.paid_sats = Some(0);
+ invoice.paid_msats = Some(1);
+ }
+ assert!(!drive(&j, &b, &node, false).await.unwrap().can_switch_method);
+ }
+ #[tokio::test]
+ async fn snapshot_commit_rechecks_changed_terms_and_unshare_before_invoice_exists() {
+ use crate::content_server::{
+ self as catalog, AccessControl, Availability, ContentCatalog, ContentItem,
+ };
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let original = ContentItem {
+ id: b.content_id.clone(),
+ filename: "file.txt".into(),
+ mime_type: "text/plain".into(),
+ size_bytes: 4,
+ description: String::new(),
+ access: AccessControl::Paid {
+ price_sats: b.price_sats,
+ accepted: vec!["lightning".into()],
+ },
+ availability: Availability::AllPeers,
+ added_at: String::new(),
+ };
+ let retained = RetainedFile {
+ sha256: "ab".repeat(32),
+ size: 4,
+ filename: original.filename.clone(),
+ mime_type: original.mime_type.clone(),
+ };
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut changed = original.clone();
+ changed.access = AccessControl::Paid {
+ price_sats: b.price_sats + 1,
+ accepted: vec![],
+ };
+ catalog::save_catalog(
+ root.path(),
+ &ContentCatalog {
+ items: vec![changed],
+ },
+ )
+ .await
+ .unwrap();
+ assert!(catalog::publish_snapshot_invoice(
+ root.path(),
+ &original,
+ &j,
+ b.clone(),
+ retained.clone()
+ )
+ .await
+ .is_err());
+ assert!(j.seller(&b).unwrap().is_none());
+ catalog::save_catalog(root.path(), &ContentCatalog { items: vec![] })
+ .await
+ .unwrap();
+ assert!(catalog::publish_snapshot_invoice(
+ root.path(),
+ &original,
+ &j,
+ b.clone(),
+ retained.clone()
+ )
+ .await
+ .is_err());
+ assert!(j.seller(&b).unwrap().is_none());
+ catalog::save_catalog(
+ root.path(),
+ &ContentCatalog {
+ items: vec![original.clone()],
+ },
+ )
+ .await
+ .unwrap();
+ let prepared = catalog::publish_snapshot_invoice(
+ root.path(),
+ &original,
+ &j,
+ b.clone(),
+ retained.clone(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(prepared.phase, Phase::Prepared);
+ assert_eq!(prepared.source, Some(retained));
+ }
+ struct Native {
+ prepares: AtomicUsize,
+ executions: std::sync::Arc,
+ lookups: AtomicUsize,
+ reject_preflight: std::sync::atomic::AtomicBool,
+ lose_reply: bool,
+ }
+ struct PreparedNative {
+ executions: std::sync::Arc,
+ hash: String,
+ lose_reply: bool,
+ }
+ impl PreparedPayment for PreparedNative {
+ async fn execute(self) -> Result {
+ self.executions.fetch_add(1, Ordering::SeqCst);
+ anyhow::ensure!(!self.lose_reply, "Response lost after dispatch");
+ Ok(serde_json::json!({"status":"succeeded","payment_hash":self.hash}))
+ }
+ }
+ impl NativeInvoiceNode for Native {
+ type Prepared = PreparedNative;
+ async fn prepare(&self, _: &str, hash: &str, _: u64) -> Result {
+ self.prepares.fetch_add(1, Ordering::SeqCst);
+ anyhow::ensure!(
+ !self.reject_preflight.load(Ordering::SeqCst),
+ "Wrong configured network before dispatch"
+ );
+ Ok(PreparedNative {
+ executions: self.executions.clone(),
+ hash: hash.into(),
+ lose_reply: self.lose_reply,
+ })
+ }
+ async fn lookup_payment(&self, hash: &str) -> Result {
+ self.lookups.fetch_add(1, Ordering::SeqCst);
+ Ok(serde_json::json!({"status":"succeeded","payment_hash":hash}))
+ }
+ }
+ impl Native {
+ fn new(lose_reply: bool) -> Self {
+ Self {
+ prepares: AtomicUsize::new(0),
+ executions: std::sync::Arc::new(AtomicUsize::new(0)),
+ lookups: AtomicUsize::new(0),
+ reject_preflight: std::sync::atomic::AtomicBool::new(false),
+ lose_reply,
+ }
+ }
+ }
+ fn prepared_native_buyer(journal: &Journal, b: &Binding) -> BuyerRecord {
+ let mut seller = journal
+ .prepare_seller_source(
+ b.clone(),
+ Some(RetainedFile {
+ sha256: "ab".repeat(32),
+ size: 4,
+ filename: "file.txt".into(),
+ mime_type: "text/plain".into(),
+ }),
+ )
+ .unwrap();
+ seller.phase = Phase::Issued;
+ seller.bolt11 = Some("ln-fixture".into());
+ journal.save_seller(&seller).unwrap();
+ let record = BuyerRecord {
+ binding: b.clone(),
+ seller_onion: "original.onion".into(),
+ external_exposure: false,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: false,
+ native_result: None,
+ last: Some(seller.status()),
+ };
+ journal.save_buyer(&record).unwrap();
+ record
+ }
+ #[tokio::test]
+ async fn native_preflight_failure_can_retry_original_operation_before_single_dispatch() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Native::new(false);
+ let journal = Journal::open(root.path()).await.unwrap();
+ prepared_native_buyer(&journal, &b);
+ node.reject_preflight.store(true, Ordering::SeqCst);
+ assert!(drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .is_err());
+ let journal = Journal::open(root.path()).await.unwrap();
+ assert!(!journal.buyer(&b.id).unwrap().unwrap().native_dispatched);
+ node.reject_preflight.store(false, Ordering::SeqCst);
+ assert_eq!(
+ drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .unwrap()["status"],
+ "succeeded"
+ );
+ let journal = Journal::open(root.path()).await.unwrap();
+ assert_eq!(
+ drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .unwrap()["status"],
+ "succeeded"
+ );
+ assert_eq!(node.prepares.load(Ordering::SeqCst), 2);
+ assert_eq!(node.executions.load(Ordering::SeqCst), 1);
+ assert_eq!(node.lookups.load(Ordering::SeqCst), 0);
+ }
+ #[tokio::test]
+ async fn native_lost_reply_restarts_with_original_hash_lookup_and_never_dispatches_twice() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Native::new(true);
+ let journal = Journal::open(root.path()).await.unwrap();
+ let original = prepared_native_buyer(&journal, &b);
+ assert!(drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .is_err());
+ let journal = Journal::open(root.path()).await.unwrap();
+ assert!(journal.buyer(&b.id).unwrap().unwrap().native_dispatched);
+ let recovered = drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .unwrap();
+ assert_eq!(
+ recovered["payment_hash"],
+ original.last.unwrap().payment_hash
+ );
+ assert_eq!(recovered["status"], "succeeded");
+ let journal = Journal::open(root.path()).await.unwrap();
+ assert_eq!(
+ journal
+ .buyer(&b.id)
+ .unwrap()
+ .unwrap()
+ .native_result
+ .as_deref(),
+ Some("succeeded")
+ );
+ drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .unwrap();
+ assert_eq!(node.prepares.load(Ordering::SeqCst), 1);
+ assert_eq!(node.executions.load(Ordering::SeqCst), 1);
+ assert_eq!(node.lookups.load(Ordering::SeqCst), 1);
+ }
+ #[tokio::test]
+ async fn retired_invoice_rejects_delayed_native_callback_without_preflight_or_payment() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Native::new(false);
+ let journal = Journal::open(root.path()).await.unwrap();
+ let mut original = prepared_native_buyer(&journal, &b);
+ original.native_dispatched = true;
+ original.native_result = Some("failed".into());
+ original.native_retired = true;
+ journal.save_buyer(&original).unwrap();
+ assert!(drive_native(root.path(), journal, &b.id, &node)
+ .await
+ .is_err());
+ assert_eq!(node.prepares.load(Ordering::SeqCst), 0);
+ assert_eq!(node.executions.load(Ordering::SeqCst), 0);
+ assert_eq!(node.lookups.load(Ordering::SeqCst), 0);
+ }
+ #[tokio::test]
+ async fn explicit_retry_links_one_fresh_uuid_and_rejects_old_callbacks() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Native::new(false);
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut old = prepared_native_buyer(&j, &b);
+ old.native_dispatched = true;
+ old.native_result = Some("failed".into());
+ j.save_buyer(&old).unwrap();
+ let new = j.retry_native(&b.id).unwrap();
+ assert_ne!(new.binding.id, b.id);
+ assert!(!new.native_dispatched);
+ assert!(!new.external_exposure);
+ assert_eq!(j.retry_native(&b.id).unwrap().binding, new.binding);
+ assert_eq!(
+ j.buyer_for(&b.buyer_did, &b.seller_did, &b.content_id)
+ .unwrap()
+ .unwrap()
+ .binding,
+ new.binding
+ );
+ assert!(j.save_buyer(&old).is_err());
+ assert!(drive_native(root.path(), j, &b.id, &node).await.is_err());
+ assert_eq!(node.executions.load(Ordering::SeqCst), 0);
+ let j = Journal::open(root.path()).await.unwrap();
+ assert_eq!(
+ j.buyer(&new.binding.id).unwrap().unwrap().binding,
+ new.binding
+ );
+ }
+ #[tokio::test]
+ async fn interrupted_retry_retirement_blocks_other_rails_and_recovers_same_uuid() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let replacement = uuid::Uuid::new_v4().to_string();
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut old = prepared_native_buyer(&j, &b);
+ old.native_dispatched = true;
+ old.native_result = Some("failed".into());
+ old.native_retired = true;
+ old.native_replacement = Some(replacement.clone());
+ j.save_buyer(&old).unwrap();
+ drop(j);
+ let j = Journal::open(root.path()).await.unwrap();
+ assert_eq!(
+ j.buyer_for(&b.buyer_did, &b.seller_did, &b.content_id)
+ .unwrap()
+ .unwrap()
+ .native_replacement,
+ Some(replacement.clone())
+ );
+ old.last.as_mut().unwrap().state = Phase::CanceledUnpaid;
+ old.last.as_mut().unwrap().can_switch_method = true;
+ j.save_buyer(&old).unwrap();
+ assert!(j
+ .buyer_for(&b.buyer_did, &b.seller_did, &b.content_id)
+ .unwrap()
+ .is_some());
+ assert_eq!(j.retry_native(&b.id).unwrap().binding.id, replacement);
+ assert_eq!(j.retry_native(&b.id).unwrap().binding.id, replacement);
+ }
+ #[tokio::test]
+ async fn explicit_retry_never_replaces_success_pending_or_externally_exposed_invoice() {
+ for (outcome, exposed) in [
+ (Some("succeeded"), false),
+ (None, false),
+ (Some("failed"), true),
+ ] {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let j = Journal::open(root.path()).await.unwrap();
+ let mut old = prepared_native_buyer(&j, &b);
+ old.native_dispatched = true;
+ old.native_result = outcome.map(str::to_owned);
+ old.external_exposure = exposed;
+ j.save_buyer(&old).unwrap();
+ assert!(j.retry_native(&b.id).is_err());
+ assert!(!j.buyer(&b.id).unwrap().unwrap().native_retired);
+ }
+ }
+ #[tokio::test]
+ async fn unavailable_seller_preflight_preserves_prepared_operation_for_retry() {
+ let root = tempfile::tempdir().unwrap();
+ let b = binding();
+ let node = Node::new();
+ let j = Journal::open(root.path()).await.unwrap();
+ let original = j.prepare_seller(b.clone()).unwrap();
+ node.reject_preflight.store(true, Ordering::SeqCst);
+ assert!(drive(&j, &b, &node, false).await.is_err());
+ assert_eq!(j.seller(&b).unwrap().unwrap().phase, Phase::Prepared);
+ assert_eq!(node.adds.load(Ordering::SeqCst), 0);
+ node.reject_preflight.store(false, Ordering::SeqCst);
+ assert_eq!(
+ drive(&j, &b, &node, false).await.unwrap().payment_hash,
+ original.payment_hash
+ );
+ assert_eq!(node.adds.load(Ordering::SeqCst), 1);
+ }
+}
diff --git a/core/archipelago/src/content_payment_admission.rs b/core/archipelago/src/content_payment_admission.rs
new file mode 100644
index 00000000..131b966b
--- /dev/null
+++ b/core/archipelago/src/content_payment_admission.rs
@@ -0,0 +1,44 @@
+//! Outermost cross-rail admission, before wallet or payment journals.
+use anyhow::Result;
+use sha2::{Digest, Sha256};
+use std::{fs, path::Path};
+pub(crate) struct Guard {
+ _file: fs::File,
+}
+pub(crate) async fn lock(data: &Path, buyer: &str, seller: &str, content: &str) -> Result {
+ let root = data.join("content-payment-admission");
+ let key = hex::encode(Sha256::digest(serde_json::to_vec(&(
+ buyer, seller, content,
+ ))?));
+ tokio::task::spawn_blocking(move || {
+ use std::os::{
+ fd::AsRawFd,
+ unix::fs::{OpenOptionsExt, PermissionsExt},
+ };
+ fs::create_dir_all(&root)?;
+ anyhow::ensure!(
+ fs::symlink_metadata(&root)?.is_dir(),
+ "Invalid payment admission directory"
+ );
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
+ let file = fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .mode(0o600)
+ .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+ .open(root.join(key))?;
+ anyhow::ensure!(file.metadata()?.is_file(), "Invalid payment admission lock");
+ loop {
+ if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 {
+ break;
+ }
+ let error = std::io::Error::last_os_error();
+ if error.kind() != std::io::ErrorKind::Interrupted {
+ return Err(error.into());
+ }
+ }
+ Ok(Guard { _file: file })
+ })
+ .await?
+}
diff --git a/core/archipelago/src/content_purchase_download.rs b/core/archipelago/src/content_purchase_download.rs
index 15afe3c2..dd0179b3 100644
--- a/core/archipelago/src/content_purchase_download.rs
+++ b/core/archipelago/src/content_purchase_download.rs
@@ -79,7 +79,7 @@ pub(crate) async fn cache(
Ok(owned)
}
-fn verified_stream(
+pub(crate) fn verified_stream(
stream: S,
expected_hash: String,
expected_size: u64,
diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs
index 42c65ebe..f25d4cf7 100644
--- a/core/archipelago/src/content_server.rs
+++ b/core/archipelago/src/content_server.rs
@@ -1923,3 +1923,41 @@ pub(crate) async fn publish_snapshot_offer(
)
.await
}
+
+/// Commit a new invoice only while its exact selected share remains current.
+/// Caller holds the invoice journal; catalog writers do not acquire that journal.
+pub(crate) async fn publish_snapshot_invoice(
+ data_dir: &Path,
+ original: &ContentItem,
+ journal: &crate::content_lightning::Journal,
+ binding: crate::content_lightning::Binding,
+ retained: crate::content_lightning::RetainedFile,
+) -> Result {
+ let _held = CATALOG_WRITES.lock().await;
+ let catalog = load_catalog(data_dir).await?;
+ let current = catalog
+ .items
+ .iter()
+ .find(|item| item.id == original.id)
+ .context("Content was unshared before invoice preparation")?;
+ anyhow::ensure!(
+ serde_json::to_value(current)? == serde_json::to_value(original)?,
+ "Shared content terms changed before invoice preparation"
+ );
+ anyhow::ensure!(
+ binding.content_id == original.id
+ && retained.filename == original.filename
+ && retained.mime_type == original.mime_type
+ && retained.size == original.size_bytes
+ && matches!(&original.access, AccessControl::Paid { price_sats, .. } if *price_sats == binding.price_sats)
+ && method_accepted(&original.access, "lightning"),
+ "Invoice snapshot terms changed"
+ );
+ let visible = match &original.availability {
+ Availability::Nobody => false,
+ Availability::AllPeers => true,
+ Availability::Specific { peers } => peers.contains(&binding.buyer_did),
+ };
+ anyhow::ensure!(visible, "Item is not shared with this invoice buyer");
+ journal.prepare_seller_source(binding, Some(retained))
+}
diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs
index 314937c3..9eb52fe9 100644
--- a/core/archipelago/src/main.rs
+++ b/core/archipelago/src/main.rs
@@ -44,6 +44,8 @@ mod content_auth;
mod content_hash;
mod content_indeehub;
mod content_invoice;
+mod content_lightning;
+mod content_payment_admission;
mod content_owned;
mod content_purchase;
mod content_purchase_executor;
diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue
index 184f7065..78c178ae 100644
--- a/neode-ui/src/views/PeerFiles.vue
+++ b/neode-ui/src/views/PeerFiles.vue
@@ -393,6 +393,9 @@
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
+ Recover original invoice
+ Original Lightning purchase · {{ lnReceipt.price_sats }} sats
+ Cancel original unpaid invoice
@@ -421,8 +424,8 @@
- {{ lnPaying ? (hasBlockingLightningReceipt ? 'Checking payment…' : 'Paying…') : (hasBlockingLightningReceipt ? 'Check payment / retry download' : 'Pay with my Lightning node') }}
- {{ hasBlockingLightningReceipt ? 'Checks the saved attempt; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}
+ {{ lnPaying ? (hasBlockingLightningReceipt ? 'Checking payment…' : 'Paying…') : (canRetryNative ? `Retry Lightning · ${lnReceipt?.price_sats} sats` : hasBlockingLightningReceipt ? (lnReceipt?.operation_id ? 'Resume original Lightning purchase' : 'Check payment / retry download') : 'Pay with my Lightning node') }}
+ {{ canRetryNative ? 'Creates a new attempt only after the original failed' : hasBlockingLightningReceipt ? (lnReceipt?.operation_id ? 'Recovers or completes the same purchase without creating another invoice' : 'Checks the saved attempt; does not send more sats') : 'Pays the seller’s invoice from your node’s Lightning wallet' }}
@@ -585,7 +588,9 @@
+ Saved request {{ invoiceOperationId.slice(0, 8) }} · recovery uses this same invoice
{{ invoiceError }}
+ Recover original invoice
paymentGeneration.value===generation&&activePaymentMatches(onion,item.id)
+ try {
+ const result=await rpcClient.call<{attempt:null|{operation_id:string;price_sats:number;external_exposure:boolean;native_failed?:boolean;native_succeeded?:boolean;status:null|{payment_hash:string;bolt11:string|null;state:string;can_switch_method:boolean}}}>({method:'content.invoice-attempt',params:{onion,content_id:item.id},timeout:15000})
+ if(!selected())return
+ if(!Object.prototype.hasOwnProperty.call(result,'attempt'))throw Error('Could not verify saved invoice operations')
+ const attempt=result.attempt;if(!attempt)return
+ if (!/^[a-f0-9-]{36}$/i.test(attempt.operation_id) || !Number.isSafeInteger(attempt.price_sats) || attempt.price_sats <= 0) throw Error('Saved invoice identity is invalid; do not pay again')
+ invoiceOperationId.value=attempt.operation_id
+ if(!attempt.status?.payment_hash){lnReceiptReadError.value=true;lnError.value='An invoice operation is saved on this node. Open the original invoice to recover it before choosing another method.';return}
+ if (!/^[a-f0-9]{64}$/i.test(attempt.status.payment_hash) || !['prepared','dispatched','issued','cancel_requested','canceled_unpaid','settled'].includes(attempt.status.state)) throw Error('Saved invoice status is invalid; do not pay again')
+ const state=attempt.status.state==='settled'||attempt.native_succeeded===true?'succeeded':(attempt.status.state==='canceled_unpaid'&&attempt.status.can_switch_method)||(!attempt.external_exposure&&attempt.native_failed===true)?'failed':'pending'
+ const receipt:LightningReceipt={operation_id:attempt.operation_id,external_exposure:attempt.external_exposure,origin:attempt.external_exposure?'external':'native',bolt11:attempt.status.bolt11 || undefined,payment_hash:attempt.status.payment_hash,price_sats:attempt.price_sats,state}
+ try { readReceipt(onion,item.id) } catch {
+ const key=receiptKey(onion,item.id), raw=localStorage.getItem(key)
+ if(raw!==null){
+ if(new TextEncoder().encode(raw).length>65536) throw Error('Saved browser receipt is too large to reconcile automatically; the original node operation is retained')
+ localStorage.setItem(`${key}:unreadable`,raw)
+ // A valid owner-node operation is durable before replacing this supplemental copy.
+ localStorage.setItem(key,JSON.stringify(receipt))
+ }
+ }
+ const previous=readReceipt(onion,item.id)
+ keepReceipt(onion,item.id,receipt,selected,previous?.state==='failed'?previous.operation_id:undefined);lnReceiptReadError.value=false
+ }catch(error){if(selected()){lnReceiptReadError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify original invoice; do not pay again'}}
+}
+async function permitFreshOtherRail(item: CatalogItem, onion: string, recoverInvoice = false) {
const generation=paymentGeneration.value
await cashuLookup
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
if (hasBlockingCashuPurchase.value) { lnError.value='Recover or cancel the saved Cashu purchase before choosing another method.'; return false }
+ if (!recoverInvoice && hasBlockingLightningReceipt.value) {lnError.value='Recover or cancel the original invoice before choosing another method.';return false}
return true
}
@@ -879,6 +911,7 @@ const invoiceQr = ref('')
const invoiceWaiting = ref(false)
const invoiceError = ref('')
const invoiceCopied = ref(false)
+const invoiceOperationId = ref(null)
// On-chain QR (pay the seller's address from any external wallet).
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
const onchainQr = ref('')
@@ -887,9 +920,10 @@ const onchainError = ref('')
const onchainCopied = ref(false)
const lnPaying = ref(false)
const lnError = ref('')
-type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number; state?: 'pending' | 'succeeded' | 'failed'; failure_reason?: string }
+type LightningReceipt = { operation_id?: string; external_exposure?: boolean; origin?: 'native' | 'external'; bolt11?: string; payment_hash: string; price_sats: number; state?: 'pending' | 'succeeded' | 'failed'; failure_reason?: string }
const lnReceipt = ref(null)
const lnReceiptReadError = ref(false)
+const canRetryNative = computed(()=>Boolean(lnReceipt.value?.operation_id && lnReceipt.value.state==='failed' && lnReceipt.value.origin==='native' && lnReceipt.value.external_exposure===false))
const hasBlockingLightningReceipt = computed(() => lnReceiptReadError.value || Boolean(lnReceipt.value && lnReceipt.value.state !== 'failed'))
const paymentActionBusy = computed(() => paymentOperations.busy.value || lnPaying.value || onchainPaying.value || ecashPreparing.value || downloading.value === payItem.value?.id)
function activePaymentMatches(onion: string, id: string) {
@@ -900,34 +934,48 @@ 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) || (receipt.state !== undefined && !['pending', 'succeeded', 'failed'].includes(receipt.state))) throw new Error('Saved payment needs recovery. Do not pay again.')
+ if ((!receipt.bolt11 && !receipt.operation_id) || !/^[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, selected = () => activePaymentMatches(onion, id)) {
+function keepReceipt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id), replaceFailedOperation?: string) {
// Must succeed before handing an invoice to a payer. A failed transfer or
// navigation must never turn Retry into a second payment.
+ const previous = readReceipt(onion, id)
+ if (previous?.operation_id && previous.operation_id !== receipt.operation_id && !(previous.state === 'failed' && replaceFailedOperation === previous.operation_id)) {
+ if (selected()) lnReceipt.value=previous
+ return previous
+ }
+ if (previous?.state === 'succeeded') {
+ if (previous.payment_hash !== receipt.payment_hash || previous.price_sats !== receipt.price_sats) throw new Error('A succeeded payment already exists. Recover its original file before replacing this receipt.')
+ receipt = { ...receipt, state: 'succeeded' }
+ }
+ if (previous?.payment_hash === receipt.payment_hash && previous.external_exposure === true) receipt = { ...receipt, external_exposure: true, origin: 'external' }
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
if (selected()) lnReceipt.value = receipt
+ return receipt
}
function keepFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, reason: string, selected = () => activePaymentMatches(onion, id)) {
- keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason }, selected)
+ const kept=keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason }, selected)
+ if(kept.operation_id!==receipt.operation_id)return
if (selected()) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
}
type InvoiceLifecycle = { paid?: boolean; state?: string; can_switch_method?: boolean }
function keepCanceledInvoice(onion: string, id: string, receipt: LightningReceipt, result: InvoiceLifecycle | undefined, selected: () => boolean) {
if (receipt.state === 'succeeded' || result?.paid !== false || result.state !== 'canceled' || result.can_switch_method !== true) return false
- keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: 'Seller confirmed the invoice is canceled and unpaid' }, selected)
+ const kept=keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: 'Seller confirmed the invoice is canceled and unpaid' }, selected)
+ if(kept.operation_id!==receipt.operation_id||kept.payment_hash!==receipt.payment_hash||kept.state!=='failed')return false
if (selected()) lnError.value = 'The seller confirmed this invoice is canceled and unpaid. You can choose another payment method.'
return true
}
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id)): Promise<'failed' | 'pending' | 'other'> {
+ if (receipt.state === 'succeeded') return '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') {
+ if (result?.status === 'failed' && receipt.origin === 'native' && receipt.external_exposure === false) {
keepFailedLightningAttempt(onion, id, receipt, result.failure_reason || 'Payment failed', selected)
return 'failed'
}
@@ -935,7 +983,7 @@ async function recoverFailedLightningAttempt(onion: string, id: string, receipt:
} catch { /* unavailable/unknown is still recoverable, never permission to pay again */ }
try {
const result = await rpcClient.call({
- method: 'content.invoice-status', params: { onion, content_id: id, payment_hash: receipt.payment_hash }, timeout: 15000,
+ method: receipt.operation_id ? 'content.invoice-recover' : 'content.invoice-status', params: receipt.operation_id ? { onion, content_id: id, operation_id: receipt.operation_id } : { onion, content_id: id, payment_hash: receipt.payment_hash }, timeout: 15000,
})
if (keepCanceledInvoice(onion, id, receipt, result, selected)) return 'failed'
if (result?.paid === true) keepReceipt(onion, id, { ...receipt, state: 'succeeded' }, selected)
@@ -1196,6 +1244,7 @@ function openPayModal(item: CatalogItem) {
invoiceWaiting.value = false
invoiceError.value = ''
invoiceCopied.value = false
+ invoiceOperationId.value = null
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = false
@@ -1210,7 +1259,7 @@ function openPayModal(item: CatalogItem) {
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
- cashuLookup = lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)
+ cashuLookup = Promise.all([lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupNodeInvoice(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)]).then(()=>undefined)
}
function closePayModal() {
@@ -1415,6 +1464,7 @@ async function prepareEcashPay() {
const generation = paymentGeneration.value
await cashuLookup
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
+ if (hasBlockingLightningReceipt.value) {lnError.value='Recover or cancel the original invoice before choosing another method.';return}
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
if (!operation) return
const price = getItemPrice(item.access)
@@ -1597,7 +1647,7 @@ async function payWithInvoice() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
- if (!await permitFreshOtherRail(item, onion)) return
+ if (!await permitFreshOtherRail(item, onion, true)) return
const operation = paymentOperations.begin('invoice', onion, item.id)
if (!operation) return
payMode.value = 'qr'
@@ -1605,16 +1655,17 @@ async function payWithInvoice() {
invoiceWaiting.value = true
try {
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, maxRetries: 1,
- })
- if (!res?.bolt11 || !res?.payment_hash) throw new Error(res?.error || 'The seller could not create an invoice.')
- const receipt: LightningReceipt = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access), state: 'pending' }
+ const res = saved?.operation_id && saved.state !== 'failed'
+ ? await rpcClient.call({method:'content.invoice-recover',params:{onion,content_id:item.id,operation_id:saved.operation_id,external_exposure:true},timeout:45000,maxRetries:1})
+ : (saved?.state === 'failed' ? null : saved) || await rpcClient.call({method:'content.invoice-create',params:{onion,content_id:item.id,price_sats:getItemPrice(item.access),external_exposure:true},timeout:45000,maxRetries:1})
+ if (paymentOperations.selected(operation) && res?.operation_id) invoiceOperationId.value=res.operation_id
+ if (!res?.bolt11 || !res?.payment_hash) throw new Error(res && 'error' in res && typeof res.error === 'string' ? res.error : 'The seller could not recover the original invoice.')
+ const receipt = keepReceipt(onion,item.id,{...saved,...res,origin:'external',external_exposure:true,state:saved?.state==='succeeded'||('paid' in res && res.paid===true)?'succeeded':'pending'},()=>paymentOperations.selected(operation))
// Preserve this request even if the modal closed; never expose it in a new modal.
- localStorage.setItem(receiptKey(onion, item.id), JSON.stringify(receipt))
if (!paymentOperations.selected(operation)) return
+ lnReceiptReadError.value=false
lnReceipt.value = receipt
- invoiceData.value = receipt
+ invoiceData.value = {...receipt,bolt11:res.bolt11}
let image = ''
try { image = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 }) } catch { /* raw invoice remains usable */ }
if (!paymentOperations.selected(operation)) return
@@ -1627,16 +1678,70 @@ async function payWithInvoice() {
}
}
+/** A separate user gesture creates a new attempt only after proven native failure. */
+async function retryNativeLightning() {
+ const item=payItem.value,onion=props.peerId||currentPeer.value?.onion,original=lnReceipt.value
+ if(!item||!onion||!original?.operation_id||original.state!=='failed'||original.external_exposure!==false||original.origin!=='native'||paymentActionBusy.value)return
+ const generation=paymentGeneration.value
+ await cashuLookup
+ if(generation!==paymentGeneration.value||!activePaymentMatches(onion,item.id)||hasBlockingCashuPurchase.value)return
+ const operation=paymentOperations.begin('native-retry',onion,item.id);if(!operation)return
+ const selected=()=>paymentOperations.selected(operation)
+ try {
+ const result=await rpcClient.call({method:'content.invoice-retry-native',params:{onion,content_id:item.id,operation_id:original.operation_id,price_sats:original.price_sats},timeout:45000,maxRetries:1})
+ if(!result.operation_id||!result.payment_hash||result.operation_id===original.operation_id||result.price_sats!==original.price_sats)throw Error(result.error||'The replacement invoice is saved but still needs recovery; no new payment sent')
+ const kept=keepReceipt(onion,item.id,{...result,origin:result.external_exposure?'external':'native',external_exposure:result.external_exposure??false,state:result.paid?'succeeded':'pending'},selected,original.operation_id)
+ if(!selected()||kept.operation_id!==result.operation_id)return
+ if(kept.external_exposure){lnError.value='The replacement invoice was already opened in another wallet. Recover or cancel that invoice first.';return}
+ invoiceOperationId.value=result.operation_id;lnReceiptReadError.value=false
+ paymentOperations.finish(operation)
+ await payWithLightning()
+ }catch(error){
+ if(selected()){
+ await lookupNodeInvoice(onion,item,generation)
+ if(selected())lnError.value=error instanceof Error?error.message:'Could not recover the explicit retry; do not start another payment'
+ }
+ }finally{paymentOperations.finish(operation)}
+}
+
+/** Reconcile a saved request without paying it or exposing its invoice. */
+async function recoverOriginalInvoice() {
+ const item=payItem.value,onion=props.peerId||currentPeer.value?.onion,id=invoiceOperationId.value
+ if(!item||!onion||!id||paymentActionBusy.value)return
+ const operation=paymentOperations.begin('invoice-recovery',onion,item.id);if(!operation)return
+ try {
+ await rpcClient.call({method:'content.invoice-create',params:{onion,content_id:item.id,operation_id:id,external_exposure:false},timeout:45000,maxRetries:1})
+ if(paymentOperations.selected(operation))await lookupNodeInvoice(onion,item,operation.generation)
+ }catch(error){if(paymentOperations.selected(operation))lnError.value=error instanceof Error?error.message:'Could not recover the original invoice; no payment sent'}
+ finally{paymentOperations.finish(operation)}
+}
+
+/** Only authenticated seller terminal evidence releases an externally payable invoice. */
+async function cancelExternalInvoice() {
+ const item=payItem.value,onion=props.peerId||currentPeer.value?.onion,receipt=lnReceipt.value
+ if(!item||!onion||!receipt?.operation_id||receipt.state==='succeeded'||paymentActionBusy.value)return
+ const operation=paymentOperations.begin('invoice-cancel',onion,item.id);if(!operation)return
+ const selected=()=>paymentOperations.selected(operation)
+ try {
+ const result=await rpcClient.call({method:'content.invoice-cancel',params:{onion,content_id:item.id,operation_id:receipt.operation_id},timeout:45000,maxRetries:1})
+ if(keepCanceledInvoice(onion,item.id,receipt,result,selected)){if(selected()){invoiceData.value=null;invoiceQr.value='';payMode.value='choose'}}
+ else if(result.paid===true){keepReceipt(onion,item.id,{...receipt,state:'succeeded'},selected);if(selected())lnError.value='This invoice settled before cancellation. Recover its paid file.'}
+ else if(selected())lnError.value='Cancellation is not confirmed. Recover this original invoice before choosing another payment method.'
+ }catch(error){if(selected())lnError.value=error instanceof Error?error.message:'Could not confirm invoice cancellation'}
+ finally{paymentOperations.finish(operation)}
+}
+
/**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. Keep the invoice before payment so uncertain outcomes can
* retry seller verification and delivery without sending a second payment.
*/
async function payWithLightning() {
+ if(canRetryNative.value){await retryNativeLightning();return}
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
- if (!await permitFreshOtherRail(item, onion)) return
+ if (!await permitFreshOtherRail(item, onion, true)) return
const operation = paymentOperations.begin('lightning', onion, item.id)
if (!operation) return
const selected = () => paymentOperations.selected(operation)
@@ -1648,35 +1753,32 @@ async function payWithLightning() {
if (inv && inv.state !== 'failed') {
const state = await recoverFailedLightningAttempt(onion, item.id, inv, selected)
if (state === 'failed') return
- if (state === 'pending') {
+ if (state === 'pending' && (!inv.operation_id || inv.external_exposure)) {
if (selected()) lnError.value = 'Payment is still settling. You can close this window; check this saved attempt later without sending more sats.'
return
}
}
if (!selected()) 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, maxRetries: 1,
+ const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; operation_id?: string; external_exposure?: boolean; price_sats?: number; paid?: boolean; state?: string; error?: string }>({
+ method: 'content.invoice-create', params: { onion, content_id: item.id, price_sats: getItemPrice(item.access), external_exposure:false }, timeout: 45000, maxRetries: 1,
})
- if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
+ if (!result?.operation_id || !result.payment_hash) throw new Error(result?.error || 'The original invoice operation needs recovery.')
if (!selected()) return
- inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access), state: 'pending' }
- keepReceipt(onion, item.id, inv, selected)
- const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
- if (pay.status === 'failed') {
- keepFailedLightningAttempt(onion, item.id, inv, pay.failure_reason || 'Payment failed', selected)
- return
- }
- if (pay.status !== 'succeeded') {
- if (selected()) lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
- return
- }
- inv = { ...inv, state: 'succeeded' }
- keepReceipt(onion, item.id, inv, selected)
+ if(result.price_sats!==undefined && result.price_sats!==getItemPrice(item.access) && result.paid!==true)throw Error('The original invoice has different terms. Review its saved price before paying.')
+ inv = { operation_id: result.operation_id, origin:'native', external_exposure:result.external_exposure ?? false, payment_hash:result.payment_hash, price_sats:result.price_sats ?? getItemPrice(item.access), state:result.paid===true?'succeeded':'pending' }
+ keepReceipt(onion,item.id,inv,selected)
+ }
+ if(inv.operation_id && inv.state!=='succeeded') {
+ if(!selected())return
+ const pay=await rpcClient.call<{status:string;failure_reason?:string}>({method:'content.invoice-pay',params:{onion,content_id:item.id,operation_id:inv.operation_id},timeout:120000,maxRetries:1})
+ if(pay.status==='failed' && inv.external_exposure===false){keepFailedLightningAttempt(onion,item.id,inv,pay.failure_reason||'Payment failed',selected);return}
+ if(pay.status!=='succeeded'){if(selected())lnError.value='The original payment is still unresolved. Retry recovers it without sending again.';return}
+ inv={...inv,state:'succeeded'};keepReceipt(onion,item.id,inv,selected)
}
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 },
+ method: inv.operation_id ? 'content.invoice-download' : 'content.download-peer-invoice',
+ params: inv.operation_id ? {onion,content_id:item.id,operation_id:inv.operation_id} : { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000,
})
if (!selected()) return
@@ -1711,8 +1813,8 @@ async function pollInvoice(scope: InvoicePollScope) {
const { item, onion, invoice: inv } = scope
try {
const res = await rpcClient.call({
- method: 'content.invoice-status',
- params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 30000,
+ method: inv.operation_id ? 'content.invoice-recover' : 'content.invoice-status',
+ params: inv.operation_id ? {onion,content_id:item.id,operation_id:inv.operation_id} : { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 30000,
})
if (!invoiceScopeSelected(scope)) return
if (keepCanceledInvoice(onion, item.id, inv, res, () => invoiceScopeSelected(scope))) {
@@ -1725,8 +1827,8 @@ async function pollInvoice(scope: InvoicePollScope) {
if (res?.paid === true) {
localStorage.setItem(receiptKey(onion, item.id), JSON.stringify({ ...inv, state: 'succeeded' }))
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, maxRetries: 1,
+ method: inv.operation_id ? 'content.invoice-download' : 'content.download-peer-invoice',
+ params: inv.operation_id ? {onion,content_id:item.id,operation_id:inv.operation_id} : { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 960000, maxRetries: 1,
})
if (!invoiceScopeSelected(scope)) return
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
diff --git a/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts b/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts
index bc047e46..0d3f0389 100644
--- a/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts
+++ b/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts
@@ -11,6 +11,8 @@ const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain',
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
const cashuQuoteFixture = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', operation_id: '12345678-1234-4234-8234-123456789abc', envelope_sha256: 'b'.repeat(64), gross_token_sats: 6, seller_net_sats: 5, wallet_debit_sats: 7, expires_at: 2_000_000_000 }
const download = vi.fn()
+// Models the node's guarded native payment RPC, not a browser-side LND call.
+const nativePay = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
@@ -18,6 +20,7 @@ async function open() {
// than a duplicate implementation of the payment state machine.
const vm = (wrapper.vm as any).$.setupState
vm.openPayModal(item)
+ await flushPromises()
return { wrapper, vm }
}
beforeEach(() => {
@@ -25,18 +28,20 @@ beforeEach(() => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.purchase') return cashuQuoteFixture
- if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
- if (method === 'content.download-peer-invoice') return download()
- return { items: [], attempts: [] }
+ if (method === 'content.invoice-create') return { operation_id:'11111111-1111-4111-8111-111111111111', bolt11: 'ln-test', payment_hash: hash, price_sats: 5,external_exposure:false }
+ if (method === 'content.invoice-pay') return nativePay()
+ if (method === 'content.download-peer-invoice' || method === 'content.invoice-download') return download()
+ return { items: [], attempts: [], attempt: null }
})
- vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
+ nativePay.mockReset().mockResolvedValue({ status: 'succeeded' } as never)
+ download.mockReset().mockResolvedValue({error:'Original payment is unresolved'})
})
describe('Lightning file delivery recovery', () => {
it('opens a confirmed on-chain delivery from HTTP cache without another payment', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.onchain-status') return { paid: true }
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
await vm.pollOnchain('bc1test')
@@ -50,7 +55,7 @@ describe('Lightning file delivery recovery', () => {
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.purchase') return { state: 'delivered', owned: true, owned_content_id: item.id, mime_type: 'video/mp4', size_bytes: 200000000 }
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
@@ -65,7 +70,7 @@ describe('Lightning file delivery recovery', () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.purchase') return await new Promise(resolve => { finish = resolve })
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
@@ -84,18 +89,22 @@ describe('Lightning file delivery recovery', () => {
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
vm.closePayModal(); vm.openPayModal(item)
await vm.payWithLightning()
- expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
- expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
+ expect(nativePay).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.invoice-create')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(2)
- expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
+ expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.invoice-download')![0].params).toEqual({onion:'peer.onion',content_id:item.id,operation_id:'11111111-1111-4111-8111-111111111111'})
wrapper.unmount()
})
- it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
- vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
+ it('recovers the same node-owned payment operation after an ambiguous reply', async () => {
+ nativePay.mockRejectedValueOnce(new Error('Connection lost')).mockResolvedValue({status:'succeeded'})
download.mockResolvedValue({ error: 'Pending' })
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
const second = await open(); await second.vm.payWithLightning()
- expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
+ // RPC recovery may be repeated; the node dispatch-once engine owns spending.
+ const requests=vi.mocked(rpcClient.call).mock.calls.filter(([v])=>v.method==='content.invoice-pay')
+ expect(requests).toHaveLength(2)
+ expect(requests.map(([v])=>v.params)).toEqual([requests[0]![0].params,requests[0]![0].params])
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([v])=>v.method==='content.invoice-create')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(1)
second.wrapper.unmount()
})
@@ -106,16 +115,16 @@ describe('Lightning file delivery recovery', () => {
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(localStorage.getItem(receiptKey)).toBeNull()
- expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
- expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.invoice-download')![0].params).toMatchObject({operation_id:'11111111-1111-4111-8111-111111111111'})
+ expect(nativePay).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('never pays again when the saved receipt is corrupt', async () => {
localStorage.setItem(receiptKey, '{broken')
const { wrapper, vm } = await open()
await vm.payWithLightning()
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
- expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
+ expect(nativePay).not.toHaveBeenCalled()
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.invoice-create')).toHaveLength(0)
wrapper.unmount()
})
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
@@ -126,18 +135,18 @@ describe('Lightning file delivery recovery', () => {
expect(vm.qrTab).toBe('lightning')
vm.selectQrTab('onchain'); await flushPromises()
expect(vm.qrTab).toBe('lightning')
- expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.invoice-create', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
wrapper.unmount()
})
it('does not send payment if the recovery record cannot be saved', async () => {
const { wrapper, vm } = await open()
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
await vm.payWithLightning()
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ expect(nativePay).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)
+ nativePay.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()
@@ -148,12 +157,12 @@ describe('Lightning file delivery recovery', () => {
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(nativePay).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'))
+ nativePay.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()
@@ -163,18 +172,18 @@ describe('Lightning file delivery recovery', () => {
expect(download).not.toHaveBeenCalled()
wrapper.unmount()
})
- it('resolves an already stuck receipt without another payment or invoice', async () => {
+ it('does not treat a legacy receipt of unknown exposure as canceled from local LND failure', 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)
+ expect(vm.hasBlockingLightningReceipt).toBe(true)
+ expect(nativePay).not.toHaveBeenCalled()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.invoice-create')).toBe(false)
wrapper.unmount()
})
it('keeps ambiguous attempts recoverable and prevents another payment method', async () => {
- vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
+ nativePay.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()
@@ -184,7 +193,7 @@ describe('Lightning file delivery recovery', () => {
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)
+ expect(nativePay).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
@@ -196,8 +205,8 @@ describe('Lightning file delivery recovery', () => {
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()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([c]) => ['content.invoice-create', 'content.download-peer-invoice'].includes(c.method))).toBe(false)
+ expect(nativePay).not.toHaveBeenCalled()
vm.closePayModal()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
wrapper.unmount()
@@ -206,17 +215,17 @@ describe('Lightning file delivery recovery', () => {
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(vi.mocked(rpcClient.call).mock.calls.filter(([c]) => c.method === 'content.invoice-create')).toHaveLength(1)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ bolt11: 'ln-test', state: 'pending' })
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
// Append inside the existing PeerFilesLightning describe; uses real mounted component.
it('does not pay when an invoice arrives after closing and reopening the same file', async () => {
let reply!: (value: unknown) => void
- vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
- ? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
+ vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.invoice-create'
+ ? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
@@ -224,7 +233,7 @@ it('does not pay when an invoice arrives after closing and reopening the same fi
vm.closePayModal(); vm.openPayModal(item)
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ expect(nativePay).not.toHaveBeenCalled()
expect(vm.payItem.id).toBe(item.id)
expect(vm.lnPaying).toBe(false)
expect(localStorage.getItem(receiptKey)).toBeNull()
@@ -232,14 +241,14 @@ it('does not pay when an invoice arrives after closing and reopening the same fi
})
it('retains a late invoice for its original file without changing another file modal', async () => {
let reply!: (value: unknown) => void
- vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
- ? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
+ vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.invoice-create'
+ ? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
const { wrapper, vm } = await open()
const pending = vm.payWithInvoice()
await flushPromises()
expect(typeof reply).toBe('function')
await vm.payWithInvoice()
- expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.invoice-create')).toHaveLength(1)
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
@@ -253,7 +262,7 @@ it('retains a late invoice for its original file without changing another file m
it('keeps a new file balance preparation busy when an older preparation finishes', async () => {
const replies: ((value: unknown) => void)[] = []
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'wallet.ecash-balance'
- ? await new Promise(resolve => { replies.push(resolve) }) : { items: [], attempts: [] })
+ ? await new Promise(resolve => { replies.push(resolve) }) : { items: [], attempts: [], attempt: null })
const { wrapper, vm } = await open()
const first = vm.prepareEcashPay()
await flushPromises()
@@ -277,7 +286,7 @@ it('cannot deliver a late paid invoice into another file modal', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.invoice-status') return { paid: true }
if (method === 'content.download-peer-invoice') return await new Promise(resolve => { reply = resolve })
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
@@ -294,7 +303,7 @@ it('cannot deliver a late paid invoice into another file modal', async () => {
})
it('persists a dispatched Lightning result after closing without changing another file', async () => {
let reply!: (value: unknown) => void
- vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise(resolve => { reply = resolve }) as never)
+ nativePay.mockImplementation(async () => await new Promise(resolve => { reply = resolve }) as never)
download.mockResolvedValue({ error: 'Delivery remains recoverable' })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
@@ -308,13 +317,13 @@ it('persists a dispatched Lightning result after closing without changing anothe
expect(vm.viewerUrl).toBeNull()
expect(vm.lnReceipt).toBeNull()
expect(vm.lnError).toBe('')
- expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
+ expect(nativePay).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
let reply!: (value: unknown) => void
- vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
- ? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
+ vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.invoice-create'
+ ? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
@@ -322,12 +331,12 @@ it('does not dispatch Lightning when its invoice arrives after component unmount
wrapper.unmount()
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ expect(nativePay).not.toHaveBeenCalled()
expect(localStorage.getItem(receiptKey)).toBeNull()
})
it('retains already dispatched Lightning evidence after unmount without opening playback', async () => {
let reply!: (value: unknown) => void
- vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise(resolve => { reply = resolve }) as never)
+ nativePay.mockImplementation(async () => await new Promise(resolve => { reply = resolve }) as never)
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4' })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
@@ -353,7 +362,7 @@ it('retains already dispatched Lightning evidence after unmount without opening
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
@@ -424,15 +433,15 @@ describe('Seller-authoritative external invoice lifecycle', () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
if (method === 'content.invoice-status') return { paid: false, state: 'canceled', can_switch_method: true }
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'failed' })
expect(vm.lnError).toContain('seller confirmed')
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
- expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-invoice', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
+ expect(nativePay).not.toHaveBeenCalled()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.invoice-create', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
wrapper.unmount()
})
it.each([
@@ -447,14 +456,14 @@ describe('Seller-authoritative external invoice lifecycle', () => {
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
if (method === 'content.invoice-status') return response
if (method === 'content.download-peer-invoice') return { error: 'Payment is still pending' }
- return { items: [], attempts: [] }
+ return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'pending' })
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
- expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.request-invoice')).toBe(false)
+ expect(nativePay).not.toHaveBeenCalled()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.invoice-create')).toBe(false)
wrapper.unmount()
})
})
@@ -464,7 +473,7 @@ describe('Durable node Cashu purchases', () => {
let purchaseCalls = 0
vi.mocked(rpcClient.call).mockImplementation(async ({method}) => {
if (method === 'content.purchase') return ++purchaseCalls === 1 ? cashuQuoteFixture : {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
- return {items:[],attempts:[]}
+ return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open()
await vm.prepareEcashPay()
@@ -489,11 +498,11 @@ describe('Durable node Cashu purchases', () => {
if(count===2)throw new Error('Connection lost; original purchase saved')
return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
}
- return {items:[],attempts:[]}
+ return {items:[],attempts:[],attempt:null}
})
const first=await open();await first.vm.prepareEcashPay();await first.vm.confirmEcashPay();first.wrapper.unmount()
const next=await open();expect(next.vm.hasBlockingCashuPurchase).toBe(true)
- await next.vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ await next.vm.payWithLightning();expect(nativePay).not.toHaveBeenCalled()
await next.vm.prepareEcashPay()
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
expect(requests).toHaveLength(3)
@@ -509,11 +518,11 @@ describe('Durable node Cashu purchases', () => {
if(++cancelCalls===1)throw new Error('Cancellation reply lost')
canceled=true;return {state:'cancelled_unspent',operation_id:cashuQuoteFixture.operation_id}
}
- return {items:[],attempts:[]}
+ return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.cancelCashuPurchase()
expect(vm.hasBlockingCashuPurchase).toBe(true)
- await vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ await vm.payWithLightning();expect(nativePay).not.toHaveBeenCalled()
await vm.cancelCashuPurchase();expect(vm.hasBlockingCashuPurchase).toBe(false)
await vm.prepareEcashPay();expect(vm.cashuQuote.operation_id).not.toBe(cashuQuoteFixture.operation_id)
wrapper.unmount()
@@ -522,11 +531,11 @@ describe('Durable node Cashu purchases', () => {
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.payment-status')return {attempts:[{operation_id:cashuQuoteFixture.operation_id,state:'token_prepared_settlement_unconfirmed'}]}
if(method==='content.purchase')return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
- return {items:[],attempts:[]}
+ return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();await vm.payWithLightning()
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
- expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.request-invoice')).toBe(false)
+ expect(nativePay).not.toHaveBeenCalled()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.invoice-create')).toBe(false)
await vm.prepareEcashPay();expect(vm.viewerUrl).toContain('/paid-file')
wrapper.unmount()
})
@@ -546,21 +555,177 @@ describe('Malformed browser Cashu marker recovery', () => {
const calls=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
expect(calls).toHaveLength(1)
expect(calls[0]?.[0].params).not.toHaveProperty('consent')
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps original malformed data and all replacement methods blocked when node recovery is unavailable', async () => {
localStorage.setItem(marker,'{original broken marker')
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.purchase')throw new Error('Node purchase journal is unavailable')
- return {items:[],attempts:[]}
+ return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.payWithLightning()
expect(localStorage.getItem(marker)).toBe('{original broken marker')
expect(localStorage.getItem(`${marker}:unreadable`)).toBeNull()
expect(vm.hasBlockingCashuPurchase).toBe(true)
expect(vm.purchaseError).toContain('journal is unavailable')
- expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
+ expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+})
+
+
+describe('External invoice recovery retains terminal settlement', () => {
+ it('reopening a paid invoice QR never downgrades the receipt or requests another invoice', async () => {
+ const settled = { bolt11: 'ln-original', payment_hash: hash, price_sats: 5, state: 'succeeded' }
+ localStorage.setItem(receiptKey, JSON.stringify(settled))
+ const { wrapper, vm } = await open()
+ await vm.payWithInvoice()
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject(settled)
+ expect(vm.lnReceipt.state).toBe('succeeded')
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.invoice-create')).toBe(false)
+ expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+})
+
+
+describe('Durable external invoice ownership', () => {
+ it('recovers a browser-lost invoice from node storage and blocks other rails', async()=>{
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false}}}:original(args))
+ const {wrapper,vm}=await open();await flushPromises();await vm.prepareEcashPay();await vm.payOnchain()
+ expect(vm.hasBlockingLightningReceipt).toBe(true)
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({bolt11:'ln-original',external_exposure:true})
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([v])=>['content.invoice-create','lnd.sendcoins','content.purchase'].includes(v.method))).toBe(false)
+ wrapper.unmount()
+ })
+ it('local LND failure cannot release an externally displayed invoice',async()=>{
+ localStorage.setItem(receiptKey,JSON.stringify({bolt11:'ln-external',payment_hash:hash,price_sats:5,origin:'native',external_exposure:true,state:'pending'}))
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='lnd.paymentstatus'?{status:'failed'}:args.method==='content.invoice-status'?{paid:false,state:'open',can_switch_method:false}:original(args))
+ const {wrapper,vm}=await open();await vm.payWithLightning()
+ expect(vm.hasBlockingLightningReceipt).toBe(true);expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+ it('settlement wins a cancellation reply and keeps paid-file recovery',async()=>{
+ localStorage.setItem(receiptKey,JSON.stringify({operation_id:'11111111-1111-4111-8111-111111111111',bolt11:'ln-external',payment_hash:hash,price_sats:5,origin:'external',external_exposure:true,state:'pending'}))
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-cancel'?{paid:true,state:'settled',can_switch_method:false}:original(args))
+ const {wrapper,vm}=await open();await vm.cancelExternalInvoice()
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({state:'succeeded'})
+ expect(vm.hasBlockingLightningReceipt).toBe(true);expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+})
+
+
+describe('Succeeded invoice reconciliation',()=>{
+ it('retains a succeeded browser receipt when node invoice metadata has not reached settled yet',async()=>{
+ const operation='11111111-1111-4111-8111-111111111111'
+ localStorage.setItem(receiptKey,JSON.stringify({operation_id:operation,payment_hash:hash,price_sats:5,origin:'native',external_exposure:false,state:'succeeded'}))
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false}}}:original(args))
+ const {wrapper,vm}=await open()
+ expect(vm.lnReceipt.state).toBe('succeeded')
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({state:'succeeded'})
+ expect(vm.hasBlockingLightningReceipt).toBe(true)
+ expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+ it('restores node-proven native success after browser storage is lost',async()=>{
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:false,native_succeeded:true,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false}}}:original(args))
+ const {wrapper,vm}=await open()
+ expect(vm.lnReceipt.state).toBe('succeeded')
+ expect(vm.hasBlockingLightningReceipt).toBe(true)
+ expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+})
+
+
+describe('Damaged supplemental invoice receipt',()=>{
+ it('reconciles only from an authoritative saved node operation and preserves the damaged bytes',async()=>{
+ localStorage.setItem(receiptKey,'{damaged receipt')
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false}}}:original(args))
+ const {wrapper,vm}=await open()
+ expect(localStorage.getItem(`${receiptKey}:unreadable`)).toBe('{damaged receipt')
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({payment_hash:hash,state:'pending',external_exposure:true})
+ expect(vm.lnReceiptReadError).toBe(false)
+ expect(vm.hasBlockingLightningReceipt).toBe(true)
+ expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+ it('retains damaged bytes and blocks replacement if the original node lookup fails',async()=>{
+ localStorage.setItem(receiptKey,'{damaged receipt')
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>{if(args.method==='content.invoice-attempt')throw Error('Original journal unavailable');return original(args)})
+ const {wrapper,vm}=await open();await vm.payWithLightning()
+ expect(localStorage.getItem(receiptKey)).toBe('{damaged receipt')
+ expect(localStorage.getItem(`${receiptKey}:unreadable`)).toBeNull()
+ expect(vm.hasBlockingLightningReceipt).toBe(true)
+ expect(nativePay).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+})
+
+
+it('recovers a saved incomplete invoice request without paying or exposing its BOLT11',async()=>{
+ let recovered=false
+ const operation='11111111-1111-4111-8111-111111111111'
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>{
+ if(args.method==='content.invoice-attempt')return {attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:recovered?{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false}:null}}
+ if(args.method==='content.invoice-create'){recovered=true;return {operation_id:operation,state:'open',payment_hash:hash}}
+ return original(args)
+ })
+ const {wrapper,vm}=await open();expect(vm.lnReceiptReadError).toBe(true)
+ await vm.recoverOriginalInvoice()
+ expect(vm.lnReceiptReadError).toBe(false)
+ expect(vi.mocked(rpcClient.call).mock.calls.find(([call])=>call.method==='content.invoice-create')![0].params).toEqual({onion:'peer.onion',content_id:item.id,operation_id:operation,external_exposure:false})
+ expect(nativePay).not.toHaveBeenCalled();expect(vm.invoiceData).toBeNull()
+ expect(vm.lnReceipt).toMatchObject({operation_id:operation,state:'pending',external_exposure:false})
+ wrapper.unmount()
+})
+
+describe('Explicit retry of a confirmed native-only failure',()=>{
+ const oldOperation='11111111-1111-4111-8111-111111111111'
+ const nextOperation='22222222-2222-4222-8222-222222222222'
+ const nextHash='b'.repeat(64)
+ it('does not retry on reopen; the explicit action pays only the new node-owned operation',async()=>{
+ localStorage.setItem(receiptKey,JSON.stringify({operation_id:oldOperation,payment_hash:hash,price_sats:5,origin:'native',external_exposure:false,state:'failed'}))
+ const original=vi.mocked(rpcClient.call).getMockImplementation()!
+ vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-retry-native'?{operation_id:nextOperation,payment_hash:nextHash,price_sats:5,external_exposure:false,state:'open'}:original(args))
+ download.mockResolvedValue({error:'Delivery still pending'})
+ const {wrapper,vm}=await open()
+ expect(nativePay).not.toHaveBeenCalled()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.invoice-retry-native')).toBe(false)
+ await vm.retryNativeLightning()
+ expect(vi.mocked(rpcClient.call).mock.calls.find(([call])=>call.method==='content.invoice-retry-native')![0].params).toMatchObject({operation_id:oldOperation,price_sats:5})
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.invoice-pay').map(([call])=>call.params)).toEqual([{onion:'peer.onion',content_id:item.id,operation_id:nextOperation}])
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({operation_id:nextOperation,payment_hash:nextHash,state:'succeeded'})
+ wrapper.unmount()
+ })
+ it('does not create a native retry for an invoice exposed to another wallet',async()=>{
+ localStorage.setItem(receiptKey,JSON.stringify({operation_id:oldOperation,payment_hash:hash,price_sats:5,bolt11:'ln-external',origin:'external',external_exposure:true,state:'failed'}))
+ const {wrapper,vm}=await open();await vm.retryNativeLightning()
+ expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.invoice-retry-native')).toBe(false)
+ expect(nativePay).not.toHaveBeenCalled();wrapper.unmount()
+ })
+ it('a late failed reply from the old attempt cannot overwrite a replacement created in another window',async()=>{
+ let resolve!: (value:unknown)=>void
+ nativePay.mockImplementation(async()=>await new Promise(value=>{resolve=value}))
+ const {wrapper,vm}=await open();const old=vm.payWithLightning();await flushPromises()
+ expect(resolve).toBeTypeOf('function')
+ const replacement={operation_id:nextOperation,payment_hash:nextHash,price_sats:5,origin:'native',external_exposure:false,state:'pending'}
+ localStorage.setItem(receiptKey,JSON.stringify(replacement))
+ resolve({status:'failed',failure_reason:'Old attempt failed'})
+ await old
+ expect(JSON.parse(localStorage.getItem(receiptKey)!)).toEqual(replacement)
+ expect(vm.lnReceipt.operation_id).toBe(nextOperation)
+ expect(vm.lnError).not.toContain('Old attempt failed')
wrapper.unmount()
})
})