diff --git a/core/Cargo.lock b/core/Cargo.lock
index 845eba7c..b0d6aa53 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -141,6 +141,7 @@ dependencies = [
"iroh",
"iroh-blobs",
"libc",
+ "lightning-invoice",
"lofty",
"mainline",
"mdns-sd",
diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml
index 4a2e974a..af13a0cc 100644
--- a/core/archipelago/Cargo.toml
+++ b/core/archipelago/Cargo.toml
@@ -73,6 +73,7 @@ chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "2.1", features = ["rand"] }
+lightning-invoice = "=0.34.1"
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration
diff --git a/core/archipelago/src/api/handler/lightning_purchase.rs b/core/archipelago/src/api/handler/lightning_purchase.rs
new file mode 100644
index 00000000..36d55e0a
--- /dev/null
+++ b/core/archipelago/src/api/handler/lightning_purchase.rs
@@ -0,0 +1,241 @@
+use super::{build_response, ApiHandler};
+use crate::content_lightning::{Binding, Journal, Phase};
+use anyhow::{Context, Result};
+use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
+use serde::{Deserialize, Serialize};
+use tokio::io::AsyncReadExt;
+pub(crate) const ROUTE: &str = "/content/lightning/v1/operation";
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Operation {
+ pub binding: Binding,
+ pub action: String,
+}
+impl ApiHandler {
+ pub(super) async fn handle_lightning_purchase(
+ &self,
+ mut request: Request
,
+ ) -> Result> {
+ anyhow::ensure!(
+ request.method() == Method::POST && request.uri().path() == ROUTE,
+ "Invalid invoice route"
+ );
+ let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
+ let mut bytes = Vec::new();
+ while let Some(chunk) = request.body_mut().data().await {
+ let chunk = chunk?;
+ anyhow::ensure!(
+ bytes.len() + chunk.len() <= 16384,
+ "Invoice request too large"
+ );
+ bytes.extend_from_slice(&chunk)
+ }
+ Ok::<_, anyhow::Error>(bytes)
+ })
+ .await
+ .context("Invoice request timed out")??;
+ let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
+ let buyer = crate::content_auth::authenticate_request(
+ request.headers(),
+ &seller,
+ &Method::POST,
+ ROUTE,
+ &bytes,
+ chrono::Utc::now().timestamp(),
+ )?;
+ let operation: Operation = serde_json::from_slice(&bytes)?;
+ anyhow::ensure!(
+ operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
+ "Invoice peer identity mismatch"
+ );
+ anyhow::ensure!(
+ matches!(
+ operation.action.as_str(),
+ "create" | "status" | "cancel" | "download"
+ ),
+ "Invalid invoice action"
+ );
+ let binding = &operation.binding;
+ let journal = Journal::open(&self.config.data_dir).await?;
+ let mut saved = journal.seller(binding)?;
+ if saved.is_none() {
+ anyhow::ensure!(
+ operation.action == "create",
+ "Unknown original invoice operation"
+ );
+ anyhow::ensure!(
+ !binding.content_id.starts_with("registered_"),
+ "Registered rentals use their native purchase contract"
+ );
+ let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
+ let item = catalog
+ .items
+ .iter()
+ .find(|v| v.id == binding.content_id)
+ .context("Shared item unavailable")?;
+ let visible = match &item.availability {
+ crate::content_server::Availability::Nobody => false,
+ crate::content_server::Availability::AllPeers => true,
+ crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
+ };
+ anyhow::ensure!(visible, "Item is not shared with this buyer");
+ anyhow::ensure!(
+ matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
+ && crate::content_server::method_accepted(&item.access, "lightning"),
+ "Invoice price or accepted method changed"
+ );
+ crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
+ .await?;
+ let source = crate::content_server::content_file_path(&self.config.data_dir, item);
+ let roots = [
+ self.config.data_dir.join("content/files"),
+ self.config.data_dir.join("filebrowser"),
+ ];
+ let (root, relative) = roots
+ .iter()
+ .find_map(|root| {
+ source
+ .strip_prefix(root)
+ .ok()
+ .map(|p| (root.clone(), p.to_path_buf()))
+ })
+ .context("Unsupported invoice source root")?;
+ let data = self.config.data_dir.clone();
+ let id = binding.content_id.clone();
+ struct CancelCopy(std::sync::Arc);
+ impl Drop for CancelCopy {
+ fn drop(&mut self) {
+ self.0.store(true, std::sync::atomic::Ordering::SeqCst);
+ }
+ }
+ let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
+ false,
+ )));
+ let cancelled = cancel_copy.0.clone();
+ let snapshot = tokio::task::spawn_blocking(move || {
+ crate::content_snapshot::prepare(
+ &data,
+ &root,
+ &id,
+ &relative,
+ &crate::media_registration::Limits {
+ max_bytes: 64 * 1024 * 1024 * 1024,
+ cancelled: &cancelled,
+ },
+ 64 * 1024 * 1024 * 1024,
+ 512 * 1024 * 1024,
+ |_| Ok(()),
+ )
+ })
+ .await??;
+ anyhow::ensure!(
+ snapshot.size == item.size_bytes,
+ "Shared file changed before invoice"
+ );
+ // Source metadata is private and committed before AddInvoice dispatch.
+ let record = crate::content_server::publish_snapshot_invoice(
+ &self.config.data_dir,
+ item,
+ &journal,
+ binding.clone(),
+ crate::content_lightning::RetainedFile {
+ sha256: snapshot.sha256,
+ size: snapshot.size,
+ filename: item.filename.clone(),
+ mime_type: item.mime_type.clone(),
+ },
+ )
+ .await?;
+ saved = Some(record);
+ }
+ let mut saved = saved.context("Missing invoice operation")?;
+ anyhow::ensure!(
+ saved.source.is_some(),
+ "Original invoice source is not prepared; no new invoice dispatched"
+ );
+ let status = if operation.action == "cancel" && saved.phase == Phase::Prepared {
+ saved.phase = Phase::CanceledUnpaid;
+ journal.save_seller(&saved)?;
+ saved.status()
+ } else if operation.action != "create"
+ && operation.action != "cancel"
+ && saved.phase == Phase::Prepared
+ {
+ saved.status()
+ } else {
+ self.rpc_handler
+ .drive_external_invoice(&journal, binding, operation.action == "cancel")
+ .await?
+ };
+ // The original legacy delivery mechanism remains usable by its hash.
+ if status.bolt11.is_some() {
+ crate::content_invoice::record_pending(
+ &self.config.data_dir,
+ &status.payment_hash,
+ &binding.content_id,
+ binding.price_sats,
+ )
+ .await?;
+ if status.state == Phase::Settled {
+ crate::content_invoice::mark_paid(&self.config.data_dir, &status.payment_hash)
+ .await?;
+ }
+ }
+ if operation.action == "download" {
+ anyhow::ensure!(
+ status.state == Phase::Settled,
+ "Original invoice has not settled"
+ );
+ let source = status
+ .source
+ .as_ref()
+ .context("Original invoice snapshot is missing")?;
+ let data = self.config.data_dir.clone();
+ let id = binding.content_id.clone();
+ let retained = source.clone();
+ struct CancelCopy(std::sync::Arc);
+ impl Drop for CancelCopy {
+ fn drop(&mut self) {
+ self.0.store(true, std::sync::atomic::Ordering::SeqCst);
+ }
+ }
+ let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
+ false,
+ )));
+ let cancelled = cancel_copy.0.clone();
+ let snapshot = tokio::task::spawn_blocking(move || {
+ crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
+ })
+ .await??;
+ let stream = futures_util::stream::try_unfold(
+ (tokio::fs::File::from_std(snapshot.file), source.size),
+ |(mut file, left)| async move {
+ if left == 0 {
+ return Ok::<_, std::io::Error>(None);
+ }
+ let mut bytes = vec![0; left.min(65536) as usize];
+ let count = file.read(&mut bytes).await?;
+ if count == 0 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::UnexpectedEof,
+ "Original invoice snapshot ended early",
+ ));
+ }
+ bytes.truncate(count);
+ Ok(Some((bytes, (file, left - count as u64))))
+ },
+ );
+ return Ok(Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", &source.mime_type)
+ .header("Content-Length", source.size)
+ .header("Cache-Control", "private, no-store")
+ .body(Body::wrap_stream(stream))?);
+ }
+ Ok(build_response(
+ StatusCode::OK,
+ "application/json",
+ Body::from(serde_json::to_vec(&status)?),
+ ))
+ }
+}
diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs
index 77d7b8c1..cda073f5 100644
--- a/core/archipelago/src/api/handler/mod.rs
+++ b/core/archipelago/src/api/handler/mod.rs
@@ -3,6 +3,7 @@ mod cdp;
mod cloud_purchase;
mod content;
mod dwn;
+pub(crate) mod lightning_purchase;
mod model_proxy;
mod node_message;
mod proxy;
@@ -454,6 +455,9 @@ impl ApiHandler {
.await;
}
+ if method == Method::POST && path == lightning_purchase::ROUTE {
+ return self.handle_lightning_purchase(req).await;
+ }
// Purchase routes bound the original body before the generic buffer.
if method == Method::POST
&& matches!(
diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs
index f7f227f5..1cb6de68 100644
--- a/core/archipelago/src/api/rpc/dispatcher.rs
+++ b/core/archipelago/src/api/rpc/dispatcher.rs
@@ -337,6 +337,15 @@ impl RpcHandler {
"content.playback-start" => self.handle_playback_start(params, session_token).await,
"content.playback-status" => self.handle_playback_status(params, session_token).await,
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
+ "content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
+ "content.invoice-download" => self.handle_lightning_operation(params, "download").await,
+ "content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
+ "content.invoice-retry-native" => {
+ self.handle_lightning_operation(params, "retry").await
+ }
+ "content.invoice-create" => self.handle_lightning_operation(params, "create").await,
+ "content.invoice-recover" => self.handle_lightning_operation(params, "status").await,
+ "content.invoice-cancel" => self.handle_lightning_operation(params, "cancel").await,
"content.purchase" => self.handle_content_purchase(params).await,
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
"content.payment-status" => self.handle_content_payment_status(params).await,
diff --git a/core/archipelago/src/api/rpc/lightning_purchase.rs b/core/archipelago/src/api/rpc/lightning_purchase.rs
new file mode 100644
index 00000000..6d768478
--- /dev/null
+++ b/core/archipelago/src/api/rpc/lightning_purchase.rs
@@ -0,0 +1,356 @@
+use super::RpcHandler;
+use crate::{
+ api::handler::lightning_purchase::{Operation, ROUTE},
+ content_lightning::{Binding, BuyerRecord, Journal, Phase, Status},
+};
+use anyhow::{Context, Result};
+use serde::Deserialize;
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Params {
+ onion: String,
+ content_id: String,
+ price_sats: Option,
+ operation_id: Option,
+ #[serde(default)]
+ external_exposure: bool,
+}
+impl RpcHandler {
+ pub(super) async fn handle_lightning_operation(
+ &self,
+ params: Option,
+ action: &str,
+ ) -> Result {
+ let params: Params = serde_json::from_value(params.context("Missing invoice operation")?)?;
+ let peer =
+ crate::federation::load_unique_payment_peer(&self.config.data_dir, ¶ms.onion)
+ .await?;
+ let fips = peer
+ .fips_npub
+ .context("Seller has no authenticated mesh connection")?;
+ let buyer =
+ crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
+ .await?
+ .did_key()?;
+ anyhow::ensure!(buyer != peer.did, "Cannot buy a file from this same node");
+ let _admission = crate::content_payment_admission::lock(
+ &self.config.data_dir,
+ &buyer,
+ &peer.did,
+ ¶ms.content_id,
+ )
+ .await?;
+ if matches!(action, "create" | "pay" | "retry") || params.external_exposure {
+ let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
+ anyhow::ensure!(
+ cashu
+ .find_buyers(&buyer, &peer.did, ¶ms.content_id)
+ .await?
+ .iter()
+ .all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
+ "Recover or cancel the original Cashu purchase before exposing a Lightning invoice"
+ );
+ }
+ let journal = Journal::open(&self.config.data_dir).await?;
+ let original = if let Some(id) = ¶ms.operation_id {
+ journal.buyer(id)?
+ } else {
+ journal.buyer_for(&buyer, &peer.did, ¶ms.content_id)?
+ };
+ if let Some(record) = &original {
+ anyhow::ensure!(
+ record.binding.buyer_did == buyer
+ && record.binding.seller_did == peer.did
+ && record.binding.content_id == params.content_id
+ && record.seller_onion == params.onion,
+ "Original invoice belongs to another purchase"
+ );
+ }
+ if action == "lookup" {
+ return Ok(match original {
+ None => serde_json::json!({"attempt":null}),
+ Some(mut record) => {
+ let mut native_result = record.native_result.clone();
+ if native_result.is_none() && record.native_dispatched {
+ if let Some(status) = &record.last {
+ if let Ok(payment) = self
+ .handle_lnd_paymentstatus(Some(
+ serde_json::json!({"payment_hash":status.payment_hash}),
+ ))
+ .await
+ {
+ if let Some(result @ ("failed" | "succeeded")) =
+ payment["status"].as_str()
+ {
+ native_result = Some(result.to_owned());
+ record.native_result = native_result.clone();
+ journal.save_buyer(&record)?;
+ }
+ }
+ }
+ }
+ let native_failed =
+ !record.external_exposure && native_result.as_deref() == Some("failed");
+ let native_succeeded = native_result.as_deref() == Some("succeeded");
+ if !record.external_exposure {
+ if let Some(status) = record.last.as_mut() {
+ status.bolt11 = None;
+ }
+ }
+ serde_json::json!({"attempt":{"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"external_exposure":record.external_exposure,"native_failed":native_failed,"native_succeeded":native_succeeded,"status":record.last}})
+ }
+ });
+ }
+ let mut record = if let Some(record) = original {
+ record
+ } else {
+ anyhow::ensure!(
+ action == "create" && params.operation_id.is_none(),
+ "Original invoice operation is unavailable"
+ );
+ BuyerRecord {
+ binding: Binding {
+ id: uuid::Uuid::new_v4().to_string(),
+ buyer_did: buyer.clone(),
+ seller_did: peer.did.clone(),
+ content_id: params.content_id.clone(),
+ price_sats: params
+ .price_sats
+ .context("Expected invoice price is required")?,
+ },
+ seller_onion: params.onion.clone(),
+ external_exposure: false,
+ native_retired: false,
+ native_replacement: None,
+ native_dispatched: false,
+ native_result: None,
+ last: None,
+ }
+ };
+ anyhow::ensure!(
+ record.binding.buyer_did == buyer
+ && record.binding.seller_did == peer.did
+ && record.binding.content_id == params.content_id
+ && record.seller_onion == params.onion
+ && params
+ .operation_id
+ .as_ref()
+ .is_none_or(|id| id == &record.binding.id),
+ "Original invoice operation changed"
+ );
+ if action == "retry" {
+ anyhow::ensure!(
+ params.operation_id.is_some()
+ && !params.external_exposure
+ && params.price_sats == Some(record.binding.price_sats),
+ "Explicit original native retry and original price required"
+ );
+ if record.native_result.is_none()
+ && record.native_dispatched
+ && !record.external_exposure
+ {
+ let hash = &record
+ .last
+ .as_ref()
+ .context("Original invoice metadata missing")?
+ .payment_hash;
+ let payment = self
+ .handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
+ .await?;
+ if matches!(payment["status"].as_str(), Some("failed" | "succeeded")) {
+ record.native_result = payment["status"].as_str().map(str::to_owned);
+ journal.save_buyer(&record)?;
+ }
+ }
+ record = journal.retry_native(&record.binding.id)?;
+ }
+ anyhow::ensure!((!record.native_retired || matches!(action,"status"|"cancel"|"download")) && (!record.native_retired || !params.external_exposure),"This native invoice was retired before changing payment method; recover the replacement purchase");
+ if action == "pay" {
+ anyhow::ensure!(
+ params.operation_id.is_some(),
+ "Original invoice operation required for native payment"
+ );
+ return crate::content_lightning::drive_native(
+ &self.config.data_dir,
+ journal,
+ &record.binding.id,
+ &super::lnd::external_invoice::NativeNode(self),
+ )
+ .await;
+ }
+ record.external_exposure |= params.external_exposure;
+ journal.save_buyer(&record)?;
+ drop(journal);
+ // Native-only local FAILED never cancels an externally exposed invoice.
+ // The seller terminal state is authoritative regardless of UI receipt loss.
+ let operation = Operation {
+ binding: record.binding.clone(),
+ action: if action == "retry" {
+ "create".into()
+ } else {
+ action.into()
+ },
+ };
+ let response = crate::fips::dial::PeerRequest::new(Some(&fips), ¶ms.onion, ROUTE)
+ .require_fips()
+ .single_delivery()
+ .timeout(std::time::Duration::from_secs(if action == "download" {
+ 900
+ } else {
+ 45
+ }))
+ .send_content_json(&self.config.data_dir, &peer.did, &operation)
+ .await;
+ let mut response = match response {
+ Ok((r, _)) => r,
+ Err(_) => {
+ return Ok(
+ serde_json::json!({"state":"unknown","operation_id":record.binding.id,"recovery_required":true,"error":"The original invoice request is saved on this node. Recover it; no replacement invoice was requested."}),
+ )
+ }
+ };
+ anyhow::ensure!(
+ response.status().is_success(),
+ "Seller could not resolve original invoice; recover operation {}",
+ record.binding.id
+ );
+ let journal = Journal::open(&self.config.data_dir).await?;
+ if action == "download" {
+ let mut paid = record
+ .last
+ .clone()
+ .context("Original invoice metadata missing; recover it first")?;
+ let source = paid
+ .source
+ .clone()
+ .context("Original invoice snapshot missing")?;
+ anyhow::ensure!(
+ response.content_length() == Some(source.size),
+ "Original invoice file length changed"
+ );
+ paid.state = Phase::Settled;
+ paid.can_switch_method = false;
+ record.last = Some(paid);
+ journal.save_buyer(&record)?;
+ drop(journal);
+ let stream = crate::content_purchase_download::verified_stream(
+ response.bytes_stream(),
+ source.sha256,
+ source.size,
+ );
+ let owned = crate::content_owned::record_purchase_stream(
+ &self.config.data_dir,
+ crate::content_owned::OwnedItem {
+ onion: params.onion,
+ content_id: params.content_id,
+ filename: source.filename,
+ mime_type: source.mime_type,
+ size_bytes: source.size,
+ paid_sats: record.binding.price_sats,
+ ecash_backend: "lightning".into(),
+ purchased_at: chrono::Utc::now().to_rfc3339(),
+ download_complete: false,
+ },
+ Box::pin(stream),
+ Some(source.size),
+ )
+ .await?;
+ return Ok(
+ serde_json::json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
+ );
+ }
+ let mut bytes = Vec::new();
+ while let Some(chunk) = response.chunk().await? {
+ anyhow::ensure!(
+ bytes.len() + chunk.len() <= 16384,
+ "Invoice response too large"
+ );
+ bytes.extend_from_slice(&chunk)
+ }
+ let status: Status = serde_json::from_slice(&bytes)?;
+ anyhow::ensure!(
+ status.binding == record.binding
+ && status.source.is_some()
+ && status.payment_hash.len() == 64
+ && status.payment_hash.bytes().all(|b| b.is_ascii_hexdigit())
+ && status.can_switch_method == (status.state == Phase::CanceledUnpaid),
+ "Seller invoice binding changed"
+ );
+ if let Some(bolt11) = &status.bolt11 {
+ let invoice: lightning_invoice::Bolt11Invoice =
+ bolt11.parse().context("Seller invoice is invalid")?;
+ invoice.check_signature()?;
+ anyhow::ensure!(
+ invoice.payment_hash().to_string() == status.payment_hash
+ && invoice.amount_milli_satoshis()
+ == record.binding.price_sats.checked_mul(1000),
+ "Invoice hash or amount differs from saved purchase"
+ );
+ }
+ if let Some(previous) = &record.last {
+ anyhow::ensure!(
+ previous.payment_hash == status.payment_hash
+ && previous.source == status.source
+ && previous
+ .bolt11
+ .as_ref()
+ .is_none_or(|v| status.bolt11.as_ref() == Some(v)),
+ "Original invoice replaced"
+ );
+ }
+ record.last = Some(status.clone());
+ journal.save_buyer(&record)?;
+ Ok(
+ serde_json::json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"payment_hash":status.payment_hash,"bolt11":if record.external_exposure{status.bolt11}else{None},"state":match status.state{Phase::Settled=>"settled",Phase::CanceledUnpaid=>"canceled",Phase::Issued=>"open",Phase::Prepared=>"prepared",Phase::Dispatched=>"unknown",Phase::CancelRequested=>"cancel_requested"},"paid":status.state==Phase::Settled,"can_switch_method":status.can_switch_method,"cancel_supported":true,"external_exposure":record.external_exposure}),
+ )
+ }
+}
+
+impl RpcHandler {
+ /// Caller holds content_payment_admission before entering any rail journal.
+ pub(super) async fn ensure_invoice_allows_other_rail(
+ &self,
+ buyer: &str,
+ seller: &str,
+ content: &str,
+ ) -> Result<()> {
+ let journal = Journal::open(&self.config.data_dir).await?;
+ if let Some(mut record) = journal.buyer_for(buyer, seller, content)? {
+ anyhow::ensure!(record.native_replacement.is_none(),
+ "An explicit native retry is being recovered; recover its replacement operation first");
+ anyhow::ensure!(
+ !record.external_exposure,
+ "An externally payable invoice remains unresolved; cancel or recover it first"
+ );
+ let status = record
+ .last
+ .clone()
+ .context("Original invoice creation is unresolved; recover it first")?;
+ anyhow::ensure!(
+ status.state != Phase::Settled,
+ "Original Lightning purchase is paid; recover its file"
+ );
+ // A local terminal failure can release only a never-exposed native
+ // attempt. This check runs under the same outer lock as QR exposure.
+ anyhow::ensure!(
+ record.native_dispatched,
+ "Original invoice has not been canceled; cancel it before replacing the method"
+ );
+ if record.native_result.as_deref() != Some("failed") {
+ let payment = self
+ .handle_lnd_paymentstatus(Some(
+ serde_json::json!({"payment_hash":status.payment_hash}),
+ ))
+ .await?;
+ anyhow::ensure!(
+ payment["status"] == "failed",
+ "Original native Lightning attempt remains unresolved"
+ );
+ }
+ record.native_result = Some("failed".into());
+ record.native_retired = true;
+ journal.save_buyer(&record)?;
+ }
+ Ok(())
+ }
+}
diff --git a/core/archipelago/src/api/rpc/lnd/external_invoice.rs b/core/archipelago/src/api/rpc/lnd/external_invoice.rs
new file mode 100644
index 00000000..b142a19a
--- /dev/null
+++ b/core/archipelago/src/api/rpc/lnd/external_invoice.rs
@@ -0,0 +1,201 @@
+use super::LND_REST_BASE_URL;
+use crate::{
+ api::rpc::RpcHandler,
+ content_lightning::{Binding, Invoice, InvoiceNode, Journal, Status},
+};
+use anyhow::{Context, Result};
+use base64::Engine;
+struct Node {
+ client: reqwest::Client,
+ macaroon: String,
+}
+fn number(v: &serde_json::Value) -> Option {
+ v.as_u64().or_else(|| v.as_str()?.parse().ok())
+}
+impl InvoiceNode for Node {
+ async fn prepare_creation(&self) -> Result<()> {
+ let info: serde_json::Value = self
+ .client
+ .get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
+ .header("Grpc-Metadata-macaroon", &self.macaroon)
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ anyhow::ensure!(
+ info["identity_pubkey"]
+ .as_str()
+ .is_some_and(|key| !key.is_empty()),
+ "LND invoice service is not ready; original preparation retained"
+ );
+ Ok(())
+ }
+ async fn lookup(&self, hash: &str) -> Result