//! Private authenticated peer protocol, not a browser payment response. //! Registered invoice creation remains closed pending end-to-end integration. use crate::content_lightning::{Binding, Journal, Phase, Status}; use anyhow::{Context, Result}; use lightning_invoice::{Bolt11Invoice, Currency}; use serde::{Deserialize, Serialize}; const MAX_REQUEST_BYTES: usize = 16384; /// Private original-creation request, not a payment approval or status request. /// No server route is registered by this type/authenticator. #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct CreationRequest { version: u8, binding: Binding, network: String, prepared_payment_hash: String, } pub(crate) fn creation_path(binding: &Binding) -> Result { let status = recovery_path(binding)?; let prefix = status .strip_suffix("/lightning-status") .context("Invalid original path")?; Ok(format!("{prefix}/lightning-create")) } /// Caller has explicitly admitted creation under the shared operation lock. /// Journal dispatch persistence precedes any future peer await; payer consent /// and lease Start remain distinct. External network policy is not inferred. fn creation_request(journal: &Journal, binding: &Binding) -> Result { creation_path(binding)?; let currency = journal .buyer_rental_currency(binding)? .context("Original trusted rental network missing")?; let prepared_payment_hash = journal.admit_buyer_rental_creation(binding)?; Ok(CreationRequest { version: 1, binding: binding.clone(), network: crate::content_lightning::rental_network_name(¤cy).into(), prepared_payment_hash, }) } pub(crate) enum CreationDisposition { CreatePrepared(Status), RecoverOriginal(Status), } /// Validate retained producer authority before constructing an invoice adapter. /// This is not sufficient across an async preflight: the eventual Add commit /// must revalidate ready/withdrawal state under the shared admission lock. /// Caller has authenticated the request and holds the existing operation lock. /// Never allocate an original here: a missing journal/pin is not permission. /// Prepared requires current ready registration; dispatched/terminal originals /// must instead use lookup-only recovery, without a new publication decision. pub(crate) fn validate_original_creation( journal: &Journal, data_dir: &std::path::Path, identity: &crate::identity::NodeIdentity, authenticated_buyer: &str, request: &CreationRequest, ) -> Result { anyhow::ensure!(request.version == 1, "Unsupported rental creation"); creation_path(&request.binding)?; anyhow::ensure!( request.binding.buyer_did == authenticated_buyer && request.binding.seller_did == identity.did_key()?, "Rental creation principal changed" ); anyhow::ensure!( !journal.has_published_preparation(&request.binding)?, "Publication-bound preparation cannot use legacy creation; use original recovery" ); let original = journal .seller(&request.binding)? .context("Original prepared seller missing")?; let status = original.status(); anyhow::ensure!( status.payment_hash == request.prepared_payment_hash, "Original creation payment hash changed" ); let currency = journal .seller_rental_currency(&request.binding)? .context("Original seller network unavailable")?; anyhow::ensure!( crate::content_lightning::rental_network_name(¤cy) == request.network, "Original creation network changed" ); if status.state == Phase::Prepared { crate::registered_media::validate_lightning_invoice_registration( data_dir, identity, authenticated_buyer, &request.binding, )?; Ok(CreationDisposition::CreatePrepared(status)) } else { Ok(CreationDisposition::RecoverOriginal(status)) } } /// Authentication only, before opening any journal. This does NOT establish /// current producer readiness, original seller/hash/network agreement or Add /// permission; the future producer admission must independently check those. fn authenticate_creation( headers: &hyper::HeaderMap, method: &hyper::Method, path: &str, bytes: &[u8], seller: &str, now: i64, ) -> Result { anyhow::ensure!( method == hyper::Method::POST && bytes.len() <= MAX_REQUEST_BYTES, "Invalid rental creation request" ); let request: CreationRequest = serde_json::from_slice(bytes)?; anyhow::ensure!( request.version == 1 && creation_path(&request.binding)? == path, "Rental creation identity changed" ); let buyer = crate::content_auth::authenticate_request(headers, seller, method, path, bytes, now)?; anyhow::ensure!( request.binding.buyer_did == buyer && request.binding.seller_did == seller, "Rental creation principal changed" ); anyhow::ensure!( request.prepared_payment_hash.len() == 64 && request .prepared_payment_hash .bytes() .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')), "Invalid original prepared payment hash" ); anyhow::ensure!( [ Currency::Bitcoin, Currency::BitcoinTestnet, Currency::Regtest, Currency::Signet, Currency::Simnet ] .iter() .any(|currency| crate::content_lightning::rental_network_name(currency) == request.network), "Unsupported requested network" ); Ok(request) } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct RecoveryRequest { version: u8, binding: Binding, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct PreparationRequest { version: u8, binding: Binding, network: String, } /// Only the persisted original native pin can populate this financial field. pub(crate) fn preparation_request( journal: &Journal, binding: &Binding, ) -> Result { recovery_path(binding)?; let currency = journal .buyer_rental_currency(binding)? .context("Original trusted rental network missing")?; Ok(PreparationRequest { version: 1, binding: binding.clone(), network: crate::content_lightning::rental_network_name(¤cy).into(), }) } /// Private prerequisite only. Caller authenticates the full request first and /// supplies local info via the pinned native transport, not an app body. /// No provider is invoked and no public creation route is enabled here. pub(crate) async fn prepare_registered_seller( data_dir: &std::path::Path, identity: &crate::identity::NodeIdentity, authenticated_buyer: &str, request: &PreparationRequest, native_info: &serde_json::Value, ) -> Result { anyhow::ensure!(request.version == 1, "Unsupported rental preparation"); recovery_path(&request.binding)?; crate::registered_media::validate_lightning_invoice_registration( data_dir, identity, authenticated_buyer, &request.binding, )?; anyhow::ensure!( crate::content_lightning::native_rental_network(native_info)? == request.network, "Requested invoice network is not offered" ); let journal = Journal::open(data_dir).await?; Ok(journal .prepare_seller_rental_network(&request.binding, &request.network, native_info)? .status()) } pub(crate) fn recovery_request(binding: &Binding) -> Result { binding.validate()?; anyhow::ensure!(binding.rental.is_some(), "Not a timed invoice"); Ok(RecoveryRequest { version: 1, binding: binding.clone(), }) } pub(crate) fn recovery_path(binding: &Binding) -> Result { recovery_request(binding)?; let path = format!( "/content/{}/rental/{}/lightning-status", binding.content_id, binding.id ); anyhow::ensure!( is_recovery_path(&path), "Unsupported registered recovery identity" ); Ok(path) } pub(crate) fn is_recovery_path(path: &str) -> bool { let parts: Vec<_> = path.split('/').collect(); let uuid = |id: &str| { uuid::Uuid::parse_str(id).is_ok_and(|value| { value.get_version_num() == 4 && value.get_variant() == uuid::Variant::RFC4122 && value.to_string() == id }) }; parts.len() == 6 && parts[0].is_empty() && parts[1] == "content" && parts[2].strip_prefix("registered_").is_some_and(uuid) && parts[3] == "rental" && uuid(parts[4]) && parts[5] == "lightning-status" } fn authenticate_recovery( headers: &hyper::HeaderMap, method: &hyper::Method, path: &str, bytes: &[u8], seller: &str, now: i64, ) -> Result { anyhow::ensure!( method == hyper::Method::POST && is_recovery_path(path) && bytes.len() <= MAX_REQUEST_BYTES, "Invalid rental recovery request" ); let request: RecoveryRequest = serde_json::from_slice(bytes)?; anyhow::ensure!( request.version == 1 && recovery_path(&request.binding)? == path, "Rental recovery identity changed" ); let buyer = crate::content_auth::authenticate_request(headers, seller, method, path, bytes, now)?; anyhow::ensure!( request.binding.buyer_did == buyer && request.binding.seller_did == seller, "Rental recovery principal changed" ); Ok(request.binding) } /// Stored-status fixture path; production uses the original-only provider refresh. #[cfg(test)] pub(crate) async fn recover_http( data_dir: &std::path::Path, seller: &str, request: hyper::Request, now: i64, ) -> Result> { recover_http_with_refresh(seller, request, now, |binding| async move { let journal = Journal::open(data_dir).await?; producer_response(&journal, &binding, &binding.buyer_did, seller) }) .await } /// Invoked only behind the trusted peer listener gate. Authenticate the complete /// bounded envelope before invoking original-only refresh. The production callback /// may look up the original, but must never prepare, create, cancel or pay it. pub(crate) async fn recover_http_with_refresh( seller: &str, mut request: hyper::Request, now: i64, refresh: F, ) -> Result> where F: FnOnce(Binding) -> Fut, Fut: std::future::Future>, { use hyper::body::HttpBody; let method = request.method().clone(); let path = request .uri() .path_and_query() .context("Missing recovery path")? .as_str() .to_owned(); 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!( chunk.len() <= MAX_REQUEST_BYTES.saturating_sub(bytes.len()), "Rental recovery body exceeds limit" ); bytes.extend_from_slice(&chunk); } Ok::<_, anyhow::Error>(bytes) }) .await .context("Rental recovery request timed out")??; let binding = authenticate_recovery(request.headers(), &method, &path, &bytes, seller, now)?; let response = refresh(binding).await?; Ok(hyper::Response::builder() .status(hyper::StatusCode::OK) .header("Content-Type", "application/json") .header("Cache-Control", "private, no-store") .body(hyper::Body::from(serde_json::to_vec(&response)?))?) } // No Debug: capability belongs only on the authenticated native peer channel. #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Response { version: u8, status: Status, #[serde(default, skip_serializing_if = "Option::is_none")] capability: Option, } /// Original settlement may recover without another provider lookup. A missing /// capability is committed once before the response can leave the producer. pub(crate) fn producer_response( journal: &Journal, binding: &Binding, authenticated_buyer: &str, local_seller: &str, ) -> Result { binding.validate()?; anyhow::ensure!( binding.buyer_did == authenticated_buyer && binding.seller_did == local_seller && binding.rental.is_some(), "Original rental peer binding changed" ); let record = journal .seller(binding)? .context("Original rental invoice missing")?; anyhow::ensure!( record.source.is_none(), "Rental cannot expose a permanent-file source" ); // Legacy read compatibility is not preparation authority. A present but // incomplete/corrupt network pin must never expose an intermediate record. let _original_network = journal.seller_rental_currency(binding)?; let capability = if record.phase == Phase::Settled { Some(journal.rental_authority(binding)?.capability) } else { None }; Ok(Response { version: 1, status: record.status(), capability, }) } #[derive(Serialize)] #[serde(untagged)] pub(crate) enum BuyerInvoiceExchange { Create(CreationRequest), Recover(RecoveryRequest), } /// Caller holds the existing admission lock. Only the first pristine v3 /// Prepared operation can dispatch creation; replay is original status only. /// Persist the marker before returning bytes, then release/reopen the journal /// around peer IO. This does not establish publication or payer consent. pub(crate) fn plan_buyer_creation_exchange( journal: &Journal, binding: &Binding, seller_onion: &str, ) -> Result<(String, BuyerInvoiceExchange)> { let buyer = journal .buyer(&binding.id)? .context("Original buyer missing")?; anyhow::ensure!( buyer.binding == *binding && buyer.seller_onion == seller_onion, "Original creation endpoint changed" ); let first = matches!( journal.buyer_rental_creation_history(binding)?, crate::content_lightning::BuyerCreationHistory::Undispatched ) && !buyer.external_exposure && !buyer.native_dispatched && buyer.native_result.is_none() && buyer.native_replacement.is_none() && buyer .last .as_ref() .is_some_and(|status| status.state == Phase::Prepared); if first { Ok(( creation_path(binding)?, BuyerInvoiceExchange::Create(creation_request(journal, binding)?), )) } else { Ok(( recovery_path(binding)?, BuyerInvoiceExchange::Recover(recovery_request(binding)?), )) } } /// Recovery only: no creation, exposure, cancellation, native payment or lease. /// The original private network pin permits recovery even while LND is offline. pub(crate) async fn recover_buyer( data_dir: &std::path::Path, binding: &Binding, seller_onion: &str, ) -> Result { let identity = crate::identity::NodeIdentity::load_existing(&data_dir.join("identity")).await?; anyhow::ensure!( identity.did_key()? == binding.buyer_did, "Original buyer identity changed" ); let peer = crate::federation::load_unique_payment_peer(data_dir, seller_onion).await?; anyhow::ensure!( peer.did == binding.seller_did, "Original seller identity changed" ); let fips = peer .fips_npub .context("Original seller mesh identity missing")?; let path = recovery_path(binding)?; let request = recovery_request(binding)?; recover_buyer_with_exchange(data_dir, binding, seller_onion, async { let (mut response, _) = crate::fips::dial::PeerRequest::new(Some(&fips), seller_onion, &path) .require_fips() .single_delivery() .timeout(std::time::Duration::from_secs(45)) .send_content_json(data_dir, &peer.did, &request) .await?; anyhow::ensure!( response.status().is_success(), "Original rental recovery unavailable" ); let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await? { anyhow::ensure!( bytes.len().saturating_add(chunk.len()) <= MAX_REQUEST_BYTES, "Rental recovery response too large" ); bytes.extend_from_slice(&chunk); } Ok(bytes) }) .await } async fn recover_buyer_with_exchange( data_dir: &std::path::Path, binding: &Binding, seller_onion: &str, exchange: impl std::future::Future>>, ) -> Result { let currency = { let journal = Journal::open(data_dir).await?; let original = journal .buyer(&binding.id)? .context("Original buyer missing")?; anyhow::ensure!( original.binding == *binding && original.seller_onion == seller_onion, "Original recovery binding changed" ); journal .buyer_rental_currency(binding)? .context("Original trusted rental network missing")? }; // Release the exclusive journal before any remote/reentrant operation. let bytes = tokio::time::timeout(std::time::Duration::from_secs(45), exchange) .await .context("Original rental recovery timed out; no replacement requested")??; anyhow::ensure!( bytes.len() <= MAX_REQUEST_BYTES, "Rental recovery response too large" ); let response: Response = serde_json::from_slice(&bytes)?; let journal = Journal::open(data_dir).await?; let current = journal .buyer(&binding.id)? .context("Original buyer missing")?; anyhow::ensure!( current.binding == *binding && current.seller_onion == seller_onion && journal.buyer_rental_currency(binding)?.as_ref() == Some(¤cy), "Original recovery changed while awaiting seller" ); let mut status = accept_response(&journal, binding, &binding.seller_did, currency, response)?; if !current.external_exposure { status.bolt11 = None; } Ok(status) } /// Accept only a response from the original authenticated seller. Currency is /// supplied by trusted native wallet configuration, never browser input. This /// returns only legacy-safe Status, not the private transport response. pub(crate) fn accept_response( journal: &Journal, binding: &Binding, authenticated_seller: &str, currency: Currency, response: Response, ) -> Result { binding.validate()?; anyhow::ensure!( journal .buyer_rental_currency(binding)? .context("Original trusted rental network missing")? == currency, "Original trusted rental network changed" ); let status = &response.status; let canonical_hash = |value: &str| { value.len() == 64 && value .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) }; anyhow::ensure!( response.version == 1 && binding.rental.is_some() && authenticated_seller == binding.seller_did && status.binding == *binding && status.source.is_none() && canonical_hash(&status.payment_hash) && status.can_switch_method == (status.state == Phase::CanceledUnpaid), "Original rental invoice response changed" ); if let crate::content_lightning::BuyerCreationHistory::Dispatched(original_hash) = journal.buyer_rental_creation_history(binding)? { anyhow::ensure!( status.payment_hash == original_hash, "Original creation response hash changed" ); } anyhow::ensure!( response.capability.is_some() == (status.state == Phase::Settled) && response.capability.as_deref().is_none_or(canonical_hash), "Invalid private rental authority" ); anyhow::ensure!( status.bolt11.is_some() || !matches!(status.state, Phase::Issued | Phase::Settled), "Original rental invoice missing" ); if let Some(bolt11) = &status.bolt11 { let invoice: Bolt11Invoice = bolt11.parse().context("Invalid original rental invoice")?; invoice.check_signature()?; anyhow::ensure!( invoice.currency() == currency && invoice.payment_hash().to_string() == status.payment_hash && invoice.amount_milli_satoshis() == binding.price_sats.checked_mul(1000), "Original rental invoice amount, hash or currency changed" ); } let mut buyer = journal .buyer(&binding.id)? .context("Original invoice buyer missing")?; anyhow::ensure!( buyer.binding == *binding && !buyer.native_retired, "Original rental buyer changed" ); if let Some(previous) = &buyer.last { anyhow::ensure!( previous.payment_hash == status.payment_hash && previous.source == status.source && previous .bolt11 .as_ref() .is_none_or(|old| status.bolt11.as_ref() == Some(old)), "Original rental invoice was replaced" ); } // Crash between these writes leaves settled-but-no-grant, which the broker // refuses. Recovery requests the original producer grant, never new payment. buyer.last = Some(status.clone()); if status.bolt11.is_some() { if let Some(selection) = journal.checkout_buyer(&binding.id)? { crate::content_lightning::checkout::validate_invoice(&selection, &buyer, None)?; } } journal.save_buyer(&buyer)?; if let Some(capability) = &response.capability { journal.retain_buyer_rental_authority( binding, authenticated_seller, &status.payment_hash, capability, )?; } Ok(response.status) } #[cfg(test)] mod tests { use super::*; use crate::content_lightning::BuyerRecord; fn binding() -> Binding { Binding { id: uuid::Uuid::new_v4().to_string(), buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([1; 32])).unwrap(), seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([2; 32])).unwrap(), content_id: format!("registered_{}", uuid::Uuid::new_v4()), price_sats: 8, rental: Some(crate::registered_rental_terms::RegisteredRentalTerms { version: 1, content_sha256: "ab".repeat(32), content_size: 1024, terms_sha256: "cd".repeat(32), viewing_seconds: 3600, }), } } #[test] fn recovery_builder_matches_registered_delivery_identifier_grammar() { let original = binding(); assert!(is_recovery_path(&recovery_path(&original).unwrap())); for unsupported in [ "00000000-0000-7000-8000-000000000001", "00000000-0000-4000-0000-000000000001", ] { let mut changed = original.clone(); changed.id = unsupported.into(); assert!(recovery_path(&changed).is_err()); changed = original.clone(); changed.content_id = format!("registered_{unsupported}"); assert!(recovery_path(&changed).is_err()); } } // Deliberate owned-fixture corruption; production save_buyer must refuse it. fn corrupt_buyer_fixture(root: &std::path::Path, journal: &Journal, buyer: &BuyerRecord) { let changed = rewrite_fixture_payload(root, "buyer", &buyer.binding.id, |payload| { *payload = serde_json::to_value(buyer).unwrap(); }); // The deliberate semantic corruption must reach the real typed reader, // not fail earlier because the storage envelope/checksum is malformed. assert_eq!( serde_json::to_value(journal.buyer(&buyer.binding.id).unwrap().unwrap()).unwrap(), changed ); } async fn creation_fixture(root: &std::path::Path) -> (Binding, BuyerRecord) { let b = binding(); let journal = Journal::open(root).await.unwrap(); let mut buyer = BuyerRecord { binding: b.clone(), seller_onion: "original.onion".into(), external_exposure: false, native_retired: false, native_replacement: None, native_dispatched: false, native_result: None, last: None, }; journal .prepare_buyer_rental_native_network( &buyer, &serde_json::json!({"chains":[{"chain":"bitcoin","network":"regtest"}]}), ) .unwrap(); buyer.last = Some(Status { binding: b.clone(), payment_hash: "ef".repeat(32), bolt11: None, state: Phase::Prepared, can_switch_method: false, source: None, }); journal.save_buyer(&buyer).unwrap(); (b, buyer) } #[tokio::test] async fn creation_plan_commits_once_and_lost_reply_reopens_as_original_recovery() { let root = tempfile::tempdir().unwrap(); let (b, mut buyer) = creation_fixture(root.path()).await; let journal = Journal::open(root.path()).await.unwrap(); assert!(plan_buyer_creation_exchange(&journal, &b, "replacement.onion").is_err()); buyer.last.as_mut().unwrap().can_switch_method = true; assert!(journal.save_buyer(&buyer).is_err()); corrupt_buyer_fixture(root.path(), &journal, &buyer); assert!(plan_buyer_creation_exchange(&journal, &b, "original.onion").is_err()); assert!(matches!( journal.buyer_rental_creation_history(&b).unwrap(), crate::content_lightning::BuyerCreationHistory::Undispatched )); buyer.last.as_mut().unwrap().can_switch_method = false; journal.save_buyer(&buyer).unwrap(); let (path, plan) = plan_buyer_creation_exchange(&journal, &b, "original.onion").unwrap(); assert_eq!(path, creation_path(&b).unwrap()); assert!(matches!(plan, BuyerInvoiceExchange::Create(_))); drop(journal); // A lost reply cannot turn reload into another creation. let journal = Journal::open(root.path()).await.unwrap(); let (path, plan) = plan_buyer_creation_exchange(&journal, &b, "original.onion").unwrap(); assert_eq!(path, recovery_path(&b).unwrap()); assert!(matches!(plan, BuyerInvoiceExchange::Recover(_))); let buyer = journal.buyer(&b.id).unwrap().unwrap(); assert!(!buyer.native_dispatched && !buyer.external_exposure); assert_eq!(buyer.last.unwrap().payment_hash, "ef".repeat(32)); } #[tokio::test] async fn legacy_creation_plan_is_recovery_only_without_upgrading_history() { let root = tempfile::tempdir().unwrap(); let (b, _) = creation_fixture(root.path()).await; let file = root .path() .join("content-lightning") .join(format!("rental-network-{}.json", b.id)); let legacy = rewrite_fixture_payload(root.path(), "rental-network", &b.id, |pin| { pin["version"] = serde_json::json!(2); pin.as_object_mut().unwrap().remove("creation"); }); assert_eq!(legacy["version"], 2); assert!(legacy.get("creation").is_none()); let original = std::fs::read(&file).unwrap(); let journal = Journal::open(root.path()).await.unwrap(); assert!(matches!( journal.buyer_rental_creation_history(&b).unwrap(), crate::content_lightning::BuyerCreationHistory::LegacyRecoveryOnly )); let (path, plan) = plan_buyer_creation_exchange(&journal, &b, "original.onion").unwrap(); assert_eq!(path, recovery_path(&b).unwrap()); assert!(matches!(plan, BuyerInvoiceExchange::Recover(_))); assert_eq!(std::fs::read(&file).unwrap(), original); } #[tokio::test] async fn retained_creation_hash_refuses_substitution_even_without_cached_status() { let root = tempfile::tempdir().unwrap(); let (b, mut buyer) = creation_fixture(root.path()).await; let journal = Journal::open(root.path()).await.unwrap(); let _ = plan_buyer_creation_exchange(&journal, &b, "original.onion").unwrap(); buyer.last = None; assert!(journal.save_buyer(&buyer).is_err()); corrupt_buyer_fixture(root.path(), &journal, &buyer); let response = |hash: String| Response { version: 1, capability: None, status: Status { binding: b.clone(), payment_hash: hash, bolt11: None, state: Phase::Prepared, can_switch_method: false, source: None, }, }; assert!(accept_response( &journal, &b, &b.seller_did, Currency::Regtest, response("cd".repeat(32)) ) .is_err()); assert!(journal.buyer(&b.id).unwrap().unwrap().last.is_none()); assert_eq!( accept_response( &journal, &b, &b.seller_did, Currency::Regtest, response("ef".repeat(32)) ) .unwrap() .payment_hash, "ef".repeat(32) ); buyer.last = Some(response("cd".repeat(32)).status); assert!(journal.save_buyer(&buyer).is_err()); corrupt_buyer_fixture(root.path(), &journal, &buyer); assert!(journal.buyer_rental_currency(&b).is_err()); } #[tokio::test] async fn recovery_refresh_runs_only_after_full_authentication_and_propagates_failure() { use std::sync::atomic::{AtomicUsize, Ordering}; let root = tempfile::tempdir().unwrap(); let identity = crate::identity::NodeIdentity::load_or_create(root.path()) .await .unwrap(); let mut b = binding(); b.buyer_did = identity.did_key().unwrap(); let path = recovery_path(&b).unwrap(); let bytes = serde_json::to_vec(&recovery_request(&b).unwrap()).unwrap(); let calls = AtomicUsize::new(0); let unsigned = hyper::Request::builder() .method("POST") .uri(&path) .body(hyper::Body::from(bytes.clone())) .unwrap(); let mut wrong_method = signed_request(&identity, &b, &path, bytes.clone()); *wrong_method.method_mut() = hyper::Method::GET; let oversized = signed_request(&identity, &b, &path, vec![b' '; MAX_REQUEST_BYTES + 1]); let wrong_path = signed_request(&identity, &b, &format!("{path}?changed=1"), bytes.clone()); for request in [unsigned, wrong_method, oversized, wrong_path] { assert!( recover_http_with_refresh(&b.seller_did, request, 1000, |_| async { calls.fetch_add(1, Ordering::SeqCst); anyhow::bail!("unauthenticated provider invocation") }) .await .is_err() ); } assert_eq!(calls.load(Ordering::SeqCst), 0); let response = recover_http_with_refresh( &b.seller_did, signed_request(&identity, &b, &path, bytes.clone()), 1000, |original| { assert_eq!(original, b); calls.fetch_add(1, Ordering::SeqCst); async move { Ok(Response { version: 1, status: Status { binding: original, payment_hash: "ab".repeat(32), bolt11: None, state: Phase::Prepared, can_switch_method: false, source: None, }, capability: None, }) } }, ) .await .unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(response.headers()["cache-control"], "private, no-store"); let response: Response = serde_json::from_slice(&hyper::body::to_bytes(response.into_body()).await.unwrap()) .unwrap(); assert_eq!(response.status.binding, b); assert!(response.capability.is_none()); let failure = recover_http_with_refresh( &b.seller_did, signed_request(&identity, &b, &path, bytes), 1000, |_| async { anyhow::bail!("original provider unavailable") }, ) .await .unwrap_err(); assert!(failure .to_string() .contains("original provider unavailable")); } #[tokio::test] async fn creation_request_retains_original_dispatch_before_returning_private_body() { let root = tempfile::tempdir().unwrap(); let b = binding(); let journal = Journal::open(root.path()).await.unwrap(); let mut buyer = BuyerRecord { binding: b.clone(), seller_onion: "original.onion".into(), external_exposure: false, native_retired: false, native_replacement: None, native_dispatched: false, native_result: None, last: None, }; let info = serde_json::json!({"chains":[{"chain":"bitcoin","network":"regtest"}]}); journal .prepare_buyer_rental_native_network(&buyer, &info) .unwrap(); assert!(creation_request(&journal, &b).is_err()); buyer.last = Some(Status { binding: b.clone(), payment_hash: "ef".repeat(32), bolt11: None, state: Phase::Prepared, can_switch_method: false, source: None, }); journal.save_buyer(&buyer).unwrap(); let request = creation_request(&journal, &b).unwrap(); let original = serde_json::to_vec(&request).unwrap(); assert_eq!(request.prepared_payment_hash, "ef".repeat(32)); assert_eq!(request.network, "regtest"); assert!(!journal.buyer(&b.id).unwrap().unwrap().native_dispatched); drop(journal); let journal = Journal::open(root.path()).await.unwrap(); assert_eq!( serde_json::to_vec(&creation_request(&journal, &b).unwrap()).unwrap(), original ); assert!(serde_json::from_slice::(&original).is_err()); } #[tokio::test] async fn creation_auth_binds_original_path_body_and_principals_without_status_confusion() { let root = tempfile::tempdir().unwrap(); let identity = crate::identity::NodeIdentity::load_or_create(root.path()) .await .unwrap(); let mut b = binding(); b.buyer_did = identity.did_key().unwrap(); let wire = CreationRequest { version: 1, binding: b.clone(), network: "regtest".into(), prepared_payment_hash: "ab".repeat(32), }; let bytes = serde_json::to_vec(&wire).unwrap(); let path = creation_path(&b).unwrap(); let signed = signed_request(&identity, &b, &path, bytes.clone()); assert!(authenticate_creation( signed.headers(), &hyper::Method::POST, &path, &bytes, &b.seller_did, 1000 ) .is_ok()); assert!(authenticate_creation( signed.headers(), &hyper::Method::GET, &path, &bytes, &b.seller_did, 1000 ) .is_err()); assert!(authenticate_creation( signed.headers(), &hyper::Method::POST, &recovery_path(&b).unwrap(), &bytes, &b.seller_did, 1000 ) .is_err()); assert!(authenticate_recovery( signed.headers(), &hyper::Method::POST, &path, &bytes, &b.seller_did, 1000 ) .is_err()); let mut changed = serde_json::to_value(&wire).unwrap(); changed["prepared_payment_hash"] = serde_json::json!("cd".repeat(32)); assert!(authenticate_creation( signed.headers(), &hyper::Method::POST, &path, &serde_json::to_vec(&changed).unwrap(), &b.seller_did, 1000 ) .is_err()); changed["extra"] = serde_json::json!(true); assert!(serde_json::from_value::(changed).is_err()); assert!(authenticate_creation( signed.headers(), &hyper::Method::POST, &path, &vec![b' '; MAX_REQUEST_BYTES + 1], &b.seller_did, 1000 ) .is_err()); } #[tokio::test] async fn original_creation_validation_refuses_unready_prepared_and_recovers_dispatched_only() { let root = tempfile::tempdir().unwrap(); let identity = crate::identity::NodeIdentity::load_or_create(root.path()) .await .unwrap(); let mut b = binding(); b.seller_did = identity.did_key().unwrap(); let journal = Journal::open(root.path()).await.unwrap(); let info = serde_json::json!({"chains":[{"chain":"bitcoin","network":"regtest"}]}); let mut original = journal .prepare_seller_rental_network(&b, "regtest", &info) .unwrap(); let mut request = CreationRequest { version: 1, binding: b.clone(), network: "regtest".into(), prepared_payment_hash: original.payment_hash.clone(), }; // No signed ready registration: preparation alone cannot admit Add. assert!(validate_original_creation( &journal, root.path(), &identity, &b.buyer_did, &request ) .is_err()); assert_eq!(journal.seller(&b).unwrap().unwrap().phase, Phase::Prepared); original.phase = Phase::Dispatched; journal.save_seller(&original).unwrap(); assert!( matches!(validate_original_creation(&journal, root.path(), &identity, &b.buyer_did, &request).unwrap(), CreationDisposition::RecoverOriginal(status) if status.state == Phase::Dispatched) ); request.prepared_payment_hash = "01".repeat(32); assert!(validate_original_creation( &journal, root.path(), &identity, &b.buyer_did, &request ) .is_err()); request.prepared_payment_hash = original.payment_hash.clone(); request.network = "mainnet".into(); assert!(validate_original_creation( &journal, root.path(), &identity, &b.buyer_did, &request ) .is_err()); request.network = "regtest".into(); assert!(validate_original_creation( &journal, root.path(), &identity, &b.seller_did, &request ) .is_err()); assert_eq!( journal.seller(&b).unwrap().unwrap().phase, Phase::Dispatched ); assert_eq!( journal.seller(&b).unwrap().unwrap().payment_hash, original.payment_hash ); } fn assert_only_invoice_journal(data: &std::path::Path) { let entries: Vec<_> = std::fs::read_dir(data) .unwrap() .map(|entry| entry.unwrap().file_name()) .collect(); assert_eq!(entries, vec![std::ffi::OsString::from("content-lightning")]); assert!(!data.join("content-entitlements").exists()); } fn signed_request( identity: &crate::identity::NodeIdentity, b: &Binding, path: &str, bytes: Vec, ) -> hyper::Request { let proof = crate::content_auth::sign_request( identity, &b.seller_did, &hyper::Method::POST, path, &bytes, 1000, ) .unwrap(); hyper::Request::builder() .method(hyper::Method::POST) .uri(path) .header(crate::content_auth::REQUEST_HEADER, proof) .body(hyper::Body::from(bytes)) .unwrap() } #[tokio::test] async fn authenticated_http_recovers_original_grant_without_dispatch_or_lease() { tokio::time::timeout(std::time::Duration::from_secs(10), async { let seller_dir = tempfile::tempdir().unwrap(); let buyer_dir = tempfile::tempdir().unwrap(); let identity = crate::identity::NodeIdentity::load_or_create(buyer_dir.path()) .await .unwrap(); let mut b = binding(); b.buyer_did = identity.did_key().unwrap(); let journal = Journal::open(seller_dir.path()).await.unwrap(); journal.prepare_seller(b.clone()).unwrap(); // Journal owns an exclusive flock: release fixture ownership before // exercising the production HTTP entry point, which opens it itself. drop(journal); eprintln!("recovery fixture: Prepared seed journal released before HTTP"); let path = recovery_path(&b).unwrap(); let bytes = serde_json::to_vec(&recovery_request(&b).unwrap()).unwrap(); let response = recover_http( seller_dir.path(), &b.seller_did, signed_request(&identity, &b, &path, bytes.clone()), 1000, ) .await .unwrap(); let response: Response = serde_json::from_slice(&hyper::body::to_bytes(response.into_body()).await.unwrap()) .unwrap(); assert_eq!(response.status.state, Phase::Prepared); assert!(response.status.bolt11.is_none() && response.capability.is_none()); let journal = Journal::open(seller_dir.path()).await.unwrap(); assert_eq!(journal.seller(&b).unwrap().unwrap().phase, Phase::Prepared); let original_hash = response.status.payment_hash.clone(); // No provider capability/configuration was supplied, no invoice was // dispatched, and no entitlement/registration/lease namespace appeared. assert_only_invoice_journal(seller_dir.path()); set_phase(&journal, &b, Phase::Settled); drop(journal); eprintln!("recovery fixture: Settled seed journal released before HTTP"); let mut grant = None; for _ in 0..2 { let response = recover_http( seller_dir.path(), &b.seller_did, signed_request(&identity, &b, &path, bytes.clone()), 1000, ) .await .unwrap(); assert_eq!(response.headers()["cache-control"], "private, no-store"); let response: Response = serde_json::from_slice( &hyper::body::to_bytes(response.into_body()).await.unwrap(), ) .unwrap(); assert_eq!(response.status.binding, b); assert_eq!(response.status.payment_hash, original_hash); assert!(response.status.source.is_none()); assert_only_invoice_journal(seller_dir.path()); let current = response.capability.unwrap(); if let Some(original) = &grant { assert_eq!(original, ¤t); } grant = Some(current); } let reopened = Journal::open(seller_dir.path()).await.unwrap(); assert_eq!( reopened.rental_authority(&b).unwrap().capability, grant.unwrap() ); }) .await .expect("HTTP recovery fixture exceeded its lock-lifetime deadline"); } #[tokio::test] async fn http_recovery_rejects_unsigned_changed_or_unknown_originals() { let data = tempfile::tempdir().unwrap(); let identity_dir = tempfile::tempdir().unwrap(); let identity = crate::identity::NodeIdentity::load_or_create(identity_dir.path()) .await .unwrap(); let mut b = binding(); b.buyer_did = identity.did_key().unwrap(); let path = recovery_path(&b).unwrap(); let bytes = serde_json::to_vec(&recovery_request(&b).unwrap()).unwrap(); let unsigned = hyper::Request::builder() .method(hyper::Method::POST) .uri(&path) .body(hyper::Body::from(bytes.clone())) .unwrap(); assert!(recover_http(data.path(), &b.seller_did, unsigned, 1000) .await .is_err()); let mut changed = signed_request(&identity, &b, &path, bytes.clone()); *changed.body_mut() = hyper::Body::from(b"{}".to_vec()); assert!(recover_http(data.path(), &b.seller_did, changed, 1000) .await .is_err()); for bad_path in [ format!("{path}?create=1"), format!("{path}/extra"), path.replace("lightning-status", "create"), ] { assert!(!is_recovery_path(&bad_path)); assert!(recover_http( data.path(), &b.seller_did, signed_request(&identity, &b, &bad_path, bytes.clone()), 1000 ) .await .is_err()); } assert!(recover_http( data.path(), &b.seller_did, signed_request(&identity, &b, &path, bytes), 1000 ) .await .is_err()); assert!(Journal::open(data.path()) .await .unwrap() .seller(&b) .unwrap() .is_none()); } #[tokio::test] async fn rejected_http_cannot_issue_a_settled_grant_or_change_the_original() { tokio::time::timeout(std::time::Duration::from_secs(10), async { let data = tempfile::tempdir().unwrap(); let buyer_dir = tempfile::tempdir().unwrap(); let foreign_dir = tempfile::tempdir().unwrap(); let buyer = crate::identity::NodeIdentity::load_or_create(buyer_dir.path()) .await .unwrap(); let foreign = crate::identity::NodeIdentity::load_or_create(foreign_dir.path()) .await .unwrap(); let mut b = binding(); b.buyer_did = buyer.did_key().unwrap(); let journal = Journal::open(data.path()).await.unwrap(); journal.prepare_seller(b.clone()).unwrap(); set_phase(&journal, &b, Phase::Settled); drop(journal); eprintln!("rejection fixture: Settled seed journal released before HTTP"); let path = recovery_path(&b).unwrap(); let bytes = serde_json::to_vec(&recovery_request(&b).unwrap()).unwrap(); assert!(recover_http( data.path(), &b.seller_did, signed_request(&foreign, &b, &path, bytes.clone()), 1000 ) .await .is_err()); let mut wrong_method = signed_request(&buyer, &b, &path, bytes.clone()); *wrong_method.method_mut() = hyper::Method::GET; assert!(recover_http(data.path(), &b.seller_did, wrong_method, 1000) .await .is_err()); assert!(recover_http( data.path(), &b.seller_did, signed_request(&buyer, &b, &path, bytes.clone()), 2000 ) .await .is_err()); let mut changed = b.clone(); changed.price_sats += 1; let changed_bytes = serde_json::to_vec(&recovery_request(&changed).unwrap()).unwrap(); assert!(recover_http( data.path(), &b.seller_did, signed_request(&buyer, &b, &path, changed_bytes), 1000 ) .await .is_err()); for (field, value) in [ ("version", serde_json::json!(2)), ("action", serde_json::json!("create")), ("action", serde_json::json!("cancel")), ("action", serde_json::json!("pay")), ("action", serde_json::json!("download")), ] { let mut wire: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); wire[field] = value; let changed = signed_request(&buyer, &b, &path, serde_json::to_vec(&wire).unwrap()); assert!(recover_http(data.path(), &b.seller_did, changed, 1000) .await .is_err()); } let oversized = signed_request(&buyer, &b, &path, vec![b' '; MAX_REQUEST_BYTES + 1]); assert!(recover_http(data.path(), &b.seller_did, oversized, 1000) .await .is_err()); let journal = Journal::open(data.path()).await.unwrap(); let record = journal.seller(&b).unwrap().unwrap(); assert_eq!(record.binding, b); assert_eq!(record.phase, Phase::Settled); assert_only_invoice_journal(data.path()); assert!(serde_json::to_value(&record) .unwrap() .get("rental_capability") .is_none()); }) .await .expect("Rejected HTTP fixture exceeded its lock-lifetime deadline"); } fn invoice(hash: &str, price: u64, currency: Currency) -> String { use std::str::FromStr; let key = bitcoin::secp256k1::SecretKey::from_slice(&[7; 32]).unwrap(); lightning_invoice::InvoiceBuilder::new(currency) .description("isolated rental fixture".into()) .payment_hash(bitcoin::hashes::sha256::Hash::from_str(hash).unwrap()) .payment_secret(lightning_invoice::PaymentSecret([42; 32])) .amount_milli_satoshis(price * 1000) .duration_since_epoch(std::time::Duration::from_secs(1000)) .min_final_cltv_expiry_delta(144) .build_signed(|hash| { bitcoin::secp256k1::Secp256k1::new().sign_ecdsa_recoverable(hash, &key) }) .unwrap() .to_string() } fn seed_buyer(journal: &Journal, b: &Binding) { journal .prepare_buyer_rental_native_network( &pristine_buyer(b), &serde_json::json!({"chains":[{"chain":"bitcoin","network":"mainnet"}]}), ) .unwrap(); } fn seed_unpinned_buyer(journal: &Journal, b: &Binding) { journal.save_buyer(&pristine_buyer(b)).unwrap(); } fn pristine_buyer(b: &Binding) -> BuyerRecord { BuyerRecord { binding: b.clone(), seller_onion: "original.onion".into(), external_exposure: false, native_retired: false, native_replacement: None, native_dispatched: false, native_result: None, last: None, } } #[tokio::test] async fn buyer_recovery_releases_journal_and_keeps_native_invoice_private() { tokio::time::timeout(std::time::Duration::from_secs(10), async { let data = tempfile::tempdir().unwrap(); let seller_data = tempfile::tempdir().unwrap(); let b = binding(); let buyer = Journal::open(data.path()).await.unwrap(); seed_buyer(&buyer, &b); drop(buyer); let seller = Journal::open(seller_data.path()).await.unwrap(); seller.prepare_seller(b.clone()).unwrap(); set_phase(&seller, &b, Phase::Settled); let status = recover_buyer_with_exchange(data.path(), &b, "original.onion", async { let reentrant = Journal::open(data.path()).await?; assert!(reentrant.buyer(&b.id)?.unwrap().last.is_none()); drop(reentrant); Ok(wire(&seller, &b)) }) .await .unwrap(); assert_eq!(status.state, Phase::Settled); assert!(status.bolt11.is_none()); assert!(!serde_json::to_string(&status) .unwrap() .contains("capability")); let buyer = Journal::open(data.path()).await.unwrap(); assert!(buyer .buyer(&b.id) .unwrap() .unwrap() .last .unwrap() .bolt11 .is_some()); assert!(buyer.buyer_rental_authority(&b).unwrap().is_some()); assert_only_invoice_journal(data.path()); }) .await .expect("bounded journal release and recovery"); } #[tokio::test] async fn buyer_recovery_missing_pin_and_invalid_bodies_do_not_mutate_original() { let data = tempfile::tempdir().unwrap(); let b = binding(); let journal = Journal::open(data.path()).await.unwrap(); seed_unpinned_buyer(&journal, &b); drop(journal); let called = std::cell::Cell::new(false); assert!( recover_buyer_with_exchange(data.path(), &b, "original.onion", async { called.set(true); Ok(Vec::new()) }) .await .is_err() ); assert!(!called.get()); let pinned = tempfile::tempdir().unwrap(); let journal = Journal::open(pinned.path()).await.unwrap(); seed_buyer(&journal, &b); drop(journal); for bytes in [vec![0; MAX_REQUEST_BYTES + 1], b"{}".to_vec()] { assert!( recover_buyer_with_exchange(pinned.path(), &b, "original.onion", async { Ok(bytes) }) .await .is_err() ); let journal = Journal::open(pinned.path()).await.unwrap(); assert!(journal.buyer(&b.id).unwrap().unwrap().last.is_none()); assert!(journal.buyer_rental_authority(&b).unwrap().is_none()); } } #[tokio::test] async fn native_network_pin_survives_reopen_and_refuses_rebinding() { let data = tempfile::tempdir().unwrap(); let b = binding(); let journal = Journal::open(data.path()).await.unwrap(); seed_buyer(&journal, &b); assert_eq!( journal.buyer_rental_currency(&b).unwrap(), Some(Currency::Bitcoin) ); assert!(journal .prepare_buyer_rental_native_network( &pristine_buyer(&b), &serde_json::json!({"chains":[{"chain":"bitcoin","network":"testnet"}]}) ) .is_err()); let mut changed = b.clone(); changed.price_sats += 1; assert!(journal.buyer_rental_currency(&changed).is_err()); drop(journal); let reopened = Journal::open(data.path()).await.unwrap(); assert_eq!( reopened.buyer_rental_currency(&b).unwrap(), Some(Currency::Bitcoin) ); assert!(reopened.buyer(&b.id).unwrap().unwrap().last.is_none()); assert!(reopened.buyer_rental_authority(&b).unwrap().is_none()); } #[tokio::test] async fn network_pin_rejects_ambiguous_info_and_post_exposure_inference() { let data = tempfile::tempdir().unwrap(); let b = binding(); let journal = Journal::open(data.path()).await.unwrap(); seed_unpinned_buyer(&journal, &b); for info in [ serde_json::json!({}), serde_json::json!({"chains":[]}), serde_json::json!({"chains":[{"chain":"litecoin","network":"mainnet"}]}), serde_json::json!({"chains":[{"chain":"bitcoin","network":"unknown"}]}), serde_json::json!({"chains":[{"chain":"bitcoin","network":"mainnet"},{"chain":"bitcoin","network":"testnet"}]}), ] { assert!(journal .prepare_buyer_rental_native_network(&pristine_buyer(&b), &info) .is_err()); assert_eq!(journal.buyer_rental_currency(&b).unwrap(), None); } // Even a pristine-looking saved buyer may have lost a creation reply. assert!(journal .prepare_buyer_rental_native_network( &pristine_buyer(&b), &serde_json::json!({"chains":[{"chain":"bitcoin","network":"mainnet"}]}) ) .is_err()); let mut buyer = journal.buyer(&b.id).unwrap().unwrap(); buyer.external_exposure = true; journal.save_buyer(&buyer).unwrap(); assert!(journal .prepare_buyer_rental_native_network( &pristine_buyer(&b), &serde_json::json!({"chains":[{"chain":"bitcoin","network":"mainnet"}]}) ) .is_err()); assert_eq!(journal.buyer_rental_currency(&b).unwrap(), None); } // Owned fixture mutation only. Keep semantic corruption distinct from the // separately tested damaged-envelope/checksum refusal. Production is unchanged. fn rewrite_fixture_payload( data: &std::path::Path, role: &str, id: &str, change: impl FnOnce(&mut serde_json::Value), ) -> serde_json::Value { use sha2::Digest; let path = data .join("content-lightning") .join(format!("{role}-{id}.json")); let mut envelope: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(envelope.as_object().unwrap().len(), 2); let original = envelope["payload"].as_str().unwrap(); assert_eq!( hex::encode(sha2::Sha256::digest(original.as_bytes())), envelope["checksum"].as_str().unwrap() ); let mut value: serde_json::Value = serde_json::from_str(original).unwrap(); assert!(value.is_object()); change(&mut value); let payload = serde_json::to_string(&value).unwrap(); envelope["checksum"] = hex::encode(sha2::Sha256::digest(payload.as_bytes())).into(); envelope["payload"] = payload.into(); std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); let stored: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); let payload = stored["payload"].as_str().unwrap(); assert_eq!( hex::encode(sha2::Sha256::digest(payload.as_bytes())), stored["checksum"].as_str().unwrap() ); assert_eq!( serde_json::from_str::(payload).unwrap(), value ); value } fn set_pin_uncommitted(data: &std::path::Path, role: &str, id: &str) { rewrite_fixture_payload(data, role, id, |pin| pin["committed"] = false.into()); } #[tokio::test] async fn seller_network_is_immutable_and_missing_committed_history_cannot_generate_new_preimage( ) { let data = tempfile::tempdir().unwrap(); let b = binding(); let info = serde_json::json!({"chains":[{"chain":"bitcoin","network":"mainnet"}]}); let journal = Journal::open(data.path()).await.unwrap(); let seller = journal .prepare_seller_rental_network(&b, "mainnet", &info) .unwrap(); assert_eq!(seller.phase, Phase::Prepared); assert!(seller.bolt11.is_none()); assert_eq!( journal .prepare_seller_rental_network(&b, "mainnet", &info) .unwrap() .payment_hash, seller.payment_hash ); let changed = serde_json::json!({"chains":[{"chain":"bitcoin","network":"testnet"}]}); assert!(journal .prepare_seller_rental_network(&b, "testnet", &changed) .is_err()); assert!(journal .prepare_seller_rental_network(&b, "testnet", &info) .is_err()); set_pin_uncommitted(data.path(), "rental-seller-network", &b.id); assert!(producer_response(&journal, &b, &b.buyer_did, &b.seller_did).is_err()); assert_eq!( journal .prepare_seller_rental_network(&b, "mainnet", &info) .unwrap() .payment_hash, seller.payment_hash ); std::fs::remove_file( data.path() .join("content-lightning") .join(format!("seller-{}.json", b.id)), ) .unwrap(); assert!(journal .prepare_seller_rental_network(&b, "mainnet", &info) .is_err()); assert!(journal.seller(&b).unwrap().is_none()); let old = binding(); journal.prepare_seller(old.clone()).unwrap(); assert!(journal .prepare_seller_rental_network(&old, "mainnet", &info) .is_err()); } #[tokio::test] async fn interrupted_network_preparation_repairs_only_exact_original_intent() { let data = tempfile::tempdir().unwrap(); let b = binding(); let journal = Journal::open(data.path()).await.unwrap(); seed_buyer(&journal, &b); // Missing committed history is NOT a precommit crash and cannot reset // exposure/dispatch flags. Separately emulate the explicit pending phase. std::fs::remove_file( data.path() .join("content-lightning") .join(format!("buyer-{}.json", b.id)), ) .unwrap(); let info = serde_json::json!({"chains":[{"chain":"bitcoin","network":"mainnet"}]}); let mut changed = pristine_buyer(&b); changed.seller_onion = "replacement.onion".into(); assert!(journal .prepare_buyer_rental_native_network(&changed, &info) .is_err()); assert!(journal.buyer(&b.id).unwrap().is_none()); assert!(journal .prepare_buyer_rental_native_network(&pristine_buyer(&b), &info) .is_err()); set_pin_uncommitted(data.path(), "rental-network", &b.id); journal .prepare_buyer_rental_native_network(&pristine_buyer(&b), &info) .unwrap(); assert_eq!( journal.buyer_rental_currency(&b).unwrap(), Some(Currency::Bitcoin) ); assert!(journal.buyer(&b.id).unwrap().unwrap().last.is_none()); std::fs::write( data.path() .join("content-lightning") .join(format!("rental-network-{}.json", b.id)), b"damaged", ) .unwrap(); assert!(journal.buyer_rental_currency(&b).is_err()); assert!(journal .prepare_buyer_rental_native_network(&pristine_buyer(&b), &info) .is_err()); } #[tokio::test] async fn response_cannot_supply_missing_trusted_network_pin() { let data = tempfile::tempdir().unwrap(); let b = binding(); let journal = Journal::open(data.path()).await.unwrap(); seed_unpinned_buyer(&journal, &b); journal.prepare_seller(b.clone()).unwrap(); set_phase(&journal, &b, Phase::Settled); let response = producer_response(&journal, &b, &b.buyer_did, &b.seller_did).unwrap(); assert!(accept_response(&journal, &b, &b.seller_did, Currency::Bitcoin, response).is_err()); assert!(journal.buyer(&b.id).unwrap().unwrap().last.is_none()); assert_eq!(journal.buyer_rental_currency(&b).unwrap(), None); assert!(journal.buyer_rental_authority(&b).unwrap().is_none()); } fn set_phase(journal: &Journal, b: &Binding, phase: Phase) { let mut record = journal.seller(b).unwrap().unwrap(); record.phase = phase; record.bolt11 = Some(invoice( &record.payment_hash, b.price_sats, Currency::Bitcoin, )); journal.save_seller(&record).unwrap(); } fn wire(journal: &Journal, b: &Binding) -> Vec { serde_json::to_vec(&producer_response(journal, b, &b.buyer_did, &b.seller_did).unwrap()) .unwrap() } #[tokio::test] async fn lost_reply_reopens_original_grant_and_reaches_native_broker_without_public_secret() { let producer_root = tempfile::tempdir().unwrap(); let buyer_root = tempfile::tempdir().unwrap(); let b = binding(); let producer = Journal::open(producer_root.path()).await.unwrap(); producer.prepare_seller(b.clone()).unwrap(); let buyer = Journal::open(buyer_root.path()).await.unwrap(); seed_buyer(&buyer, &b); set_phase(&producer, &b, Phase::Issued); accept_response( &buyer, &b, &b.seller_did, Currency::Bitcoin, serde_json::from_slice(&wire(&producer, &b)).unwrap(), ) .unwrap(); assert!(buyer.buyer_rental_authority(&b).unwrap().is_none()); // Synthetic durable settlement, not a real wallet payment. set_phase(&producer, &b, Phase::Settled); let lost = wire(&producer, &b); drop(producer); let producer = Journal::open(producer_root.path()).await.unwrap(); assert_eq!(wire(&producer, &b), lost); let decoded: Response = serde_json::from_slice(&lost).unwrap(); let capability = decoded.capability.clone().unwrap(); let public = accept_response(&buyer, &b, &b.seller_did, Currency::Bitcoin, decoded).unwrap(); assert!(!serde_json::to_string(&public) .unwrap() .contains(&capability)); assert!(!format!("{public:?}").contains(&capability)); drop(buyer); let buyer = Journal::open(buyer_root.path()).await.unwrap(); accept_response( &buyer, &b, &b.seller_did, Currency::Bitcoin, serde_json::from_slice(&lost).unwrap(), ) .unwrap(); drop(buyer); let resolved = crate::rental_buyer::load(buyer_root.path(), &b.id) .await .unwrap(); assert_eq!(resolved.capability, capability); assert_eq!(resolved.purchase.viewing_seconds, 3600); } #[tokio::test] async fn changed_peer_terms_invoice_or_capability_is_rejected_before_buyer_mutation() { let producer_root = tempfile::tempdir().unwrap(); let buyer_root = tempfile::tempdir().unwrap(); let b = binding(); let producer = Journal::open(producer_root.path()).await.unwrap(); producer.prepare_seller(b.clone()).unwrap(); set_phase(&producer, &b, Phase::Settled); let bytes = wire(&producer, &b); let original: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let buyer = Journal::open(buyer_root.path()).await.unwrap(); seed_buyer(&buyer, &b); assert!(producer_response(&producer, &b, &b.seller_did, &b.seller_did).is_err()); assert!(producer_response(&producer, &b, &b.buyer_did, &b.buyer_did).is_err()); assert!(accept_response( &buyer, &b, &b.buyer_did, Currency::Bitcoin, serde_json::from_slice(&bytes).unwrap() ) .is_err()); assert!(accept_response( &buyer, &b, &b.seller_did, Currency::BitcoinTestnet, serde_json::from_slice(&bytes).unwrap() ) .is_err()); for case in 0..7 { let mut changed = original.clone(); match case { 0 => changed["version"] = serde_json::json!(2), 1 => { changed["status"]["binding"]["rental"]["viewing_seconds"] = serde_json::json!(3601) } 2 => changed["capability"] = serde_json::Value::Null, 3 => changed["capability"] = serde_json::json!("malformed"), 4 => changed["status"]["payment_hash"] = serde_json::json!("ab".repeat(32)), 5 => changed["status"]["bolt11"] = serde_json::Value::Null, 6 => { changed["status"]["bolt11"] = serde_json::json!(invoice( original["status"]["payment_hash"].as_str().unwrap(), 9, Currency::Bitcoin )) } _ => unreachable!(), } assert!(accept_response( &buyer, &b, &b.seller_did, Currency::Bitcoin, serde_json::from_value(changed).unwrap() ) .is_err()); assert!(buyer.buyer(&b.id).unwrap().unwrap().last.is_none()); assert!(buyer.buyer_rental_authority(&b).unwrap().is_none()); } } #[tokio::test] async fn unpaid_response_cannot_supply_private_capability_or_unknown_fields() { let producer_root = tempfile::tempdir().unwrap(); let buyer_root = tempfile::tempdir().unwrap(); let b = binding(); let producer = Journal::open(producer_root.path()).await.unwrap(); producer.prepare_seller(b.clone()).unwrap(); let mut value: serde_json::Value = serde_json::from_slice(&wire(&producer, &b)).unwrap(); assert!(value.get("capability").is_none()); value["capability"] = serde_json::json!("ef".repeat(32)); let buyer = Journal::open(buyer_root.path()).await.unwrap(); seed_buyer(&buyer, &b); assert!(accept_response( &buyer, &b, &b.seller_did, Currency::Bitcoin, serde_json::from_value(value.clone()).unwrap() ) .is_err()); value["downgrade"] = serde_json::json!(true); assert!(serde_json::from_value::(value).is_err()); assert!(buyer.buyer(&b.id).unwrap().unwrap().last.is_none()); } }