From 6547ae05fa843204bef015a71213b5de19182284 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 07:11:50 -0400 Subject: [PATCH] Integrate two-phase on-chain purchase recovery --- .../src/api/handler/onchain_purchase.rs | 712 +++++++ core/archipelago/src/api/rpc/dispatcher.rs | 8 + core/archipelago/src/api/rpc/lnd/mod.rs | 1 + .../src/api/rpc/lnd/onchain_purchase.rs | 1468 ++++++++++++++ core/archipelago/src/api/rpc/lnd/wallet.rs | 2 +- core/archipelago/src/api/rpc/mod.rs | 18 + .../src/api/rpc/onchain_purchase.rs | 668 +++++++ core/archipelago/src/content_lightning.rs | 4 +- core/archipelago/src/content_onchain.rs | 1741 +++++++++++++++++ core/archipelago/src/content_onchain_plan.rs | 250 +++ .../archipelago/src/content_onchain_seller.rs | 551 ++++++ core/archipelago/src/content_server.rs | 85 + core/archipelago/src/main.rs | 3 + docs/paid-content-recovery-followup.md | 270 +++ .../__tests__/peerOnchainPurchase.test.ts | 27 + .../src/composables/peerOnchainPurchase.ts | 38 + neode-ui/src/views/PeerFiles.vue | 211 +- .../__tests__/PeerFilesLightning.test.ts | 140 +- 18 files changed, 6084 insertions(+), 113 deletions(-) create mode 100644 core/archipelago/src/api/handler/onchain_purchase.rs create mode 100644 core/archipelago/src/api/rpc/lnd/onchain_purchase.rs create mode 100644 core/archipelago/src/api/rpc/onchain_purchase.rs create mode 100644 core/archipelago/src/content_onchain.rs create mode 100644 core/archipelago/src/content_onchain_plan.rs create mode 100644 core/archipelago/src/content_onchain_seller.rs create mode 100644 neode-ui/src/composables/__tests__/peerOnchainPurchase.test.ts create mode 100644 neode-ui/src/composables/peerOnchainPurchase.ts diff --git a/core/archipelago/src/api/handler/onchain_purchase.rs b/core/archipelago/src/api/handler/onchain_purchase.rs new file mode 100644 index 00000000..a60f79cc --- /dev/null +++ b/core/archipelago/src/api/handler/onchain_purchase.rs @@ -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 { + 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 { + self.0.onchain_purchase_wallet().await?.allocate().await + } + async fn received(&self, address: &str, amount: u64) -> Result { + self.0 + .onchain_purchase_wallet() + .await? + .received(address, amount) + .await + } +} +impl ApiHandler { + pub(super) async fn handle_onchain_purchase( + &self, + request: Request, + ) -> Result> { + self.handle_onchain_purchase_with_wallet(request, &NativeSellerWallet(&self.rpc_handler)) + .await + } + async fn handle_onchain_purchase_with_wallet( + &self, + mut request: Request, + wallet: &W, + ) -> Result> { + 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); + 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 { + Ok(crate::content_onchain::ChainNetwork::Regtest) + } + async fn preflight(&self, _: crate::content_onchain::ChainNetwork) -> Result<()> { + Ok(()) + } + async fn allocate(&self) -> Result { + 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 { + Ok(false) + } + } + struct HttpFixture { + wallet: Arc, + 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::().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::().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()); + } +} diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 1cb6de68..b1dd36ad 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs index a7c6ac21..248ba232 100644 --- a/core/archipelago/src/api/rpc/lnd/mod.rs +++ b/core/archipelago/src/api/rpc/lnd/mod.rs @@ -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; diff --git a/core/archipelago/src/api/rpc/lnd/onchain_purchase.rs b/core/archipelago/src/api/rpc/lnd/onchain_purchase.rs new file mode 100644 index 00000000..68d7f627 --- /dev/null +++ b/core/archipelago/src/api/rpc/lnd/onchain_purchase.rs @@ -0,0 +1,1468 @@ +//! Draft adapter for exact-template on-chain purchases. No owner RPC uses it yet. +//! No FundPsbt call: all inputs/outputs are saved before individually leasing. +use crate::content_onchain::{ + self as engine, FeePolicy, Funded, Journal, Lease, Record, Signed, Wallet, +}; +use anyhow::{Context, Result}; +use base64::Engine; +use bitcoin::{ + absolute::LockTime, consensus, psbt::Psbt, transaction::Version, Amount, OutPoint, ScriptBuf, + Sequence, Transaction, TxIn, TxOut, Witness, +}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +const MAX_SATS: u64 = 2_100_000_000_000_000; +const MAX_INPUTS: usize = 32; +const LEASE_SECONDS: u64 = 600; +/// Change address must already be durably prepared by the caller and confirmed +/// as a local internal address. This adapter never silently derives/reuses one. +pub(crate) struct PlanRequest { + pub change_address: String, + pub max_fee_sats: u64, + pub sat_per_vbyte: Option, +} +pub(crate) struct LndPurchaseWallet { + client: reqwest::Client, + base: String, + macaroon: String, +} +impl LndPurchaseWallet { + pub(crate) fn new(client: reqwest::Client, base: String, macaroon: String) -> Self { + Self { + client, + base: base.trim_end_matches('/').into(), + macaroon, + } + } + async fn raw(&self, path: &str, body: Option) -> Result { + let url = format!("{}{}", self.base, path); + let request = if let Some(body) = body { + self.client.post(url).json(&body) + } else { + self.client.get(url) + }; + Ok(request + .header("Grpc-Metadata-macaroon", &self.macaroon) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await?) + } + async fn json(&self, path: &str, body: Option) -> Result { + let response = self.raw(path, body).await?; + Self::read_json(response).await + } + async fn read_json(mut response: reqwest::Response) -> Result { + anyhow::ensure!( + response.status().is_success(), + "Wallet operation unavailable ({})", + response.status() + ); + let mut bytes = vec![]; + while let Some(chunk) = response.chunk().await? { + anyhow::ensure!( + bytes.len() + chunk.len() <= 4 * 1024 * 1024, + "Wallet response too large" + ); + bytes.extend_from_slice(&chunk); + } + Ok(serde_json::from_slice(&bytes)?) + } + async fn all_leases(&self) -> Result> { + let body = self + .json("/v2/wallet/utxos/leases", Some(json!({}))) + .await?; + body["locked_utxos"] + .as_array() + .context("Wallet omitted leases")? + .iter() + .map(parse_lease) + .collect() + } + async fn available(&self) -> Result> { + let body = self + .json( + "/v2/wallet/utxos", + Some(json!({"min_confs":1,"max_confs":2147483647,"account":"default"})), + ) + .await?; + let leased: HashSet<_> = self + .all_leases() + .await? + .iter() + .map(|l| l.outpoint()) + .collect::>()?; + let mut seen = HashSet::new(); + let mut result = vec![]; + for row in body["utxos"].as_array().context("Wallet omitted UTXOs")? { + let outpoint = parse_outpoint(&row["outpoint"])?; + anyhow::ensure!(seen.insert(outpoint), "Duplicate wallet output"); + if leased.contains(&outpoint) { + continue; + } + if integer(&row["confirmations"])? < 1 { + continue; + } + let script = row["pk_script"].as_str().context("Missing UTXO script")?; + let decoded = ScriptBuf::from_bytes(hex::decode(script)?); + if !decoded.is_p2wpkh() && !decoded.is_p2tr() { + continue; + } + let value = integer(&row["amount_sat"])?; + anyhow::ensure!( + value > 0 && value <= MAX_SATS, + "Invalid wallet output value" + ); + result.push(Lease { + lock_id: String::new(), + txid: outpoint.txid.to_string(), + vout: outpoint.vout, + value_sats: value, + script: script.to_ascii_lowercase(), + expires_at: 0, + }); + } + result.sort_by_key(|l| (l.value_sats, l.txid.clone(), l.vout)); + Ok(result) + } + async fn reserve(&self) -> Result { + let reserve = integer( + &self + .json("/v2/wallet/reserve?additional_public_channels=0", None) + .await?["required_reserve"], + )?; + anyhow::ensure!(reserve <= MAX_SATS, "Invalid channel reserve"); + Ok(reserve) + } + async fn verify_wallet(&self, record: &Record) -> Result<()> { + let expected_network = record.network().context("Missing original network")?; + let info = self.json("/v1/getinfo", None).await?; + anyhow::ensure!( + info["synced_to_chain"] == true, + "Wallet is not synced; no payment prepared" + ); + let network = match expected_network { + engine::ChainNetwork::Mainnet => "mainnet", + engine::ChainNetwork::Testnet => "testnet", + engine::ChainNetwork::Signet => "signet", + engine::ChainNetwork::Regtest => "regtest", + }; + let chains = info["chains"] + .as_array() + .context("Missing wallet network")?; + anyhow::ensure!( + chains.len() == 1 && chains[0]["chain"] == "bitcoin" && chains[0]["network"] == network, + "Wallet and original payment networks differ" + ); + let accounts = self.json("/v2/wallet/accounts?name=default", None).await?; + let accounts = accounts["accounts"] + .as_array() + .context("Wallet account information unavailable")?; + anyhow::ensure!( + !accounts.is_empty() + && accounts + .iter() + .all(|a| a["name"] == "default" && a["watch_only"] == false), + "Native purchase signing requires a local spending account" + ); + Ok(()) + } + async fn verify_change(&self, record: &Record, address: &str) -> Result { + let network = record.network().context("Missing original network")?; + let parsed = address + .parse::>()? + .require_network(network.bitcoin())?; + let script = parsed.script_pubkey(); + anyhow::ensure!( + script.is_p2wpkh() || script.is_p2tr(), + "Unsupported change address type" + ); + anyhow::ensure!( + record + .quote + .as_ref() + .map(|q| q.script().map(|s| s != script)) + .transpose()? + .unwrap_or(true), + "Change cannot be the seller address" + ); + let addresses = self + .json("/v2/wallet/addresses?account_name=default", None) + .await?; + let mut owned = false; + for account in addresses["account_with_addresses"] + .as_array() + .context("Wallet address ownership unavailable")? + { + if account["name"] != "default" { + continue; + } + for candidate in account["addresses"] + .as_array() + .context("Wallet omitted addresses")? + { + if candidate["is_internal"] == true + && candidate["address"].as_str() == Some(address) + { + owned = true; + } + } + } + anyhow::ensure!( + owned, + "Change address is not a verified internal wallet address" + ); + Ok(script) + } + /// Persist allocation uncertainty before NextAddr. Never retry an unknown + /// allocation: LND provides no caller idempotency key for this operation. + pub(crate) async fn prepare_change(&self, journal: &Journal) -> Result { + let mut record = journal.load()?.context("Missing original purchase")?; + if let Some(engine::ChangeAddress::Ready { address }) = &record.change_address { + self.verify_change(&record, address).await?; + return Ok(address.clone()); + } + anyhow::ensure!(record.change_address.is_none(), "Original change address allocation is ambiguous; preserve this operation for recovery, do not allocate another"); + anyhow::ensure!( + matches!( + record.phase, + engine::Phase::OfferPrepared | engine::Phase::Quoted + ) && record.retirement.is_none() + && !record.externally_exposed + && !record.settled, + "Original payment cannot allocate change" + ); + self.verify_wallet(&record).await?; + record.change_address = Some(engine::ChangeAddress::Dispatched); + record.mutations = record + .mutations + .checked_add(1) + .context("Mutation sequence exhausted")?; + journal.save(&record)?; + let reply = self + .json( + "/v2/wallet/address/next", + Some(json!({"account":"default","type":"WITNESS_PUBKEY_HASH","change":true})), + ) + .await?; + let address = reply["addr"] + .as_str() + .context("Original change allocation reply omitted address")? + .to_owned(); + self.verify_change(&record, &address).await?; + record.change_address = Some(engine::ChangeAddress::Ready { + address: address.clone(), + }); + journal.save(&record)?; + Ok(address) + } + /// Review produces only typed inputs/amounts/scripts and fee policy. There + /// is no recipient address, raw transaction, or signable PSBT at this stage. + pub(crate) async fn prepare_plan( + &self, + journal: &Journal, + request: PlanRequest, + ) -> Result { + use crate::content_onchain_plan::{FundingPlan, PlanInput}; + let record = journal.load()?.context("Original purchase missing")?; + anyhow::ensure!( + record.phase == engine::Phase::OfferPrepared + && record.retirement.is_none() + && !record.externally_exposed, + "Original offer cannot prepare a new plan" + ); + let offer = record.offer.as_ref().context("Original offer missing")?; + offer.validate()?; + anyhow::ensure!( + request.max_fee_sats > 0 && request.max_fee_sats <= MAX_SATS, + "Explicit fee budget required" + ); + self.verify_wallet(&record).await?; + let change = self.verify_change(&record, &request.change_address).await?; + let rate = match request.sat_per_vbyte { + Some(rate) => { + anyhow::ensure!((1..=5000).contains(&rate), "Unsupported fee rate"); + rate + } + None => super::fee_policy::estimated_sat_per_vbyte( + &self.json("/v2/wallet/estimatefee/1", None).await?, + )?, + }; + let available = self.available().await?; + let reserve = self.reserve().await?; + let available_sats = total(&available)?; + let mut selected = vec![]; + let mut choice = None; + for input in available.iter().take(MAX_INPUTS) { + selected.push(input.clone()); + let selected_sats = total(&selected)?; + if available_sats + .checked_sub(selected_sats) + .context("Invalid available balance")? + < reserve + { + break; + } + let fee = rate + .checked_mul(crate::content_onchain_plan::max_vsize( + &selected, + Some(&change), + )?) + .context("Fee overflow")?; + let Some(available_fee) = selected_sats.checked_sub(record.binding.price_sats) else { + continue; + }; + let (change_sats, fee) = + if let Some(change_sats) = available_fee.checked_sub(fee).filter(|n| *n >= 546) { + (change_sats, fee) + } else { + let minimum = rate + .checked_mul(crate::content_onchain_plan::max_vsize(&selected, None)?) + .context("Fee overflow")?; + if available_fee < minimum { + continue; + } + (0, available_fee) + }; + anyhow::ensure!(fee<=request.max_fee_sats,"Fast fee exceeds maximum; no seller address or inputs allocated, cancel or review another method"); + choice = Some((selected.clone(), change_sats, fee)); + break; + } + let (selected,change_sats,fee_sats)=choice.context("Insufficient supported confirmed funds after channel reserve; seller address is not allocated")?; + let mut inputs = vec![]; + for mut lease in selected { + lease.lock_id = record.lock_id.clone(); + let previous = self + .json(&format!("/v2/wallet/tx?txid={}", lease.txid), None) + .await?; + inputs.push(PlanInput { + lease, + previous_tx_hex: previous["raw_tx_hex"] + .as_str() + .context("Original input transaction unavailable")? + .into(), + }); + } + let plan = FundingPlan { + offer_sha256: offer.hash()?, + inputs, + change_address: request.change_address, + change_script: hex::encode(change.as_bytes()), + change_sats, + fee_sats, + fee_rate_sat_vbyte: rate, + max_fee_sats: request.max_fee_sats, + }; + engine::save_plan(journal, plan) + } + /// Read-only preparation. The caller's explicit fee budget is mandatory; + /// omitted rate uses the existing Fast/next-block estimate, never a fixed rate. + pub(crate) async fn prepare_exact( + &self, + journal: &Journal, + request: PlanRequest, + ) -> Result { + let record = journal.load()?.context("Missing original purchase")?; + anyhow::ensure!( + record.phase == engine::Phase::Quoted && !record.externally_exposed && !record.settled, + "Original payment already started or address exposed" + ); + anyhow::ensure!( + request.max_fee_sats > 0 && request.max_fee_sats <= MAX_SATS, + "Explicit fee budget required" + ); + if let Some(change) = &record.change_address { + anyhow::ensure!( + matches!(change, engine::ChangeAddress::Ready { address } if address == &request.change_address), + "Recover original change allocation before preparing payment" + ); + } + self.verify_wallet(&record).await?; + let change = self.verify_change(&record, &request.change_address).await?; + let rate = match request.sat_per_vbyte { + Some(rate) => { + anyhow::ensure!( + (1..=5000).contains(&rate), + "Fee rate outside supported bounds" + ); + rate + } + None => super::fee_policy::estimated_sat_per_vbyte( + &self.json("/v2/wallet/estimatefee/1", None).await?, + )?, + }; + let available = self.available().await?; + let reserve = self.reserve().await?; + let (mut psbt, mut inputs, fee) = select_template( + &record, + &change, + &available, + reserve, + rate, + request.max_fee_sats, + )?; + // Include exact previous transactions as well as witness UTXOs. Never + // rely on aggregate transaction amounts or unverified supplied values. + for (metadata, input) in psbt.inputs.iter_mut().zip(&inputs) { + let previous = self + .json(&format!("/v2/wallet/tx?txid={}", input.txid), None) + .await?; + let raw = previous["raw_tx_hex"] + .as_str() + .context("Original input transaction unavailable")?; + let tx: Transaction = consensus::deserialize(&hex::decode(raw)?)?; + anyhow::ensure!( + tx.compute_txid().to_string() == input.txid, + "Input transaction changed" + ); + let output = tx + .output + .get(input.vout as usize) + .context("Input output missing")?; + anyhow::ensure!( + output.value.to_sat() == input.value_sats + && hex::encode(output.script_pubkey.as_bytes()) == input.script, + "Original input output changed" + ); + metadata.non_witness_utxo = Some(tx); + } + for input in &mut inputs { + input.lock_id = record.lock_id.clone(); + } + // The absolute approved fee is authoritative. This conservative rate + // bound derived from unsigned size also bounds every finalized size. + let max_rate = fee.div_ceil(psbt.unsigned_tx.vsize() as u64); + let policy = FeePolicy { + change_script: hex::encode(change.as_bytes()), + max_fee_sats: request.max_fee_sats, + max_fee_rate_sat_vbyte: max_rate, + }; + let template = Funded { + psbt_base64: base64::engine::general_purpose::STANDARD.encode(psbt.serialize()), + leases: inputs, + }; + engine::save_exact_template(journal, policy, template) + } +} +fn integer(value: &Value) -> Result { + match value { + Value::String(s) if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) => { + s.parse().context("Invalid wallet integer") + } + value => value.as_u64().context("Missing wallet integer"), + } +} +fn parse_outpoint(value: &Value) -> Result { + let txid = if let Some(s) = value["txid_str"].as_str().filter(|s| !s.is_empty()) { + s.parse()? + } else { + let mut raw = base64::engine::general_purpose::STANDARD + .decode(value["txid_bytes"].as_str().context("Missing outpoint")?)?; + anyhow::ensure!(raw.len() == 32, "Invalid outpoint hash"); + raw.reverse(); + hex::encode(raw).parse()? + }; + Ok(OutPoint { + txid, + vout: u32::try_from(integer(&value["output_index"])?).context("Invalid output index")?, + }) +} +fn parse_lease(value: &Value) -> Result { + let id = base64::engine::general_purpose::STANDARD + .decode(value["id"].as_str().context("Missing lease owner")?)?; + anyhow::ensure!(id.len() == 32, "Invalid lease owner"); + let point = parse_outpoint(&value["outpoint"])?; + Ok(Lease { + lock_id: hex::encode(id), + txid: point.txid.to_string(), + vout: point.vout, + value_sats: integer(&value["value"])?, + script: hex::encode( + base64::engine::general_purpose::STANDARD.decode( + value["pk_script"] + .as_str() + .context("Missing lease script")?, + )?, + ), + expires_at: integer(&value["expiration"])?, + }) +} +fn total(inputs: &[Lease]) -> Result { + inputs.iter().try_fold(0u64, |sum, input| { + sum.checked_add(input.value_sats) + .filter(|v| *v <= MAX_SATS) + .context("Wallet amount overflow") + }) +} +fn transaction( + record: &Record, + change: &ScriptBuf, + inputs: &[Lease], + change_value: Option, +) -> Result { + let quote = record.quote.as_ref().context("Missing original quote")?; + let mut output = vec![TxOut { + value: Amount::from_sat(quote.binding.price_sats), + script_pubkey: quote.script()?, + }]; + if let Some(value) = change_value { + output.push(TxOut { + value: Amount::from_sat(value), + script_pubkey: change.clone(), + }); + } + Ok(Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .iter() + .map(|input| { + Ok(TxIn { + previous_output: input.outpoint()?, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }) + }) + .collect::>()?, + output, + }) +} +fn max_signed_vsize(mut tx: Transaction, inputs: &[Lease]) -> Result { + for (input, lease) in tx.input.iter_mut().zip(inputs) { + let script = ScriptBuf::from_bytes(hex::decode(&lease.script)?); + input.witness = if script.is_p2wpkh() { + Witness::from_slice(&[vec![0; 73], vec![0; 33]]) + } else if script.is_p2tr() { + Witness::from_slice(&[vec![0; 65]]) + } else { + anyhow::bail!("Unsupported signing input") + }; + } + Ok(tx.vsize() as u64) +} +fn select_template( + record: &Record, + change: &ScriptBuf, + available: &[Lease], + reserve: u64, + rate: u64, + max_fee: u64, +) -> Result<(Psbt, Vec, u64)> { + let quote = record.quote.as_ref().context("Missing original quote")?; + let available_sats = total(available)?; + let mut selected = vec![]; + for input in available.iter().take(MAX_INPUTS) { + selected.push(input.clone()); + let selected_sats = total(&selected)?; + // Leave channel reserve in confirmed, unselected outputs. Do not count + // the proposed (not-yet-confirmed) change as reserve. + if available_sats + .checked_sub(selected_sats) + .context("Invalid available amount")? + < reserve + { + break; + } + let skeleton = transaction(record, change, &selected, Some(546))?; + let fee = rate + .checked_mul(max_signed_vsize(skeleton, &selected)?) + .context("Fee overflow")?; + let Some(remainder) = selected_sats + .checked_sub(quote.binding.price_sats) + .and_then(|v| v.checked_sub(fee)) + else { + continue; + }; + let (tx, fee) = if remainder >= 546 { + ( + transaction(record, change, &selected, Some(remainder))?, + fee, + ) + } else { + let tx = transaction(record, change, &selected, None)?; + let minimum = rate + .checked_mul(max_signed_vsize(tx.clone(), &selected)?) + .context("Fee overflow")?; + let fee = selected_sats - quote.binding.price_sats; + if fee < minimum { + continue; + } + (tx, fee) + }; + anyhow::ensure!( + fee <= max_fee, + "Estimated Fast fee exceeds the approved fee cap; no inputs leased" + ); + let mut psbt = Psbt::from_unsigned_tx(tx)?; + for (metadata, lease) in psbt.inputs.iter_mut().zip(&selected) { + metadata.witness_utxo = Some(TxOut { + value: Amount::from_sat(lease.value_sats), + script_pubkey: ScriptBuf::from_bytes(hex::decode(&lease.script)?), + }); + } + return Ok((psbt, selected, fee)); + } + anyhow::bail!("Insufficient supported confirmed outputs after channel reserve (maximum 32 inputs); no inputs leased") +} +impl Wallet for LndPurchaseWallet { + async fn prepare_funding(&self, record: &Record) -> Result<()> { + self.verify_wallet(record).await?; + if let Some(engine::ChangeAddress::Ready { address }) = &record.change_address { + self.verify_change(record, address).await?; + } + let planned_inputs: Vec = if let Some(template) = &record.template { + engine::validate_funded(record, template)?; + template.leases.clone() + } else { + let plan = record + .plan + .as_ref() + .context("Exact funding plan must be saved before leasing")?; + plan.validate(record)?; + plan.inputs.iter().map(|i| i.lease.clone()).collect() + }; + let planned: HashSet<_> = planned_inputs + .iter() + .map(|l| l.outpoint()) + .collect::>()?; + let available = self.available().await?; + let owned = self.leases(&record.lock_id).await?; + for expected in &planned_inputs { + anyhow::ensure!(available.iter().chain(&owned).any(|actual|actual.txid==expected.txid&&actual.vout==expected.vout&&actual.value_sats==expected.value_sats&&actual.script==expected.script),"Original planned input is unavailable; no replacement input or seller address requested"); + } + let other: Vec<_> = available + .into_iter() + .filter(|l| l.outpoint().is_ok_and(|p| !planned.contains(&p))) + .collect(); + anyhow::ensure!( + total(&other)? >= self.reserve().await?, + "Channel reserve changed; original inputs remain reserved, no transaction signed" + ); + Ok(()) + } + async fn fund(&self, _: &Record) -> Result { + anyhow::bail!( + "Automatic FundPsbt is disabled; persist an exact template and lease its inputs" + ) + } + async fn leases(&self, lock_id: &str) -> Result> { + Ok(self + .all_leases() + .await? + .into_iter() + .filter(|l| l.lock_id == lock_id) + .collect()) + } + async fn lease(&self, lease: &Lease) -> Result<()> { + let id = base64::engine::general_purpose::STANDARD.encode(hex::decode(&lease.lock_id)?); + let response=self.json("/v2/wallet/utxos/lease",Some(json!({"id":id,"outpoint":{"txid_str":lease.txid,"output_index":lease.vout},"expiration_seconds":LEASE_SECONDS.to_string()}))).await?; + anyhow::ensure!( + integer(&response["expiration"])? > u64::try_from(chrono::Utc::now().timestamp())?, + "Wallet did not confirm original input lease" + ); + Ok(()) + } + async fn sign(&self, funded: &Funded) -> Result { + let response = self + .json( + "/v2/wallet/psbt/finalize", + Some(json!({"funded_psbt":funded.psbt_base64,"account":"default"})), + ) + .await?; + let raw = base64::engine::general_purpose::STANDARD.decode( + response["raw_final_tx"] + .as_str() + .context("Missing original signed transaction")?, + )?; + let tx: Transaction = consensus::deserialize(&raw)?; + Ok(Signed { + raw_hex: hex::encode(raw), + txid: tx.compute_txid().to_string(), + }) + } + async fn publish(&self, signed: &Signed) -> Result<()> { + let raw = base64::engine::general_purpose::STANDARD.encode(hex::decode(&signed.raw_hex)?); + let response = self + .json("/v2/wallet/tx", Some(json!({"tx_hex":raw}))) + .await?; + anyhow::ensure!( + response + .get("publish_error") + .and_then(Value::as_str) + .is_none_or(str::is_empty), + "Original transaction publication remains unconfirmed" + ); + Ok(()) + } + async fn transaction_known(&self, txid: &str) -> Result { + let _: bitcoin::Txid = txid.parse()?; + let response = self + .raw(&format!("/v2/wallet/tx?txid={txid}"), None) + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(false); + } + anyhow::ensure!( + response.status().is_success(), + "Original transaction lookup unavailable" + ); + let body = Self::read_json(response).await?; + anyhow::ensure!( + body["tx_hash"].as_str() == Some(txid), + "Wallet returned a different transaction" + ); + Ok(true) + } +} + +impl super::RpcHandler { + pub(crate) async fn onchain_purchase_wallet(&self) -> Result { + let (client, macaroon) = self.lnd_client().await?; + Ok(LndPurchaseWallet::new( + client, + super::LND_REST_BASE_URL.into(), + macaroon, + )) + } +} +impl LndPurchaseWallet { + pub(crate) async fn network(&self) -> Result { + let info = self.json("/v1/getinfo", None).await?; + anyhow::ensure!(info["synced_to_chain"] == true, "Wallet is not synced"); + let chains = info["chains"] + .as_array() + .context("Wallet network unavailable")?; + anyhow::ensure!( + chains.len() == 1 && chains[0]["chain"] == "bitcoin", + "Unsupported wallet chain" + ); + match chains[0]["network"].as_str() { + Some("mainnet") => Ok(engine::ChainNetwork::Mainnet), + Some("testnet") => Ok(engine::ChainNetwork::Testnet), + Some("signet") => Ok(engine::ChainNetwork::Signet), + Some("regtest") => Ok(engine::ChainNetwork::Regtest), + _ => anyhow::bail!("Unsupported wallet network"), + } + } +} +impl crate::content_onchain_seller::Wallet for LndPurchaseWallet { + async fn network(&self) -> Result { + LndPurchaseWallet::network(self).await + } + async fn preflight(&self, network: engine::ChainNetwork) -> Result<()> { + anyhow::ensure!( + self.network().await? == network, + "Original seller network changed" + ); + let accounts = self.json("/v2/wallet/accounts?name=default", None).await?; + let accounts = accounts["accounts"] + .as_array() + .context("Wallet account unavailable")?; + anyhow::ensure!( + !accounts.is_empty() + && accounts + .iter() + .all(|a| a["name"] == "default" && a["watch_only"] == false), + "Seller wallet account unavailable" + ); + Ok(()) + } + async fn allocate(&self) -> Result { + let body = self + .json( + "/v2/wallet/address/next", + Some(json!({"account":"default","type":"WITNESS_PUBKEY_HASH","change":false})), + ) + .await?; + Ok(body["addr"] + .as_str() + .context("Receive allocation reply omitted address")? + .to_owned()) + } + async fn received(&self, address: &str, amount: u64) -> Result { + let body = self.json("/v1/transactions", None).await?; + Ok(super::wallet::confirmed_address_sats(&body, address)? >= amount) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_lightning::{Binding, RetainedFile}; + use hyper::{ + service::{make_service_fn, service_fn}, + Body, Response, Server, StatusCode, + }; + use std::{ + convert::Infallible, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + }; + fn script(byte: u8) -> ScriptBuf { + let mut bytes = vec![0, 20]; + bytes.extend([byte; 20]); + ScriptBuf::from_bytes(bytes) + } + fn address(byte: u8) -> String { + bitcoin::Address::from_script(&script(byte), bitcoin::Network::Regtest) + .unwrap() + .to_string() + } + fn previous(byte: u8, value: u64) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(vec![1, byte]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(value), + script_pubkey: script(byte), + }], + } + } + struct Mock { + calls: Mutex>, + leases: Mutex>, + previous: Vec, + published: Mutex>, + lose_change: AtomicBool, + lose_lease: AtomicBool, + lose_sign: AtomicBool, + lose_publish: AtomicBool, + reserve: u64, + rate: u64, + record_path: std::path::PathBuf, + locked_id: String, + } + impl Mock { + fn count(&self, path: &str) -> usize { + self.calls + .lock() + .unwrap() + .iter() + .filter(|(p, _)| p == path) + .count() + } + fn saved(&self) -> Record { + let envelope: Value = + serde_json::from_slice(&std::fs::read(&self.record_path).unwrap()).unwrap(); + serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap() + } + } + fn lease_value(lease: &Lease) -> Value { + json!({"id":base64::engine::general_purpose::STANDARD.encode(hex::decode(&lease.lock_id).unwrap()), + "outpoint":{"txid_str":lease.txid,"output_index":lease.vout},"value":lease.value_sats.to_string(), + "pk_script":base64::engine::general_purpose::STANDARD.encode(hex::decode(&lease.script).unwrap()),"expiration":lease.expires_at.to_string()}) + } + async fn fixture( + rate: u64, + reserve: u64, + ) -> ( + tempfile::TempDir, + Journal, + LndPurchaseWallet, + Arc, + tokio::task::JoinHandle<()>, + ) { + fixture_mode(rate, reserve, false).await + } + async fn fixture_mode( + rate: u64, + reserve: u64, + offer_only: bool, + ) -> ( + tempfile::TempDir, + Journal, + LndPurchaseWallet, + Arc, + tokio::task::JoinHandle<()>, + ) { + let data = tempfile::tempdir().unwrap(); + let 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, + }; + let journal = Journal::open(data.path(), &binding.id).await.unwrap(); + let record = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap(); + let lock_id = record.lock_id.clone(); + journal.save(&record).unwrap(); + if offer_only { + engine::accept_offer( + &journal, + crate::content_onchain_plan::Offer { + binding: binding.clone(), + network: engine::ChainNetwork::Regtest, + recipient_script_type: "p2wpkh".into(), + source: RetainedFile { + sha256: "a".repeat(64), + size: 4, + filename: "file".into(), + mime_type: "text/plain".into(), + }, + }, + ) + .unwrap(); + } else { + engine::accept_quote( + &journal, + engine::Quote { + binding: binding.clone(), + address: address(1), + network: engine::ChainNetwork::Regtest, + source: RetainedFile { + sha256: "a".repeat(64), + size: 4, + filename: "file".into(), + mime_type: "text/plain".into(), + }, + }, + ) + .unwrap(); + } + let state = Arc::new(Mock { + calls: Mutex::new(vec![]), + leases: Mutex::new(vec![]), + previous: vec![previous(3, 1500), previous(4, 2500), previous(5, 10000)], + published: Mutex::new(None), + lose_change: AtomicBool::new(false), + lose_lease: AtomicBool::new(false), + lose_sign: AtomicBool::new(false), + lose_publish: AtomicBool::new(false), + rate, + reserve, + record_path: data + .path() + .join("content-onchain") + .join(format!("{}.json", binding.id)), + locked_id: lock_id, + }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let factory = state.clone(); + let service = make_service_fn(move |_| { + let state = factory.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |request: hyper::Request| { + let state = state.clone(); + async move { + let path = request.uri().path().to_string(); + let query = request.uri().query().unwrap_or("").to_string(); + let bytes = hyper::body::to_bytes(request.into_body()).await.unwrap(); + let body: Value = if bytes.is_empty() { + json!({}) + } else { + serde_json::from_slice(&bytes).unwrap() + }; + state + .calls + .lock() + .unwrap() + .push((path.clone(), body.clone())); + let mut status = StatusCode::OK; + let mut lost = false; + let value = match path.as_str() { + "/v1/getinfo" => { + json!({"synced_to_chain":true,"chains":[{"chain":"bitcoin","network":"regtest"}]}) + } + "/v2/wallet/accounts" => { + json!({"accounts":[{"name":"default","watch_only":false}]}) + } + "/v2/wallet/address/next" => { + let saved = state.saved(); + assert_eq!( + saved.change_address, + Some(engine::ChangeAddress::Dispatched) + ); + assert_eq!(body["change"], true); + lost = state.lose_change.swap(false, Ordering::SeqCst); + json!({"addr":address(2)}) + } + "/v2/wallet/addresses" => { + json!({"account_with_addresses":[{"name":"default","addresses":[{"address":address(2),"is_internal":true}]}]}) + } + "/v2/wallet/estimatefee/1" => { + json!({"sat_per_kw":(state.rate*250).to_string()}) + } + "/v2/wallet/reserve" => { + json!({"required_reserve":state.reserve.to_string()}) + } + "/v2/wallet/utxos" => { + json!({"utxos":state.previous.iter().map(|tx|json!({"outpoint":{"txid_str":tx.compute_txid().to_string(),"output_index":0}, + "confirmations":6,"amount_sat":tx.output[0].value.to_sat().to_string(),"pk_script":hex::encode(tx.output[0].script_pubkey.as_bytes())})).collect::>()}) + } + "/v2/wallet/utxos/leases" => { + json!({"locked_utxos":state.leases.lock().unwrap().iter().map(lease_value).collect::>()}) + } + "/v2/wallet/utxos/lease" => { + let saved = state.saved(); + assert!(matches!( + saved.phase, + engine::Phase::LeaseDispatched + | engine::Phase::PlanLeaseDispatched + | engine::Phase::InputsLeased + | engine::Phase::AddressAllocationDispatched + | engine::Phase::Funded + | engine::Phase::SigningDispatched + )); + assert!(saved.template.is_some() || saved.plan.is_some()); + let id = hex::encode( + base64::engine::general_purpose::STANDARD + .decode(body["id"].as_str().unwrap()) + .unwrap(), + ); + assert_eq!(id, state.locked_id); + let point = parse_outpoint(&body["outpoint"]).unwrap(); + let planned: Vec = saved + .template + .as_ref() + .map(|t| t.leases.clone()) + .unwrap_or_else(|| { + saved + .plan + .as_ref() + .unwrap() + .inputs + .iter() + .map(|i| i.lease.clone()) + .collect() + }); + assert!(planned.iter().any(|l| l.outpoint().unwrap() == point)); + let tx = state + .previous + .iter() + .find(|t| t.compute_txid() == point.txid) + .unwrap(); + let mut leases = state.leases.lock().unwrap(); + if leases + .iter() + .any(|l| l.outpoint().unwrap() == point && l.lock_id != id) + { + status = StatusCode::CONFLICT; + json!({"error":"already locked"}) + } else { + leases.retain(|l| l.outpoint().unwrap() != point); + let expiration = chrono::Utc::now().timestamp() as u64 + 600; + leases.push(Lease { + lock_id: id, + txid: point.txid.to_string(), + vout: point.vout, + value_sats: tx.output[0].value.to_sat(), + script: hex::encode(tx.output[0].script_pubkey.as_bytes()), + expires_at: expiration, + }); + lost = state.lose_lease.swap(false, Ordering::SeqCst); + json!({"expiration":expiration.to_string()}) + } + } + "/v2/wallet/psbt/finalize" => { + let saved = state.saved(); + assert_eq!(saved.phase, engine::Phase::SigningDispatched); + assert_eq!( + body["funded_psbt"].as_str(), + Some(saved.funded.as_ref().unwrap().psbt_base64.as_str()) + ); + let mut tx = engine::decode_psbt(saved.funded.as_ref().unwrap()) + .unwrap() + .unsigned_tx; + for input in &mut tx.input { + input.witness = + Witness::from_slice(&[vec![1; 72], vec![2; 33]]); + } + lost = state.lose_sign.swap(false, Ordering::SeqCst); + json!({"raw_final_tx":base64::engine::general_purpose::STANDARD.encode(consensus::serialize(&tx))}) + } + "/v2/wallet/tx" if body.get("tx_hex").is_some() => { + let saved = state.saved(); + assert!(matches!( + saved.phase, + engine::Phase::BroadcastDispatched | engine::Phase::Published + )); + let raw = base64::engine::general_purpose::STANDARD + .decode(body["tx_hex"].as_str().unwrap()) + .unwrap(); + assert_eq!( + hex::encode(&raw), + saved.signed.as_ref().unwrap().raw_hex + ); + *state.published.lock().unwrap() = + Some(consensus::deserialize(&raw).unwrap()); + lost = state.lose_publish.swap(false, Ordering::SeqCst); + json!({}) + } + "/v2/wallet/tx" => { + let requested = query.strip_prefix("txid=").unwrap(); + let tx = state + .previous + .iter() + .find(|t| t.compute_txid().to_string() == requested) + .cloned() + .or_else(|| { + state + .published + .lock() + .unwrap() + .as_ref() + .filter(|t| t.compute_txid().to_string() == requested) + .cloned() + }); + if let Some(tx) = tx { + json!({"tx_hash":tx.compute_txid().to_string(),"raw_tx_hex":hex::encode(consensus::serialize(&tx))}) + } else { + status = StatusCode::NOT_FOUND; + json!({"error":"not found"}) + } + } + _ => panic!("Unexpected wallet route {path}"), + }; + let response_body = if lost { + Body::wrap_stream(futures_util::stream::once(async { + Err::(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "lost reply after mutation", + )) + })) + } else { + Body::from(value.to_string()) + }; + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(response_body) + .unwrap(), + ) + } + })) + } + }); + let server = Server::from_tcp(listener).unwrap().serve(service); + let task = tokio::spawn(async move { + server.await.unwrap(); + }); + let wallet = LndPurchaseWallet::new( + reqwest::Client::new(), + format!("http://{addr}"), + "test".into(), + ); + (data, journal, wallet, state, task) + } + fn request() -> PlanRequest { + PlanRequest { + change_address: address(2), + max_fee_sats: 1000, + sat_per_vbyte: None, + } + } + #[tokio::test] + async fn exact_template_is_saved_before_lease_and_all_lost_replies_recover_same_transaction() { + let (data, j, wallet, state, server) = fixture(2, 2000).await; + let planned = wallet.prepare_exact(&j, request()).await.unwrap(); + let original = planned.template.clone().unwrap(); + let id = planned.binding.id.clone(); + assert_eq!(planned.phase, engine::Phase::TemplatePrepared); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + assert_eq!(state.count("/v2/wallet/estimatefee/1"), 1); + state.lose_lease.store(true, Ordering::SeqCst); + assert!(engine::drive(&j, &wallet, engine::Action::Lease, None) + .await + .is_err()); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + assert_eq!(j.load().unwrap().unwrap().template, Some(original.clone())); + let leased = engine::drive(&j, &wallet, engine::Action::Lease, None) + .await + .unwrap(); + assert_eq!(leased.phase, engine::Phase::Funded); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 1); + state.lose_sign.store(true, Ordering::SeqCst); + assert!(engine::drive(&j, &wallet, engine::Action::Sign, None) + .await + .is_err()); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + let signed = engine::drive(&j, &wallet, engine::Action::Sign, None) + .await + .unwrap(); + assert_eq!( + signed.funded.as_ref().unwrap().psbt_base64, + original.psbt_base64 + ); + { + let calls = state.calls.lock().unwrap(); + let signs: Vec<_> = calls + .iter() + .filter(|(p, _)| p == "/v2/wallet/psbt/finalize") + .collect(); + assert_eq!(signs.len(), 2); + assert_eq!(signs[0].1, signs[1].1); + } + state.lose_publish.store(true, Ordering::SeqCst); + assert!(engine::drive(&j, &wallet, engine::Action::Publish, None) + .await + .is_err()); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + let recovered = engine::drive(&j, &wallet, engine::Action::Status, None) + .await + .unwrap(); + assert_eq!(recovered.phase, engine::Phase::Published); + assert_eq!(recovered.signed, signed.signed); + engine::drive(&j, &wallet, engine::Action::Publish, None) + .await + .unwrap(); + assert_eq!( + state + .calls + .lock() + .unwrap() + .iter() + .filter(|(p, b)| p == "/v2/wallet/tx" && b.get("tx_hex").is_some()) + .count(), + 1 + ); + assert_eq!(state.count("/v2/wallet/psbt/fund"), 0); + server.abort(); + } + #[tokio::test] + async fn fee_cap_and_unknown_change_fail_before_any_lease_or_signing() { + let (_data, j, wallet, state, server) = fixture(2, 2000).await; + let mut too_low = request(); + too_low.max_fee_sats = 1; + assert!(wallet.prepare_exact(&j, too_low).await.is_err()); + assert_eq!(j.load().unwrap().unwrap().phase, engine::Phase::Quoted); + let mut foreign = request(); + foreign.change_address = address(9); + assert!(wallet.prepare_exact(&j, foreign).await.is_err()); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + assert_eq!(state.count("/v2/wallet/psbt/finalize"), 0); + server.abort(); + } + #[tokio::test] + async fn leased_outputs_and_channel_reserve_are_excluded_and_explicit_rate_wins() { + let (_data, j, wallet, state, server) = fixture(5000, 2000).await; + let first = &state.previous[0]; + state.leases.lock().unwrap().push(Lease { + lock_id: "f".repeat(64), + txid: first.compute_txid().to_string(), + vout: 0, + value_sats: 1500, + script: hex::encode(script(3).as_bytes()), + expires_at: 4_000_000_000, + }); + let mut explicit = request(); + explicit.sat_per_vbyte = Some(1); + let prepared = wallet.prepare_exact(&j, explicit).await.unwrap(); + let leases = &prepared.template.as_ref().unwrap().leases; + assert_eq!(leases.len(), 1); + assert_eq!(leases[0].value_sats, 2500); + assert_eq!(state.count("/v2/wallet/estimatefee/1"), 0); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + server.abort(); + } + #[tokio::test] + async fn channel_reserve_and_foreign_lease_races_cannot_silently_choose_new_inputs() { + let (_data, j, wallet, state, server) = fixture(2, 14000).await; + assert!(wallet.prepare_exact(&j, request()).await.is_err()); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + server.abort(); + let (_data, j, wallet, state, server) = fixture(2, 2000).await; + let prepared = wallet.prepare_exact(&j, request()).await.unwrap(); + let mut taken = prepared.template.as_ref().unwrap().leases[0].clone(); + taken.lock_id = "f".repeat(64); + taken.expires_at = 4_000_000_000; + state.leases.lock().unwrap().push(taken); + assert!(engine::drive(&j, &wallet, engine::Action::Lease, None) + .await + .is_err()); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + assert_eq!(state.count("/v2/wallet/psbt/finalize"), 0); + assert_eq!(j.load().unwrap().unwrap().template, prepared.template); + server.abort(); + } + #[tokio::test] + async fn change_allocation_lost_reply_is_durable_and_never_repeated() { + let (data, j, wallet, state, server) = fixture(2, 2000).await; + let id = j.load().unwrap().unwrap().binding.id; + state.lose_change.store(true, Ordering::SeqCst); + assert!(wallet.prepare_change(&j).await.is_err()); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + assert_eq!( + j.load().unwrap().unwrap().change_address, + Some(engine::ChangeAddress::Dispatched) + ); + assert!(wallet + .prepare_change(&j) + .await + .unwrap_err() + .to_string() + .contains("ambiguous")); + assert!(wallet.prepare_exact(&j, request()).await.is_err()); + assert_eq!(state.count("/v2/wallet/address/next"), 1); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + server.abort(); + } + #[tokio::test] + async fn original_change_survives_reload_and_stale_record_cannot_replace_it() { + let (data, j, wallet, state, server) = fixture(2, 2000).await; + let stale = j.load().unwrap().unwrap(); + assert_eq!(wallet.prepare_change(&j).await.unwrap(), address(2)); + assert!(j.save(&stale).is_err()); + drop(j); + let j = Journal::open(data.path(), &stale.binding.id).await.unwrap(); + assert_eq!(wallet.prepare_change(&j).await.unwrap(), address(2)); + assert_eq!(state.count("/v2/wallet/address/next"), 1); + wallet.prepare_exact(&j, request()).await.unwrap(); + server.abort(); + } + #[tokio::test] + async fn funded_lease_renewal_lost_reply_recovers_before_signing_same_psbt() { + let (data, j, wallet, state, server) = fixture(2, 2000).await; + wallet.prepare_change(&j).await.unwrap(); + wallet.prepare_exact(&j, request()).await.unwrap(); + let funded = engine::drive(&j, &wallet, engine::Action::Lease, None) + .await + .unwrap(); + for lease in state.leases.lock().unwrap().iter_mut() { + lease.expires_at = 1; + } + state.lose_lease.store(true, Ordering::SeqCst); + assert!(engine::drive(&j, &wallet, engine::Action::Sign, None) + .await + .is_err()); + assert_eq!(j.load().unwrap().unwrap().phase, engine::Phase::Funded); + assert_eq!(state.count("/v2/wallet/psbt/finalize"), 0); + drop(j); + let j = Journal::open(data.path(), &funded.binding.id) + .await + .unwrap(); + let signed = engine::drive(&j, &wallet, engine::Action::Sign, None) + .await + .unwrap(); + assert_eq!(signed.funded, funded.funded); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 2); + assert_eq!(state.count("/v2/wallet/psbt/finalize"), 1); + assert_eq!(state.count("/v2/wallet/address/next"), 1); + server.abort(); + } + #[tokio::test] + async fn stolen_expired_input_blocks_original_signing_without_reselection() { + let (_data, j, wallet, state, server) = fixture(2, 2000).await; + wallet.prepare_exact(&j, request()).await.unwrap(); + let funded = engine::drive(&j, &wallet, engine::Action::Lease, None) + .await + .unwrap(); + for lease in state.leases.lock().unwrap().iter_mut() { + lease.lock_id = "f".repeat(64); + } + assert!(engine::drive(&j, &wallet, engine::Action::Sign, None) + .await + .is_err()); + let retained = j.load().unwrap().unwrap(); + assert_eq!(retained.phase, engine::Phase::Funded); + assert_eq!(retained.funded, funded.funded); + assert_eq!(state.count("/v2/wallet/psbt/finalize"), 0); + assert_eq!(state.count("/v2/wallet/estimatefee/1"), 1); + server.abort(); + } + #[tokio::test] + async fn offer_review_is_not_signable_and_fee_failure_can_retire_without_seller_allocation() { + let (_data, j, wallet, state, server) = fixture_mode(2, 0, true).await; + let change = wallet.prepare_change(&j).await.unwrap(); + let mut low = request(); + low.change_address = change.clone(); + low.max_fee_sats = 1; + assert!(wallet.prepare_plan(&j, low).await.is_err()); + let before = j.load().unwrap().unwrap(); + assert!(before.can_retire_unallocated()); + assert!(before.quote.is_none()); + assert!(before.template.is_none()); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + assert_eq!(state.count("/v2/wallet/psbt/finalize"), 0); + let mut req = request(); + req.change_address = change; + let planned = wallet.prepare_plan(&j, req).await.unwrap(); + assert_eq!(planned.phase, engine::Phase::PlanPrepared); + assert!(planned.template.is_none()); + assert!(planned.quote.is_none()); + assert!(planned.can_retire_unallocated()); + let ack = crate::content_onchain_seller::UnallocatedAck { + binding: planned.binding.clone(), + state: "cancelled_unallocated".into(), + address: Value::Null, + allocation_dispatched: false, + can_switch_method: true, + }; + let retired = engine::retire_unallocated(&j, ack).unwrap(); + assert!(retired.retirement.is_some()); + assert!(engine::lease_plan(&j, &wallet).await.is_err()); + server.abort(); + } + #[tokio::test] + async fn plan_leases_recover_before_real_recipient_binding_and_never_change_inputs() { + let (data, j, wallet, state, server) = fixture_mode(2, 0, true).await; + let mut req = request(); + req.change_address = wallet.prepare_change(&j).await.unwrap(); + let plan = wallet.prepare_plan(&j, req).await.unwrap(); + assert!(engine::mark_address_allocation(&j, false).is_err()); + state.lose_lease.store(true, Ordering::SeqCst); + assert!(engine::lease_plan(&j, &wallet).await.is_err()); + let id = plan.binding.id.clone(); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + let leased = engine::lease_plan(&j, &wallet).await.unwrap(); + assert_eq!(leased.phase, engine::Phase::InputsLeased); + assert_eq!(leased.plan, plan.plan); + assert!(leased.quote.is_none()); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 1); + engine::mark_address_allocation(&j, false).unwrap(); + let offer = plan.offer.unwrap(); + engine::accept_quote( + &j, + engine::Quote { + binding: offer.binding, + address: address(1), + network: offer.network, + source: offer.source, + }, + ) + .unwrap(); + let bound = engine::bind_plan(&j).unwrap(); + let tx = engine::decode_psbt(bound.template.as_ref().unwrap()) + .unwrap() + .unsigned_tx; + assert_eq!(tx.output[0].script_pubkey, script(1)); + assert_eq!(tx.output[0].value.to_sat(), 546); + assert_eq!(bound.plan, leased.plan); + engine::drive(&j, &wallet, engine::Action::Lease, None) + .await + .unwrap(); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 1); + assert_eq!(state.count("/v2/wallet/psbt/fund"), 0); + server.abort(); + } + #[tokio::test] + async fn no_change_plan_does_not_require_funding_an_unused_change_output() { + let (_data, j, wallet, state, server) = fixture_mode(8, 0, true).await; + let mut req = request(); + req.change_address = wallet.prepare_change(&j).await.unwrap(); + let planned = wallet.prepare_plan(&j, req).await.unwrap(); + let plan = planned.plan.unwrap(); + assert_eq!(plan.inputs.len(), 1); + assert_eq!(plan.change_sats, 0); + assert_eq!(plan.fee_sats, 954); + assert!(plan.fee_sats <= plan.max_fee_sats); + assert_eq!(state.count("/v2/wallet/utxos/lease"), 0); + server.abort(); + } +} diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index cc0f3184..75f7601c 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -1347,7 +1347,7 @@ fn psbt_key_origin_report(psbt_base64: &str) -> Result { /// 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 { +pub(super) fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result { use std::collections::{HashMap, HashSet}; const MAX_SATS: u64 = 21_000_000 * 100_000_000; fn integer(value: &serde_json::Value) -> Result { diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index c8b735a1..9f5710ac 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -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", diff --git a/core/archipelago/src/api/rpc/onchain_purchase.rs b/core/archipelago/src/api/rpc/onchain_purchase.rs new file mode 100644 index 00000000..0f094e64 --- /dev/null +++ b/core/archipelago/src/api/rpc/onchain_purchase.rs @@ -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, + price_sats: Option, + max_fee_sats: Option, + sat_per_vbyte: Option, + template_sha256: Option, + plan_sha256: Option, +} +fn public(record: &Record) -> Result { + 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 { + 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, + action: &str, + ) -> Result { + 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()); + } +} diff --git a/core/archipelago/src/content_lightning.rs b/core/archipelago/src/content_lightning.rs index 2e41938e..66de3080 100644 --- a/core/archipelago/src/content_lightning.rs +++ b/core/archipelago/src/content_lightning.rs @@ -19,7 +19,7 @@ pub(crate) struct Binding { pub price_sats: u64, } impl Binding { - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { anyhow::ensure!( uuid::Uuid::parse_str(&self.id)?.to_string() == self.id, "Invalid invoice operation" @@ -61,7 +61,7 @@ pub(crate) struct RetainedFile { pub mime_type: String, } impl RetainedFile { - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { anyhow::ensure!( self.sha256.len() == 64 && self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) diff --git a/core/archipelago/src/content_onchain.rs b/core/archipelago/src/content_onchain.rs new file mode 100644 index 00000000..55760c74 --- /dev/null +++ b/core/archipelago/src/content_onchain.rs @@ -0,0 +1,1741 @@ +//! Durable native on-chain purchase engine. Not yet connected to owner RPC/UI. +//! One operation owns one address and one funded transaction. Funding ambiguity +//! never invokes coin selection again; broadcast retries reuse saved bytes only. +use crate::content_lightning::{Binding, RetainedFile}; +use anyhow::{Context, Result}; +use base64::Engine; +use bitcoin::{consensus, psbt::Psbt, Transaction}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +fn valid_onion(value: &str) -> bool { + value.len() == 62 + && value.ends_with(".onion") + && value.as_bytes()[..56] + .iter() + .copied() + .all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)) +} +const MAX_RECORD: usize = 2 * 1024 * 1024; +const MAX_SATS: u64 = 2_100_000_000_000_000; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ChainNetwork { + Mainnet, + Testnet, + Signet, + Regtest, +} +impl ChainNetwork { + pub(crate) fn bitcoin(self) -> bitcoin::Network { + match self { + Self::Mainnet => bitcoin::Network::Bitcoin, + Self::Testnet => bitcoin::Network::Testnet, + Self::Signet => bitcoin::Network::Signet, + Self::Regtest => bitcoin::Network::Regtest, + } + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Quote { + pub binding: Binding, + pub address: String, + pub network: ChainNetwork, + pub source: RetainedFile, +} +impl Quote { + fn validate(&self) -> Result<()> { + self.binding.validate()?; + anyhow::ensure!( + (546..=MAX_SATS).contains(&self.binding.price_sats), + "Invalid on-chain price" + ); + self.address + .parse::>()? + .require_network(self.network.bitcoin())?; + self.source.validate()?; + Ok(()) + } + pub(crate) fn script(&self) -> Result { + Ok(self + .address + .parse::>()? + .require_network(self.network.bitcoin())? + .script_pubkey()) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FeePolicy { + /// Locally owned change script verified by the wallet adapter, never seller supplied. + pub change_script: String, + pub max_fee_sats: u64, + pub max_fee_rate_sat_vbyte: u64, +} +impl FeePolicy { + fn validate(&self) -> Result<()> { + let script = bitcoin::ScriptBuf::from_bytes(hex::decode(&self.change_script)?); + anyhow::ensure!( + script.is_p2wpkh() || script.is_p2tr(), + "Unsupported wallet change script" + ); + anyhow::ensure!( + self.max_fee_sats > 0 + && self.max_fee_sats <= MAX_SATS + && self.max_fee_rate_sat_vbyte > 0 + && self.max_fee_rate_sat_vbyte <= MAX_SATS, + "Invalid original fee limit" + ); + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Lease { + pub lock_id: String, + pub txid: String, + pub vout: u32, + pub value_sats: u64, + pub script: String, + pub expires_at: u64, +} +impl Lease { + pub(crate) fn outpoint(&self) -> Result { + Ok(bitcoin::OutPoint { + txid: self.txid.parse()?, + vout: self.vout, + }) + } + pub(crate) fn validate(&self, lock_id: &str) -> Result<()> { + anyhow::ensure!( + self.lock_id == lock_id && self.value_sats > 0 && self.value_sats <= MAX_SATS, + "Foreign or invalid wallet lease" + ); + self.outpoint()?; + let script = bitcoin::ScriptBuf::from_bytes(hex::decode(&self.script)?); + anyhow::ensure!( + script.is_p2wpkh() || script.is_p2tr(), + "Unsupported leased input script" + ); + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Funded { + pub psbt_base64: String, + pub leases: Vec, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Signed { + pub raw_hex: String, + pub txid: String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Phase { + AddressRequested, + OfferPrepared, + PlanPrepared, + PlanLeaseDispatched, + InputsLeased, + AddressAllocationDispatched, + Quoted, + TemplatePrepared, + LeaseDispatched, + FundingDispatched, + Funded, + SigningDispatched, + Signed, + BroadcastDispatched, + Published, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum ChangeAddress { + Dispatched, + Ready { address: String }, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Record { + pub binding: Binding, + pub seller_onion: String, + pub quote: Option, + #[serde(default)] + pub offer: Option, + #[serde(default)] + pub plan: Option, + pub externally_exposed: bool, + pub phase: Phase, + pub lock_id: String, + pub policy: Option, + #[serde(default)] + pub change_address: Option, + #[serde(default)] + pub template: Option, + pub funded: Option, + pub signed: Option, + pub observed_leases: Vec, + pub mutations: u64, + pub settled: bool, + #[serde(default)] + pub retirement: Option, +} +impl Record { + pub fn new(binding: Binding, seller_onion: String) -> Result { + binding.validate()?; + anyhow::ensure!( + (546..=MAX_SATS).contains(&binding.price_sats), + "Invalid on-chain price" + ); + anyhow::ensure!(valid_onion(&seller_onion), "Invalid seller address"); + use rand::RngCore; + let mut lock_id = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut lock_id); + Ok(Self { + binding, + seller_onion, + quote: None, + offer: None, + plan: None, + externally_exposed: false, + phase: Phase::AddressRequested, + lock_id: hex::encode(lock_id), + policy: None, + change_address: None, + template: None, + funded: None, + signed: None, + observed_leases: vec![], + mutations: 0, + settled: false, + retirement: None, + }) + } + pub(crate) fn can_retire_unallocated(&self) -> bool { + matches!( + self.phase, + Phase::AddressRequested | Phase::OfferPrepared | Phase::PlanPrepared + ) && self.quote.is_none() + && !self.externally_exposed + && !self.settled + && !matches!(self.change_address, Some(ChangeAddress::Dispatched)) + && self.policy.is_none() + && self.template.is_none() + && self.funded.is_none() + && self.signed.is_none() + && self.observed_leases.is_empty() + && self.mutations + == u64::from(matches!( + self.change_address, + Some(ChangeAddress::Ready { .. }) + )) + } + fn validate(&self) -> Result<()> { + self.binding.validate()?; + if let Some(ack) = &self.retirement { + ack.validate(&self.binding)?; + anyhow::ensure!( + matches!( + self.phase, + Phase::AddressRequested | Phase::OfferPrepared | Phase::PlanPrepared + ) && self.quote.is_none() + && !self.externally_exposed + && !self.settled + && !matches!(self.change_address, Some(ChangeAddress::Dispatched)) + && self.policy.is_none() + && self.template.is_none() + && self.funded.is_none() + && self.signed.is_none() + && self.observed_leases.is_empty() + && self.mutations + == u64::from(matches!( + self.change_address, + Some(ChangeAddress::Ready { .. }) + )), + "Mutated or allocated purchase cannot be retired" + ); + } + anyhow::ensure!( + hex::decode(&self.lock_id)?.len() == 32, + "Invalid original lease identifier" + ); + anyhow::ensure!(valid_onion(&self.seller_onion), "Invalid seller address"); + if let Some(change) = &self.change_address { + let network = self + .network() + .context("Change allocation requires original offer")?; + if let ChangeAddress::Ready { address } = change { + let script = address + .parse::>()? + .require_network(network.bitcoin())? + .script_pubkey(); + anyhow::ensure!( + (script.is_p2wpkh() || script.is_p2tr()) + && self + .quote + .as_ref() + .map(|q| q.script().map(|s| s != script)) + .transpose()? + .unwrap_or(true), + "Invalid original change address" + ); + if let Some(policy) = &self.policy { + anyhow::ensure!( + policy.change_script == hex::encode(script.as_bytes()), + "Original change address changed" + ); + } + } else { + anyhow::ensure!( + matches!(self.phase, Phase::OfferPrepared | Phase::Quoted) + && self.template.is_none() + && self.plan.is_none(), + "Ambiguous change address cannot fund payment" + ); + } + } + if let Some(offer) = &self.offer { + offer.validate()?; + anyhow::ensure!( + offer.binding == self.binding, + "Original offer binding changed" + ); + } + if let Some(plan) = &self.plan { + plan.validate(self)?; + } + if let Some(quote) = &self.quote { + quote.validate()?; + if let Some(offer) = &self.offer { + offer.check_quote(quote)?; + } + anyhow::ensure!(quote.binding == self.binding, "Changed purchase quote"); + } + if let Some(policy) = &self.policy { + policy.validate()?; + } + anyhow::ensure!( + self.phase <= Phase::AddressAllocationDispatched || self.quote.is_some(), + "Original address is missing" + ); + match self.phase { + Phase::AddressRequested => anyhow::ensure!( + self.quote.is_none() + && self.policy.is_none() + && self.funded.is_none() + && self.signed.is_none(), + "Invalid address preparation state" + ), + Phase::OfferPrepared + | Phase::PlanPrepared + | Phase::PlanLeaseDispatched + | Phase::InputsLeased + | Phase::AddressAllocationDispatched => { + anyhow::ensure!( + self.offer.is_some() + && self.quote.is_none() + && self.template.is_none() + && self.funded.is_none() + && self.signed.is_none() + && self.policy.is_none(), + "Invalid offer/plan state" + ); + if matches!( + self.phase, + Phase::PlanPrepared | Phase::PlanLeaseDispatched | Phase::InputsLeased + ) { + anyhow::ensure!(self.plan.is_some(), "Original funding plan missing"); + } + } + Phase::Quoted => anyhow::ensure!( + self.policy.is_none() && self.funded.is_none() && self.signed.is_none(), + "Unexpected native payment material" + ), + Phase::TemplatePrepared | Phase::LeaseDispatched => anyhow::ensure!( + self.template.is_some() && self.funded.is_none() && self.signed.is_none(), + "Invalid saved template state" + ), + Phase::FundingDispatched => anyhow::ensure!( + self.funded.is_none() && self.signed.is_none(), + "Unexpected signing material before funding recovery" + ), + Phase::Funded | Phase::SigningDispatched => { + anyhow::ensure!(self.signed.is_none(), "Signed result has incorrect phase") + } + _ => {} + } + if self.externally_exposed { + anyhow::ensure!( + self.phase == Phase::Quoted + && self.policy.is_none() + && self.plan.is_none() + && self.observed_leases.is_empty() + && self.template.is_none() + && self.funded.is_none() + && self.signed.is_none(), + "Exposed address cannot also start native payment" + ); + } + if matches!( + self.phase, + Phase::TemplatePrepared + | Phase::LeaseDispatched + | Phase::FundingDispatched + | Phase::Funded + | Phase::SigningDispatched + | Phase::Signed + | Phase::BroadcastDispatched + | Phase::Published + ) { + anyhow::ensure!(self.policy.is_some(), "Original fee policy is missing"); + } + let mut observed = std::collections::HashSet::new(); + for lease in &self.observed_leases { + lease.validate(&self.lock_id)?; + anyhow::ensure!( + observed.insert(lease.outpoint()?), + "Duplicate lease evidence" + ); + } + if let Some(template) = &self.template { + validate_funded(self, template)?; + if let (Some(plan), Some(quote)) = (&self.plan, &self.quote) { + let (policy, expected) = plan.bind(self, quote)?; + anyhow::ensure!( + template == &expected && self.policy.as_ref() == Some(&policy), + "Actual transaction differs from reviewed funding plan" + ); + } + } + if let Some(funded) = &self.funded { + validate_funded(self, funded)?; + if let Some(template) = &self.template { + anyhow::ensure!( + template.psbt_base64 == funded.psbt_base64, + "Original unsigned template changed" + ); + validate_lease_identity(&template.leases, &funded.leases)?; + } + } + if matches!( + self.phase, + Phase::Funded + | Phase::SigningDispatched + | Phase::Signed + | Phase::BroadcastDispatched + | Phase::Published + ) { + anyhow::ensure!( + self.funded.is_some(), + "Original funded transaction is missing" + ); + } + if let Some(signed) = &self.signed { + validate_signed(self, signed)?; + } + if matches!( + self.phase, + Phase::Signed | Phase::BroadcastDispatched | Phase::Published + ) { + anyhow::ensure!( + self.signed.is_some(), + "Original signed transaction is missing" + ); + } + Ok(()) + } + /// Even an unconfirmed external address remains payable. There is no + /// timeout/empty-wallet transition that grants another rail admission. + pub(crate) fn network(&self) -> Option { + self.quote + .as_ref() + .map(|q| q.network) + .or_else(|| self.offer.as_ref().map(|o| o.network)) + } + pub fn blocks_other_rails(&self) -> bool { + self.retirement.is_none() + } +} +#[derive(Serialize, Deserialize)] +struct Envelope { + payload: String, + checksum: String, +} +pub(crate) struct Journal { + directory: PathBuf, + id: String, + _lock: fs::File, +} +impl Journal { + /// Caller holds content_payment_admission before this operation lock. + pub async fn open(data: &Path, id: &str) -> Result { + anyhow::ensure!( + uuid::Uuid::parse_str(id)?.to_string() == id, + "Invalid purchase operation" + ); + let data = data.to_path_buf(); + let id = id.to_owned(); + tokio::task::spawn_blocking(move || { + use std::os::{ + fd::AsRawFd, + unix::fs::{OpenOptionsExt, PermissionsExt}, + }; + fs::create_dir_all(&data)?; + let data = fs::canonicalize(data)?; + let directory = data.join("content-onchain"); + fs::create_dir_all(&directory)?; + anyhow::ensure!( + fs::symlink_metadata(&directory)?.is_dir(), + "Invalid on-chain journal" + ); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + fs::File::open(&data)?.sync_all()?; + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(directory.join(format!("{id}.lock")))?; + anyhow::ensure!( + lock.metadata()?.is_file(), + "Invalid on-chain operation lock" + ); + loop { + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == 0 { + break; + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error.into()); + } + } + Ok(Self { + directory, + id, + _lock: lock, + }) + }) + .await? + } + pub fn load(&self) -> Result> { + read_record(&self.directory, &self.id) + } + /// Caller holds the per-buyer/seller/item admission lock. Atomic record reads + /// avoid holding another item's operation lock while finding this item. + pub fn find_for( + data: &Path, + buyer: &str, + seller: &str, + content: &str, + ) -> Result> { + let directory = data.join("content-onchain"); + let entries = match fs::read_dir(&directory) { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + anyhow::ensure!( + fs::symlink_metadata(&directory)?.is_dir(), + "Invalid on-chain recovery directory" + ); + let mut found = None; + for entry in entries { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("Invalid recovery filename"))?; + let Some(id) = name.strip_suffix(".json") else { + continue; + }; + anyhow::ensure!( + uuid::Uuid::parse_str(id)?.to_string() == id, + "Invalid recovery filename" + ); + let record = + read_record(&directory, id)?.context("Original recovery record disappeared")?; + if record.binding.buyer_did == buyer + && record.binding.seller_did == seller + && record.binding.content_id == content + && record.blocks_other_rails() + { + anyhow::ensure!( + found.is_none(), + "Multiple original on-chain attempts require recovery; do not pay again" + ); + found = Some(record); + } + } + Ok(found) + } + pub fn save(&self, record: &Record) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + record.validate()?; + anyhow::ensure!(record.binding.id == self.id, "Wrong journal operation"); + if let Some(old) = self.load()? { + anyhow::ensure!( + old.retirement + .as_ref() + .is_none_or(|ack| record.retirement.as_ref() == Some(ack)), + "Retired operation cannot be revived" + ); + anyhow::ensure!( + old.binding == record.binding + && old.seller_onion == record.seller_onion + && old.lock_id == record.lock_id, + "Original payment binding changed" + ); + anyhow::ensure!( + !old.externally_exposed || record.externally_exposed, + "Address exposure cannot be undone" + ); + anyhow::ensure!( + !old.settled || record.settled, + "Confirmed purchase cannot become unpaid" + ); + anyhow::ensure!( + old.offer + .as_ref() + .is_none_or(|o| record.offer.as_ref() == Some(o)), + "Original offer changed" + ); + anyhow::ensure!( + old.plan + .as_ref() + .is_none_or(|p| record.plan.as_ref() == Some(p)), + "Original funding plan changed; cancel and review again before any input mutation" + ); + anyhow::ensure!( + old.quote + .as_ref() + .is_none_or(|q| record.quote.as_ref() == Some(q)), + "Original quote changed" + ); + match &old.change_address { + Some(ChangeAddress::Ready { .. }) => anyhow::ensure!( + old.change_address == record.change_address, + "Original change allocation changed" + ), + Some(ChangeAddress::Dispatched) => anyhow::ensure!( + record.change_address.is_some(), + "Ambiguous change allocation cannot be forgotten" + ), + None => {} + } + anyhow::ensure!( + old.policy + .as_ref() + .is_none_or(|p| record.policy.as_ref() == Some(p)), + "Original fee limit changed" + ); + anyhow::ensure!( + old.template + .as_ref() + .is_none_or(|t| record.template.as_ref() == Some(t)), + "Original transaction template changed" + ); + anyhow::ensure!( + old.funded + .as_ref() + .is_none_or(|p| record.funded.as_ref() == Some(p)), + "Original funded transaction changed" + ); + anyhow::ensure!( + old.signed + .as_ref() + .is_none_or(|p| record.signed.as_ref() == Some(p)), + "Original signed transaction changed" + ); + anyhow::ensure!( + record.phase >= old.phase, + "Original transaction phase regressed" + ); + anyhow::ensure!( + record.mutations >= old.mutations, + "Mutation sequence regressed" + ); + } + let payload = serde_json::to_string(record)?; + let bytes = serde_json::to_vec(&Envelope { + checksum: hex::encode(Sha256::digest(payload.as_bytes())), + payload, + })?; + anyhow::ensure!(bytes.len() <= MAX_RECORD, "Recovery record is too large"); + let temporary = self + .directory + .join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, self.directory.join(format!("{}.json", self.id)))?; + fs::File::open(&self.directory)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result + } +} +fn read_record(directory: &Path, id: &str) -> Result> { + use std::os::unix::fs::OpenOptionsExt; + let file = match fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(directory.join(format!("{id}.json"))) + { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + anyhow::ensure!(file.metadata()?.is_file(), "Invalid recovery record"); + let mut bytes = vec![]; + file.take((MAX_RECORD + 1) as u64).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() <= MAX_RECORD, "Recovery record is too large"); + let envelope: Envelope = serde_json::from_slice(&bytes) + .context("Original payment recovery is damaged; do not pay again")?; + anyhow::ensure!( + hex::encode(Sha256::digest(envelope.payload.as_bytes())) == envelope.checksum, + "Original payment checksum changed" + ); + let record: Record = serde_json::from_str(&envelope.payload)?; + record.validate()?; + anyhow::ensure!( + record.binding.id == id, + "Original operation identifier changed" + ); + Ok(Some(record)) +} + +pub(crate) fn decode_psbt(funded: &Funded) -> Result { + anyhow::ensure!(funded.psbt_base64.len() <= MAX_RECORD / 2, "PSBT too large"); + Ok(Psbt::deserialize( + &base64::engine::general_purpose::STANDARD.decode(&funded.psbt_base64)?, + )?) +} +pub(crate) fn validate_funded(record: &Record, funded: &Funded) -> Result { + use std::collections::{HashMap, HashSet}; + let quote = record.quote.as_ref().context("Missing original quote")?; + let policy = record + .policy + .as_ref() + .context("Missing original fee limit")?; + let psbt = decode_psbt(funded)?; + let tx = &psbt.unsigned_tx; + anyhow::ensure!( + !tx.input.is_empty() && tx.input.len() == psbt.inputs.len(), + "Invalid funded inputs" + ); + let mut leases = HashMap::new(); + for lease in &funded.leases { + lease.validate(&record.lock_id)?; + anyhow::ensure!( + leases.insert(lease.outpoint()?, lease).is_none(), + "Duplicate wallet lease" + ); + } + anyhow::ensure!( + leases.len() == tx.input.len(), + "Funding selected unexpected inputs" + ); + let mut seen = HashSet::new(); + let mut input_sats = 0u64; + for (input, metadata) in tx.input.iter().zip(&psbt.inputs) { + anyhow::ensure!( + seen.insert(input.previous_output), + "Duplicate transaction input" + ); + let lease = leases + .get(&input.previous_output) + .context("Input is not leased to this operation")?; + let utxo = metadata + .witness_utxo + .as_ref() + .context("Input value is not verifiable")?; + anyhow::ensure!( + utxo.value.to_sat() == lease.value_sats + && utxo.script_pubkey.as_bytes() == hex::decode(&lease.script)?, + "Leased input metadata changed" + ); + anyhow::ensure!( + input.script_sig.is_empty() && input.witness.is_empty(), + "Funded PSBT unexpectedly signed" + ); + input_sats = input_sats + .checked_add(lease.value_sats) + .filter(|v| *v <= MAX_SATS) + .context("Invalid input sum")?; + } + let recipient = quote.script()?; + let change = bitcoin::ScriptBuf::from_bytes(hex::decode(&policy.change_script)?); + anyhow::ensure!( + recipient != change, + "Recipient and change scripts must differ" + ); + let mut found = false; + let mut change_found = false; + let mut output_sats = 0u64; + for output in &tx.output { + if output.script_pubkey == recipient { + anyhow::ensure!( + !found && output.value.to_sat() == quote.binding.price_sats, + "Recipient amount changed" + ); + found = true; + } else { + anyhow::ensure!( + !change_found && output.script_pubkey == change, + "Unknown or duplicate change output" + ); + change_found = true; + } + output_sats = output_sats + .checked_add(output.value.to_sat()) + .filter(|v| *v <= MAX_SATS) + .context("Invalid output sum")?; + } + anyhow::ensure!(found, "Missing original purchase output"); + let fee = input_sats + .checked_sub(output_sats) + .context("Outputs exceed inputs")?; + anyhow::ensure!( + fee > 0 && fee <= policy.max_fee_sats, + "Original fee budget exceeded" + ); + Ok(fee) +} +pub(crate) fn validate_signed(record: &Record, signed: &Signed) -> Result<()> { + let funded = record.funded.as_ref().context("Missing saved PSBT")?; + let fee = validate_funded(record, funded)?; + let expected = decode_psbt(funded)?.unsigned_tx; + anyhow::ensure!( + signed.raw_hex.len() <= MAX_RECORD / 2, + "Signed transaction too large" + ); + let tx: Transaction = consensus::deserialize(&hex::decode(&signed.raw_hex)?)?; + anyhow::ensure!( + tx.compute_txid().to_string() == signed.txid, + "Signed transaction identifier changed" + ); + anyhow::ensure!( + tx.version == expected.version + && tx.lock_time == expected.lock_time + && tx.output == expected.output + && tx.input.len() == expected.input.len(), + "Signed transaction terms changed" + ); + for (actual, original) in tx.input.iter().zip(&expected.input) { + anyhow::ensure!( + actual.previous_output == original.previous_output + && actual.sequence == original.sequence + && actual.script_sig.is_empty() + && !actual.witness.is_empty(), + "Signed transaction input changed or incomplete" + ); + } + let policy = record + .policy + .as_ref() + .context("Missing original fee policy")?; + let limit = policy + .max_fee_rate_sat_vbyte + .checked_mul(tx.vsize() as u64) + .context("Fee rate overflow")?; + anyhow::ensure!(fee <= limit, "Original fee rate exceeded"); + Ok(()) +} + +fn validate_lease_identity(expected: &[Lease], actual: &[Lease]) -> Result<()> { + use std::collections::HashMap; + let mut map = HashMap::new(); + for lease in actual { + anyhow::ensure!( + map.insert(lease.outpoint()?, lease).is_none(), + "Duplicate input lease" + ); + } + anyhow::ensure!( + expected.len() == map.len(), + "Original leased input set changed" + ); + for lease in expected { + let found = map + .get(&lease.outpoint()?) + .context("Original input lease missing")?; + anyhow::ensure!( + found.lock_id == lease.lock_id + && found.script == lease.script + && found.value_sats == lease.value_sats, + "Original lease binding changed" + ); + } + Ok(()) +} +/// Adapter can install only a read-only prepared, validated transaction. Every +/// byte and input owner is durable before any LeaseOutput request can be made. +pub(crate) fn save_exact_template( + journal: &Journal, + policy: FeePolicy, + template: Funded, +) -> Result { + let mut record = journal.load()?.context("Missing original purchase")?; + anyhow::ensure!( + record.phase == Phase::Quoted && !record.externally_exposed && !record.settled, + "Original address is already exposed or payment started" + ); + policy.validate()?; + record.policy = Some(policy); + validate_funded(&record, &template)?; + record.template = Some(template); + record.phase = Phase::TemplatePrepared; + journal.save(&record)?; + Ok(record) +} + +pub(crate) fn validate_current_leases( + record: &Record, + original: &[Lease], + current: &[Lease], +) -> Result<()> { + use std::collections::HashMap; + let mut lookup = HashMap::new(); + let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid clock")?; + for lease in current { + lease.validate(&record.lock_id)?; + anyhow::ensure!( + lease.expires_at > now, + "Original input lease expired; recover its lease before signing" + ); + anyhow::ensure!( + lookup.insert(lease.outpoint()?, lease).is_none(), + "Duplicate lease evidence" + ); + } + anyhow::ensure!( + lookup.len() == original.len(), + "Original input leases are missing or changed" + ); + for lease in original { + let actual = lookup + .get(&lease.outpoint()?) + .context("Original input lease is missing")?; + anyhow::ensure!( + actual.value_sats == lease.value_sats && actual.script == lease.script, + "Original input lease changed" + ); + } + Ok(()) +} + +async fn reconcile_template_leases( + journal: &Journal, + wallet: &W, + record: &mut Record, +) -> Result<()> { + let template = record + .template + .clone() + .context("Original template missing")?; + validate_funded(record, &template)?; + for expected in &template.leases { + let current = wallet.leases(&record.lock_id).await?; + let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid clock")?; + if let Some(owned) = current + .iter() + .find(|l| l.txid == expected.txid && l.vout == expected.vout) + { + validate_lease_identity(std::slice::from_ref(expected), std::slice::from_ref(owned))?; + if owned.expires_at > now.saturating_add(60) { + continue; + } + } + // Preserve Funded/SigningDispatched intent and immutable original PSBT. + // Lost renewal replies never allow a replacement input or payment. + let phase = if record.phase < Phase::Funded { + Phase::LeaseDispatched + } else { + record.phase + }; + mark_mutation(journal, record, phase)?; + wallet.lease(expected).await?; + } + let current = wallet.leases(&record.lock_id).await?; + validate_current_leases(record, &template.leases, ¤t)?; + record.observed_leases = current; + journal.save(record)?; + Ok(()) +} + +/// Legacy funding recovery reads lease diagnostics only; it cannot reconstruct +/// a missing transaction. The exact-template adapter instead persists all inputs +/// and outputs before individual leases. Neither path releases unknown leases. +pub(crate) trait Wallet { + /// Read-only capability/network/change-ownership/fee preflight. + async fn prepare_funding(&self, record: &Record) -> Result<()>; + async fn fund(&self, record: &Record) -> Result; + async fn leases(&self, lock_id: &str) -> Result>; + /// Lease/renew exactly this saved outpoint to its saved owner; no selection. + async fn lease(&self, _lease: &Lease) -> Result<()> { + anyhow::bail!("Explicit input leasing is unsupported") + } + + async fn sign(&self, funded: &Funded) -> Result; + async fn publish(&self, signed: &Signed) -> Result<()>; + async fn transaction_known(&self, txid: &str) -> Result; +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Action { + Fund, + Lease, + RecoverFunding, + Sign, + Publish, + Status, +} +fn mark_mutation(journal: &Journal, record: &mut Record, phase: Phase) -> Result<()> { + record.phase = phase; + record.mutations = record + .mutations + .checked_add(1) + .context("Mutation sequence exhausted")?; + journal.save(record) +} +pub(crate) fn accept_offer( + journal: &Journal, + offer: crate::content_onchain_plan::Offer, +) -> Result { + offer.validate()?; + let mut record = journal.load()?.context("Original operation missing")?; + anyhow::ensure!( + record.binding == offer.binding && record.retirement.is_none(), + "Original offer changed or retired" + ); + if let Some(old) = &record.offer { + anyhow::ensure!(old == &offer, "Original offer changed"); + return Ok(record); + } + anyhow::ensure!( + record.phase == Phase::AddressRequested && record.quote.is_none(), + "Original address already requested" + ); + record.offer = Some(offer); + record.phase = Phase::OfferPrepared; + journal.save(&record)?; + Ok(record) +} +pub(crate) fn save_plan( + journal: &Journal, + plan: crate::content_onchain_plan::FundingPlan, +) -> Result { + let mut record = journal.load()?.context("Original operation missing")?; + anyhow::ensure!( + record.phase == Phase::OfferPrepared + && record.retirement.is_none() + && !record.externally_exposed, + "Original operation cannot prepare new funding" + ); + plan.validate(&record)?; + record.plan = Some(plan); + record.phase = Phase::PlanPrepared; + journal.save(&record)?; + Ok(record) +} +pub(crate) async fn lease_plan(journal: &Journal, wallet: &W) -> Result { + let mut record = journal.load()?.context("Original operation missing")?; + anyhow::ensure!( + matches!( + record.phase, + Phase::PlanPrepared + | Phase::PlanLeaseDispatched + | Phase::InputsLeased + | Phase::AddressAllocationDispatched + ) && record.retirement.is_none() + && !record.externally_exposed, + "Original funding plan cannot lease inputs" + ); + let plan = record + .plan + .clone() + .context("Original funding plan missing")?; + plan.validate(&record)?; + wallet.prepare_funding(&record).await?; + for input in &plan.inputs { + let current = wallet.leases(&record.lock_id).await?; + let now = u64::try_from(chrono::Utc::now().timestamp())?; + if let Some(owned) = current + .iter() + .find(|l| l.txid == input.lease.txid && l.vout == input.lease.vout) + { + validate_lease_identity( + std::slice::from_ref(&input.lease), + std::slice::from_ref(owned), + )?; + if owned.expires_at > now.saturating_add(60) { + continue; + } + } + let phase = if record.phase < Phase::InputsLeased { + Phase::PlanLeaseDispatched + } else { + record.phase + }; + mark_mutation(journal, &mut record, phase)?; + wallet.lease(&input.lease).await?; + } + let current = wallet.leases(&record.lock_id).await?; + let expected: Vec<_> = plan.inputs.iter().map(|i| i.lease.clone()).collect(); + validate_current_leases(&record, &expected, ¤t)?; + record.observed_leases = current; + if record.phase < Phase::InputsLeased { + record.phase = Phase::InputsLeased; + } + journal.save(&record)?; + Ok(record) +} +pub(crate) fn mark_address_allocation(journal: &Journal, external: bool) -> Result { + let mut record = journal.load()?.context("Original operation missing")?; + anyhow::ensure!( + record.retirement.is_none() && record.quote.is_none(), + "Original operation cannot allocate address" + ); + if record.phase == Phase::AddressAllocationDispatched { + return Ok(record); + } + anyhow::ensure!( + if external { + record.phase == Phase::OfferPrepared + && record.plan.is_none() + && record.observed_leases.is_empty() + } else { + record.phase == Phase::InputsLeased && record.plan.is_some() + }, + "Review and confirm original plan before address allocation" + ); + mark_mutation(journal, &mut record, Phase::AddressAllocationDispatched)?; + Ok(record) +} +pub(crate) fn bind_plan(journal: &Journal) -> Result { + let record = journal.load()?.context("Original operation missing")?; + if record.template.is_some() { + return Ok(record); + } + let plan = record.plan.as_ref().context("Original plan missing")?; + let quote = record + .quote + .as_ref() + .context("Original allocated address missing")?; + let (policy, template) = plan.bind(&record, quote)?; + save_exact_template(journal, policy, template) +} +pub(crate) fn retire_unallocated( + journal: &Journal, + ack: crate::content_onchain_seller::UnallocatedAck, +) -> Result { + let mut record = journal.load()?.context("Original operation missing")?; + ack.validate(&record.binding)?; + record.retirement = Some(ack); + record.validate()?; + journal.save(&record)?; + Ok(record) +} +pub(crate) fn accept_quote(journal: &Journal, quote: Quote) -> Result { + quote.validate()?; + let mut record = journal.load()?.context("Missing original operation")?; + anyhow::ensure!( + record.binding == quote.binding, + "Original address request changed" + ); + if let Some(original) = &record.quote { + anyhow::ensure!(original == "e, "Original address or source changed"); + return Ok(record); + } + anyhow::ensure!( + matches!( + record.phase, + Phase::AddressRequested | Phase::AddressAllocationDispatched + ), + "Invalid address operation state" + ); + record.quote = Some(quote); + record.phase = Phase::Quoted; + journal.save(&record)?; + Ok(record) +} +pub(crate) fn expose_address(journal: &Journal) -> Result { + let mut record = journal.load()?.context("Missing original operation")?; + anyhow::ensure!( + record.phase == Phase::Quoted + && !record.settled + && record.plan.is_none() + && record.observed_leases.is_empty() + && record.template.is_none() + && record.funded.is_none(), + "Native payment already started or purchase paid" + ); + record.externally_exposed = true; + journal.save(&record)?; + Ok(record.quote.context("Missing original address")?.address) +} +pub(crate) async fn drive( + journal: &Journal, + wallet: &W, + action: Action, + policy: Option, +) -> Result { + let mut record = journal.load()?.context("Missing original operation")?; + if action == Action::Status { + if let Some(signed) = &record.signed { + if matches!(record.phase, Phase::BroadcastDispatched | Phase::Published) + && wallet.transaction_known(&signed.txid).await? + { + record.phase = Phase::Published; + journal.save(&record)?; + } + } + return Ok(record); + } + anyhow::ensure!( + !record.externally_exposed, + "Original external address remains payable; recover it without another send" + ); + anyhow::ensure!( + !record.settled, + "Original purchase is paid; recover its download" + ); + if let (Some(original), Some(requested)) = (&record.policy, &policy) { + anyhow::ensure!(original == requested, "Original fee policy changed"); + } + match action { + Action::Fund => { + anyhow::ensure!( + record.phase == Phase::Quoted, + "Original funding already attempted; recover its leases" + ); + let policy = policy.context("Explicit original fee policy required")?; + policy.validate()?; + record.policy = Some(policy); + wallet.prepare_funding(&record).await?; + mark_mutation(journal, &mut record, Phase::FundingDispatched)?; + let funded = wallet.fund(&record).await?; + validate_funded(&record, &funded)?; + record.funded = Some(funded); + record.phase = Phase::Funded; + journal.save(&record)?; + } + Action::Lease => { + anyhow::ensure!( + matches!( + record.phase, + Phase::TemplatePrepared + | Phase::LeaseDispatched + | Phase::Funded + | Phase::SigningDispatched + ), + "Original template is not awaiting leases" + ); + wallet.prepare_funding(&record).await?; + let template = record + .template + .clone() + .context("Original template missing")?; + validate_funded(&record, &template)?; + reconcile_template_leases(journal, wallet, &mut record).await?; + if record.funded.is_none() { + let owned = wallet.leases(&record.lock_id).await?; + record.funded = Some(Funded { + psbt_base64: template.psbt_base64, + leases: owned, + }); + record.phase = Phase::Funded; + journal.save(&record)?; + } + } + Action::RecoverFunding => { + anyhow::ensure!( + record.phase == Phase::FundingDispatched, + "Original funding is not ambiguous" + ); + let leases = wallet.leases(&record.lock_id).await?; + for lease in &leases { + lease.validate(&record.lock_id)?; + } + record.observed_leases = leases.clone(); + journal.save(&record)?; + // Leases do not contain the original transaction/PSBT. They are + // diagnostic evidence only, never authority to synthesize another + // transaction or retry FundPsbt after an ambiguous response. + } + Action::Sign => { + anyhow::ensure!( + matches!(record.phase, Phase::Funded | Phase::SigningDispatched), + "Original funding must be recovered first" + ); + let funded = record + .funded + .clone() + .context("Missing original funded PSBT")?; + validate_funded(&record, &funded)?; + if record.template.is_some() { + wallet.prepare_funding(&record).await?; + reconcile_template_leases(journal, wallet, &mut record).await?; + } + let current = wallet.leases(&record.lock_id).await?; + validate_current_leases(&record, &funded.leases, ¤t)?; + mark_mutation(journal, &mut record, Phase::SigningDispatched)?; + let signed = wallet.sign(&funded).await?; + validate_signed(&record, &signed)?; + record.signed = Some(signed); + record.phase = Phase::Signed; + journal.save(&record)?; + } + Action::Publish => { + anyhow::ensure!( + matches!( + record.phase, + Phase::Signed | Phase::BroadcastDispatched | Phase::Published + ), + "No original signed transaction" + ); + let signed = record + .signed + .clone() + .context("Missing original signed transaction")?; + validate_signed(&record, &signed)?; + if wallet.transaction_known(&signed.txid).await? { + record.phase = Phase::Published; + journal.save(&record)?; + return Ok(record); + } + let phase = if record.phase == Phase::Published { + Phase::Published + } else { + Phase::BroadcastDispatched + }; + mark_mutation(journal, &mut record, phase)?; + wallet.publish(&signed).await?; + record.phase = Phase::Published; + journal.save(&record)?; + } + Action::Status => unreachable!(), + } + Ok(record) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{ + absolute::LockTime, transaction::Version, Amount, ScriptBuf, Sequence, TxIn, TxOut, Witness, + }; + use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Mutex, + }; + fn script(byte: u8) -> ScriptBuf { + let mut bytes = vec![0, 20]; + bytes.extend([byte; 20]); + ScriptBuf::from_bytes(bytes) + } + fn policy() -> FeePolicy { + FeePolicy { + change_script: hex::encode(script(2).as_bytes()), + max_fee_sats: 1000, + max_fee_rate_sat_vbyte: 20, + } + } + 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: "paid-file".into(), + price_sats: 546, + } + } + async fn prepared() -> (tempfile::TempDir, Journal) { + let data = tempfile::tempdir().unwrap(); + let binding = binding(); + let journal = Journal::open(data.path(), &binding.id).await.unwrap(); + let record = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap(); + journal.save(&record).unwrap(); + accept_quote( + &journal, + Quote { + binding, + address: bitcoin::Address::from_script(&script(1), bitcoin::Network::Regtest) + .unwrap() + .to_string(), + network: ChainNetwork::Regtest, + source: RetainedFile { + sha256: "a".repeat(64), + size: 4, + filename: "bought.txt".into(), + mime_type: "text/plain".into(), + }, + }, + ) + .unwrap(); + (data, journal) + } + fn funded(record: &Record) -> Funded { + let lease = Lease { + lock_id: record.lock_id.clone(), + txid: "b".repeat(64), + vout: 0, + value_sats: 2000, + script: hex::encode(script(3).as_bytes()), + expires_at: 4_000_000_000, + }; + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: lease.outpoint().unwrap(), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![ + TxOut { + value: Amount::from_sat(546), + script_pubkey: record.quote.as_ref().unwrap().script().unwrap(), + }, + TxOut { + value: Amount::from_sat(954), + script_pubkey: script(2), + }, + ], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(lease.value_sats), + script_pubkey: script(3), + }); + Funded { + psbt_base64: base64::engine::general_purpose::STANDARD.encode(psbt.serialize()), + leases: vec![lease], + } + } + fn signed(funded: &Funded) -> Signed { + let mut tx = decode_psbt(funded).unwrap().unsigned_tx; + tx.input[0].witness = Witness::from_slice(&[vec![1; 72], vec![2; 33]]); + Signed { + txid: tx.compute_txid().to_string(), + raw_hex: hex::encode(consensus::serialize(&tx)), + } + } + struct MockWallet { + fail_preflight: AtomicBool, + lost_fund: AtomicBool, + lost_sign: AtomicBool, + lost_publish: AtomicBool, + funds: AtomicUsize, + signs: AtomicUsize, + publishes: AtomicUsize, + known: AtomicBool, + leases: Mutex>, + signed_inputs: Mutex>, + published: Mutex>, + } + impl MockWallet { + fn new() -> Self { + Self { + fail_preflight: AtomicBool::new(false), + lost_fund: AtomicBool::new(false), + lost_sign: AtomicBool::new(false), + lost_publish: AtomicBool::new(false), + funds: AtomicUsize::new(0), + signs: AtomicUsize::new(0), + publishes: AtomicUsize::new(0), + known: AtomicBool::new(false), + leases: Mutex::new(vec![]), + signed_inputs: Mutex::new(vec![]), + published: Mutex::new(vec![]), + } + } + } + impl Wallet for MockWallet { + async fn prepare_funding(&self, _: &Record) -> Result<()> { + anyhow::ensure!( + !self.fail_preflight.load(Ordering::SeqCst), + "wallet unavailable before funding" + ); + Ok(()) + } + async fn fund(&self, record: &Record) -> Result { + self.funds.fetch_add(1, Ordering::SeqCst); + let funded = funded(record); + *self.leases.lock().unwrap() = funded.leases.clone(); + anyhow::ensure!( + !self.lost_fund.load(Ordering::SeqCst), + "funding reply lost after leasing" + ); + Ok(funded) + } + async fn leases(&self, _: &str) -> Result> { + Ok(self.leases.lock().unwrap().clone()) + } + async fn sign(&self, funded: &Funded) -> Result { + self.signs.fetch_add(1, Ordering::SeqCst); + self.signed_inputs + .lock() + .unwrap() + .push(funded.psbt_base64.clone()); + anyhow::ensure!(!self.lost_sign.load(Ordering::SeqCst), "signing reply lost"); + Ok(signed(funded)) + } + async fn publish(&self, signed: &Signed) -> Result<()> { + self.publishes.fetch_add(1, Ordering::SeqCst); + self.published.lock().unwrap().push(signed.raw_hex.clone()); + self.known.store(true, Ordering::SeqCst); + anyhow::ensure!( + !self.lost_publish.load(Ordering::SeqCst), + "publish reply lost after broadcast" + ); + Ok(()) + } + async fn transaction_known(&self, _: &str) -> Result { + Ok(self.known.load(Ordering::SeqCst)) + } + } + #[tokio::test] + async fn funding_preflight_failure_is_definitely_undispatched() { + let (_data, j) = prepared().await; + let wallet = MockWallet::new(); + wallet.fail_preflight.store(true, Ordering::SeqCst); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + let record = j.load().unwrap().unwrap(); + assert_eq!(record.phase, Phase::Quoted); + assert_eq!(record.mutations, 0); + assert_eq!(wallet.funds.load(Ordering::SeqCst), 0); + wallet.fail_preflight.store(false, Ordering::SeqCst); + assert_eq!( + drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .unwrap() + .phase, + Phase::Funded + ); + } + #[tokio::test] + async fn lost_funding_reply_preserves_original_lease_diagnostics_without_refunding() { + let (data, j) = prepared().await; + let id = j.id.clone(); + let wallet = MockWallet::new(); + wallet.lost_fund.store(true, Ordering::SeqCst); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + assert_eq!(j.load().unwrap().unwrap().phase, Phase::FundingDispatched); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + assert_eq!( + drive(&j, &wallet, Action::Status, None) + .await + .unwrap() + .phase, + Phase::FundingDispatched + ); + let recovered = drive(&j, &wallet, Action::RecoverFunding, None) + .await + .unwrap(); + assert_eq!(recovered.phase, Phase::FundingDispatched); + assert_eq!(recovered.observed_leases, *wallet.leases.lock().unwrap()); + assert!(recovered.funded.is_none()); + assert!(drive(&j, &wallet, Action::Sign, None).await.is_err()); + assert_eq!(wallet.funds.load(Ordering::SeqCst), 1); + assert_eq!(wallet.signs.load(Ordering::SeqCst), 0); + assert_eq!(wallet.publishes.load(Ordering::SeqCst), 0); + } + #[tokio::test] + async fn missing_or_foreign_funding_leases_never_allow_fresh_coin_selection() { + let (_data, j) = prepared().await; + let wallet = MockWallet::new(); + wallet.lost_fund.store(true, Ordering::SeqCst); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + let original = wallet.leases.lock().unwrap().clone(); + wallet.leases.lock().unwrap().clear(); + assert_eq!( + drive(&j, &wallet, Action::RecoverFunding, None) + .await + .unwrap() + .phase, + Phase::FundingDispatched + ); + let mut foreign = original; + foreign[0].lock_id = "f".repeat(64); + *wallet.leases.lock().unwrap() = foreign; + assert!(drive(&j, &wallet, Action::RecoverFunding, None) + .await + .is_err()); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + assert_eq!(wallet.funds.load(Ordering::SeqCst), 1); + } + #[tokio::test] + async fn duplicate_lease_diagnostics_cannot_replace_original_funding() { + let (_data, j) = prepared().await; + let wallet = MockWallet::new(); + wallet.lost_fund.store(true, Ordering::SeqCst); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + { + let mut leases = wallet.leases.lock().unwrap(); + let duplicate = leases[0].clone(); + leases.push(duplicate); + } + assert!(drive(&j, &wallet, Action::RecoverFunding, None) + .await + .is_err()); + assert_eq!(j.load().unwrap().unwrap().phase, Phase::FundingDispatched); + assert_eq!(wallet.signs.load(Ordering::SeqCst), 0); + } + #[tokio::test] + async fn signing_and_broadcast_reply_loss_reuse_original_psbt_and_raw_transaction() { + let (data, j) = prepared().await; + let id = j.id.clone(); + let wallet = MockWallet::new(); + drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .unwrap(); + wallet.lost_sign.store(true, Ordering::SeqCst); + assert!(drive(&j, &wallet, Action::Sign, None).await.is_err()); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + assert_eq!(j.load().unwrap().unwrap().phase, Phase::SigningDispatched); + wallet.lost_sign.store(false, Ordering::SeqCst); + let signed = drive(&j, &wallet, Action::Sign, None).await.unwrap(); + { + let attempts = wallet.signed_inputs.lock().unwrap(); + assert_eq!(attempts[0], attempts[1]); + } + wallet.lost_publish.store(true, Ordering::SeqCst); + assert!(drive(&j, &wallet, Action::Publish, None).await.is_err()); + assert_eq!(j.load().unwrap().unwrap().signed, signed.signed); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + assert_eq!( + drive(&j, &wallet, Action::Status, None) + .await + .unwrap() + .phase, + Phase::Published + ); + drive(&j, &wallet, Action::Publish, None).await.unwrap(); + assert_eq!(wallet.publishes.load(Ordering::SeqCst), 1); + // Eviction is not permission to make a new transaction. An explicit + // rebroadcast sends the exact bytes whose txid is already durable. + wallet.known.store(false, Ordering::SeqCst); + wallet.lost_publish.store(false, Ordering::SeqCst); + drive(&j, &wallet, Action::Publish, None).await.unwrap(); + let sent = wallet.published.lock().unwrap(); + assert_eq!(sent[0], sent[1]); + assert_eq!(wallet.funds.load(Ordering::SeqCst), 1); + } + #[tokio::test] + async fn expired_or_missing_input_leases_stop_before_signing() { + let (_data, j) = prepared().await; + let wallet = MockWallet::new(); + drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .unwrap(); + wallet.leases.lock().unwrap()[0].expires_at = 1; + assert!(drive(&j, &wallet, Action::Sign, None).await.is_err()); + assert_eq!(wallet.signs.load(Ordering::SeqCst), 0); + assert_eq!(j.load().unwrap().unwrap().phase, Phase::Funded); + } + #[tokio::test] + async fn exposed_address_cannot_expire_into_new_native_payment_or_another_rail() { + let (data, j) = prepared().await; + let id = j.id.clone(); + let address = expose_address(&j).unwrap(); + drop(j); + let j = Journal::open(data.path(), &id).await.unwrap(); + let wallet = MockWallet::new(); + assert_eq!(expose_address(&j).unwrap(), address); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + let record = drive(&j, &wallet, Action::Status, None).await.unwrap(); + assert!(record.blocks_other_rails()); + assert!(record.externally_exposed); + assert_eq!(wallet.funds.load(Ordering::SeqCst), 0); + } + #[tokio::test] + async fn fee_output_input_and_signed_mutations_fail_before_remote_sign_or_broadcast() { + let (_data, j) = prepared().await; + let mut record = j.load().unwrap().unwrap(); + record.policy = Some(policy()); + record.phase = Phase::FundingDispatched; + let original = funded(&record); + for variant in 0..4 { + let mut altered = original.clone(); + let mut psbt = decode_psbt(&altered).unwrap(); + match variant { + 0 => psbt.unsigned_tx.output[0].value = Amount::from_sat(547), + 1 => psbt.unsigned_tx.output[1].script_pubkey = script(9), + 2 => psbt.unsigned_tx.output[1].value = Amount::from_sat(1), + _ => psbt.inputs[0].witness_utxo.as_mut().unwrap().value = Amount::from_sat(9000), + } + altered.psbt_base64 = + base64::engine::general_purpose::STANDARD.encode(psbt.serialize()); + assert!(validate_funded(&record, &altered).is_err()); + } + record.funded = Some(original.clone()); + record.phase = Phase::Funded; + let good = signed(&original); + validate_signed(&record, &good).unwrap(); + let mut tx: Transaction = + consensus::deserialize(&hex::decode(&good.raw_hex).unwrap()).unwrap(); + tx.output[0].value = Amount::from_sat(547); + let bad = Signed { + txid: tx.compute_txid().to_string(), + raw_hex: hex::encode(consensus::serialize(&tx)), + }; + assert!(validate_signed(&record, &bad).is_err()); + } + #[tokio::test] + async fn journal_damage_and_late_state_regression_never_restore_spending_permission() { + let (data, j) = prepared().await; + let wallet = MockWallet::new(); + let old = j.load().unwrap().unwrap(); + drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .unwrap(); + assert!(j.save(&old).is_err()); + let target = data + .path() + .join("content-onchain") + .join(format!("{}.json", j.id)); + fs::write(&target, b"broken original operation").unwrap(); + assert!(drive(&j, &wallet, Action::Fund, Some(policy())) + .await + .is_err()); + assert_eq!(wallet.funds.load(Ordering::SeqCst), 1); + } +} diff --git a/core/archipelago/src/content_onchain_plan.rs b/core/archipelago/src/content_onchain_plan.rs new file mode 100644 index 00000000..6882085a --- /dev/null +++ b/core/archipelago/src/content_onchain_plan.rs @@ -0,0 +1,250 @@ +//! Non-signable funding intent. No recipient address or executable PSBT exists +//! until explicit Pay has leased these exact inputs and recovered seller allocation. +use crate::{ + content_lightning::{Binding, RetainedFile}, + content_onchain::{ChainNetwork, FeePolicy, Funded, Lease, Quote, Record}, +}; +use anyhow::{Context, Result}; +use base64::Engine; +use bitcoin::{ + absolute::LockTime, consensus, psbt::Psbt, transaction::Version, Amount, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, Witness, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +const MAX_SATS: u64 = 2_100_000_000_000_000; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Offer { + pub binding: Binding, + pub source: RetainedFile, + pub network: ChainNetwork, + /// This version accepts only a native P2WPKH recipient (22 script bytes). + pub recipient_script_type: String, +} +impl Offer { + pub fn validate(&self) -> Result<()> { + self.binding.validate()?; + self.source.validate()?; + anyhow::ensure!( + (546..=MAX_SATS).contains(&self.binding.price_sats) + && self.recipient_script_type == "p2wpkh", + "Unsupported original on-chain offer" + ); + Ok(()) + } + pub fn hash(&self) -> Result { + self.validate()?; + Ok(hex::encode(Sha256::digest(serde_json::to_vec(self)?))) + } + pub fn check_quote(&self, quote: &Quote) -> Result<()> { + self.validate()?; + anyhow::ensure!( + quote.binding == self.binding + && quote.source == self.source + && quote.network == self.network + && quote.script()?.is_p2wpkh(), + "Allocated address changed the original offer" + ); + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlanInput { + pub lease: Lease, + pub previous_tx_hex: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FundingPlan { + pub offer_sha256: String, + pub inputs: Vec, + pub change_address: String, + pub change_script: String, + pub change_sats: u64, + pub fee_sats: u64, + pub fee_rate_sat_vbyte: u64, + pub max_fee_sats: u64, +} +impl FundingPlan { + pub fn hash(&self) -> Result { + Ok(hex::encode(Sha256::digest(serde_json::to_vec(self)?))) + } + pub fn validate(&self, record: &Record) -> Result<()> { + let offer = record.offer.as_ref().context("Original offer missing")?; + offer.validate()?; + anyhow::ensure!( + self.offer_sha256 == offer.hash()? && offer.binding == record.binding, + "Funding plan belongs to a different offer" + ); + let change = self + .change_address + .parse::>()? + .require_network(offer.network.bitcoin())? + .script_pubkey(); + anyhow::ensure!( + hex::encode(change.as_bytes()) == self.change_script + && (change.is_p2wpkh() || change.is_p2tr()), + "Invalid original change script" + ); + anyhow::ensure!( + matches!(&record.change_address,Some(crate::content_onchain::ChangeAddress::Ready{address}) if address==&self.change_address), + "Funding plan change allocation changed" + ); + anyhow::ensure!( + !self.inputs.is_empty() + && self.inputs.len() <= 32 + && self.max_fee_sats > 0 + && self.max_fee_sats <= MAX_SATS + && (1..=5000).contains(&self.fee_rate_sat_vbyte), + "Invalid funding plan limits" + ); + anyhow::ensure!( + self.change_sats == 0 || self.change_sats >= 546, + "Dust change is unsupported" + ); + let mut seen = std::collections::HashSet::new(); + let mut total = 0u64; + for input in &self.inputs { + input.lease.validate(&record.lock_id)?; + anyhow::ensure!( + input.lease.expires_at == 0 && seen.insert(input.lease.outpoint()?), + "Invalid or duplicate planned input" + ); + anyhow::ensure!( + input.previous_tx_hex.len() <= 2 * 1024 * 1024, + "Previous transaction too large" + ); + let previous: Transaction = + consensus::deserialize(&hex::decode(&input.previous_tx_hex)?)?; + anyhow::ensure!( + previous.compute_txid().to_string() == input.lease.txid, + "Original input transaction changed" + ); + let output = previous + .output + .get(input.lease.vout as usize) + .context("Original input index missing")?; + anyhow::ensure!( + output.value.to_sat() == input.lease.value_sats + && hex::encode(output.script_pubkey.as_bytes()) == input.lease.script, + "Original input metadata changed" + ); + total = total + .checked_add(input.lease.value_sats) + .filter(|v| *v <= MAX_SATS) + .context("Input sum overflow")?; + } + let debit = offer + .binding + .price_sats + .checked_add(self.change_sats) + .and_then(|v| v.checked_add(self.fee_sats)) + .context("Payment amount overflow")?; + anyhow::ensure!( + total == debit && self.fee_sats > 0 && self.fee_sats <= self.max_fee_sats, + "Funding plan fee or outputs changed" + ); + let leases: Vec<_> = self.inputs.iter().map(|i| i.lease.clone()).collect(); + let minimum = self + .fee_rate_sat_vbyte + .checked_mul(max_vsize( + &leases, + if self.change_sats == 0 { + None + } else { + Some(&change) + }, + )?) + .context("Fee estimate overflow")?; + anyhow::ensure!( + self.fee_sats >= minimum, + "Funding plan fee does not cover reviewed rate" + ); + Ok(()) + } + /// Only now, with the real original seller address, construct a PSBT. + pub fn bind(&self, record: &Record, quote: &Quote) -> Result<(FeePolicy, Funded)> { + self.validate(record)?; + record.offer.as_ref().unwrap().check_quote(quote)?; + let recipient = quote.script()?; + let change = ScriptBuf::from_bytes(hex::decode(&self.change_script)?); + anyhow::ensure!(recipient != change, "Recipient cannot equal local change"); + let mut outputs = vec![TxOut { + value: Amount::from_sat(record.binding.price_sats), + script_pubkey: recipient, + }]; + if self.change_sats > 0 { + outputs.push(TxOut { + value: Amount::from_sat(self.change_sats), + script_pubkey: change, + }); + } + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: self + .inputs + .iter() + .map(|i| { + Ok(TxIn { + previous_output: i.lease.outpoint()?, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }) + }) + .collect::>>()?, + output: outputs, + }; + let max_rate = self.fee_sats.div_ceil(tx.vsize() as u64); + let mut psbt = Psbt::from_unsigned_tx(tx)?; + for (metadata, input) in psbt.inputs.iter_mut().zip(&self.inputs) { + metadata.witness_utxo = Some(TxOut { + value: Amount::from_sat(input.lease.value_sats), + script_pubkey: ScriptBuf::from_bytes(hex::decode(&input.lease.script)?), + }); + metadata.non_witness_utxo = Some(consensus::deserialize(&hex::decode( + &input.previous_tx_hex, + )?)?); + } + Ok(( + FeePolicy { + change_script: self.change_script.clone(), + max_fee_sats: self.max_fee_sats, + max_fee_rate_sat_vbyte: max_rate, + }, + Funded { + psbt_base64: base64::engine::general_purpose::STANDARD.encode(psbt.serialize()), + leases: self.inputs.iter().map(|i| i.lease.clone()).collect(), + }, + )) + } +} +/// Weight calculation only: never constructs a placeholder-address transaction. +/// Versions, sequence and locktime are fixed by this protocol; <=32 inputs/2 outputs +/// mean single-byte CompactSize counts. Native inputs have empty scriptSig. +pub(crate) fn max_vsize(inputs: &[Lease], change: Option<&ScriptBuf>) -> Result { + anyhow::ensure!( + !inputs.is_empty() && inputs.len() <= 32, + "Unsupported input count" + ); + let mut stripped = 4 + 1 + 41 * (inputs.len() as u64) + 1 + 8 + 1 + 22 + 4; + if let Some(script) = change { + anyhow::ensure!(script.len() < 253, "Unsupported change script length"); + stripped += 8 + 1 + script.len() as u64; + } + let mut witness = 2u64; + for input in inputs { + let script = ScriptBuf::from_bytes(hex::decode(&input.script)?); + witness += if script.is_p2wpkh() { + 109 + } else if script.is_p2tr() { + 67 + } else { + anyhow::bail!("Unsupported signing input") + }; + } + Ok((stripped * 4 + witness).div_ceil(4)) +} diff --git a/core/archipelago/src/content_onchain_seller.rs b/core/archipelago/src/content_onchain_seller.rs new file mode 100644 index 00000000..d4978b11 --- /dev/null +++ b/core/archipelago/src/content_onchain_seller.rs @@ -0,0 +1,551 @@ +//! Buyer-bound seller address allocation. Unknown allocation never creates a replacement. +use crate::{ + content_lightning::{Binding, RetainedFile}, + content_onchain::{ChainNetwork, Quote}, +}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum Allocation { + Prepared, + Dispatched, + Ready { address: String }, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Record { + pub binding: Binding, + pub source: RetainedFile, + pub network: ChainNetwork, + pub allocation: Allocation, + pub paid: bool, +} +impl Record { + fn validate(&self) -> Result<()> { + self.binding.validate()?; + self.source.validate()?; + anyhow::ensure!( + (546..=2_100_000_000_000_000).contains(&self.binding.price_sats), + "On-chain price below supported minimum" + ); + if let Allocation::Ready { address } = &self.allocation { + self.quote()? + .context("Missing original address")? + .script()?; + anyhow::ensure!(!address.is_empty(), "Missing address"); + } + anyhow::ensure!( + !self.paid || matches!(self.allocation, Allocation::Ready { .. }), + "Paid operation lacks address" + ); + Ok(()) + } + pub fn offer(&self) -> Result { + let offer = crate::content_onchain_plan::Offer { + binding: self.binding.clone(), + source: self.source.clone(), + network: self.network, + recipient_script_type: "p2wpkh".into(), + }; + offer.validate()?; + Ok(offer) + } + pub fn quote(&self) -> Result> { + Ok(match &self.allocation { + Allocation::Ready { address } => Some(Quote { + binding: self.binding.clone(), + address: address.clone(), + network: self.network, + source: self.source.clone(), + }), + _ => None, + }) + } +} +/// Terminal proof for an operation whose receive allocation was never dispatched. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct UnallocatedAck { + pub binding: Binding, + pub state: String, + pub address: serde_json::Value, + pub allocation_dispatched: bool, + pub can_switch_method: bool, +} +impl UnallocatedAck { + pub fn validate(&self, binding: &Binding) -> Result<()> { + binding.validate()?; + anyhow::ensure!( + &self.binding == binding + && self.state == "cancelled_unallocated" + && self.address.is_null() + && !self.allocation_dispatched + && self.can_switch_method, + "Invalid unallocated retirement acknowledgement" + ); + Ok(()) + } +} +#[derive(Serialize, Deserialize)] +struct Envelope { + payload: String, + checksum: String, +} +pub(crate) struct Journal { + directory: PathBuf, + _lock: fs::File, +} +impl Journal { + pub async fn open(data_dir: &Path) -> Result { + let data = data_dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + use std::os::{ + fd::AsRawFd, + unix::fs::{OpenOptionsExt, PermissionsExt}, + }; + fs::create_dir_all(&data)?; + let data = fs::canonicalize(data)?; + let directory = data.join("content-onchain-seller"); + fs::create_dir_all(&directory)?; + anyhow::ensure!( + fs::symlink_metadata(&directory)?.is_dir(), + "On-chain seller journal is not a directory" + ); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + fs::File::open(&data)?.sync_all()?; + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(directory.join(".lock"))?; + anyhow::ensure!(lock.metadata()?.is_file(), "Invalid on-chain seller lock"); + loop { + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == 0 { + break; + } + let e = std::io::Error::last_os_error(); + if e.kind() != std::io::ErrorKind::Interrupted { + return Err(e.into()); + } + } + Ok(Self { + directory, + _lock: lock, + }) + }) + .await? + } + fn path(&self, role: &str, id: &str) -> Result { + anyhow::ensure!( + matches!(role, "seller" | "retired") && uuid::Uuid::parse_str(id)?.to_string() == id, + "Invalid on-chain seller journal key" + ); + Ok(self.directory.join(format!("{role}-{id}.json"))) + } + fn read(&self, role: &str, id: &str) -> Result> { + use std::os::unix::fs::OpenOptionsExt; + let file = match fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(self.path(role, id)?) + { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + anyhow::ensure!(file.metadata()?.is_file(), "Invalid on-chain seller record"); + let mut bytes = Vec::new(); + file.take(65537).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() <= 65536, "On-chain seller record too large"); + let envelope: Envelope = serde_json::from_slice(&bytes) + .context("On-chain seller recovery damaged; do not pay again")?; + anyhow::ensure!( + hex::encode(Sha256::digest(envelope.payload.as_bytes())) == envelope.checksum, + "On-chain seller recovery checksum changed" + ); + Ok(Some(serde_json::from_str(&envelope.payload)?)) + } + fn write(&self, role: &str, id: &str, value: &T) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + let payload = serde_json::to_string(value)?; + let bytes = serde_json::to_vec(&Envelope { + checksum: hex::encode(Sha256::digest(payload.as_bytes())), + payload, + })?; + anyhow::ensure!(bytes.len() <= 65536, "On-chain seller record too large"); + let temporary = self + .directory + .join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let mut f = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary)?; + f.write_all(&bytes)?; + f.sync_all()?; + fs::rename(&temporary, self.path(role, id)?)?; + fs::File::open(&self.directory)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result + } + + pub fn retirement(&self, binding: &Binding) -> Result> { + binding.validate()?; + let saved: Option = self.read("retired", &binding.id)?; + if let Some(ack) = &saved { + ack.validate(binding)?; + } + Ok(saved) + } + pub fn retire_unallocated(&self, binding: &Binding) -> Result { + if let Some(ack) = self.retirement(binding)? { + return Ok(ack); + } + if let Some(saved) = self.load(binding)? { + anyhow::ensure!(saved.allocation==Allocation::Prepared && !saved.paid,"Original address allocation was dispatched or paid; recover it without another payment"); + } + // Even an absent operation gets a durable tombstone. A delayed create + // must observe retirement rather than allocating after the acknowledgement. + let ack = UnallocatedAck { + binding: binding.clone(), + state: "cancelled_unallocated".into(), + address: serde_json::Value::Null, + allocation_dispatched: false, + can_switch_method: true, + }; + ack.validate(binding)?; + self.write("retired", &binding.id, &ack)?; + Ok(ack) + } + pub fn load(&self, binding: &Binding) -> Result> { + binding.validate()?; + let saved: Option = self.read("seller", &binding.id)?; + if let Some(record) = &saved { + record.validate()?; + anyhow::ensure!( + &record.binding == binding, + "Original seller operation binding changed" + ); + } + Ok(saved) + } + pub fn save(&self, record: &Record) -> Result<()> { + record.validate()?; + anyhow::ensure!( + self.retirement(&record.binding)?.is_none(), + "Original address operation is retired" + ); + if let Some(old) = self.load(&record.binding)? { + anyhow::ensure!( + old.source == record.source && old.network == record.network, + "Original file/network changed" + ); + anyhow::ensure!( + !old.paid || record.paid, + "Confirmed purchase cannot become unpaid" + ); + match old.allocation { + Allocation::Ready { .. } => anyhow::ensure!( + old.allocation == record.allocation, + "Original receive address changed" + ), + Allocation::Dispatched => anyhow::ensure!( + record.allocation != Allocation::Prepared, + "Ambiguous address allocation cannot restart" + ), + Allocation::Prepared => {} + } + } + self.write("seller", &record.binding.id, record) + } + pub fn prepare( + &self, + binding: Binding, + source: RetainedFile, + network: ChainNetwork, + ) -> Result { + anyhow::ensure!( + self.retirement(&binding)?.is_none(), + "Original address operation is retired" + ); + if let Some(old) = self.load(&binding)? { + anyhow::ensure!( + old.source == source && old.network == network, + "Original sale changed" + ); + return Ok(old); + } + let record = Record { + binding, + source, + network, + allocation: Allocation::Prepared, + paid: false, + }; + self.save(&record)?; + Ok(record) + } +} +pub(crate) trait Wallet { + async fn network(&self) -> Result { + anyhow::bail!("Seller wallet network unavailable") + } + async fn preflight(&self, network: ChainNetwork) -> Result<()>; + async fn allocate(&self) -> Result; + async fn received(&self, address: &str, amount: u64) -> Result; +} +pub(crate) async fn drive( + journal: &Journal, + binding: &Binding, + create: bool, + wallet: &W, +) -> Result { + anyhow::ensure!( + journal.retirement(binding)?.is_none(), + "Original address operation is retired" + ); + let mut saved = journal + .load(binding)? + .context("Original on-chain sale missing")?; + if saved.allocation == Allocation::Prepared && create { + wallet.preflight(saved.network).await?; + saved.allocation = Allocation::Dispatched; + journal.save(&saved)?; + let address = wallet.allocate().await?; + saved.allocation = Allocation::Ready { address }; + journal.save(&saved)?; + } + if !saved.paid { + if let Allocation::Ready { address } = &saved.allocation { + if wallet.received(address, saved.binding.price_sats).await? { + saved.paid = true; + journal.save(&saved)?; + } + } + } + Ok(saved) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Mutex, + }; + struct Mock { + calls: AtomicUsize, + lost: AtomicBool, + paid: AtomicBool, + preflight_fails: AtomicBool, + observed: Mutex>, + } + impl Wallet for Mock { + async fn preflight(&self, _: ChainNetwork) -> Result<()> { + anyhow::ensure!( + !self.preflight_fails.load(Ordering::SeqCst), + "Wallet unavailable before allocation" + ); + Ok(()) + } + async fn allocate(&self) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + anyhow::ensure!( + !self.lost.swap(false, Ordering::SeqCst), + "Lost address allocation reply" + ); + Ok(address()) + } + async fn received(&self, address: &str, _: u64) -> Result { + self.observed.lock().unwrap().push(address.into()); + Ok(self.paid.load(Ordering::SeqCst)) + } + } + fn address() -> String { + let mut script = vec![0, 20]; + script.extend([1; 20]); + bitcoin::Address::from_script( + &bitcoin::ScriptBuf::from_bytes(script), + bitcoin::Network::Regtest, + ) + .unwrap() + .to_string() + } + fn mock() -> Mock { + Mock { + calls: AtomicUsize::new(0), + lost: AtomicBool::new(false), + paid: AtomicBool::new(false), + preflight_fails: AtomicBool::new(false), + observed: Mutex::new(vec![]), + } + } + async fn fixture() -> (tempfile::TempDir, Journal, Binding) { + let data = tempfile::tempdir().unwrap(); + let journal = Journal::open(data.path()).await.unwrap(); + let 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, + }; + journal + .prepare( + binding.clone(), + RetainedFile { + sha256: "a".repeat(64), + size: 4, + filename: "original.txt".into(), + mime_type: "text/plain".into(), + }, + ChainNetwork::Regtest, + ) + .unwrap(); + (data, journal, binding) + } + #[tokio::test] + async fn lost_address_response_never_allocates_again_even_after_restart() { + let (data, journal, binding) = fixture().await; + let wallet = mock(); + wallet.lost.store(true, Ordering::SeqCst); + assert!(drive(&journal, &binding, true, &wallet).await.is_err()); + drop(journal); + let journal = Journal::open(data.path()).await.unwrap(); + let recovered = drive(&journal, &binding, true, &wallet).await.unwrap(); + assert_eq!(recovered.allocation, Allocation::Dispatched); + assert!(!recovered.paid); + assert_eq!(wallet.calls.load(Ordering::SeqCst), 1); + assert!(wallet.observed.lock().unwrap().is_empty()); + } + #[tokio::test] + async fn preflight_retry_and_read_only_status_preserve_single_allocation() { + let (_data, journal, binding) = fixture().await; + let wallet = mock(); + drive(&journal, &binding, false, &wallet).await.unwrap(); + assert_eq!(wallet.calls.load(Ordering::SeqCst), 0); + wallet.preflight_fails.store(true, Ordering::SeqCst); + assert!(drive(&journal, &binding, true, &wallet).await.is_err()); + assert_eq!( + journal.load(&binding).unwrap().unwrap().allocation, + Allocation::Prepared + ); + wallet.preflight_fails.store(false, Ordering::SeqCst); + drive(&journal, &binding, true, &wallet).await.unwrap(); + drive(&journal, &binding, true, &wallet).await.unwrap(); + assert_eq!(wallet.calls.load(Ordering::SeqCst), 1); + } + #[tokio::test] + async fn paid_source_survives_reload_and_stale_unpaid_callback_cannot_regress() { + let (data, journal, binding) = fixture().await; + let wallet = mock(); + let unpaid = drive(&journal, &binding, true, &wallet).await.unwrap(); + wallet.paid.store(true, Ordering::SeqCst); + let paid = drive(&journal, &binding, false, &wallet).await.unwrap(); + assert!(paid.paid); + assert!(journal.save(&unpaid).is_err()); + drop(journal); + let journal = Journal::open(data.path()).await.unwrap(); + wallet.paid.store(false, Ordering::SeqCst); + assert_eq!( + drive(&journal, &binding, false, &wallet).await.unwrap(), + paid + ); + assert_eq!(paid.source.filename, "original.txt"); + assert_eq!(wallet.calls.load(Ordering::SeqCst), 1); + let mut other = binding.clone(); + other.buyer_did = binding.seller_did.clone(); + assert!(journal.load(&other).is_err()); + } + #[tokio::test] + async fn cancel_before_allocation_survives_lost_ack_and_blocks_delayed_create() { + let (data, journal, binding) = fixture().await; + let wallet = mock(); + wallet.preflight_fails.store(true, Ordering::SeqCst); + assert!(drive(&journal, &binding, true, &wallet).await.is_err()); + let original = journal.load(&binding).unwrap().unwrap(); + let ack = journal.retire_unallocated(&binding).unwrap(); + ack.validate(&binding).unwrap(); + assert!(ack.address.is_null()); + assert!(!ack.allocation_dispatched); + drop(journal); + let journal = Journal::open(data.path()).await.unwrap(); + assert_eq!(journal.retire_unallocated(&binding).unwrap(), ack); + wallet.preflight_fails.store(false, Ordering::SeqCst); + assert!(drive(&journal, &binding, true, &wallet).await.is_err()); + assert!(journal + .prepare(binding.clone(), original.source, original.network) + .is_err()); + assert_eq!(wallet.calls.load(Ordering::SeqCst), 0); + } + #[tokio::test] + async fn absent_operation_retirement_is_a_tombstone_not_absence_evidence() { + let (_data, journal, binding) = fixture().await; + let mut unknown = binding.clone(); + unknown.id = uuid::Uuid::new_v4().to_string(); + let ack = journal.retire_unallocated(&unknown).unwrap(); + assert!(journal.load(&unknown).unwrap().is_none()); + let original = journal.load(&binding).unwrap().unwrap(); + assert!(journal + .prepare(unknown.clone(), original.source, original.network) + .is_err()); + assert_eq!(journal.retirement(&unknown).unwrap(), Some(ack)); + } + #[tokio::test] + async fn dispatched_unknown_and_issued_addresses_cannot_be_retired() { + let (_data, journal, binding) = fixture().await; + let wallet = mock(); + wallet.lost.store(true, Ordering::SeqCst); + assert!(drive(&journal, &binding, true, &wallet).await.is_err()); + assert!(journal.retire_unallocated(&binding).is_err()); + assert!(journal.retirement(&binding).unwrap().is_none()); + drop(journal); + let (_data, journal, binding) = fixture().await; + let wallet = mock(); + drive(&journal, &binding, true, &wallet).await.unwrap(); + assert!(journal.retire_unallocated(&binding).is_err()); + assert_eq!(wallet.calls.load(Ordering::SeqCst), 1); + } + #[test] + fn retirement_ack_requires_explicit_null_address_and_all_terminal_fields() { + let 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, + }; + let complete = serde_json::json!({"binding":binding,"state":"cancelled_unallocated","address":null,"allocation_dispatched":false,"can_switch_method":true}); + for key in [ + "address", + "state", + "allocation_dispatched", + "can_switch_method", + ] { + let mut partial = complete.clone(); + partial.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(partial).is_err(), + "missing {key}" + ); + } + serde_json::from_value::(complete) + .unwrap() + .validate(&binding) + .unwrap(); + } +} diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index f25d4cf7..566436a7 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -1961,3 +1961,88 @@ pub(crate) async fn publish_snapshot_invoice( anyhow::ensure!(visible, "Item is not shared with this invoice buyer"); journal.prepare_seller_source(binding, Some(retained)) } + +pub(crate) async fn publish_snapshot_onchain( + data_dir: &Path, + original: &ContentItem, + journal: &crate::content_onchain_seller::Journal, + binding: crate::content_lightning::Binding, + retained: crate::content_lightning::RetainedFile, + network: crate::content_onchain::ChainNetwork, +) -> Result { + let _held = CATALOG_WRITES.lock().await; + let catalog = load_catalog(data_dir).await?; + let current = catalog + .items + .iter() + .find(|item| item.id == original.id) + .context("Content was unshared before invoice preparation")?; + anyhow::ensure!( + serde_json::to_value(current)? == serde_json::to_value(original)?, + "Shared content terms changed before invoice preparation" + ); + anyhow::ensure!( + binding.content_id == original.id + && retained.filename == original.filename + && retained.mime_type == original.mime_type + && retained.size == original.size_bytes + && matches!(&original.access, AccessControl::Paid { price_sats, .. } if *price_sats == binding.price_sats) + && method_accepted(&original.access, "onchain"), + "Invoice snapshot terms changed" + ); + let visible = match &original.availability { + Availability::Nobody => false, + Availability::AllPeers => true, + Availability::Specific { peers } => peers.contains(&binding.buyer_did), + }; + anyhow::ensure!(visible, "Item is not shared with this invoice buyer"); + journal.prepare(binding, retained, network) +} + +/// Serialize the first allocation with catalog changes. Previously allocated +/// operations recover their original terms even if sharing changes afterwards. +pub(crate) async fn allocate_onchain_offer( + data_dir: &Path, + journal: &crate::content_onchain_seller::Journal, + binding: &crate::content_lightning::Binding, + wallet: &W, +) -> Result { + let saved = journal.load(binding)?.context("Original offer missing")?; + if saved.allocation != crate::content_onchain_seller::Allocation::Prepared { + return crate::content_onchain_seller::drive(journal, binding, true, wallet).await; + } + let source_data = data_dir.to_path_buf(); + let source_id = binding.content_id.clone(); + let source = saved.source.clone(); + tokio::task::spawn_blocking(move || { + crate::content_snapshot::open_matching( + &source_data, + &source_id, + &source.sha256, + source.size, + ) + }) + .await??; + let _held = CATALOG_WRITES.lock().await; + let catalog = load_catalog(data_dir).await?; + let item = catalog + .items + .iter() + .find(|item| item.id == binding.content_id) + .context("Original offer is no longer shared; cancel before allocation")?; + let visible = match &item.availability { + Availability::Nobody => false, + Availability::AllPeers => true, + Availability::Specific { peers } => peers.contains(&binding.buyer_did), + }; + anyhow::ensure!( + visible + && item.filename == saved.source.filename + && item.mime_type == saved.source.mime_type + && item.size_bytes == saved.source.size + && matches!(&item.access,AccessControl::Paid {price_sats,..} if *price_sats==binding.price_sats) + && method_accepted(&item.access, "onchain"), + "Original offer terms changed; no seller address allocated" + ); + crate::content_onchain_seller::drive(journal, binding, true, wallet).await +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 9eb52fe9..b0d9e3f0 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -45,6 +45,9 @@ mod content_hash; mod content_indeehub; mod content_invoice; mod content_lightning; +mod content_onchain; +mod content_onchain_plan; +mod content_onchain_seller; mod content_payment_admission; mod content_owned; mod content_purchase; diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 5366d44b..9e790db3 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -860,3 +860,273 @@ Written regression coverage: four output-attribution cases, two mocked sidecar spend cases, and two mounted UI cases (unsupported Ark and unknown on-chain verification). No test execution is claimed until the queued isolated backend and focused UI runs complete. + +### Durable on-chain engine draft — not wired or qualified + +A separate follow-on draft adds a checksummed per-operation buyer journal and +mock wallet boundary. It preserves the original quote, fee limits, unique UTXO +lease ID, funded PSBT, signed bytes and computed transaction ID. Funding, +signing and publication have durable dispatch markers. A retry after signing +ambiguity uses the saved PSBT; publication recovery checks the saved txid and an +explicit rebroadcast uses identical bytes. Output/input/change/fee checks occur +before signing, and signed transaction structure is checked before publication. + +A lost FundPsbt response is **not yet fully recoverable**. ListLeases exposes +owned outpoints, values, scripts and expiry, but does not reconstruct the exact +original PSBT. The draft records those diagnostics and remains blocked from new +funding/signing. It has no reconstruction hook that could synthesize a different +transaction from leases. Missing or expired leases do not authorize a fresh +payment. The pinned LND schema's custom lock ID is useful provenance, not an +idempotency key or proof that the original funding request did not execute. + +Nine mock/file tests are written but unrun. This engine has no live wallet +adapter, seller protocol, cross-rail RPC integration or UI wiring yet; it is not +a complete deployed on-chain flow. Explicit owned-lease renewal/release and a +supported original-funding recovery strategy still require implementation. +Publicly exposed addresses stay payable and block replacement; settled receipts +are monotonic. All remaining durable address, snapshot and legacy delivery work +listed above stays open. + +### Exact-template LND adapter draft — isolated and unqualified + +The follow-on adapter avoids FundPsbt entirely. Read-only preparation validates +wallet network/sync and spending-account ownership, obtains the current Fast +(next-block) estimate unless an explicit rate is supplied, and requires an +absolute fee cap. It selects at most 32 confirmed native P2WPKH/P2TR inputs, +excludes every existing lease, and leaves the reported channel reserve in +unselected confirmed outputs. It verifies previous transaction bytes against +each selected outpoint/value/script and persists the exact PSBT before any +LeaseOutput request. A caller-prepared change address must be verified as an +internal address in the default account; this draft never calls NextAddr. + +Each lease dispatch is durable before HTTP. A lost reply is recovered by reading +leases under the saved owner ID, then acquiring or renewing only the same saved +outpoint. No replacement input or different payment is selected. Signing retries +use the saved PSBT; publication retries use the saved signed bytes. All remote +responses are bounded. Four loopback HTTP cases are written for lost lease, +signing and publication replies; fee/change rejection before mutation; existing +leases and channel reserve; and a foreign lease race. They assert journal state +before each mocked mutation and assert that FundPsbt is never called. + +These four cases and the earlier nine engine cases are **unrun**. Only formatting +and whitespace checks have run. The adapter has no owner RPC, seller protocol, +cross-rail admission or UI wiring. Durable change-address preparation, renewal +once the operation has already reached Funded, deliberate lease release, +real regtest signing/fee verification, and preservation against concurrent +outside wallet/channel operations remain open. Read-only reserve checks are +conservative but are not a global LND coin-selection lock. Exposed recipient +addresses cannot be retired on timeout. The mocked signatures prove request and +transaction identity only, not cryptographic signing. No live leases, signing, +funding, publication or payments were performed. + +Schema review used the node's pinned LND v0.21.2-beta WalletKit definitions and +btcwallet v0.16.19 implementation: `lnrpc/walletrpc/walletkit.proto`, +`walletkit.yaml`, `walletkit_server.go`, and `wallet/psbt.go`. ListLeases cannot +recover an unknown original funded PSBT; the exact-template path removes that +ambiguity by committing the original transaction before leasing. + +### Change allocation and post-funding lease recovery draft + +The next isolated checkpoint persists an explicit change-address allocation +marker before WalletKit NextAddr. A confirmed local internal address is saved +and reused after reload; an absent/malformed/lost reply remains an ambiguous +allocation and cannot trigger another NextAddr or transaction preparation. +Stale records cannot erase or replace a saved allocation. This deliberately does +not guess an address by comparing the wallet's global address list, since other +wallet consumers may derive addresses concurrently. + +Exact-template leases can now be reconciled after Funded and after an ambiguous +signing reply. Before signing, the engine renews only the original saved inputs +under the same owner ID, keeping the original funded PSBT immutable. The durable +phase remains Funded/SigningDispatched during renewal, so a lost renewal reply +can be looked up after reload. A foreign lease blocks signing; it never causes +coin reselection. Four additional HTTP/file cases cover allocation ambiguity, +reload/stale records, lost renewal reply, and an expired input taken by another +owner. All 17 engine/adapter cases remain unrun pending the coordinated slot. + +Owner/seller protocol, cross-rail admission and UI integration are still the +next work, not implemented by this checkpoint. No live wallet mutations occurred. + +### Owner/seller/rail/UI wiring draft — isolated, uncompiled + +New owner methods (`content.onchain-attempt/create/expose/prepare/pay/recover/ +download`) bind the owner identity, unique verified seller and content before +finding or creating a durable UUID. The seller route authenticates the signed +request body and keeps a buyer-bound source snapshot and original address +allocation. Its allocation marker precedes NextAddr; a lost reply stays unknown. +Repeated status/download requests never allocate an address. Paid status and +original source metadata survive catalog changes and cannot regress through a +stale record. Delivery uses the retained snapshot and verifies size/hash into +the existing owned-file cache. + +The owner returns the receive address to the browser only after durable external +exposure. Native preparation returns the saved fee and template hash; a later +confirmation must match that same hash. Dispatch resumes original lease/sign/ +broadcast phases rather than generic sendcoins. Modern Cashu and Lightning +admission rejects a saved on-chain liability, and on-chain dispatch/exposure +rechecks those rails under the shared outer admission lock. Confirmed Cashu +receipt replay is exempt from the new on-chain guard; it remains recovery. + +PeerFiles looks up the node operation when reopening, blocks replacement rails +on unknown lookup, reviews actual fee/network under an explicit fee cap, and +uses a separate confirmation click. Delayed callbacks cannot mutate another +modal or continue preparation/payment after its selection changes. Read-only +polling and download recovery keep the original operation ID; no localStorage +marker is treated as authority for a fresh payment. Dashboard-origin policy was +extended to the new owner methods without bypassing authentication or CSRF. + +This checkpoint adds three seller-engine cases, two buyer-discovery/corruption +cases, three frontend parser cases and three mounted confirmation/reload/stale +callback cases, and updates existing on-chain tests to the durable RPCs. The +22 engine/adapter/seller/discovery cases and all affected frontend tests are +UNRUN; no compile or browser/live acceptance is claimed. Formatting and diff +checks only. Root coordinates the next isolated qualification slot. + +Remaining gates: typed HTTP owner/seller roundtrip and authentication tests; +actual regtest signing/lease/rebroadcast validation; mobile/desktop fee-dialog +checks; integration with existing legacy exposed addresses and unjournaled +payments; legacy Fedimint/token receipt recovery; and safe cancellation of a +provably unallocated original operation. Currently a saved on-chain operation +conservatively blocks replacement even when seller preflight failed before +allocation; no timeout is used to retire a payable address. Large ordinary Cloud +snapshot preparation retains its known bounded-timeout/readiness limitation. +Shared LND channel/other-wallet races are not globally locked by these local +per-item admission guards. No lease release, fee replacement or input reselection +is performed. No production tree, live wallet or deployed app was modified. + +### Provably unallocated cancellation draft + +`content.onchain-cancel` now asks the authenticated seller to retire the original +buyer/UUID binding. Before acknowledging cancellation, the seller writes and +fsyncs a terminal tombstone. This includes an operation it has never received: +absence alone is not the proof, and a delayed create must encounter the saved +tombstone. A prepared operation may be retired only before address allocation +was dispatched. A dispatched/unknown allocation, issued address or paid sale +cannot be retired. Repeating cancellation after a lost acknowledgement returns +the same saved terminal result without deriving another address. + +The owner accepts only the matching explicit `cancelled_unallocated` result with +`address: null`, `allocation_dispatched: false` and `can_switch_method: true`. +Its own record must still have no quote/exposure, change allocation, lease, +funding/signing/publication material or wallet mutation. It saves that result +before allowing another rail. A stale record cannot revive retirement. Original +operation lookups by ID can recover this terminal result; admission lookup +ignores only validated durable retirements. Missing/corrupt/ambiguous records +still block payment. The UI offers “Cancel if no address was issued” and unlocks +choices only after the matching terminal response, retaining ownership checks +for delayed replies. + +Address response audit: native preparation/status/lookup return a null address. +The explicit exposure path writes the exposure marker and reloads the record +before returning the address. A new regression checks redaction before exposure, +its persistence across reload, and rejection of retirement afterward. + +Six backend cases and four frontend/parser cases were added for lost-ack replay, +absent-operation tombstones, dispatched/issued refusal, cross-rail admission, +address redaction and stale cancellation callbacks. These and the prior cases +remain UNRUN: now 28 backend engine/adapter/seller/discovery cases. No heavy +qualification or live wallet operation was performed. Authenticated HTTP fault +roundtrips and real regtest/browser qualification remain required before rollout. + +### Native flow review: common preflight dead end remains + +The current draft still allocates the seller's receive address on the first +Review click, **before** buyer balance, channel-reserve and fee-cap checks. It +then prepares the buyer's change address and transaction; only a second click +leases/signs/publishes. Thus a buyer balance or fee-preflight failure can leave +an issued seller address, no browser exposure, and no signed/broadcast payment. +The unallocated cancellation protocol intentionally cannot retire that case. +It fixes failures before seller allocation dispatch only; it is not a complete +solution to the user's native method-switching problem. + +Two-phase seller offer/preparation could defer allocation until explicit Pay, +but a simple preliminary balance check cannot eliminate subsequent races. A +separate native-unexposed retirement protocol would require durable buyer sealing +against late signing/dispatch, explicit seller acknowledgement and reviewed +late-arrival handling. Neither approach is implemented by this review. Exposed +or unknown allocations and ambiguous payment mutations remain absolute blocks; +no timeout/empty lookup is permission to replace a payment. + +Three authenticated loopback HTTP regression drafts exercise the production +handler with temporary node identities: signed cancel/replay after dropping the +reply and delayed create; unsigned/tampered/wrong-recipient rejection; and refusal +to retire dispatched or issued addresses. These add no product behavior. They +remain UNRUN with the prior tests (31 backend cases total), and must use the +isolated backend runner. The detailed sequence is also preserved in +`/tmp/archy-onchain-native-flow-review.txt` for the coordinating agent. + +### Isolated two-phase on-chain draft — 7 October + +Unqualified source checkpoint only: no compiler, backend/UI tests or live wallet +mutations have run for this draft. It is not part of the deployed candidate. + +Review now requests a retained seller offer without allocating a seller address. +A typed FundingPlan binds the original offer, inputs and previous transactions, +verified change address, dynamic Fast fee and explicit cap. It contains no PSBT +or placeholder recipient. A separate Pay confirms its hash, rechecks inputs and +leases those exact outpoints before requesting the original seller address. +Only then is the final PSBT constructed and checked against the reviewed plan. +Lost lease/allocation/sign/broadcast replies retain that original operation. + +Authenticated seller HTTP handling has an injectable wallet boundary; production +loads wallet credentials only after request authentication reaches that boundary. +Offer/create does not allocate. Explicit allocate revalidates current sharing, +price and retained bytes before the first allocation; dispatched/paid operations +continue recovering original terms. Tests are written for offer/cancel/body-tamper, +lost HTTP replies, lost wallet allocation replies, fee-review cancellation and +original input recovery. They remain unrun. + +A confirmed local change derivation plus an unallocated offer/plan can be retired +only after the seller's durable unallocated acknowledgement. An unknown change +allocation, any lease mutation, uncertain seller allocation or exposed address +continues to block replacement payments. This does not implement retirement of +native-unexposed addresses after Pay, migration of legacy exposed addresses, +legacy Fedimint receipts, fee-bumping or large-file background readiness. + +Queued qualification (run serially only when the parent releases the slot): + +```sh +cd /home/archipelago/Projects/archy-payment-edge-fixes +CARGO_TARGET_DIR=/home/archipelago/Projects/archy/core/target CARGO_BUILD_JOBS=2 nice -n 10 ionice -c 2 -n 7 bash scripts/test-backend-isolated.sh onchain +cd neode-ui +nice -n 10 npm exec -- vitest run --maxWorkers=1 src/composables/__tests__/peerOnchainPurchase.test.ts src/composables/peerPaymentOperations.test.ts src/views/__tests__/PeerFilesLightning.test.ts src/views/__tests__/PeerFilesRefresh.test.ts +nice -n 10 npm exec -- vue-tsc -b +``` + +Capture/check source hashes around each run. Follow with full isolated backend, +full dashboard tests, build and mobile/desktop flow checks before integration or +deployment. Compilation/type errors or fault-test failures are still possible; +formatting and diff checks alone are not qualification. + + +### Two-phase on-chain focused qualification — 7 October + +The isolated draft now compiles. The first compile stopped on an inherited +rental_readiness moved-value error; the exact total_bytes-before-move correction +already present in the active tree was carried into this isolated branch. +Read-only review also fixed valid no-change input selection: it must not require +funding an unused change output. Its mocked regression passes within the original +explicit fee cap and performs no input lease. + +The first completed test run passed 40 and failed six. Five HTTP fixtures had a +1 KiB storage budget below snapshot metadata overhead; the sixth expected a lease +request that the stronger read-only foreign-lease check now rejects before +mutation. Correcting only those fixtures/expectations gave: + +- Isolated backend `onchain` scope: **46 passed, 0 failed**, no skips; + 1,917 unrelated tests filtered. All 424 captured backend inputs unchanged. +- Affected dashboard tests: **69 passed across four files** (68 in the main run, + one ownership-helper test run separately after correcting its command path). +- Actual `vue-tsc -b`: **passed**. All 509 captured UI inputs unchanged. + +Receipts: `/tmp/archy-onchain-two-phase-final-tests.log`, +`/tmp/archy-onchain-two-phase-final-inputs.json`, +`/tmp/archy-onchain-ui-focused-tests.log`, +`/tmp/archy-onchain-ui-ownership-helper-tests.log`, +`/tmp/archy-onchain-ui-typecheck.log`, `/tmp/archy-onchain-ui-inputs.json`. +Failed compile/test logs remain beside these as separate evidence. + +This qualifies only the isolated focused source scope. Full integrated backend/UI +regressions, production artifacts, actual LND regtest signing/lease/broadcast +acceptance, mobile/desktop flow checks and deployment remain open. No live money, +address allocation, input lease, signing or broadcast was performed. diff --git a/neode-ui/src/composables/__tests__/peerOnchainPurchase.test.ts b/neode-ui/src/composables/__tests__/peerOnchainPurchase.test.ts new file mode 100644 index 00000000..ee096f6a --- /dev/null +++ b/neode-ui/src/composables/__tests__/peerOnchainPurchase.test.ts @@ -0,0 +1,27 @@ +import { describe,it,expect } from 'vitest' +import { parseOnchainAttempt } from '../peerOnchainPurchase' +const original={operation_id:'11111111-1111-4111-8111-111111111111',price_sats:546,phase:'template_prepared',network:'mainnet',external_exposure:false,address:null,fee_sats:142,max_fee_sats:1000,template_sha256:'a'.repeat(64),plan_sha256:null,txid:null,paid:false,change_allocation_ambiguous:false,can_switch_method:false,retired_unallocated:false} +describe('durable on-chain owner state',()=>{ + it('preserves original amount and fee for explicit confirmation',()=>expect(parseOnchainAttempt(original)).toEqual(original)) + it('rejects unknown state and incoherent exposure or fee metadata',()=>{ + for(const value of [{},{...original,can_switch_method:true},{...original,address:'bc1hidden'},{...original,external_exposure:true},{...original,fee_sats:1001},{...original,price_sats:545},{...original,template_sha256:'bad'},{...original,phase:'failed'}])expect(()=>parseOnchainAttempt(value)).toThrow() + }) + it('does not equate allocation ambiguity with permission to switch',()=>{ + const result=parseOnchainAttempt({...original,phase:'quoted',fee_sats:null,max_fee_sats:null,template_sha256:null,change_allocation_ambiguous:true}) + expect(result.can_switch_method).toBe(false);expect(result.change_allocation_ambiguous).toBe(true) + }) +}) + +it('accepts only a coherent terminal unallocated acknowledgement for switching',()=>{ + const retired={...original,phase:'address_requested',network:null,fee_sats:null,max_fee_sats:null,template_sha256:null,retired_unallocated:true,can_switch_method:true} + expect(parseOnchainAttempt(retired).can_switch_method).toBe(true) + for(const value of [{...retired,address:'bc1issued'},{...retired,external_exposure:true},{...retired,paid:true},{...retired,change_allocation_ambiguous:true},{...retired,phase:'funded'},{...retired,txid:'b'.repeat(64)}])expect(()=>parseOnchainAttempt(value)).toThrow() +}) + +it('requires original plan confirmation and permits only unallocated plan retirement',()=>{ + const plan={...original,phase:'plan_prepared',template_sha256:null,plan_sha256:'c'.repeat(64)} + expect(parseOnchainAttempt(plan).plan_sha256).toBe(plan.plan_sha256) + expect(()=>parseOnchainAttempt({...plan,plan_sha256:null})).toThrow() + expect(parseOnchainAttempt({...plan,can_switch_method:true,retired_unallocated:true}).can_switch_method).toBe(true) + expect(()=>parseOnchainAttempt({...plan,phase:'plan_lease_dispatched',can_switch_method:true,retired_unallocated:true})).toThrow() +}) diff --git a/neode-ui/src/composables/peerOnchainPurchase.ts b/neode-ui/src/composables/peerOnchainPurchase.ts new file mode 100644 index 00000000..b68fe8a8 --- /dev/null +++ b/neode-ui/src/composables/peerOnchainPurchase.ts @@ -0,0 +1,38 @@ +/** Durable owner-node state; never infer an unpaid address from a browser timeout. */ +export interface OnchainAttempt { + operation_id: string + price_sats: number + phase: 'address_requested' | 'offer_prepared' | 'plan_prepared' | 'plan_lease_dispatched' | 'inputs_leased' | 'address_allocation_dispatched' | 'quoted' | 'template_prepared' | 'lease_dispatched' | 'funding_dispatched' | 'funded' | 'signing_dispatched' | 'signed' | 'broadcast_dispatched' | 'published' + network: 'mainnet' | 'testnet' | 'signet' | 'regtest' | null + external_exposure: boolean + address: string | null + fee_sats: number | null + max_fee_sats: number | null + template_sha256: string | null + plan_sha256: string | null + txid: string | null + paid: boolean + change_allocation_ambiguous: boolean + can_switch_method: boolean + retired_unallocated: boolean +} +const phases = new Set(['address_requested','offer_prepared','plan_prepared','plan_lease_dispatched','inputs_leased','address_allocation_dispatched','quoted','template_prepared','lease_dispatched','funding_dispatched','funded','signing_dispatched','signed','broadcast_dispatched','published']) +export function parseOnchainAttempt(value: unknown): OnchainAttempt { + if (!value || typeof value !== 'object') throw Error('Original on-chain state is unavailable; do not pay again') + const v = { plan_sha256: null, ...value } as Record + const integer = (n:unknown) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 + const hex = (s:unknown) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s) + if (typeof v.operation_id !== 'string' || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(v.operation_id) + || !integer(v.price_sats) || Number(v.price_sats)<546 || !phases.has(String(v.phase)) + || ![null,'mainnet','testnet','signet','regtest'].includes(v.network as string|null) + || typeof v.external_exposure !== 'boolean' || typeof v.paid !== 'boolean' + || typeof v.change_allocation_ambiguous !== 'boolean' || typeof v.retired_unallocated !== 'boolean' || v.can_switch_method !== v.retired_unallocated + || !(v.address === null || typeof v.address === 'string' && v.address.length>0) + || !(v.fee_sats === null || integer(v.fee_sats)) || !(v.max_fee_sats === null || integer(v.max_fee_sats)) + || !(v.plan_sha256 === null || hex(v.plan_sha256)) || !(v.template_sha256 === null || hex(v.template_sha256)) || !(v.txid === null || hex(v.txid))) throw Error('Original on-chain state is invalid; do not pay again') + if (v.external_exposure && !v.address || !v.external_exposure && v.address !== null + || (v.template_sha256 !== null || v.plan_sha256 !== null) && (v.fee_sats === null || v.max_fee_sats === null || Number(v.fee_sats)>Number(v.max_fee_sats))) throw Error('Original on-chain payment terms changed') + if (['plan_prepared','plan_lease_dispatched','inputs_leased'].includes(String(v.phase)) && (v.plan_sha256 === null || v.network === null || v.external_exposure || v.address !== null)) throw Error('Original funding plan is incomplete') + if (v.retired_unallocated && (!['address_requested','offer_prepared','plan_prepared'].includes(String(v.phase)) || v.external_exposure || v.paid || v.address !== null || v.template_sha256 !== null || v.txid !== null || v.change_allocation_ambiguous)) throw Error('Retired on-chain operation contains payment liability') + return v as unknown as OnchainAttempt +} diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 3fe5f758..92bb25ea 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -454,11 +454,17 @@ - {{ onchainPaying ? 'Sending…' : 'Pay on-chain from my node' }} - Sends Bitcoin on-chain from your node’s wallet (slower) + {{ onchainPaying ? 'Recovering original transaction…' : onchainAttempt?.paid ? 'Recover original download' : (onchainAttempt?.plan_sha256 || onchainAttempt?.template_sha256) ? 'Confirm / resume original transaction' : onchainAttempt?.external_exposure ? 'Check original Bitcoin payment' : 'Review on-chain payment' }} + Reviews the current Fast fee before sending the saved transaction + +

Original Bitcoin payment: {{ onchainAttempt.price_sats }} sats + {{ onchainAttempt.fee_sats }} sats network fee · {{ onchainAttempt.network }}. Confirming resumes this same funding plan; the seller address is allocated only after Pay.

+ +

The original change-address reply was lost. This saved operation needs recovery; no replacement address or payment will be created.

{{ lnError }}

@@ -608,6 +614,7 @@