1814 lines
70 KiB
Rust
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(¤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<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(¤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<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(¤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<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(¤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<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, ¤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::<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());
|
|
}
|
|
}
|