Integrate two-phase on-chain purchase recovery
This commit is contained in:
@@ -0,0 +1,712 @@
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{content_lightning::Binding, content_onchain_seller::Journal};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
pub(crate) const ROUTE: &str = "/content/onchain/v1/operation";
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Operation {
|
||||
pub binding: Binding,
|
||||
pub action: String,
|
||||
}
|
||||
// Load wallet credentials only after authenticated request validation reaches a
|
||||
// wallet operation. Tests inject the same typed boundary without live services.
|
||||
struct NativeSellerWallet<'a>(&'a crate::api::rpc::RpcHandler);
|
||||
impl crate::content_onchain_seller::Wallet for NativeSellerWallet<'_> {
|
||||
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
|
||||
self.0.onchain_purchase_wallet().await?.network().await
|
||||
}
|
||||
async fn preflight(&self, network: crate::content_onchain::ChainNetwork) -> Result<()> {
|
||||
self.0
|
||||
.onchain_purchase_wallet()
|
||||
.await?
|
||||
.preflight(network)
|
||||
.await
|
||||
}
|
||||
async fn allocate(&self) -> Result<String> {
|
||||
self.0.onchain_purchase_wallet().await?.allocate().await
|
||||
}
|
||||
async fn received(&self, address: &str, amount: u64) -> Result<bool> {
|
||||
self.0
|
||||
.onchain_purchase_wallet()
|
||||
.await?
|
||||
.received(address, amount)
|
||||
.await
|
||||
}
|
||||
}
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_onchain_purchase(
|
||||
&self,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
self.handle_onchain_purchase_with_wallet(request, &NativeSellerWallet(&self.rpc_handler))
|
||||
.await
|
||||
}
|
||||
async fn handle_onchain_purchase_with_wallet<W: crate::content_onchain_seller::Wallet>(
|
||||
&self,
|
||||
mut request: Request<Body>,
|
||||
wallet: &W,
|
||||
) -> Result<Response<Body>> {
|
||||
anyhow::ensure!(
|
||||
request.method() == Method::POST && request.uri().path() == ROUTE,
|
||||
"Invalid on-chain purchase route"
|
||||
);
|
||||
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = request.body_mut().data().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"On-chain purchase request too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk)
|
||||
}
|
||||
Ok::<_, anyhow::Error>(bytes)
|
||||
})
|
||||
.await
|
||||
.context("On-chain purchase request timed out")??;
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = crate::content_auth::authenticate_request(
|
||||
request.headers(),
|
||||
&seller,
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?;
|
||||
let operation: Operation = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
|
||||
"On-chain purchase peer identity mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
operation.action.as_str(),
|
||||
"create" | "offer" | "allocate" | "status" | "download" | "cancel"
|
||||
),
|
||||
"Invalid on-chain purchase action"
|
||||
);
|
||||
let binding = &operation.binding;
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let retired = if operation.action == "cancel" {
|
||||
Some(journal.retire_unallocated(binding)?)
|
||||
} else {
|
||||
journal.retirement(binding)?
|
||||
};
|
||||
if let Some(ack) = retired {
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&ack)?),
|
||||
));
|
||||
}
|
||||
let mut saved = journal.load(binding)?;
|
||||
if saved.is_none() {
|
||||
anyhow::ensure!(
|
||||
matches!(operation.action.as_str(), "create" | "offer"),
|
||||
"Unknown original on-chain purchase operation"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!binding.content_id.starts_with("registered_"),
|
||||
"Registered rentals use their native purchase contract"
|
||||
);
|
||||
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
|
||||
let item = catalog
|
||||
.items
|
||||
.iter()
|
||||
.find(|v| v.id == binding.content_id)
|
||||
.context("Shared item unavailable")?;
|
||||
let visible = match &item.availability {
|
||||
crate::content_server::Availability::Nobody => false,
|
||||
crate::content_server::Availability::AllPeers => true,
|
||||
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
|
||||
};
|
||||
anyhow::ensure!(visible, "Item is not shared with this buyer");
|
||||
anyhow::ensure!(
|
||||
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
|
||||
&& crate::content_server::method_accepted(&item.access, "onchain"),
|
||||
"On-chain purchase price or accepted method changed"
|
||||
);
|
||||
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
|
||||
.await?;
|
||||
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
|
||||
let roots = [
|
||||
self.config.data_dir.join("content/files"),
|
||||
self.config.data_dir.join("filebrowser"),
|
||||
];
|
||||
let (root, relative) = roots
|
||||
.iter()
|
||||
.find_map(|root| {
|
||||
source
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|p| (root.clone(), p.to_path_buf()))
|
||||
})
|
||||
.context("Unsupported on-chain purchase source root")?;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for CancelCopy {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)));
|
||||
let cancelled = cancel_copy.0.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::prepare(
|
||||
&data,
|
||||
&root,
|
||||
&id,
|
||||
&relative,
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 64 * 1024 * 1024 * 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
64 * 1024 * 1024 * 1024,
|
||||
512 * 1024 * 1024,
|
||||
|_| Ok(()),
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
anyhow::ensure!(
|
||||
snapshot.size == item.size_bytes,
|
||||
"Shared file changed before on-chain purchase"
|
||||
);
|
||||
// Source metadata is private and committed before address allocation.
|
||||
let record = crate::content_server::publish_snapshot_onchain(
|
||||
&self.config.data_dir,
|
||||
item,
|
||||
&journal,
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: snapshot.size,
|
||||
filename: item.filename.clone(),
|
||||
mime_type: item.mime_type.clone(),
|
||||
},
|
||||
wallet.network().await?,
|
||||
)
|
||||
.await?;
|
||||
saved = Some(record);
|
||||
}
|
||||
saved.context("Missing original on-chain operation")?;
|
||||
let status = if operation.action == "allocate" {
|
||||
crate::content_server::allocate_onchain_offer(
|
||||
&self.config.data_dir,
|
||||
&journal,
|
||||
binding,
|
||||
wallet,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
crate::content_onchain_seller::drive(&journal, binding, false, wallet).await?
|
||||
};
|
||||
if operation.action == "download" {
|
||||
anyhow::ensure!(status.paid, "Original on-chain purchase has not settled");
|
||||
let source = &status.source;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
let retained = source.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
||||
})
|
||||
.await??;
|
||||
let stream = futures_util::stream::try_unfold(
|
||||
(tokio::fs::File::from_std(snapshot.file), source.size),
|
||||
|(mut file, left)| async move {
|
||||
if left == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
let mut bytes = vec![0; left.min(65536) as usize];
|
||||
let count = file.read(&mut bytes).await?;
|
||||
if count == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"Original on-chain purchase snapshot ended early",
|
||||
));
|
||||
}
|
||||
bytes.truncate(count);
|
||||
Ok(Some((bytes, (file, left - count as u64))))
|
||||
},
|
||||
);
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", &source.mime_type)
|
||||
.header("Content-Length", source.size)
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.body(Body::wrap_stream(stream))?);
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&status)?),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content_onchain_seller::{Allocation, UnallocatedAck};
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
#[derive(Default)]
|
||||
struct MockWallet {
|
||||
allocations: std::sync::atomic::AtomicUsize,
|
||||
lose_reply: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl crate::content_onchain_seller::Wallet for MockWallet {
|
||||
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
|
||||
Ok(crate::content_onchain::ChainNetwork::Regtest)
|
||||
}
|
||||
async fn preflight(&self, _: crate::content_onchain::ChainNetwork) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn allocate(&self) -> Result<String> {
|
||||
self.allocations
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
anyhow::ensure!(
|
||||
!self
|
||||
.lose_reply
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst),
|
||||
"Simulated lost allocation response"
|
||||
);
|
||||
let mut bytes = vec![0, 20];
|
||||
bytes.extend([17u8; 20]);
|
||||
Ok(bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(bytes),
|
||||
bitcoin::Network::Regtest,
|
||||
)?
|
||||
.to_string())
|
||||
}
|
||||
async fn received(&self, _: &str, _: u64) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
struct HttpFixture {
|
||||
wallet: Arc<MockWallet>,
|
||||
data: tempfile::TempDir,
|
||||
_buyer_data: tempfile::TempDir,
|
||||
buyer: crate::identity::NodeIdentity,
|
||||
seller: String,
|
||||
url: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for HttpFixture {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
async fn fixture() -> HttpFixture {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let buyer_data = tempfile::tempdir().unwrap();
|
||||
let buyer = crate::identity::NodeIdentity::load_or_create(buyer_data.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = Arc::new(
|
||||
ApiHandler::new(
|
||||
config,
|
||||
Arc::new(crate::state::StateManager::new()),
|
||||
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&handler.self_pubkey_hex).unwrap();
|
||||
let wallet = Arc::new(MockWallet::default());
|
||||
let server_wallet = wallet.clone();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = hyper::Server::from_tcp(listener)
|
||||
.unwrap()
|
||||
.serve(make_service_fn(move |_| {
|
||||
let handler = handler.clone();
|
||||
let wallet = server_wallet.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request| {
|
||||
let handler = handler.clone();
|
||||
let wallet = wallet.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(
|
||||
handler
|
||||
.handle_onchain_purchase_with_wallet(request, wallet.as_ref())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
Body::from("{\"error\":\"rejected\"}"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}));
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
HttpFixture {
|
||||
wallet,
|
||||
data,
|
||||
_buyer_data: buyer_data,
|
||||
buyer,
|
||||
seller,
|
||||
url,
|
||||
task,
|
||||
}
|
||||
}
|
||||
impl HttpFixture {
|
||||
fn binding(&self) -> Binding {
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: self.buyer.did_key().unwrap(),
|
||||
seller_did: self.seller.clone(),
|
||||
content_id: "file".into(),
|
||||
price_sats: 546,
|
||||
}
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
body: &[u8],
|
||||
signed_body: Option<&[u8]>,
|
||||
audience: Option<&str>,
|
||||
) -> reqwest::Response {
|
||||
let mut request = reqwest::Client::new()
|
||||
.post(format!("{}{}", self.url, ROUTE))
|
||||
.header("content-type", "application/json")
|
||||
.body(body.to_vec());
|
||||
if let Some(signed) = signed_body {
|
||||
let proof = crate::content_auth::sign_request(
|
||||
&self.buyer,
|
||||
audience.unwrap_or(&self.seller),
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
signed,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
request = request.header(crate::content_auth::REQUEST_HEADER, proof);
|
||||
}
|
||||
request.send().await.unwrap()
|
||||
}
|
||||
async fn operation(&self, binding: &Binding, action: &str) -> reqwest::Response {
|
||||
let body = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: action.into(),
|
||||
})
|
||||
.unwrap();
|
||||
self.send(&body, Some(&body), None).await
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authenticated_cancel_roundtrip_lost_reply_and_delayed_create_return_same_retirement() {
|
||||
let server = fixture().await;
|
||||
let binding = server.binding();
|
||||
// Drop the original reply after headers: terminal state must already be durable.
|
||||
let first = server.operation(&binding, "cancel").await;
|
||||
assert_eq!(first.status(), reqwest::StatusCode::OK);
|
||||
drop(first);
|
||||
let replay = server.operation(&binding, "cancel").await;
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::OK);
|
||||
let ack: UnallocatedAck = replay.json().await.unwrap();
|
||||
ack.validate(&binding).unwrap();
|
||||
let delayed = server.operation(&binding, "create").await;
|
||||
assert_eq!(delayed.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(delayed.json::<UnallocatedAck>().await.unwrap(), ack);
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(journal.retirement(&binding).unwrap(), Some(ack));
|
||||
assert!(journal.load(&binding).unwrap().is_none());
|
||||
assert!(!server.data.path().join("content-snapshots").exists());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_http_rejects_missing_proof_body_tamper_and_wrong_seller_without_tombstone(
|
||||
) {
|
||||
let server = fixture().await;
|
||||
let binding = server.binding();
|
||||
let body = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "cancel".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server.send(&body, None, None).await.status().is_success());
|
||||
let mut changed = binding.clone();
|
||||
changed.price_sats += 1;
|
||||
let changed = serde_json::to_vec(&Operation {
|
||||
binding: changed,
|
||||
action: "cancel".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server
|
||||
.send(&changed, Some(&body), None)
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let wrong = crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap();
|
||||
assert!(!server
|
||||
.send(&body, Some(&body), Some(&wrong))
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authenticated_cancel_cannot_retire_dispatched_or_issued_address() {
|
||||
let server = fixture().await;
|
||||
let mut script = vec![0, 20];
|
||||
script.extend([1; 20]);
|
||||
let address = bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(script),
|
||||
bitcoin::Network::Regtest,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
for allocation in [Allocation::Dispatched, Allocation::Ready { address }] {
|
||||
let binding = server.binding();
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
let mut record = journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: "a".repeat(64),
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
crate::content_onchain::ChainNetwork::Regtest,
|
||||
)
|
||||
.unwrap();
|
||||
record.allocation = allocation.clone();
|
||||
journal.save(&record).unwrap();
|
||||
drop(journal);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
allocation
|
||||
);
|
||||
}
|
||||
}
|
||||
async fn seed_unallocated_offer(server: &HttpFixture) -> Binding {
|
||||
let binding = server.binding();
|
||||
crate::content_server::save_catalog(
|
||||
server.data.path(),
|
||||
&crate::content_server::ContentCatalog {
|
||||
items: vec![crate::content_server::ContentItem {
|
||||
id: binding.content_id.clone(),
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 4,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: crate::content_server::Availability::AllPeers,
|
||||
access: crate::content_server::AccessControl::Paid {
|
||||
price_sats: 546,
|
||||
accepted: vec!["onchain".into()],
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let root = server.data.path().join("content/files");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("original.txt"), b"test").unwrap();
|
||||
let cancelled = std::sync::atomic::AtomicBool::new(false);
|
||||
let snapshot = crate::content_snapshot::prepare(
|
||||
server.data.path(),
|
||||
&root,
|
||||
&binding.content_id,
|
||||
std::path::Path::new("original.txt"),
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
1024 * 1024,
|
||||
0,
|
||||
|_| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
crate::content_onchain::ChainNetwork::Regtest,
|
||||
)
|
||||
.unwrap();
|
||||
binding
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_offer_never_allocates_or_returns_a_receive_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
let result = server.operation(&binding, "offer").await;
|
||||
assert_eq!(result.status(), reqwest::StatusCode::OK);
|
||||
let body: serde_json::Value = result.json().await.unwrap();
|
||||
assert_eq!(body["allocation"]["state"], "prepared");
|
||||
assert!(body["allocation"].get("address").is_none());
|
||||
assert!(body.get("address").is_none());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reviewed_offer_can_cancel_and_delayed_explicit_allocate_cannot_revive_it() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
assert_eq!(
|
||||
server.operation(&binding, "offer").await.status(),
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
let retired: UnallocatedAck = server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
retired.validate(&binding).unwrap();
|
||||
// Represents a delayed Pay request from the old modal after cancellation.
|
||||
let late = server.operation(&binding, "allocate").await;
|
||||
assert_eq!(late.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(late.json::<UnallocatedAck>().await.unwrap(), retired);
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
assert_eq!(journal.retirement(&binding).unwrap(), Some(retired));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_authenticated_offer_body_to_allocate_cannot_dispatch_an_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
let reviewed = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "offer".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let changed = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "allocate".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server
|
||||
.send(&changed, Some(&reviewed), None)
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_allocation_reuses_original_address_after_lost_http_reply() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
assert!(server
|
||||
.operation(&binding, "offer")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0
|
||||
);
|
||||
// Caller loses the response after seller durability; recovery returns the same record.
|
||||
drop(server.operation(&binding, "allocate").await);
|
||||
let recovered: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(recovered.quote().unwrap().is_some());
|
||||
let repeated: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered, repeated);
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lost_wallet_allocation_reply_never_allocates_a_second_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
server
|
||||
.wallet
|
||||
.lose_reply
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(!server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let recovered: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered.allocation, Allocation::Dispatched);
|
||||
assert!(recovered.quote().unwrap().is_none());
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,14 @@ impl RpcHandler {
|
||||
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
||||
"content.playback-status" => self.handle_playback_status(params, session_token).await,
|
||||
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
|
||||
"content.onchain-cancel" => self.handle_onchain_operation(params, "cancel").await,
|
||||
"content.onchain-attempt" => self.handle_onchain_operation(params, "lookup").await,
|
||||
"content.onchain-create" => self.handle_onchain_operation(params, "create").await,
|
||||
"content.onchain-expose" => self.handle_onchain_operation(params, "expose").await,
|
||||
"content.onchain-prepare" => self.handle_onchain_operation(params, "prepare").await,
|
||||
"content.onchain-pay" => self.handle_onchain_operation(params, "pay").await,
|
||||
"content.onchain-recover" => self.handle_onchain_operation(params, "status").await,
|
||||
"content.onchain-download" => self.handle_onchain_operation(params, "download").await,
|
||||
"content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
|
||||
"content.invoice-download" => self.handle_lightning_operation(params, "download").await,
|
||||
"content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
|
||||
|
||||
@@ -4,6 +4,7 @@ mod fee_bump;
|
||||
mod fee_policy;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
pub(super) mod onchain_purchase;
|
||||
mod payments;
|
||||
mod seed_backup;
|
||||
mod wallet;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1347,7 +1347,7 @@ fn psbt_key_origin_report(psbt_base64: &str) -> Result<PsbtKeyOriginReport> {
|
||||
/// LND's transaction `amount` is the wallet-wide net amount, not the value
|
||||
/// paid to a purchase address. Attribute only confirmed output values, once
|
||||
/// per outpoint. Missing/malformed evidence is unknown, never proof of payment.
|
||||
fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result<u64> {
|
||||
pub(super) fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result<u64> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
const MAX_SATS: u64 = 21_000_000 * 100_000_000;
|
||||
fn integer(value: &serde_json::Value) -> Result<u64> {
|
||||
|
||||
@@ -18,6 +18,7 @@ mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
mod lightning_purchase;
|
||||
mod onchain_purchase;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod media_registration;
|
||||
@@ -110,6 +111,15 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
|
||||
| "media.registration.context"
|
||||
| "media.registration.resolve"
|
||||
| "content.rental-purchase"
|
||||
|
||||
| "content.onchain-cancel"
|
||||
| "content.onchain-attempt"
|
||||
| "content.onchain-create"
|
||||
| "content.onchain-expose"
|
||||
| "content.onchain-prepare"
|
||||
| "content.onchain-pay"
|
||||
| "content.onchain-recover"
|
||||
| "content.onchain-download"
|
||||
| "content.invoice-pay"
|
||||
| "content.invoice-download"
|
||||
| "content.invoice-attempt"
|
||||
@@ -837,6 +847,14 @@ mod nostr_signing_origin_tests {
|
||||
"media.registration.context",
|
||||
"media.registration.resolve",
|
||||
"content.rental-purchase",
|
||||
"content.onchain-cancel",
|
||||
"content.onchain-attempt",
|
||||
"content.onchain-create",
|
||||
"content.onchain-expose",
|
||||
"content.onchain-prepare",
|
||||
"content.onchain-pay",
|
||||
"content.onchain-recover",
|
||||
"content.onchain-download",
|
||||
"content.purchase",
|
||||
"content.cancel-purchase",
|
||||
"content.playback-handle",
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Owner-only original-operation on-chain flow. No generic sendcoins fallback.
|
||||
use super::RpcHandler;
|
||||
use crate::{
|
||||
content_lightning::Binding,
|
||||
content_onchain::{self as engine, Journal, Phase, Record},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Params {
|
||||
onion: String,
|
||||
content_id: String,
|
||||
operation_id: Option<String>,
|
||||
price_sats: Option<u64>,
|
||||
max_fee_sats: Option<u64>,
|
||||
sat_per_vbyte: Option<u64>,
|
||||
template_sha256: Option<String>,
|
||||
plan_sha256: Option<String>,
|
||||
}
|
||||
fn public(record: &Record) -> Result<Value> {
|
||||
let fee = record
|
||||
.template
|
||||
.as_ref()
|
||||
.map(|t| engine::validate_funded(record, t))
|
||||
.transpose()?;
|
||||
let template_sha256 = record
|
||||
.template
|
||||
.as_ref()
|
||||
.map(|t| hex::encode(Sha256::digest(t.psbt_base64.as_bytes())));
|
||||
Ok(
|
||||
json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"phase":record.phase,
|
||||
"network":record.network(),"external_exposure":record.externally_exposed,
|
||||
"address":if record.externally_exposed {record.quote.as_ref().map(|q|q.address.as_str())}else{None},
|
||||
"fee_sats":fee.or_else(|| record.plan.as_ref().map(|p|p.fee_sats)),
|
||||
"max_fee_sats":record.policy.as_ref().map(|p|p.max_fee_sats).or_else(||record.plan.as_ref().map(|p|p.max_fee_sats)),
|
||||
"template_sha256":template_sha256,"plan_sha256":record.plan.as_ref().map(|p|p.hash()).transpose()?,
|
||||
"txid":record.signed.as_ref().map(|s|s.txid.as_str()),"paid":record.settled,
|
||||
"change_allocation_ambiguous":matches!(record.change_address,Some(engine::ChangeAddress::Dispatched)),
|
||||
"can_switch_method":record.retirement.is_some(),"retired_unallocated":record.retirement.is_some()}),
|
||||
)
|
||||
}
|
||||
impl RpcHandler {
|
||||
async fn request_onchain_allocation(
|
||||
&self,
|
||||
record: &Record,
|
||||
fips: &str,
|
||||
) -> Result<crate::content_onchain_seller::Record> {
|
||||
let operation = crate::api::handler::onchain_purchase::Operation {
|
||||
binding: record.binding.clone(),
|
||||
action: "allocate".into(),
|
||||
};
|
||||
let (mut response, _) = crate::fips::dial::PeerRequest::new(
|
||||
Some(fips),
|
||||
&record.seller_onion,
|
||||
crate::api::handler::onchain_purchase::ROUTE,
|
||||
)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(45))
|
||||
.send_content_json(
|
||||
&self.config.data_dir,
|
||||
&record.binding.seller_did,
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
.context("Original seller allocation reply unavailable; recover the same operation")?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Original seller allocation remains unresolved"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"Seller response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let saved: crate::content_onchain_seller::Record = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
saved.binding == record.binding,
|
||||
"Seller changed original purchase"
|
||||
);
|
||||
if let Some(offer) = &record.offer {
|
||||
anyhow::ensure!(saved.offer()? == *offer, "Seller changed original offer");
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
pub(super) async fn ensure_onchain_allows_other_rail(
|
||||
&self,
|
||||
buyer: &str,
|
||||
seller: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
Journal::find_for(&self.config.data_dir, buyer, seller, content)?.is_none(),
|
||||
"An original on-chain purchase remains recoverable; do not pay again or switch methods"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) async fn handle_onchain_operation(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
action: &str,
|
||||
) -> Result<Value> {
|
||||
let params: Params = serde_json::from_value(params.context("Missing on-chain operation")?)?;
|
||||
anyhow::ensure!(
|
||||
!params.content_id.starts_with("registered_"),
|
||||
"Registered rentals require their native purchase contract"
|
||||
);
|
||||
let peer =
|
||||
crate::federation::load_unique_payment_peer(&self.config.data_dir, ¶ms.onion)
|
||||
.await?;
|
||||
let buyer =
|
||||
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
||||
.await?
|
||||
.did_key()?;
|
||||
anyhow::ensure!(buyer != peer.did, "Cannot buy from this same node");
|
||||
let _admission = crate::content_payment_admission::lock(
|
||||
&self.config.data_dir,
|
||||
&buyer,
|
||||
&peer.did,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
let original = if let Some(id) = ¶ms.operation_id {
|
||||
let journal = Journal::open(&self.config.data_dir, id).await?;
|
||||
let original = journal.load()?;
|
||||
if let Some(record) = &original {
|
||||
anyhow::ensure!(
|
||||
record.binding.buyer_did == buyer
|
||||
&& record.binding.seller_did == peer.did
|
||||
&& record.binding.content_id == params.content_id,
|
||||
"Original on-chain operation belongs to another purchase"
|
||||
);
|
||||
}
|
||||
original
|
||||
} else {
|
||||
Journal::find_for(&self.config.data_dir, &buyer, &peer.did, ¶ms.content_id)?
|
||||
};
|
||||
if action == "lookup" {
|
||||
return Ok(json!({"attempt":original.as_ref().map(public).transpose()?}));
|
||||
}
|
||||
if let Some(id) = ¶ms.operation_id {
|
||||
anyhow::ensure!(
|
||||
original.as_ref().is_some_and(|r| &r.binding.id == id),
|
||||
"Original on-chain operation changed"
|
||||
);
|
||||
}
|
||||
let mut record = if let Some(record) = original {
|
||||
record
|
||||
} else {
|
||||
anyhow::ensure!(
|
||||
matches!(action, "create" | "expose") && params.operation_id.is_none(),
|
||||
"Recover original on-chain operation first"
|
||||
);
|
||||
self.ensure_invoice_allows_other_rail(&buyer, &peer.did, ¶ms.content_id)
|
||||
.await?;
|
||||
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
cashu
|
||||
.find_buyers(&buyer, &peer.did, ¶ms.content_id)
|
||||
.await?
|
||||
.iter()
|
||||
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
|
||||
"Recover or cancel original Cashu purchase first"
|
||||
);
|
||||
Record::new(
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: buyer,
|
||||
seller_did: peer.did.clone(),
|
||||
content_id: params.content_id.clone(),
|
||||
price_sats: params.price_sats.context("Expected price required")?,
|
||||
},
|
||||
params.onion.clone(),
|
||||
)?
|
||||
};
|
||||
anyhow::ensure!(
|
||||
record.seller_onion == params.onion
|
||||
&& params
|
||||
.price_sats
|
||||
.is_none_or(|p| p == record.binding.price_sats),
|
||||
"Original payment address or price changed"
|
||||
);
|
||||
let journal = Journal::open(&self.config.data_dir, &record.binding.id).await?;
|
||||
if journal.load()?.is_none() {
|
||||
journal.save(&record)?;
|
||||
}
|
||||
if record.retirement.is_some() {
|
||||
return public(&record);
|
||||
}
|
||||
if action == "cancel" {
|
||||
anyhow::ensure!(params.operation_id.is_some() && record.can_retire_unallocated(),"An allocated or mutated on-chain purchase cannot be canceled; recover its original payment");
|
||||
}
|
||||
if matches!(action, "create" | "expose" | "prepare" | "pay") && !record.settled {
|
||||
// Recheck while the same admission guard is held, including resumes
|
||||
// from another window and records predating this owner flow.
|
||||
self.ensure_invoice_allows_other_rail(
|
||||
&record.binding.buyer_did,
|
||||
&peer.did,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
cashu
|
||||
.find_buyers(&record.binding.buyer_did, &peer.did, ¶ms.content_id)
|
||||
.await?
|
||||
.iter()
|
||||
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
|
||||
"Another saved Cashu liability must be recovered before on-chain dispatch"
|
||||
);
|
||||
}
|
||||
if action == "prepare" {
|
||||
anyhow::ensure!(
|
||||
params.operation_id.is_some(),
|
||||
"Original operation ID required"
|
||||
);
|
||||
if record.template.is_none() && record.plan.is_none() {
|
||||
let max_fee_sats = params
|
||||
.max_fee_sats
|
||||
.context("Explicit maximum fee required")?;
|
||||
anyhow::ensure!(
|
||||
(1..=2_100_000_000_000_000).contains(&max_fee_sats),
|
||||
"Invalid maximum fee"
|
||||
);
|
||||
if let Some(rate) = params.sat_per_vbyte {
|
||||
anyhow::ensure!((1..=5000).contains(&rate), "Invalid fee rate");
|
||||
}
|
||||
let wallet = self.onchain_purchase_wallet().await?;
|
||||
let change = wallet.prepare_change(&journal).await?;
|
||||
record = wallet
|
||||
.prepare_plan(
|
||||
&journal,
|
||||
super::lnd::onchain_purchase::PlanRequest {
|
||||
change_address: change,
|
||||
max_fee_sats,
|
||||
sat_per_vbyte: params.sat_per_vbyte,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
return public(&record);
|
||||
}
|
||||
if action == "pay" {
|
||||
anyhow::ensure!(
|
||||
params.operation_id.is_some(),
|
||||
"Original operation ID required"
|
||||
);
|
||||
if record.settled {
|
||||
return public(&record);
|
||||
}
|
||||
if let Some(plan) = &record.plan {
|
||||
anyhow::ensure!(
|
||||
params.plan_sha256.as_deref() == Some(plan.hash()?.as_str()),
|
||||
"Confirm the original saved funding plan before payment"
|
||||
);
|
||||
} else {
|
||||
let template = record
|
||||
.template
|
||||
.as_ref()
|
||||
.context("Review the original fee first")?;
|
||||
anyhow::ensure!(
|
||||
params.template_sha256.as_deref()
|
||||
== Some(
|
||||
hex::encode(Sha256::digest(template.psbt_base64.as_bytes())).as_str()
|
||||
),
|
||||
"Confirm the original saved transaction before payment"
|
||||
);
|
||||
}
|
||||
let wallet = self.onchain_purchase_wallet().await?;
|
||||
if record.plan.is_some() && record.quote.is_none() {
|
||||
record = engine::lease_plan(&journal, &wallet).await?;
|
||||
engine::mark_address_allocation(&journal, false)?;
|
||||
let status = self
|
||||
.request_onchain_allocation(
|
||||
&record,
|
||||
peer.fips_npub
|
||||
.as_deref()
|
||||
.context("Seller has no authenticated mesh connection")?,
|
||||
)
|
||||
.await?;
|
||||
let quote = status
|
||||
.quote()?
|
||||
.context("Original seller allocation is unresolved; recover this operation")?;
|
||||
record = engine::accept_quote(&journal, quote)?;
|
||||
}
|
||||
if record.plan.is_some() && record.template.is_none() {
|
||||
record = engine::bind_plan(&journal)?;
|
||||
}
|
||||
if matches!(
|
||||
record.phase,
|
||||
Phase::TemplatePrepared | Phase::LeaseDispatched
|
||||
) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Lease, None).await?;
|
||||
}
|
||||
if matches!(record.phase, Phase::Funded | Phase::SigningDispatched) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Sign, None).await?;
|
||||
}
|
||||
if matches!(
|
||||
record.phase,
|
||||
Phase::Signed | Phase::BroadcastDispatched | Phase::Published
|
||||
) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Publish, None).await?;
|
||||
}
|
||||
return public(&record);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
action,
|
||||
"create" | "status" | "expose" | "download" | "cancel"
|
||||
),
|
||||
"Unsupported on-chain action"
|
||||
);
|
||||
let fips = peer
|
||||
.fips_npub
|
||||
.context("Seller has no authenticated mesh connection")?;
|
||||
if action == "expose" && record.offer.is_some() && record.quote.is_none() {
|
||||
engine::mark_address_allocation(&journal, true)?;
|
||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||
record = engine::accept_quote(
|
||||
&journal,
|
||||
status
|
||||
.quote()?
|
||||
.context("Original seller allocation is unresolved; recover this operation")?,
|
||||
)?;
|
||||
}
|
||||
let remote_action = if action == "create" || action == "expose" && record.offer.is_none() {
|
||||
"offer"
|
||||
} else if action == "expose" {
|
||||
"status"
|
||||
} else {
|
||||
action
|
||||
};
|
||||
let operation = crate::api::handler::onchain_purchase::Operation {
|
||||
binding: record.binding.clone(),
|
||||
action: remote_action.into(),
|
||||
};
|
||||
let route = crate::api::handler::onchain_purchase::ROUTE;
|
||||
let remote = crate::fips::dial::PeerRequest::new(Some(&fips), ¶ms.onion, route)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(if action == "download" {
|
||||
900
|
||||
} else {
|
||||
45
|
||||
}))
|
||||
.send_content_json(&self.config.data_dir, &peer.did, &operation)
|
||||
.await;
|
||||
let (mut response, _) = match remote {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(
|
||||
json!({"attempt":public(&record)?,"recovery_required":true,"error":"Original on-chain request is saved. Recover this operation; do not request another address or pay again."}),
|
||||
)
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Seller could not recover original on-chain purchase {}; retain it",
|
||||
record.binding.id
|
||||
);
|
||||
if action == "download" {
|
||||
let source = record
|
||||
.quote
|
||||
.as_ref()
|
||||
.context("Recover original address first")?
|
||||
.source
|
||||
.clone();
|
||||
anyhow::ensure!(
|
||||
response.content_length() == Some(source.size),
|
||||
"Original file length changed"
|
||||
);
|
||||
record.settled = true;
|
||||
journal.save(&record)?;
|
||||
let stream = crate::content_purchase_download::verified_stream(
|
||||
response.bytes_stream(),
|
||||
source.sha256,
|
||||
source.size,
|
||||
);
|
||||
let owned = crate::content_owned::record_purchase_stream(
|
||||
&self.config.data_dir,
|
||||
crate::content_owned::OwnedItem {
|
||||
onion: params.onion,
|
||||
content_id: params.content_id,
|
||||
filename: source.filename,
|
||||
mime_type: source.mime_type,
|
||||
size_bytes: source.size,
|
||||
paid_sats: record.binding.price_sats,
|
||||
ecash_backend: "onchain".into(),
|
||||
purchased_at: chrono::Utc::now().to_rfc3339(),
|
||||
download_complete: false,
|
||||
},
|
||||
Box::pin(stream),
|
||||
Some(source.size),
|
||||
)
|
||||
.await?;
|
||||
return Ok(
|
||||
json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
|
||||
);
|
||||
}
|
||||
let mut bytes = vec![];
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"On-chain response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&bytes)?;
|
||||
if body["state"] == "cancelled_unallocated" {
|
||||
let ack: crate::content_onchain_seller::UnallocatedAck = serde_json::from_value(body)?;
|
||||
record = engine::retire_unallocated(&journal, ack)?;
|
||||
return public(&record);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
action != "cancel",
|
||||
"Seller did not acknowledge unallocated retirement; preserve original operation"
|
||||
);
|
||||
let status: crate::content_onchain_seller::Record = serde_json::from_value(body)?;
|
||||
anyhow::ensure!(
|
||||
status.binding == record.binding,
|
||||
"Seller changed original purchase"
|
||||
);
|
||||
if record.offer.is_none() && record.quote.is_none() {
|
||||
record = engine::accept_offer(&journal, status.offer()?)?;
|
||||
}
|
||||
if let Some(quote) = status.quote()? {
|
||||
record = engine::accept_quote(&journal, quote)?;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!status.paid || record.quote.is_some(),
|
||||
"Paid purchase lacks original address"
|
||||
);
|
||||
record.settled |= status.paid;
|
||||
journal.save(&record)?;
|
||||
if action == "expose" && !record.settled {
|
||||
if record.quote.is_none() {
|
||||
engine::mark_address_allocation(&journal, true)?;
|
||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||
record = engine::accept_quote(
|
||||
&journal,
|
||||
status.quote()?.context(
|
||||
"Original seller allocation is unresolved; recover this operation",
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
engine::expose_address(&journal)?;
|
||||
record = journal.load()?.context("Original record unavailable")?;
|
||||
}
|
||||
public(&record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn binding() -> Binding {
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
|
||||
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap(),
|
||||
content_id: "file".into(),
|
||||
price_sats: 546,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn buyer_discovery_retains_unresolved_address_and_rejects_duplicate_operations() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let saved = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
|
||||
let j = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
j.save(&saved).unwrap();
|
||||
drop(j);
|
||||
let found = Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(found.binding.id, binding.id);
|
||||
assert!(found.blocks_other_rails());
|
||||
assert!(public(&found).unwrap()["address"].is_null());
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.seller_did,
|
||||
&binding.buyer_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut second = binding.clone();
|
||||
second.id = uuid::Uuid::new_v4().to_string();
|
||||
let j = Journal::open(data.path(), &second.id).await.unwrap();
|
||||
j.save(&Record::new(second, saved.seller_onion).unwrap())
|
||||
.unwrap();
|
||||
drop(j);
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupted_node_record_cannot_be_treated_as_permission_to_pay_again() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let j = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
j.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
|
||||
.unwrap();
|
||||
drop(j);
|
||||
std::fs::write(
|
||||
data.path()
|
||||
.join("content-onchain")
|
||||
.join(format!("{}.json", binding.id)),
|
||||
b"{}",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn only_durable_matching_empty_ack_releases_cross_rail_and_stale_callback_cannot_revive()
|
||||
{
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let _rail = crate::content_payment_admission::lock(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
let original = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
|
||||
journal.save(&original).unwrap();
|
||||
let ack = crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: binding.clone(),
|
||||
state: "cancelled_unallocated".into(),
|
||||
address: serde_json::Value::Null,
|
||||
allocation_dispatched: false,
|
||||
can_switch_method: true,
|
||||
};
|
||||
for wrong in [
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
allocation_dispatched: true,
|
||||
..ack.clone()
|
||||
},
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
address: serde_json::json!("not-empty"),
|
||||
..ack.clone()
|
||||
},
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
..binding.clone()
|
||||
},
|
||||
..ack.clone()
|
||||
},
|
||||
] {
|
||||
assert!(engine::retire_unallocated(&journal, wrong).is_err());
|
||||
}
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
let retired = engine::retire_unallocated(&journal, ack).unwrap();
|
||||
assert!(!retired.blocks_other_rails());
|
||||
assert!(public(&retired).unwrap()["can_switch_method"] == true);
|
||||
assert!(journal.save(&original).is_err());
|
||||
drop(journal);
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut replacement = binding.clone();
|
||||
replacement.id = uuid::Uuid::new_v4().to_string();
|
||||
let journal = Journal::open(data.path(), &replacement.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(replacement.clone(), original.seller_onion).unwrap())
|
||||
.unwrap();
|
||||
drop(journal);
|
||||
assert_eq!(
|
||||
Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.binding
|
||||
.id,
|
||||
replacement.id
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn owner_address_is_redacted_until_exposure_is_durable_and_cannot_then_be_retired() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
|
||||
.unwrap();
|
||||
let mut bytes = vec![0, 20];
|
||||
bytes.extend([1; 20]);
|
||||
let address = bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(bytes),
|
||||
bitcoin::Network::Regtest,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let saved = engine::accept_quote(
|
||||
&journal,
|
||||
engine::Quote {
|
||||
binding: binding.clone(),
|
||||
address: address.clone(),
|
||||
network: engine::ChainNetwork::Regtest,
|
||||
source: crate::content_lightning::RetainedFile {
|
||||
sha256: "a".repeat(64),
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(public(&saved).unwrap()["address"].is_null());
|
||||
let ack = crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: binding.clone(),
|
||||
state: "cancelled_unallocated".into(),
|
||||
address: serde_json::Value::Null,
|
||||
allocation_dispatched: false,
|
||||
can_switch_method: true,
|
||||
};
|
||||
assert!(engine::retire_unallocated(&journal, ack.clone()).is_err());
|
||||
assert_eq!(engine::expose_address(&journal).unwrap(), address);
|
||||
drop(journal);
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
let exposed = journal.load().unwrap().unwrap();
|
||||
assert!(exposed.externally_exposed);
|
||||
assert_eq!(public(&exposed).unwrap()["address"], address);
|
||||
assert!(engine::retire_unallocated(&journal, ack).is_err());
|
||||
assert!(exposed.blocks_other_rails());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user