Files
archy/core/archipelago/src/lightning_rental_protocol.rs

1814 lines
70 KiB
Rust

//! 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<String> {
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<CreationRequest> {
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(&currency).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<CreationDisposition> {
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(&currency) == 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<CreationRequest> {
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<PreparationRequest> {
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(&currency).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<Status> {
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<RecoveryRequest> {
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<String> {
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<Binding> {
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<hyper::Body>,
now: i64,
) -> Result<hyper::Response<hyper::Body>> {
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<F, Fut>(
seller: &str,
mut request: hyper::Request<hyper::Body>,
now: i64,
refresh: F,
) -> Result<hyper::Response<hyper::Body>>
where
F: FnOnce(Binding) -> Fut,
Fut: std::future::Future<Output = Result<Response>>,
{
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<String>,
}
/// 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<Response> {
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<Status> {
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<Output = Result<Vec<u8>>>,
) -> Result<Status> {
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(&currency),
"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<Status> {
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::<RecoveryRequest>(&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::<CreationRequest>(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<u8>,
) -> hyper::Request<hyper::Body> {
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, &current);
}
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::<serde_json::Value>(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<u8> {
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::<Response>(value).is_err());
assert!(buyer.buyer(&b.id).unwrap().unwrap().last.is_none());
}
}