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.
This commit is contained in:
Generated
+1
@@ -141,6 +141,7 @@ dependencies = [
|
|||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
"libc",
|
"libc",
|
||||||
|
"lightning-invoice",
|
||||||
"lofty",
|
"lofty",
|
||||||
"mainline",
|
"mainline",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ chrono = "0.4"
|
|||||||
|
|
||||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||||
bip39 = { version = "2.1", features = ["rand"] }
|
bip39 = { version = "2.1", features = ["rand"] }
|
||||||
|
lightning-invoice = "=0.34.1"
|
||||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|||||||
@@ -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<Body>,
|
||||||
|
) -> Result<Response<Body>> {
|
||||||
|
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<std::sync::atomic::AtomicBool>);
|
||||||
|
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<std::sync::atomic::AtomicBool>);
|
||||||
|
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)?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod cdp;
|
|||||||
mod cloud_purchase;
|
mod cloud_purchase;
|
||||||
mod content;
|
mod content;
|
||||||
mod dwn;
|
mod dwn;
|
||||||
|
pub(crate) mod lightning_purchase;
|
||||||
mod model_proxy;
|
mod model_proxy;
|
||||||
mod node_message;
|
mod node_message;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
@@ -454,6 +455,9 @@ impl ApiHandler {
|
|||||||
.await;
|
.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.
|
// Purchase routes bound the original body before the generic buffer.
|
||||||
if method == Method::POST
|
if method == Method::POST
|
||||||
&& matches!(
|
&& matches!(
|
||||||
|
|||||||
@@ -337,6 +337,15 @@ impl RpcHandler {
|
|||||||
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
||||||
"content.playback-status" => self.handle_playback_status(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.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.purchase" => self.handle_content_purchase(params).await,
|
||||||
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
|
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
|
||||||
"content.payment-status" => self.handle_content_payment_status(params).await,
|
"content.payment-status" => self.handle_content_payment_status(params).await,
|
||||||
|
|||||||
@@ -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<u64>,
|
||||||
|
operation_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
external_exposure: bool,
|
||||||
|
}
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_lightning_operation(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u64> {
|
||||||
|
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<Option<Invoice>> {
|
||||||
|
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<Status> {
|
||||||
|
// 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<serde_json::Value> {
|
||||||
|
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<PreparedNativePayment> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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::Prepared> {
|
||||||
|
self.0
|
||||||
|
.prepare_bound_invoice_payment(invoice, hash, amount)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn lookup_payment(&self, hash: &str) -> Result<serde_json::Value> {
|
||||||
|
self.0
|
||||||
|
.handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod channels;
|
mod channels;
|
||||||
|
pub(super) mod external_invoice;
|
||||||
mod fee_bump;
|
mod fee_bump;
|
||||||
mod fee_policy;
|
mod fee_policy;
|
||||||
mod info;
|
mod info;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ fn payment_failure_reason(reason: &str) -> &'static str {
|
|||||||
|
|
||||||
/// Preserve terminal LND state as structured data. An RPC exception is an
|
/// Preserve terminal LND state as structured data. An RPC exception is an
|
||||||
/// ambiguous outcome to callers and must not hide a verified unpaid failure.
|
/// 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,
|
payment: &serde_json::Value,
|
||||||
hash: &str,
|
hash: &str,
|
||||||
decoded_amt: i64,
|
decoded_amt: i64,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod fips;
|
|||||||
mod handshake;
|
mod handshake;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod interfaces;
|
mod interfaces;
|
||||||
|
mod lightning_purchase;
|
||||||
pub(crate) mod lnd;
|
pub(crate) mod lnd;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
mod media_registration;
|
mod media_registration;
|
||||||
@@ -109,6 +110,13 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
|
|||||||
| "media.registration.context"
|
| "media.registration.context"
|
||||||
| "media.registration.resolve"
|
| "media.registration.resolve"
|
||||||
| "content.rental-purchase"
|
| "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.purchase"
|
||||||
| "content.cancel-purchase"
|
| "content.cancel-purchase"
|
||||||
| "content.playback-handle"
|
| "content.playback-handle"
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ impl RpcHandler {
|
|||||||
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
||||||
.await?;
|
.await?;
|
||||||
let buyer = identity.did_key()?;
|
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(
|
let result = caller::purchase(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
&buyer,
|
&buyer,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<Guard> {
|
||||||
|
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?
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ pub(crate) async fn cache(
|
|||||||
Ok(owned)
|
Ok(owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verified_stream<S, E>(
|
pub(crate) fn verified_stream<S, E>(
|
||||||
stream: S,
|
stream: S,
|
||||||
expected_hash: String,
|
expected_hash: String,
|
||||||
expected_size: u64,
|
expected_size: u64,
|
||||||
|
|||||||
@@ -1923,3 +1923,41 @@ pub(crate) async fn publish_snapshot_offer(
|
|||||||
)
|
)
|
||||||
.await
|
.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<crate::content_lightning::SellerRecord> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ mod content_auth;
|
|||||||
mod content_hash;
|
mod content_hash;
|
||||||
mod content_indeehub;
|
mod content_indeehub;
|
||||||
mod content_invoice;
|
mod content_invoice;
|
||||||
|
mod content_lightning;
|
||||||
|
mod content_payment_admission;
|
||||||
mod content_owned;
|
mod content_owned;
|
||||||
mod content_purchase;
|
mod content_purchase;
|
||||||
mod content_purchase_executor;
|
mod content_purchase_executor;
|
||||||
|
|||||||
@@ -393,6 +393,9 @@
|
|||||||
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<button v-if="lnReceiptReadError && invoiceOperationId" type="button" class="glass-button w-full min-h-11 rounded-xl mb-3" :disabled="paymentActionBusy" @click="recoverOriginalInvoice">Recover original invoice</button>
|
||||||
|
<p v-if="lnReceipt?.operation_id" class="text-xs text-white/60 mb-3">Original Lightning purchase · {{ lnReceipt.price_sats }} sats</p>
|
||||||
|
<button v-if="lnReceipt?.operation_id && lnReceipt.state !== 'succeeded' && lnReceipt.state !== 'failed'" class="glass-button w-full min-h-11 rounded-xl mb-3" :disabled="paymentActionBusy" @click="cancelExternalInvoice">Cancel original unpaid invoice</button>
|
||||||
<!-- Step 1: choose a payment method — only the methods the SELLER
|
<!-- Step 1: choose a payment method — only the methods the SELLER
|
||||||
accepts for this item are offered -->
|
accepts for this item are offered -->
|
||||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||||
@@ -421,8 +424,8 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
<span class="block text-base text-white">{{ lnPaying ? (hasBlockingLightningReceipt ? 'Checking payment…' : 'Paying…') : (hasBlockingLightningReceipt ? 'Check payment / retry download' : 'Pay with my Lightning node') }}</span>
|
<span class="block text-base text-white">{{ 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') }}</span>
|
||||||
<span class="block text-sm text-white/50">{{ hasBlockingLightningReceipt ? 'Checks the saved attempt; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
|
<span class="block text-sm text-white/50">{{ 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' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -585,7 +588,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="invoiceOperationId" class="mt-3 text-xs text-white/50">Saved request {{ invoiceOperationId.slice(0, 8) }} · recovery uses this same invoice</p>
|
||||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||||
|
<button v-if="invoiceError && !invoiceWaiting" type="button" class="glass-button min-h-11 w-full mt-3 rounded-lg" :disabled="paymentActionBusy" @click="payWithInvoice">Recover original invoice</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -862,11 +867,38 @@ async function lookupCashuPurchase(onion: string, item: CatalogItem, generation:
|
|||||||
if (selected()) { cashuRecoveryError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify saved purchases' }
|
if (selected()) { cashuRecoveryError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify saved purchases' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function permitFreshOtherRail(item: CatalogItem, onion: string) {
|
async function lookupNodeInvoice(onion:string,item:CatalogItem,generation:number) {
|
||||||
|
const selected=()=>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
|
const generation=paymentGeneration.value
|
||||||
await cashuLookup
|
await cashuLookup
|
||||||
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
|
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 (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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +911,7 @@ const invoiceQr = ref('')
|
|||||||
const invoiceWaiting = ref(false)
|
const invoiceWaiting = ref(false)
|
||||||
const invoiceError = ref('')
|
const invoiceError = ref('')
|
||||||
const invoiceCopied = ref(false)
|
const invoiceCopied = ref(false)
|
||||||
|
const invoiceOperationId = ref<string | null>(null)
|
||||||
// On-chain QR (pay the seller's address from any external wallet).
|
// On-chain QR (pay the seller's address from any external wallet).
|
||||||
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
|
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
|
||||||
const onchainQr = ref('')
|
const onchainQr = ref('')
|
||||||
@@ -887,9 +920,10 @@ const onchainError = ref('')
|
|||||||
const onchainCopied = ref(false)
|
const onchainCopied = ref(false)
|
||||||
const lnPaying = ref(false)
|
const lnPaying = ref(false)
|
||||||
const lnError = ref('')
|
const lnError = ref('')
|
||||||
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number; 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<LightningReceipt | null>(null)
|
const lnReceipt = ref<LightningReceipt | null>(null)
|
||||||
const lnReceiptReadError = ref(false)
|
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 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)
|
const paymentActionBusy = computed(() => paymentOperations.busy.value || lnPaying.value || onchainPaying.value || ecashPreparing.value || downloading.value === payItem.value?.id)
|
||||||
function activePaymentMatches(onion: string, id: string) {
|
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))
|
const raw = localStorage.getItem(receiptKey(onion, id))
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const receipt = JSON.parse(raw) as LightningReceipt
|
const receipt = JSON.parse(raw) as LightningReceipt
|
||||||
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash) || (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
|
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
|
// Must succeed before handing an invoice to a payer. A failed transfer or
|
||||||
// navigation must never turn Retry into a second payment.
|
// navigation must never turn Retry into a second payment.
|
||||||
|
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))
|
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
||||||
if (selected()) lnReceipt.value = receipt
|
if (selected()) lnReceipt.value = receipt
|
||||||
|
return receipt
|
||||||
}
|
}
|
||||||
function keepFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, reason: string, selected = () => activePaymentMatches(onion, id)) {
|
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.`
|
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 }
|
type InvoiceLifecycle = { paid?: boolean; state?: string; can_switch_method?: boolean }
|
||||||
function keepCanceledInvoice(onion: string, id: string, receipt: LightningReceipt, result: InvoiceLifecycle | undefined, selected: () => 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
|
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.'
|
if (selected()) lnError.value = 'The seller confirmed this invoice is canceled and unpaid. You can choose another payment method.'
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id)): Promise<'failed' | 'pending' | 'other'> {
|
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
|
// 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.
|
// can distinguish that from a lost reply; never infer failure from error text.
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<{ status?: string; failure_reason?: string }>({
|
const result = await rpcClient.call<{ status?: string; failure_reason?: string }>({
|
||||||
method: 'lnd.paymentstatus', params: { payment_hash: receipt.payment_hash }, timeout: 15000,
|
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)
|
keepFailedLightningAttempt(onion, id, receipt, result.failure_reason || 'Payment failed', selected)
|
||||||
return 'failed'
|
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 */ }
|
} catch { /* unavailable/unknown is still recoverable, never permission to pay again */ }
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<InvoiceLifecycle>({
|
const result = await rpcClient.call<InvoiceLifecycle>({
|
||||||
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 (keepCanceledInvoice(onion, id, receipt, result, selected)) return 'failed'
|
||||||
if (result?.paid === true) keepReceipt(onion, id, { ...receipt, state: 'succeeded' }, selected)
|
if (result?.paid === true) keepReceipt(onion, id, { ...receipt, state: 'succeeded' }, selected)
|
||||||
@@ -1196,6 +1244,7 @@ function openPayModal(item: CatalogItem) {
|
|||||||
invoiceWaiting.value = false
|
invoiceWaiting.value = false
|
||||||
invoiceError.value = ''
|
invoiceError.value = ''
|
||||||
invoiceCopied.value = false
|
invoiceCopied.value = false
|
||||||
|
invoiceOperationId.value = null
|
||||||
onchainData.value = null
|
onchainData.value = null
|
||||||
onchainQr.value = ''
|
onchainQr.value = ''
|
||||||
onchainWaiting.value = false
|
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.`
|
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.' }
|
} catch { lnReceiptReadError.value = true; lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
||||||
onchainPaying.value = false
|
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() {
|
function closePayModal() {
|
||||||
@@ -1415,6 +1464,7 @@ async function prepareEcashPay() {
|
|||||||
const generation = paymentGeneration.value
|
const generation = paymentGeneration.value
|
||||||
await cashuLookup
|
await cashuLookup
|
||||||
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
|
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)
|
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
|
||||||
if (!operation) return
|
if (!operation) return
|
||||||
const price = getItemPrice(item.access)
|
const price = getItemPrice(item.access)
|
||||||
@@ -1597,7 +1647,7 @@ async function payWithInvoice() {
|
|||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion) return
|
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)
|
const operation = paymentOperations.begin('invoice', onion, item.id)
|
||||||
if (!operation) return
|
if (!operation) return
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
@@ -1605,16 +1655,17 @@ async function payWithInvoice() {
|
|||||||
invoiceWaiting.value = true
|
invoiceWaiting.value = true
|
||||||
try {
|
try {
|
||||||
const saved = readReceipt(onion, item.id)
|
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 }>({
|
const res = saved?.operation_id && saved.state !== 'failed'
|
||||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1,
|
? await rpcClient.call<LightningReceipt & {paid?:boolean;error?:string}>({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<LightningReceipt & {paid?:boolean;error?:string}>({method:'content.invoice-create',params:{onion,content_id:item.id,price_sats:getItemPrice(item.access),external_exposure:true},timeout:45000,maxRetries:1})
|
||||||
if (!res?.bolt11 || !res?.payment_hash) throw new Error(res?.error || 'The seller could not create an invoice.')
|
if (paymentOperations.selected(operation) && res?.operation_id) invoiceOperationId.value=res.operation_id
|
||||||
const receipt: LightningReceipt = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access), state: 'pending' }
|
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.
|
// 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
|
if (!paymentOperations.selected(operation)) return
|
||||||
|
lnReceiptReadError.value=false
|
||||||
lnReceipt.value = receipt
|
lnReceipt.value = receipt
|
||||||
invoiceData.value = receipt
|
invoiceData.value = {...receipt,bolt11:res.bolt11}
|
||||||
let image = ''
|
let image = ''
|
||||||
try { image = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 }) } catch { /* raw invoice remains usable */ }
|
try { image = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 }) } catch { /* raw invoice remains usable */ }
|
||||||
if (!paymentOperations.selected(operation)) return
|
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<LightningReceipt & {paid?:boolean;error?:string}>({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<InvoiceLifecycle>({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
|
* 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
|
* release the file. Keep the invoice before payment so uncertain outcomes can
|
||||||
* retry seller verification and delivery without sending a second payment.
|
* retry seller verification and delivery without sending a second payment.
|
||||||
*/
|
*/
|
||||||
async function payWithLightning() {
|
async function payWithLightning() {
|
||||||
|
if(canRetryNative.value){await retryNativeLightning();return}
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
|
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)
|
const operation = paymentOperations.begin('lightning', onion, item.id)
|
||||||
if (!operation) return
|
if (!operation) return
|
||||||
const selected = () => paymentOperations.selected(operation)
|
const selected = () => paymentOperations.selected(operation)
|
||||||
@@ -1648,35 +1753,32 @@ async function payWithLightning() {
|
|||||||
if (inv && inv.state !== 'failed') {
|
if (inv && inv.state !== 'failed') {
|
||||||
const state = await recoverFailedLightningAttempt(onion, item.id, inv, selected)
|
const state = await recoverFailedLightningAttempt(onion, item.id, inv, selected)
|
||||||
if (state === 'failed') return
|
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.'
|
if (selected()) lnError.value = 'Payment is still settling. You can close this window; check this saved attempt later without sending more sats.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!selected()) return
|
if (!selected()) return
|
||||||
if (!inv || inv.state === 'failed') {
|
if (!inv || inv.state === 'failed') {
|
||||||
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; operation_id?: string; external_exposure?: boolean; price_sats?: number; paid?: boolean; state?: string; error?: string }>({
|
||||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1,
|
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
|
if (!selected()) return
|
||||||
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access), state: 'pending' }
|
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.')
|
||||||
keepReceipt(onion, item.id, inv, selected)
|
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' }
|
||||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
keepReceipt(onion,item.id,inv,selected)
|
||||||
if (pay.status === 'failed') {
|
}
|
||||||
keepFailedLightningAttempt(onion, item.id, inv, pay.failure_reason || 'Payment failed', selected)
|
if(inv.operation_id && inv.state!=='succeeded') {
|
||||||
return
|
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 !== 'succeeded') {
|
if(pay.status==='failed' && inv.external_exposure===false){keepFailedLightningAttempt(onion,item.id,inv,pay.failure_reason||'Payment failed',selected);return}
|
||||||
if (selected()) lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
if(pay.status!=='succeeded'){if(selected())lnError.value='The original payment is still unresolved. Retry recovers it without sending again.';return}
|
||||||
return
|
inv={...inv,state:'succeeded'};keepReceipt(onion,item.id,inv,selected)
|
||||||
}
|
|
||||||
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 }>({
|
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-invoice',
|
method: inv.operation_id ? 'content.invoice-download' : 'content.download-peer-invoice',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
params: 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,
|
timeout: 960000,
|
||||||
})
|
})
|
||||||
if (!selected()) return
|
if (!selected()) return
|
||||||
@@ -1711,8 +1813,8 @@ async function pollInvoice(scope: InvoicePollScope) {
|
|||||||
const { item, onion, invoice: inv } = scope
|
const { item, onion, invoice: inv } = scope
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<InvoiceLifecycle>({
|
const res = await rpcClient.call<InvoiceLifecycle>({
|
||||||
method: 'content.invoice-status',
|
method: inv.operation_id ? 'content.invoice-recover' : '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,
|
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 (!invoiceScopeSelected(scope)) return
|
||||||
if (keepCanceledInvoice(onion, item.id, inv, res, () => invoiceScopeSelected(scope))) {
|
if (keepCanceledInvoice(onion, item.id, inv, res, () => invoiceScopeSelected(scope))) {
|
||||||
@@ -1725,8 +1827,8 @@ async function pollInvoice(scope: InvoicePollScope) {
|
|||||||
if (res?.paid === true) {
|
if (res?.paid === true) {
|
||||||
localStorage.setItem(receiptKey(onion, item.id), JSON.stringify({ ...inv, state: 'succeeded' }))
|
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 }>({
|
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-invoice',
|
method: inv.operation_id ? 'content.invoice-download' : '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,
|
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 (!invoiceScopeSelected(scope)) return
|
||||||
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
|
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
|
||||||
|
|||||||
@@ -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 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 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()
|
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() {
|
async function open() {
|
||||||
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -18,6 +20,7 @@ async function open() {
|
|||||||
// than a duplicate implementation of the payment state machine.
|
// than a duplicate implementation of the payment state machine.
|
||||||
const vm = (wrapper.vm as any).$.setupState
|
const vm = (wrapper.vm as any).$.setupState
|
||||||
vm.openPayModal(item)
|
vm.openPayModal(item)
|
||||||
|
await flushPromises()
|
||||||
return { wrapper, vm }
|
return { wrapper, vm }
|
||||||
}
|
}
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -25,18 +28,20 @@ beforeEach(() => {
|
|||||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
|
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
if (method === 'content.purchase') return cashuQuoteFixture
|
if (method === 'content.purchase') return cashuQuoteFixture
|
||||||
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
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.download-peer-invoice') return download()
|
if (method === 'content.invoice-pay') return nativePay()
|
||||||
return { items: [], attempts: [] }
|
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', () => {
|
describe('Lightning file delivery recovery', () => {
|
||||||
it('opens a confirmed on-chain delivery from HTTP cache without another payment', async () => {
|
it('opens a confirmed on-chain delivery from HTTP cache without another payment', async () => {
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
if (method === 'content.onchain-status') return { paid: true }
|
if (method === 'content.onchain-status') return { paid: true }
|
||||||
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
|
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()
|
const { wrapper, vm } = await open()
|
||||||
await vm.pollOnchain('bc1test')
|
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 () => {
|
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
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 }
|
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()
|
const { wrapper, vm } = await open()
|
||||||
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
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
|
let finish!: (value: unknown) => void
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
if (method === 'content.purchase') return await new Promise(resolve => { finish = resolve })
|
if (method === 'content.purchase') return await new Promise(resolve => { finish = resolve })
|
||||||
return { items: [], attempts: [] }
|
return { items: [], attempts: [], attempt: null }
|
||||||
})
|
})
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
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 })
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
|
||||||
vm.closePayModal(); vm.openPayModal(item)
|
vm.closePayModal(); vm.openPayModal(item)
|
||||||
await vm.payWithLightning()
|
await vm.payWithLightning()
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
expect(nativePay).toHaveBeenCalledTimes(1)
|
||||||
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)
|
||||||
expect(download).toHaveBeenCalledTimes(2)
|
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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
|
it('recovers the same node-owned payment operation after an ambiguous reply', async () => {
|
||||||
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
|
nativePay.mockRejectedValueOnce(new Error('Connection lost')).mockResolvedValue({status:'succeeded'})
|
||||||
download.mockResolvedValue({ error: 'Pending' })
|
download.mockResolvedValue({ error: 'Pending' })
|
||||||
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
|
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
|
||||||
const second = await open(); await second.vm.payWithLightning()
|
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)
|
expect(download).toHaveBeenCalledTimes(1)
|
||||||
second.wrapper.unmount()
|
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.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||||
expect(vm.viewerMime).toBe('video/mp4')
|
expect(vm.viewerMime).toBe('video/mp4')
|
||||||
expect(localStorage.getItem(receiptKey)).toBeNull()
|
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(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.invoice-download')![0].params).toMatchObject({operation_id:'11111111-1111-4111-8111-111111111111'})
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
expect(nativePay).toHaveBeenCalledTimes(1)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('never pays again when the saved receipt is corrupt', async () => {
|
it('never pays again when the saved receipt is corrupt', async () => {
|
||||||
localStorage.setItem(receiptKey, '{broken')
|
localStorage.setItem(receiptKey, '{broken')
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
await vm.payWithLightning()
|
await vm.payWithLightning()
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.invoice-create')).toHaveLength(0)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
|
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')
|
expect(vm.qrTab).toBe('lightning')
|
||||||
vm.selectQrTab('onchain'); await flushPromises()
|
vm.selectQrTab('onchain'); await flushPromises()
|
||||||
expect(vm.qrTab).toBe('lightning')
|
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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('does not send payment if the recovery record cannot be saved', async () => {
|
it('does not send payment if the recovery record cannot be saved', async () => {
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
|
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
|
||||||
await vm.payWithLightning()
|
await vm.payWithLightning()
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
save.mockRestore(); wrapper.unmount()
|
save.mockRestore(); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('reopens ecash after a returned terminal failure while retaining failed-attempt history', async () => {
|
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()!
|
const original = vi.mocked(rpcClient.call).getMockImplementation()!
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'wallet.ecash-balance' ? { cashu_sats: 10 } : original(args))
|
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'wallet.ecash-balance' ? { cashu_sats: 10 } : original(args))
|
||||||
const { wrapper, vm } = await open()
|
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')
|
expect(wrapper.text()).toContain('Pay from this node’s ecash wallet')
|
||||||
await vm.prepareEcashPay()
|
await vm.prepareEcashPay()
|
||||||
expect(vm.ecashPlan.chosen).toBe('cashu')
|
expect(vm.ecashPlan.chosen).toBe('cashu')
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
expect(nativePay).toHaveBeenCalledTimes(1)
|
||||||
expect(download).not.toHaveBeenCalled()
|
expect(download).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('resolves an old-backend exception using definitive LND state', async () => {
|
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()!
|
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))
|
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()
|
const { wrapper, vm } = await open(); await vm.payWithLightning()
|
||||||
@@ -163,18 +172,18 @@ describe('Lightning file delivery recovery', () => {
|
|||||||
expect(download).not.toHaveBeenCalled()
|
expect(download).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
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 }))
|
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
|
||||||
const original = vi.mocked(rpcClient.call).getMockImplementation()!
|
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))
|
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()
|
const { wrapper, vm } = await open(); await vm.payWithLightning()
|
||||||
expect(vm.hasBlockingLightningReceipt).toBe(false)
|
expect(vm.hasBlockingLightningReceipt).toBe(true)
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.request-invoice')).toBe(false)
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.invoice-create')).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('keeps ambiguous attempts recoverable and prevents another payment method', async () => {
|
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()!
|
const original = vi.mocked(rpcClient.call).getMockImplementation()!
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'unknown' } : original(args))
|
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'unknown' } : original(args))
|
||||||
const { wrapper, vm } = await open(); await vm.payWithLightning()
|
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' }
|
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
||||||
await vm.confirmEcashPay(); await vm.payOnchain()
|
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(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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -196,8 +205,8 @@ describe('Lightning file delivery recovery', () => {
|
|||||||
await vm.payWithLightning()
|
await vm.payWithLightning()
|
||||||
expect(vm.lnPaying).toBe(false)
|
expect(vm.lnPaying).toBe(false)
|
||||||
expect(vm.lnError).toContain('close this window')
|
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(vi.mocked(rpcClient.call).mock.calls.some(([c]) => ['content.invoice-create', 'content.download-peer-invoice'].includes(c.method))).toBe(false)
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
vm.closePayModal()
|
vm.closePayModal()
|
||||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
|
||||||
wrapper.unmount()
|
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' }))
|
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'failed-old', payment_hash: hash, price_sats: 5, state: 'failed' }))
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
await vm.payWithInvoice()
|
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(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ bolt11: 'ln-test', state: 'pending' })
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Append inside the existing PeerFilesLightning describe; uses real mounted component.
|
// 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 () => {
|
it('does not pay when an invoice arrives after closing and reopening the same file', async () => {
|
||||||
let reply!: (value: unknown) => void
|
let reply!: (value: unknown) => void
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.invoice-create'
|
||||||
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
|
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
const pending = vm.payWithLightning()
|
const pending = vm.payWithLightning()
|
||||||
await flushPromises()
|
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)
|
vm.closePayModal(); vm.openPayModal(item)
|
||||||
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||||
await pending
|
await pending
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(vm.payItem.id).toBe(item.id)
|
expect(vm.payItem.id).toBe(item.id)
|
||||||
expect(vm.lnPaying).toBe(false)
|
expect(vm.lnPaying).toBe(false)
|
||||||
expect(localStorage.getItem(receiptKey)).toBeNull()
|
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 () => {
|
it('retains a late invoice for its original file without changing another file modal', async () => {
|
||||||
let reply!: (value: unknown) => void
|
let reply!: (value: unknown) => void
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.invoice-create'
|
||||||
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
|
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
const pending = vm.payWithInvoice()
|
const pending = vm.payWithInvoice()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(typeof reply).toBe('function')
|
expect(typeof reply).toBe('function')
|
||||||
await vm.payWithInvoice()
|
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' })
|
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
|
||||||
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||||
await pending
|
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 () => {
|
it('keeps a new file balance preparation busy when an older preparation finishes', async () => {
|
||||||
const replies: ((value: unknown) => void)[] = []
|
const replies: ((value: unknown) => void)[] = []
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'wallet.ecash-balance'
|
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 { wrapper, vm } = await open()
|
||||||
const first = vm.prepareEcashPay()
|
const first = vm.prepareEcashPay()
|
||||||
await flushPromises()
|
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 }) => {
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
if (method === 'content.invoice-status') return { paid: true }
|
if (method === 'content.invoice-status') return { paid: true }
|
||||||
if (method === 'content.download-peer-invoice') return await new Promise(resolve => { reply = resolve })
|
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 { wrapper, vm } = await open()
|
||||||
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
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 () => {
|
it('persists a dispatched Lightning result after closing without changing another file', async () => {
|
||||||
let reply!: (value: unknown) => void
|
let reply!: (value: unknown) => void
|
||||||
vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
|
nativePay.mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
|
||||||
download.mockResolvedValue({ error: 'Delivery remains recoverable' })
|
download.mockResolvedValue({ error: 'Delivery remains recoverable' })
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
const pending = vm.payWithLightning()
|
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.viewerUrl).toBeNull()
|
||||||
expect(vm.lnReceipt).toBeNull()
|
expect(vm.lnReceipt).toBeNull()
|
||||||
expect(vm.lnError).toBe('')
|
expect(vm.lnError).toBe('')
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
expect(nativePay).toHaveBeenCalledTimes(1)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
|
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
|
||||||
let reply!: (value: unknown) => void
|
let reply!: (value: unknown) => void
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.invoice-create'
|
||||||
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
|
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
const pending = vm.payWithLightning()
|
const pending = vm.payWithLightning()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -322,12 +331,12 @@ it('does not dispatch Lightning when its invoice arrives after component unmount
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||||
await pending
|
await pending
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(localStorage.getItem(receiptKey)).toBeNull()
|
expect(localStorage.getItem(receiptKey)).toBeNull()
|
||||||
})
|
})
|
||||||
it('retains already dispatched Lightning evidence after unmount without opening playback', async () => {
|
it('retains already dispatched Lightning evidence after unmount without opening playback', async () => {
|
||||||
let reply!: (value: unknown) => void
|
let reply!: (value: unknown) => void
|
||||||
vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
|
nativePay.mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
|
||||||
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4' })
|
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4' })
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
const pending = vm.payWithLightning()
|
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 () => {
|
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
|
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
|
||||||
return { items: [], attempts: [] }
|
return { items: [], attempts: [], attempt: null }
|
||||||
})
|
})
|
||||||
const { wrapper, vm } = await open()
|
const { wrapper, vm } = await open()
|
||||||
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
|
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 }) => {
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
|
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
|
||||||
if (method === 'content.invoice-status') return { paid: false, state: 'canceled', can_switch_method: true }
|
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()
|
const { wrapper, vm } = await open()
|
||||||
await vm.payWithLightning()
|
await vm.payWithLightning()
|
||||||
expect(vm.hasBlockingLightningReceipt).toBe(false)
|
expect(vm.hasBlockingLightningReceipt).toBe(false)
|
||||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'failed' })
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'failed' })
|
||||||
expect(vm.lnError).toContain('seller confirmed')
|
expect(vm.lnError).toContain('seller confirmed')
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-invoice', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.invoice-create', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it.each([
|
it.each([
|
||||||
@@ -447,14 +456,14 @@ describe('Seller-authoritative external invoice lifecycle', () => {
|
|||||||
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
|
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
|
||||||
if (method === 'content.invoice-status') return response
|
if (method === 'content.invoice-status') return response
|
||||||
if (method === 'content.download-peer-invoice') return { error: 'Payment is still pending' }
|
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()
|
const { wrapper, vm } = await open()
|
||||||
await vm.payWithLightning()
|
await vm.payWithLightning()
|
||||||
expect(vm.hasBlockingLightningReceipt).toBe(true)
|
expect(vm.hasBlockingLightningReceipt).toBe(true)
|
||||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'pending' })
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'pending' })
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.request-invoice')).toBe(false)
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.invoice-create')).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -464,7 +473,7 @@ describe('Durable node Cashu purchases', () => {
|
|||||||
let purchaseCalls = 0
|
let purchaseCalls = 0
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({method}) => {
|
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'}
|
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()
|
const {wrapper,vm}=await open()
|
||||||
await vm.prepareEcashPay()
|
await vm.prepareEcashPay()
|
||||||
@@ -489,11 +498,11 @@ describe('Durable node Cashu purchases', () => {
|
|||||||
if(count===2)throw new Error('Connection lost; original purchase saved')
|
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 {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 first=await open();await first.vm.prepareEcashPay();await first.vm.confirmEcashPay();first.wrapper.unmount()
|
||||||
const next=await open();expect(next.vm.hasBlockingCashuPurchase).toBe(true)
|
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()
|
await next.vm.prepareEcashPay()
|
||||||
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
|
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
|
||||||
expect(requests).toHaveLength(3)
|
expect(requests).toHaveLength(3)
|
||||||
@@ -509,11 +518,11 @@ describe('Durable node Cashu purchases', () => {
|
|||||||
if(++cancelCalls===1)throw new Error('Cancellation reply lost')
|
if(++cancelCalls===1)throw new Error('Cancellation reply lost')
|
||||||
canceled=true;return {state:'cancelled_unspent',operation_id:cashuQuoteFixture.operation_id}
|
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()
|
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.cancelCashuPurchase()
|
||||||
expect(vm.hasBlockingCashuPurchase).toBe(true)
|
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.cancelCashuPurchase();expect(vm.hasBlockingCashuPurchase).toBe(false)
|
||||||
await vm.prepareEcashPay();expect(vm.cashuQuote.operation_id).not.toBe(cashuQuoteFixture.operation_id)
|
await vm.prepareEcashPay();expect(vm.cashuQuote.operation_id).not.toBe(cashuQuoteFixture.operation_id)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -522,11 +531,11 @@ describe('Durable node Cashu purchases', () => {
|
|||||||
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
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.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'}
|
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()
|
const {wrapper,vm}=await open();await vm.payWithLightning()
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.request-invoice')).toBe(false)
|
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')
|
await vm.prepareEcashPay();expect(vm.viewerUrl).toContain('/paid-file')
|
||||||
wrapper.unmount()
|
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')
|
const calls=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
|
||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.[0].params).not.toHaveProperty('consent')
|
expect(calls[0]?.[0].params).not.toHaveProperty('consent')
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
expect(nativePay).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('keeps original malformed data and all replacement methods blocked when node recovery is unavailable', async () => {
|
it('keeps original malformed data and all replacement methods blocked when node recovery is unavailable', async () => {
|
||||||
localStorage.setItem(marker,'{original broken marker')
|
localStorage.setItem(marker,'{original broken marker')
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
||||||
if(method==='content.purchase')throw new Error('Node purchase journal is unavailable')
|
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()
|
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.payWithLightning()
|
||||||
expect(localStorage.getItem(marker)).toBe('{original broken marker')
|
expect(localStorage.getItem(marker)).toBe('{original broken marker')
|
||||||
expect(localStorage.getItem(`${marker}:unreadable`)).toBeNull()
|
expect(localStorage.getItem(`${marker}:unreadable`)).toBeNull()
|
||||||
expect(vm.hasBlockingCashuPurchase).toBe(true)
|
expect(vm.hasBlockingCashuPurchase).toBe(true)
|
||||||
expect(vm.purchaseError).toContain('journal is unavailable')
|
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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user