Integrate two-phase on-chain purchase recovery
This commit is contained in:
@@ -0,0 +1,712 @@
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{content_lightning::Binding, content_onchain_seller::Journal};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
pub(crate) const ROUTE: &str = "/content/onchain/v1/operation";
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Operation {
|
||||
pub binding: Binding,
|
||||
pub action: String,
|
||||
}
|
||||
// Load wallet credentials only after authenticated request validation reaches a
|
||||
// wallet operation. Tests inject the same typed boundary without live services.
|
||||
struct NativeSellerWallet<'a>(&'a crate::api::rpc::RpcHandler);
|
||||
impl crate::content_onchain_seller::Wallet for NativeSellerWallet<'_> {
|
||||
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
|
||||
self.0.onchain_purchase_wallet().await?.network().await
|
||||
}
|
||||
async fn preflight(&self, network: crate::content_onchain::ChainNetwork) -> Result<()> {
|
||||
self.0
|
||||
.onchain_purchase_wallet()
|
||||
.await?
|
||||
.preflight(network)
|
||||
.await
|
||||
}
|
||||
async fn allocate(&self) -> Result<String> {
|
||||
self.0.onchain_purchase_wallet().await?.allocate().await
|
||||
}
|
||||
async fn received(&self, address: &str, amount: u64) -> Result<bool> {
|
||||
self.0
|
||||
.onchain_purchase_wallet()
|
||||
.await?
|
||||
.received(address, amount)
|
||||
.await
|
||||
}
|
||||
}
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_onchain_purchase(
|
||||
&self,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
self.handle_onchain_purchase_with_wallet(request, &NativeSellerWallet(&self.rpc_handler))
|
||||
.await
|
||||
}
|
||||
async fn handle_onchain_purchase_with_wallet<W: crate::content_onchain_seller::Wallet>(
|
||||
&self,
|
||||
mut request: Request<Body>,
|
||||
wallet: &W,
|
||||
) -> Result<Response<Body>> {
|
||||
anyhow::ensure!(
|
||||
request.method() == Method::POST && request.uri().path() == ROUTE,
|
||||
"Invalid on-chain purchase route"
|
||||
);
|
||||
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = request.body_mut().data().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"On-chain purchase request too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk)
|
||||
}
|
||||
Ok::<_, anyhow::Error>(bytes)
|
||||
})
|
||||
.await
|
||||
.context("On-chain purchase request timed out")??;
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = crate::content_auth::authenticate_request(
|
||||
request.headers(),
|
||||
&seller,
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?;
|
||||
let operation: Operation = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
|
||||
"On-chain purchase peer identity mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
operation.action.as_str(),
|
||||
"create" | "offer" | "allocate" | "status" | "download" | "cancel"
|
||||
),
|
||||
"Invalid on-chain purchase action"
|
||||
);
|
||||
let binding = &operation.binding;
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let retired = if operation.action == "cancel" {
|
||||
Some(journal.retire_unallocated(binding)?)
|
||||
} else {
|
||||
journal.retirement(binding)?
|
||||
};
|
||||
if let Some(ack) = retired {
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&ack)?),
|
||||
));
|
||||
}
|
||||
let mut saved = journal.load(binding)?;
|
||||
if saved.is_none() {
|
||||
anyhow::ensure!(
|
||||
matches!(operation.action.as_str(), "create" | "offer"),
|
||||
"Unknown original on-chain purchase operation"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!binding.content_id.starts_with("registered_"),
|
||||
"Registered rentals use their native purchase contract"
|
||||
);
|
||||
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
|
||||
let item = catalog
|
||||
.items
|
||||
.iter()
|
||||
.find(|v| v.id == binding.content_id)
|
||||
.context("Shared item unavailable")?;
|
||||
let visible = match &item.availability {
|
||||
crate::content_server::Availability::Nobody => false,
|
||||
crate::content_server::Availability::AllPeers => true,
|
||||
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
|
||||
};
|
||||
anyhow::ensure!(visible, "Item is not shared with this buyer");
|
||||
anyhow::ensure!(
|
||||
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
|
||||
&& crate::content_server::method_accepted(&item.access, "onchain"),
|
||||
"On-chain purchase price or accepted method changed"
|
||||
);
|
||||
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
|
||||
.await?;
|
||||
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
|
||||
let roots = [
|
||||
self.config.data_dir.join("content/files"),
|
||||
self.config.data_dir.join("filebrowser"),
|
||||
];
|
||||
let (root, relative) = roots
|
||||
.iter()
|
||||
.find_map(|root| {
|
||||
source
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|p| (root.clone(), p.to_path_buf()))
|
||||
})
|
||||
.context("Unsupported on-chain purchase source root")?;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for CancelCopy {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)));
|
||||
let cancelled = cancel_copy.0.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::prepare(
|
||||
&data,
|
||||
&root,
|
||||
&id,
|
||||
&relative,
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 64 * 1024 * 1024 * 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
64 * 1024 * 1024 * 1024,
|
||||
512 * 1024 * 1024,
|
||||
|_| Ok(()),
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
anyhow::ensure!(
|
||||
snapshot.size == item.size_bytes,
|
||||
"Shared file changed before on-chain purchase"
|
||||
);
|
||||
// Source metadata is private and committed before address allocation.
|
||||
let record = crate::content_server::publish_snapshot_onchain(
|
||||
&self.config.data_dir,
|
||||
item,
|
||||
&journal,
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: snapshot.size,
|
||||
filename: item.filename.clone(),
|
||||
mime_type: item.mime_type.clone(),
|
||||
},
|
||||
wallet.network().await?,
|
||||
)
|
||||
.await?;
|
||||
saved = Some(record);
|
||||
}
|
||||
saved.context("Missing original on-chain operation")?;
|
||||
let status = if operation.action == "allocate" {
|
||||
crate::content_server::allocate_onchain_offer(
|
||||
&self.config.data_dir,
|
||||
&journal,
|
||||
binding,
|
||||
wallet,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
crate::content_onchain_seller::drive(&journal, binding, false, wallet).await?
|
||||
};
|
||||
if operation.action == "download" {
|
||||
anyhow::ensure!(status.paid, "Original on-chain purchase has not settled");
|
||||
let source = &status.source;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
let retained = source.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
||||
})
|
||||
.await??;
|
||||
let stream = futures_util::stream::try_unfold(
|
||||
(tokio::fs::File::from_std(snapshot.file), source.size),
|
||||
|(mut file, left)| async move {
|
||||
if left == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
let mut bytes = vec![0; left.min(65536) as usize];
|
||||
let count = file.read(&mut bytes).await?;
|
||||
if count == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"Original on-chain purchase snapshot ended early",
|
||||
));
|
||||
}
|
||||
bytes.truncate(count);
|
||||
Ok(Some((bytes, (file, left - count as u64))))
|
||||
},
|
||||
);
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", &source.mime_type)
|
||||
.header("Content-Length", source.size)
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.body(Body::wrap_stream(stream))?);
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&status)?),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content_onchain_seller::{Allocation, UnallocatedAck};
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
#[derive(Default)]
|
||||
struct MockWallet {
|
||||
allocations: std::sync::atomic::AtomicUsize,
|
||||
lose_reply: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl crate::content_onchain_seller::Wallet for MockWallet {
|
||||
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
|
||||
Ok(crate::content_onchain::ChainNetwork::Regtest)
|
||||
}
|
||||
async fn preflight(&self, _: crate::content_onchain::ChainNetwork) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn allocate(&self) -> Result<String> {
|
||||
self.allocations
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
anyhow::ensure!(
|
||||
!self
|
||||
.lose_reply
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst),
|
||||
"Simulated lost allocation response"
|
||||
);
|
||||
let mut bytes = vec![0, 20];
|
||||
bytes.extend([17u8; 20]);
|
||||
Ok(bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(bytes),
|
||||
bitcoin::Network::Regtest,
|
||||
)?
|
||||
.to_string())
|
||||
}
|
||||
async fn received(&self, _: &str, _: u64) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
struct HttpFixture {
|
||||
wallet: Arc<MockWallet>,
|
||||
data: tempfile::TempDir,
|
||||
_buyer_data: tempfile::TempDir,
|
||||
buyer: crate::identity::NodeIdentity,
|
||||
seller: String,
|
||||
url: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for HttpFixture {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
async fn fixture() -> HttpFixture {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let buyer_data = tempfile::tempdir().unwrap();
|
||||
let buyer = crate::identity::NodeIdentity::load_or_create(buyer_data.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = Arc::new(
|
||||
ApiHandler::new(
|
||||
config,
|
||||
Arc::new(crate::state::StateManager::new()),
|
||||
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&handler.self_pubkey_hex).unwrap();
|
||||
let wallet = Arc::new(MockWallet::default());
|
||||
let server_wallet = wallet.clone();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = hyper::Server::from_tcp(listener)
|
||||
.unwrap()
|
||||
.serve(make_service_fn(move |_| {
|
||||
let handler = handler.clone();
|
||||
let wallet = server_wallet.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request| {
|
||||
let handler = handler.clone();
|
||||
let wallet = wallet.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(
|
||||
handler
|
||||
.handle_onchain_purchase_with_wallet(request, wallet.as_ref())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
Body::from("{\"error\":\"rejected\"}"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}));
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
HttpFixture {
|
||||
wallet,
|
||||
data,
|
||||
_buyer_data: buyer_data,
|
||||
buyer,
|
||||
seller,
|
||||
url,
|
||||
task,
|
||||
}
|
||||
}
|
||||
impl HttpFixture {
|
||||
fn binding(&self) -> Binding {
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: self.buyer.did_key().unwrap(),
|
||||
seller_did: self.seller.clone(),
|
||||
content_id: "file".into(),
|
||||
price_sats: 546,
|
||||
}
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
body: &[u8],
|
||||
signed_body: Option<&[u8]>,
|
||||
audience: Option<&str>,
|
||||
) -> reqwest::Response {
|
||||
let mut request = reqwest::Client::new()
|
||||
.post(format!("{}{}", self.url, ROUTE))
|
||||
.header("content-type", "application/json")
|
||||
.body(body.to_vec());
|
||||
if let Some(signed) = signed_body {
|
||||
let proof = crate::content_auth::sign_request(
|
||||
&self.buyer,
|
||||
audience.unwrap_or(&self.seller),
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
signed,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
request = request.header(crate::content_auth::REQUEST_HEADER, proof);
|
||||
}
|
||||
request.send().await.unwrap()
|
||||
}
|
||||
async fn operation(&self, binding: &Binding, action: &str) -> reqwest::Response {
|
||||
let body = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: action.into(),
|
||||
})
|
||||
.unwrap();
|
||||
self.send(&body, Some(&body), None).await
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authenticated_cancel_roundtrip_lost_reply_and_delayed_create_return_same_retirement() {
|
||||
let server = fixture().await;
|
||||
let binding = server.binding();
|
||||
// Drop the original reply after headers: terminal state must already be durable.
|
||||
let first = server.operation(&binding, "cancel").await;
|
||||
assert_eq!(first.status(), reqwest::StatusCode::OK);
|
||||
drop(first);
|
||||
let replay = server.operation(&binding, "cancel").await;
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::OK);
|
||||
let ack: UnallocatedAck = replay.json().await.unwrap();
|
||||
ack.validate(&binding).unwrap();
|
||||
let delayed = server.operation(&binding, "create").await;
|
||||
assert_eq!(delayed.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(delayed.json::<UnallocatedAck>().await.unwrap(), ack);
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(journal.retirement(&binding).unwrap(), Some(ack));
|
||||
assert!(journal.load(&binding).unwrap().is_none());
|
||||
assert!(!server.data.path().join("content-snapshots").exists());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_http_rejects_missing_proof_body_tamper_and_wrong_seller_without_tombstone(
|
||||
) {
|
||||
let server = fixture().await;
|
||||
let binding = server.binding();
|
||||
let body = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "cancel".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server.send(&body, None, None).await.status().is_success());
|
||||
let mut changed = binding.clone();
|
||||
changed.price_sats += 1;
|
||||
let changed = serde_json::to_vec(&Operation {
|
||||
binding: changed,
|
||||
action: "cancel".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server
|
||||
.send(&changed, Some(&body), None)
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let wrong = crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap();
|
||||
assert!(!server
|
||||
.send(&body, Some(&body), Some(&wrong))
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authenticated_cancel_cannot_retire_dispatched_or_issued_address() {
|
||||
let server = fixture().await;
|
||||
let mut script = vec![0, 20];
|
||||
script.extend([1; 20]);
|
||||
let address = bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(script),
|
||||
bitcoin::Network::Regtest,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
for allocation in [Allocation::Dispatched, Allocation::Ready { address }] {
|
||||
let binding = server.binding();
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
let mut record = journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: "a".repeat(64),
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
crate::content_onchain::ChainNetwork::Regtest,
|
||||
)
|
||||
.unwrap();
|
||||
record.allocation = allocation.clone();
|
||||
journal.save(&record).unwrap();
|
||||
drop(journal);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
allocation
|
||||
);
|
||||
}
|
||||
}
|
||||
async fn seed_unallocated_offer(server: &HttpFixture) -> Binding {
|
||||
let binding = server.binding();
|
||||
crate::content_server::save_catalog(
|
||||
server.data.path(),
|
||||
&crate::content_server::ContentCatalog {
|
||||
items: vec![crate::content_server::ContentItem {
|
||||
id: binding.content_id.clone(),
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 4,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: crate::content_server::Availability::AllPeers,
|
||||
access: crate::content_server::AccessControl::Paid {
|
||||
price_sats: 546,
|
||||
accepted: vec!["onchain".into()],
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let root = server.data.path().join("content/files");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("original.txt"), b"test").unwrap();
|
||||
let cancelled = std::sync::atomic::AtomicBool::new(false);
|
||||
let snapshot = crate::content_snapshot::prepare(
|
||||
server.data.path(),
|
||||
&root,
|
||||
&binding.content_id,
|
||||
std::path::Path::new("original.txt"),
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
1024 * 1024,
|
||||
0,
|
||||
|_| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
crate::content_onchain::ChainNetwork::Regtest,
|
||||
)
|
||||
.unwrap();
|
||||
binding
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_offer_never_allocates_or_returns_a_receive_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
let result = server.operation(&binding, "offer").await;
|
||||
assert_eq!(result.status(), reqwest::StatusCode::OK);
|
||||
let body: serde_json::Value = result.json().await.unwrap();
|
||||
assert_eq!(body["allocation"]["state"], "prepared");
|
||||
assert!(body["allocation"].get("address").is_none());
|
||||
assert!(body.get("address").is_none());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reviewed_offer_can_cancel_and_delayed_explicit_allocate_cannot_revive_it() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
assert_eq!(
|
||||
server.operation(&binding, "offer").await.status(),
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
let retired: UnallocatedAck = server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
retired.validate(&binding).unwrap();
|
||||
// Represents a delayed Pay request from the old modal after cancellation.
|
||||
let late = server.operation(&binding, "allocate").await;
|
||||
assert_eq!(late.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(late.json::<UnallocatedAck>().await.unwrap(), retired);
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
assert_eq!(journal.retirement(&binding).unwrap(), Some(retired));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_authenticated_offer_body_to_allocate_cannot_dispatch_an_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
let reviewed = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "offer".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let changed = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "allocate".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server
|
||||
.send(&changed, Some(&reviewed), None)
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_allocation_reuses_original_address_after_lost_http_reply() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
assert!(server
|
||||
.operation(&binding, "offer")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0
|
||||
);
|
||||
// Caller loses the response after seller durability; recovery returns the same record.
|
||||
drop(server.operation(&binding, "allocate").await);
|
||||
let recovered: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(recovered.quote().unwrap().is_some());
|
||||
let repeated: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered, repeated);
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lost_wallet_allocation_reply_never_allocates_a_second_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
server
|
||||
.wallet
|
||||
.lose_reply
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(!server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let recovered: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered.allocation, Allocation::Dispatched);
|
||||
assert!(recovered.quote().unwrap().is_none());
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,14 @@ impl RpcHandler {
|
||||
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
||||
"content.playback-status" => self.handle_playback_status(params, session_token).await,
|
||||
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
|
||||
"content.onchain-cancel" => self.handle_onchain_operation(params, "cancel").await,
|
||||
"content.onchain-attempt" => self.handle_onchain_operation(params, "lookup").await,
|
||||
"content.onchain-create" => self.handle_onchain_operation(params, "create").await,
|
||||
"content.onchain-expose" => self.handle_onchain_operation(params, "expose").await,
|
||||
"content.onchain-prepare" => self.handle_onchain_operation(params, "prepare").await,
|
||||
"content.onchain-pay" => self.handle_onchain_operation(params, "pay").await,
|
||||
"content.onchain-recover" => self.handle_onchain_operation(params, "status").await,
|
||||
"content.onchain-download" => self.handle_onchain_operation(params, "download").await,
|
||||
"content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
|
||||
"content.invoice-download" => self.handle_lightning_operation(params, "download").await,
|
||||
"content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
|
||||
|
||||
@@ -4,6 +4,7 @@ mod fee_bump;
|
||||
mod fee_policy;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
pub(super) mod onchain_purchase;
|
||||
mod payments;
|
||||
mod seed_backup;
|
||||
mod wallet;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1347,7 +1347,7 @@ fn psbt_key_origin_report(psbt_base64: &str) -> Result<PsbtKeyOriginReport> {
|
||||
/// LND's transaction `amount` is the wallet-wide net amount, not the value
|
||||
/// paid to a purchase address. Attribute only confirmed output values, once
|
||||
/// per outpoint. Missing/malformed evidence is unknown, never proof of payment.
|
||||
fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result<u64> {
|
||||
pub(super) fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result<u64> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
const MAX_SATS: u64 = 21_000_000 * 100_000_000;
|
||||
fn integer(value: &serde_json::Value) -> Result<u64> {
|
||||
|
||||
@@ -18,6 +18,7 @@ mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
mod lightning_purchase;
|
||||
mod onchain_purchase;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod media_registration;
|
||||
@@ -110,6 +111,15 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
|
||||
| "media.registration.context"
|
||||
| "media.registration.resolve"
|
||||
| "content.rental-purchase"
|
||||
|
||||
| "content.onchain-cancel"
|
||||
| "content.onchain-attempt"
|
||||
| "content.onchain-create"
|
||||
| "content.onchain-expose"
|
||||
| "content.onchain-prepare"
|
||||
| "content.onchain-pay"
|
||||
| "content.onchain-recover"
|
||||
| "content.onchain-download"
|
||||
| "content.invoice-pay"
|
||||
| "content.invoice-download"
|
||||
| "content.invoice-attempt"
|
||||
@@ -837,6 +847,14 @@ mod nostr_signing_origin_tests {
|
||||
"media.registration.context",
|
||||
"media.registration.resolve",
|
||||
"content.rental-purchase",
|
||||
"content.onchain-cancel",
|
||||
"content.onchain-attempt",
|
||||
"content.onchain-create",
|
||||
"content.onchain-expose",
|
||||
"content.onchain-prepare",
|
||||
"content.onchain-pay",
|
||||
"content.onchain-recover",
|
||||
"content.onchain-download",
|
||||
"content.purchase",
|
||||
"content.cancel-purchase",
|
||||
"content.playback-handle",
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Owner-only original-operation on-chain flow. No generic sendcoins fallback.
|
||||
use super::RpcHandler;
|
||||
use crate::{
|
||||
content_lightning::Binding,
|
||||
content_onchain::{self as engine, Journal, Phase, Record},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Params {
|
||||
onion: String,
|
||||
content_id: String,
|
||||
operation_id: Option<String>,
|
||||
price_sats: Option<u64>,
|
||||
max_fee_sats: Option<u64>,
|
||||
sat_per_vbyte: Option<u64>,
|
||||
template_sha256: Option<String>,
|
||||
plan_sha256: Option<String>,
|
||||
}
|
||||
fn public(record: &Record) -> Result<Value> {
|
||||
let fee = record
|
||||
.template
|
||||
.as_ref()
|
||||
.map(|t| engine::validate_funded(record, t))
|
||||
.transpose()?;
|
||||
let template_sha256 = record
|
||||
.template
|
||||
.as_ref()
|
||||
.map(|t| hex::encode(Sha256::digest(t.psbt_base64.as_bytes())));
|
||||
Ok(
|
||||
json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"phase":record.phase,
|
||||
"network":record.network(),"external_exposure":record.externally_exposed,
|
||||
"address":if record.externally_exposed {record.quote.as_ref().map(|q|q.address.as_str())}else{None},
|
||||
"fee_sats":fee.or_else(|| record.plan.as_ref().map(|p|p.fee_sats)),
|
||||
"max_fee_sats":record.policy.as_ref().map(|p|p.max_fee_sats).or_else(||record.plan.as_ref().map(|p|p.max_fee_sats)),
|
||||
"template_sha256":template_sha256,"plan_sha256":record.plan.as_ref().map(|p|p.hash()).transpose()?,
|
||||
"txid":record.signed.as_ref().map(|s|s.txid.as_str()),"paid":record.settled,
|
||||
"change_allocation_ambiguous":matches!(record.change_address,Some(engine::ChangeAddress::Dispatched)),
|
||||
"can_switch_method":record.retirement.is_some(),"retired_unallocated":record.retirement.is_some()}),
|
||||
)
|
||||
}
|
||||
impl RpcHandler {
|
||||
async fn request_onchain_allocation(
|
||||
&self,
|
||||
record: &Record,
|
||||
fips: &str,
|
||||
) -> Result<crate::content_onchain_seller::Record> {
|
||||
let operation = crate::api::handler::onchain_purchase::Operation {
|
||||
binding: record.binding.clone(),
|
||||
action: "allocate".into(),
|
||||
};
|
||||
let (mut response, _) = crate::fips::dial::PeerRequest::new(
|
||||
Some(fips),
|
||||
&record.seller_onion,
|
||||
crate::api::handler::onchain_purchase::ROUTE,
|
||||
)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(45))
|
||||
.send_content_json(
|
||||
&self.config.data_dir,
|
||||
&record.binding.seller_did,
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
.context("Original seller allocation reply unavailable; recover the same operation")?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Original seller allocation remains unresolved"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"Seller response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let saved: crate::content_onchain_seller::Record = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
saved.binding == record.binding,
|
||||
"Seller changed original purchase"
|
||||
);
|
||||
if let Some(offer) = &record.offer {
|
||||
anyhow::ensure!(saved.offer()? == *offer, "Seller changed original offer");
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
pub(super) async fn ensure_onchain_allows_other_rail(
|
||||
&self,
|
||||
buyer: &str,
|
||||
seller: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
Journal::find_for(&self.config.data_dir, buyer, seller, content)?.is_none(),
|
||||
"An original on-chain purchase remains recoverable; do not pay again or switch methods"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) async fn handle_onchain_operation(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
action: &str,
|
||||
) -> Result<Value> {
|
||||
let params: Params = serde_json::from_value(params.context("Missing on-chain operation")?)?;
|
||||
anyhow::ensure!(
|
||||
!params.content_id.starts_with("registered_"),
|
||||
"Registered rentals require their native purchase contract"
|
||||
);
|
||||
let peer =
|
||||
crate::federation::load_unique_payment_peer(&self.config.data_dir, ¶ms.onion)
|
||||
.await?;
|
||||
let buyer =
|
||||
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
||||
.await?
|
||||
.did_key()?;
|
||||
anyhow::ensure!(buyer != peer.did, "Cannot buy from this same node");
|
||||
let _admission = crate::content_payment_admission::lock(
|
||||
&self.config.data_dir,
|
||||
&buyer,
|
||||
&peer.did,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
let original = if let Some(id) = ¶ms.operation_id {
|
||||
let journal = Journal::open(&self.config.data_dir, id).await?;
|
||||
let original = journal.load()?;
|
||||
if let Some(record) = &original {
|
||||
anyhow::ensure!(
|
||||
record.binding.buyer_did == buyer
|
||||
&& record.binding.seller_did == peer.did
|
||||
&& record.binding.content_id == params.content_id,
|
||||
"Original on-chain operation belongs to another purchase"
|
||||
);
|
||||
}
|
||||
original
|
||||
} else {
|
||||
Journal::find_for(&self.config.data_dir, &buyer, &peer.did, ¶ms.content_id)?
|
||||
};
|
||||
if action == "lookup" {
|
||||
return Ok(json!({"attempt":original.as_ref().map(public).transpose()?}));
|
||||
}
|
||||
if let Some(id) = ¶ms.operation_id {
|
||||
anyhow::ensure!(
|
||||
original.as_ref().is_some_and(|r| &r.binding.id == id),
|
||||
"Original on-chain operation changed"
|
||||
);
|
||||
}
|
||||
let mut record = if let Some(record) = original {
|
||||
record
|
||||
} else {
|
||||
anyhow::ensure!(
|
||||
matches!(action, "create" | "expose") && params.operation_id.is_none(),
|
||||
"Recover original on-chain operation first"
|
||||
);
|
||||
self.ensure_invoice_allows_other_rail(&buyer, &peer.did, ¶ms.content_id)
|
||||
.await?;
|
||||
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
cashu
|
||||
.find_buyers(&buyer, &peer.did, ¶ms.content_id)
|
||||
.await?
|
||||
.iter()
|
||||
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
|
||||
"Recover or cancel original Cashu purchase first"
|
||||
);
|
||||
Record::new(
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: buyer,
|
||||
seller_did: peer.did.clone(),
|
||||
content_id: params.content_id.clone(),
|
||||
price_sats: params.price_sats.context("Expected price required")?,
|
||||
},
|
||||
params.onion.clone(),
|
||||
)?
|
||||
};
|
||||
anyhow::ensure!(
|
||||
record.seller_onion == params.onion
|
||||
&& params
|
||||
.price_sats
|
||||
.is_none_or(|p| p == record.binding.price_sats),
|
||||
"Original payment address or price changed"
|
||||
);
|
||||
let journal = Journal::open(&self.config.data_dir, &record.binding.id).await?;
|
||||
if journal.load()?.is_none() {
|
||||
journal.save(&record)?;
|
||||
}
|
||||
if record.retirement.is_some() {
|
||||
return public(&record);
|
||||
}
|
||||
if action == "cancel" {
|
||||
anyhow::ensure!(params.operation_id.is_some() && record.can_retire_unallocated(),"An allocated or mutated on-chain purchase cannot be canceled; recover its original payment");
|
||||
}
|
||||
if matches!(action, "create" | "expose" | "prepare" | "pay") && !record.settled {
|
||||
// Recheck while the same admission guard is held, including resumes
|
||||
// from another window and records predating this owner flow.
|
||||
self.ensure_invoice_allows_other_rail(
|
||||
&record.binding.buyer_did,
|
||||
&peer.did,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
cashu
|
||||
.find_buyers(&record.binding.buyer_did, &peer.did, ¶ms.content_id)
|
||||
.await?
|
||||
.iter()
|
||||
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
|
||||
"Another saved Cashu liability must be recovered before on-chain dispatch"
|
||||
);
|
||||
}
|
||||
if action == "prepare" {
|
||||
anyhow::ensure!(
|
||||
params.operation_id.is_some(),
|
||||
"Original operation ID required"
|
||||
);
|
||||
if record.template.is_none() && record.plan.is_none() {
|
||||
let max_fee_sats = params
|
||||
.max_fee_sats
|
||||
.context("Explicit maximum fee required")?;
|
||||
anyhow::ensure!(
|
||||
(1..=2_100_000_000_000_000).contains(&max_fee_sats),
|
||||
"Invalid maximum fee"
|
||||
);
|
||||
if let Some(rate) = params.sat_per_vbyte {
|
||||
anyhow::ensure!((1..=5000).contains(&rate), "Invalid fee rate");
|
||||
}
|
||||
let wallet = self.onchain_purchase_wallet().await?;
|
||||
let change = wallet.prepare_change(&journal).await?;
|
||||
record = wallet
|
||||
.prepare_plan(
|
||||
&journal,
|
||||
super::lnd::onchain_purchase::PlanRequest {
|
||||
change_address: change,
|
||||
max_fee_sats,
|
||||
sat_per_vbyte: params.sat_per_vbyte,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
return public(&record);
|
||||
}
|
||||
if action == "pay" {
|
||||
anyhow::ensure!(
|
||||
params.operation_id.is_some(),
|
||||
"Original operation ID required"
|
||||
);
|
||||
if record.settled {
|
||||
return public(&record);
|
||||
}
|
||||
if let Some(plan) = &record.plan {
|
||||
anyhow::ensure!(
|
||||
params.plan_sha256.as_deref() == Some(plan.hash()?.as_str()),
|
||||
"Confirm the original saved funding plan before payment"
|
||||
);
|
||||
} else {
|
||||
let template = record
|
||||
.template
|
||||
.as_ref()
|
||||
.context("Review the original fee first")?;
|
||||
anyhow::ensure!(
|
||||
params.template_sha256.as_deref()
|
||||
== Some(
|
||||
hex::encode(Sha256::digest(template.psbt_base64.as_bytes())).as_str()
|
||||
),
|
||||
"Confirm the original saved transaction before payment"
|
||||
);
|
||||
}
|
||||
let wallet = self.onchain_purchase_wallet().await?;
|
||||
if record.plan.is_some() && record.quote.is_none() {
|
||||
record = engine::lease_plan(&journal, &wallet).await?;
|
||||
engine::mark_address_allocation(&journal, false)?;
|
||||
let status = self
|
||||
.request_onchain_allocation(
|
||||
&record,
|
||||
peer.fips_npub
|
||||
.as_deref()
|
||||
.context("Seller has no authenticated mesh connection")?,
|
||||
)
|
||||
.await?;
|
||||
let quote = status
|
||||
.quote()?
|
||||
.context("Original seller allocation is unresolved; recover this operation")?;
|
||||
record = engine::accept_quote(&journal, quote)?;
|
||||
}
|
||||
if record.plan.is_some() && record.template.is_none() {
|
||||
record = engine::bind_plan(&journal)?;
|
||||
}
|
||||
if matches!(
|
||||
record.phase,
|
||||
Phase::TemplatePrepared | Phase::LeaseDispatched
|
||||
) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Lease, None).await?;
|
||||
}
|
||||
if matches!(record.phase, Phase::Funded | Phase::SigningDispatched) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Sign, None).await?;
|
||||
}
|
||||
if matches!(
|
||||
record.phase,
|
||||
Phase::Signed | Phase::BroadcastDispatched | Phase::Published
|
||||
) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Publish, None).await?;
|
||||
}
|
||||
return public(&record);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
action,
|
||||
"create" | "status" | "expose" | "download" | "cancel"
|
||||
),
|
||||
"Unsupported on-chain action"
|
||||
);
|
||||
let fips = peer
|
||||
.fips_npub
|
||||
.context("Seller has no authenticated mesh connection")?;
|
||||
if action == "expose" && record.offer.is_some() && record.quote.is_none() {
|
||||
engine::mark_address_allocation(&journal, true)?;
|
||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||
record = engine::accept_quote(
|
||||
&journal,
|
||||
status
|
||||
.quote()?
|
||||
.context("Original seller allocation is unresolved; recover this operation")?,
|
||||
)?;
|
||||
}
|
||||
let remote_action = if action == "create" || action == "expose" && record.offer.is_none() {
|
||||
"offer"
|
||||
} else if action == "expose" {
|
||||
"status"
|
||||
} else {
|
||||
action
|
||||
};
|
||||
let operation = crate::api::handler::onchain_purchase::Operation {
|
||||
binding: record.binding.clone(),
|
||||
action: remote_action.into(),
|
||||
};
|
||||
let route = crate::api::handler::onchain_purchase::ROUTE;
|
||||
let remote = crate::fips::dial::PeerRequest::new(Some(&fips), ¶ms.onion, route)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(if action == "download" {
|
||||
900
|
||||
} else {
|
||||
45
|
||||
}))
|
||||
.send_content_json(&self.config.data_dir, &peer.did, &operation)
|
||||
.await;
|
||||
let (mut response, _) = match remote {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(
|
||||
json!({"attempt":public(&record)?,"recovery_required":true,"error":"Original on-chain request is saved. Recover this operation; do not request another address or pay again."}),
|
||||
)
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Seller could not recover original on-chain purchase {}; retain it",
|
||||
record.binding.id
|
||||
);
|
||||
if action == "download" {
|
||||
let source = record
|
||||
.quote
|
||||
.as_ref()
|
||||
.context("Recover original address first")?
|
||||
.source
|
||||
.clone();
|
||||
anyhow::ensure!(
|
||||
response.content_length() == Some(source.size),
|
||||
"Original file length changed"
|
||||
);
|
||||
record.settled = true;
|
||||
journal.save(&record)?;
|
||||
let stream = crate::content_purchase_download::verified_stream(
|
||||
response.bytes_stream(),
|
||||
source.sha256,
|
||||
source.size,
|
||||
);
|
||||
let owned = crate::content_owned::record_purchase_stream(
|
||||
&self.config.data_dir,
|
||||
crate::content_owned::OwnedItem {
|
||||
onion: params.onion,
|
||||
content_id: params.content_id,
|
||||
filename: source.filename,
|
||||
mime_type: source.mime_type,
|
||||
size_bytes: source.size,
|
||||
paid_sats: record.binding.price_sats,
|
||||
ecash_backend: "onchain".into(),
|
||||
purchased_at: chrono::Utc::now().to_rfc3339(),
|
||||
download_complete: false,
|
||||
},
|
||||
Box::pin(stream),
|
||||
Some(source.size),
|
||||
)
|
||||
.await?;
|
||||
return Ok(
|
||||
json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
|
||||
);
|
||||
}
|
||||
let mut bytes = vec![];
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"On-chain response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&bytes)?;
|
||||
if body["state"] == "cancelled_unallocated" {
|
||||
let ack: crate::content_onchain_seller::UnallocatedAck = serde_json::from_value(body)?;
|
||||
record = engine::retire_unallocated(&journal, ack)?;
|
||||
return public(&record);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
action != "cancel",
|
||||
"Seller did not acknowledge unallocated retirement; preserve original operation"
|
||||
);
|
||||
let status: crate::content_onchain_seller::Record = serde_json::from_value(body)?;
|
||||
anyhow::ensure!(
|
||||
status.binding == record.binding,
|
||||
"Seller changed original purchase"
|
||||
);
|
||||
if record.offer.is_none() && record.quote.is_none() {
|
||||
record = engine::accept_offer(&journal, status.offer()?)?;
|
||||
}
|
||||
if let Some(quote) = status.quote()? {
|
||||
record = engine::accept_quote(&journal, quote)?;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!status.paid || record.quote.is_some(),
|
||||
"Paid purchase lacks original address"
|
||||
);
|
||||
record.settled |= status.paid;
|
||||
journal.save(&record)?;
|
||||
if action == "expose" && !record.settled {
|
||||
if record.quote.is_none() {
|
||||
engine::mark_address_allocation(&journal, true)?;
|
||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||
record = engine::accept_quote(
|
||||
&journal,
|
||||
status.quote()?.context(
|
||||
"Original seller allocation is unresolved; recover this operation",
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
engine::expose_address(&journal)?;
|
||||
record = journal.load()?.context("Original record unavailable")?;
|
||||
}
|
||||
public(&record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn binding() -> Binding {
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
|
||||
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap(),
|
||||
content_id: "file".into(),
|
||||
price_sats: 546,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn buyer_discovery_retains_unresolved_address_and_rejects_duplicate_operations() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let saved = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
|
||||
let j = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
j.save(&saved).unwrap();
|
||||
drop(j);
|
||||
let found = Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(found.binding.id, binding.id);
|
||||
assert!(found.blocks_other_rails());
|
||||
assert!(public(&found).unwrap()["address"].is_null());
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.seller_did,
|
||||
&binding.buyer_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut second = binding.clone();
|
||||
second.id = uuid::Uuid::new_v4().to_string();
|
||||
let j = Journal::open(data.path(), &second.id).await.unwrap();
|
||||
j.save(&Record::new(second, saved.seller_onion).unwrap())
|
||||
.unwrap();
|
||||
drop(j);
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupted_node_record_cannot_be_treated_as_permission_to_pay_again() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let j = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
j.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
|
||||
.unwrap();
|
||||
drop(j);
|
||||
std::fs::write(
|
||||
data.path()
|
||||
.join("content-onchain")
|
||||
.join(format!("{}.json", binding.id)),
|
||||
b"{}",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn only_durable_matching_empty_ack_releases_cross_rail_and_stale_callback_cannot_revive()
|
||||
{
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let _rail = crate::content_payment_admission::lock(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
let original = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
|
||||
journal.save(&original).unwrap();
|
||||
let ack = crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: binding.clone(),
|
||||
state: "cancelled_unallocated".into(),
|
||||
address: serde_json::Value::Null,
|
||||
allocation_dispatched: false,
|
||||
can_switch_method: true,
|
||||
};
|
||||
for wrong in [
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
allocation_dispatched: true,
|
||||
..ack.clone()
|
||||
},
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
address: serde_json::json!("not-empty"),
|
||||
..ack.clone()
|
||||
},
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
..binding.clone()
|
||||
},
|
||||
..ack.clone()
|
||||
},
|
||||
] {
|
||||
assert!(engine::retire_unallocated(&journal, wrong).is_err());
|
||||
}
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
let retired = engine::retire_unallocated(&journal, ack).unwrap();
|
||||
assert!(!retired.blocks_other_rails());
|
||||
assert!(public(&retired).unwrap()["can_switch_method"] == true);
|
||||
assert!(journal.save(&original).is_err());
|
||||
drop(journal);
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut replacement = binding.clone();
|
||||
replacement.id = uuid::Uuid::new_v4().to_string();
|
||||
let journal = Journal::open(data.path(), &replacement.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(replacement.clone(), original.seller_onion).unwrap())
|
||||
.unwrap();
|
||||
drop(journal);
|
||||
assert_eq!(
|
||||
Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.binding
|
||||
.id,
|
||||
replacement.id
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn owner_address_is_redacted_until_exposure_is_durable_and_cannot_then_be_retired() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
|
||||
.unwrap();
|
||||
let mut bytes = vec![0, 20];
|
||||
bytes.extend([1; 20]);
|
||||
let address = bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(bytes),
|
||||
bitcoin::Network::Regtest,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let saved = engine::accept_quote(
|
||||
&journal,
|
||||
engine::Quote {
|
||||
binding: binding.clone(),
|
||||
address: address.clone(),
|
||||
network: engine::ChainNetwork::Regtest,
|
||||
source: crate::content_lightning::RetainedFile {
|
||||
sha256: "a".repeat(64),
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(public(&saved).unwrap()["address"].is_null());
|
||||
let ack = crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: binding.clone(),
|
||||
state: "cancelled_unallocated".into(),
|
||||
address: serde_json::Value::Null,
|
||||
allocation_dispatched: false,
|
||||
can_switch_method: true,
|
||||
};
|
||||
assert!(engine::retire_unallocated(&journal, ack.clone()).is_err());
|
||||
assert_eq!(engine::expose_address(&journal).unwrap(), address);
|
||||
drop(journal);
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
let exposed = journal.load().unwrap().unwrap();
|
||||
assert!(exposed.externally_exposed);
|
||||
assert_eq!(public(&exposed).unwrap()["address"], address);
|
||||
assert!(engine::retire_unallocated(&journal, ack).is_err());
|
||||
assert!(exposed.blocks_other_rails());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String> {
|
||||
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<PlanInput>,
|
||||
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<String> {
|
||||
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::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()?
|
||||
.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::<Result<Vec<_>>>()?,
|
||||
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<u64> {
|
||||
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))
|
||||
}
|
||||
@@ -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<crate::content_onchain_plan::Offer> {
|
||||
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<Option<Quote>> {
|
||||
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<Self> {
|
||||
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<PathBuf> {
|
||||
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<T: serde::de::DeserializeOwned>(&self, role: &str, id: &str) -> Result<Option<T>> {
|
||||
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<T: Serialize>(&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<Option<UnallocatedAck>> {
|
||||
binding.validate()?;
|
||||
let saved: Option<UnallocatedAck> = self.read("retired", &binding.id)?;
|
||||
if let Some(ack) = &saved {
|
||||
ack.validate(binding)?;
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
pub fn retire_unallocated(&self, binding: &Binding) -> Result<UnallocatedAck> {
|
||||
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<Option<Record>> {
|
||||
binding.validate()?;
|
||||
let saved: Option<Record> = 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<Record> {
|
||||
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<ChainNetwork> {
|
||||
anyhow::bail!("Seller wallet network unavailable")
|
||||
}
|
||||
async fn preflight(&self, network: ChainNetwork) -> Result<()>;
|
||||
async fn allocate(&self) -> Result<String>;
|
||||
async fn received(&self, address: &str, amount: u64) -> Result<bool>;
|
||||
}
|
||||
pub(crate) async fn drive<W: Wallet>(
|
||||
journal: &Journal,
|
||||
binding: &Binding,
|
||||
create: bool,
|
||||
wallet: &W,
|
||||
) -> Result<Record> {
|
||||
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<Vec<String>>,
|
||||
}
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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::<UnallocatedAck>(partial).is_err(),
|
||||
"missing {key}"
|
||||
);
|
||||
}
|
||||
serde_json::from_value::<UnallocatedAck>(complete)
|
||||
.unwrap()
|
||||
.validate(&binding)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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<crate::content_onchain_seller::Record> {
|
||||
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<W: crate::content_onchain_seller::Wallet>(
|
||||
data_dir: &Path,
|
||||
journal: &crate::content_onchain_seller::Journal,
|
||||
binding: &crate::content_lightning::Binding,
|
||||
wallet: &W,
|
||||
) -> Result<crate::content_onchain_seller::Record> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<string,unknown>
|
||||
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
|
||||
}
|
||||
@@ -454,11 +454,17 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ onchainPaying ? 'Sending…' : 'Pay on-chain from my node' }}</span>
|
||||
<span class="block text-sm text-white/50">Sends Bitcoin on-chain from your node’s wallet (slower)</span>
|
||||
<span class="block text-base text-white">{{ 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' }}</span>
|
||||
<span class="block text-sm text-white/50">Reviews the current Fast fee before sending the saved transaction</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<label v-if="acceptsMethod(payItem.access, 'onchain') && !(onchainAttempt?.plan_sha256 || onchainAttempt?.template_sha256)" class="block text-sm text-white/70">Maximum network fee (sats)
|
||||
<input v-model="onchainFeeCap" type="number" min="1" step="1" class="mt-1 w-full min-h-11 rounded-xl bg-white/10 px-3" :disabled="paymentActionBusy" />
|
||||
</label>
|
||||
<p v-if="(onchainAttempt?.plan_sha256 || onchainAttempt?.template_sha256)" class="text-sm text-white/70 break-words">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.</p>
|
||||
<button v-if="onchainAttempt && ['address_requested', 'offer_prepared', 'plan_prepared'].includes(onchainAttempt.phase) && !onchainAttempt.change_allocation_ambiguous" type="button" class="glass-button w-full min-h-11 rounded-xl px-3 py-2" :disabled="paymentActionBusy" @click="cancelOriginalOnchain">Cancel if no address was issued</button>
|
||||
<p v-if="onchainAttempt?.change_allocation_ambiguous" class="text-sm text-amber-300">The original change-address reply was lost. This saved operation needs recovery; no replacement address or payment will be created.</p>
|
||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||
</div>
|
||||
|
||||
@@ -608,6 +614,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseOnchainAttempt, type OnchainAttempt } from '@/composables/peerOnchainPurchase'
|
||||
import { parseCashuQuote, readCashuAttempt, keepCashuAttempt, keepAuthoritativeCashuQuote, archiveMalformedCashuAttempt, clearCashuAttempt, type CashuQuote } from '@/composables/peerCashuPurchase'
|
||||
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
|
||||
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||
@@ -893,10 +900,11 @@ async function lookupNodeInvoice(onion:string,item:CatalogItem,generation:number
|
||||
keepReceipt(onion,item.id,receipt,selected,previous?.state==='failed'?previous.operation_id:undefined);lnReceiptReadError.value=false
|
||||
}catch(error){if(selected()){lnReceiptReadError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify original invoice; do not pay again'}}
|
||||
}
|
||||
async function permitFreshOtherRail(item: CatalogItem, onion: string, recoverInvoice = false) {
|
||||
async function permitFreshOtherRail(item: CatalogItem, onion: string, recoverInvoice = false, recoverOnchain = false) {
|
||||
const generation=paymentGeneration.value
|
||||
await cashuLookup
|
||||
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
|
||||
if (!recoverOnchain && (onchainAttempt.value || onchainLookupError.value)) { lnError.value='Recover the original on-chain purchase before choosing another method.'; return false }
|
||||
if (hasBlockingCashuPurchase.value) { lnError.value='Recover or cancel the saved Cashu purchase before choosing another method.'; return false }
|
||||
if (!recoverInvoice && hasBlockingLightningReceipt.value) {lnError.value='Recover or cancel the original invoice before choosing another method.';return false}
|
||||
return true
|
||||
@@ -913,6 +921,9 @@ const invoiceError = ref('')
|
||||
const invoiceCopied = ref(false)
|
||||
const invoiceOperationId = ref<string | null>(null)
|
||||
// On-chain QR (pay the seller's address from any external wallet).
|
||||
const onchainAttempt = ref<OnchainAttempt | null>(null)
|
||||
const onchainLookupError = ref(false)
|
||||
const onchainFeeCap = ref('1000')
|
||||
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
|
||||
const onchainQr = ref('')
|
||||
const onchainWaiting = ref(false)
|
||||
@@ -1245,6 +1256,8 @@ function openPayModal(item: CatalogItem) {
|
||||
invoiceError.value = ''
|
||||
invoiceCopied.value = false
|
||||
invoiceOperationId.value = null
|
||||
onchainAttempt.value = null
|
||||
onchainLookupError.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = false
|
||||
@@ -1259,7 +1272,7 @@ function openPayModal(item: CatalogItem) {
|
||||
if (lnReceipt.value?.state === 'failed') lnError.value = `Previous Lightning attempt failed: ${lnReceipt.value.failure_reason || 'Payment failed'}. You can choose another method.`
|
||||
} catch { lnReceiptReadError.value = true; lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
||||
onchainPaying.value = false
|
||||
cashuLookup = Promise.all([lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupNodeInvoice(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)]).then(()=>undefined)
|
||||
cashuLookup = Promise.all([lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupNodeInvoice(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupOnchainPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)]).then(()=>undefined)
|
||||
}
|
||||
|
||||
function closePayModal() {
|
||||
@@ -1320,7 +1333,7 @@ function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
loadOnchainQr()
|
||||
} else if (onchainData.value && !onchainPaying.value) {
|
||||
onchainPaying.value = true
|
||||
pollOnchain(onchainData.value.address)
|
||||
onchainAttempt.value && pollOnchain(onchainAttempt.value.operation_id)
|
||||
}
|
||||
} else {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
@@ -1338,35 +1351,50 @@ function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
|
||||
* lands, then release the file (the address is the gate token).
|
||||
*/
|
||||
async function loadOnchainQr() {
|
||||
if (hasBlockingLightningReceipt.value) return
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
if (!await permitFreshOtherRail(item, onion)) return
|
||||
if (getItemPrice(item.access) < 546) { onchainError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
|
||||
const operation = paymentOperations.begin('onchain-qr', onion, item.id)
|
||||
if (!operation) return
|
||||
onchainError.value = ''
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = true
|
||||
async function lookupOnchainPurchase(onion: string, item: CatalogItem, generation: number) {
|
||||
const selected=()=>generation===paymentGeneration.value && activePaymentMatches(onion,item.id)
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ method: 'content.request-onchain', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1 })
|
||||
if (!req?.address || !req?.amount_sats) throw new Error(req?.error || 'The seller could not provide an on-chain address.')
|
||||
if (!Number.isSafeInteger(req.amount_sats) || req.amount_sats !== getItemPrice(item.access) || req.amount_sats < 546) throw new Error('The seller changed the payment amount. Refresh the file before paying.')
|
||||
// A closed modal has never displayed this address: do not expose a late QR.
|
||||
if (!paymentOperations.selected(operation)) return
|
||||
let image = ''
|
||||
try { image = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${(req.amount_sats / 1e8).toFixed(8)}`, { margin: 1, width: 240 }) } catch { /* raw address is available */ }
|
||||
if (!paymentOperations.selected(operation)) return
|
||||
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
|
||||
onchainQr.value = image
|
||||
onchainPaying.value = true
|
||||
void pollOnchain(req.address)
|
||||
} catch (error) {
|
||||
if (paymentOperations.selected(operation)) onchainError.value = error instanceof Error ? error.message : 'Could not request an on-chain address'
|
||||
} finally { if (paymentOperations.finish(operation)) onchainWaiting.value = false }
|
||||
const result=await rpcClient.call<{attempt?:unknown}>({method:'content.onchain-attempt',params:{onion,content_id:item.id},timeout:15000,maxRetries:1})
|
||||
if(!selected()) return
|
||||
if(!result || !Object.prototype.hasOwnProperty.call(result,'attempt')) throw Error('Could not verify original on-chain operation; do not pay again')
|
||||
onchainAttempt.value=result.attempt===null?null:parseOnchainAttempt(result.attempt)
|
||||
onchainLookupError.value=false
|
||||
} catch(error) {if(selected()){onchainLookupError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify original on-chain purchase'}}
|
||||
}
|
||||
async function cancelOriginalOnchain() {
|
||||
const item=payItem.value,onion=props.peerId||currentPeer.value?.onion,original=onchainAttempt.value
|
||||
if(!item||!onion||!original||paymentActionBusy.value)return
|
||||
const operation=paymentOperations.begin('onchain-cancel',onion,item.id);if(!operation)return
|
||||
try {
|
||||
const result=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-cancel',params:{onion,content_id:item.id,operation_id:original.operation_id},timeout:60000,maxRetries:1}))
|
||||
if(result.operation_id!==original.operation_id || !result.retired_unallocated || !result.can_switch_method)throw Error('Seller has not confirmed that no address was allocated. Keep the original operation.')
|
||||
if(!paymentOperations.selected(operation))return
|
||||
onchainAttempt.value=null;onchainLookupError.value=false;lnError.value='Unallocated on-chain request canceled. You can choose another method.'
|
||||
}catch(error){if(paymentOperations.selected(operation))lnError.value=error instanceof Error?error.message:'Original address allocation is unresolved; do not pay again'}
|
||||
finally{paymentOperations.finish(operation)}
|
||||
}
|
||||
async function loadOnchainQr() {
|
||||
const item=payItem.value,onion=props.peerId || currentPeer.value?.onion
|
||||
if(!item || !onion || !await permitFreshOtherRail(item,onion,false,true)) return
|
||||
if(!onchainAttempt.value && getItemPrice(item.access)<546){onchainError.value='On-chain payment requires at least 546 sats.';return}
|
||||
const operation=paymentOperations.begin('onchain-qr',onion,item.id);if(!operation)return
|
||||
onchainWaiting.value=true;onchainError.value=''
|
||||
try {
|
||||
if(onchainLookupError.value) throw Error('Recover original on-chain state before displaying another address')
|
||||
const result=await rpcClient.call<unknown>({method:'content.onchain-expose',params:{onion,content_id:item.id,price_sats:onchainAttempt.value?.price_sats ?? getItemPrice(item.access),...(onchainAttempt.value?{operation_id:onchainAttempt.value.operation_id}:{})},timeout:60000,maxRetries:1})
|
||||
const attempt=parseOnchainAttempt(result)
|
||||
if(!onchainAttempt.value && attempt.price_sats!==getItemPrice(item.access))throw Error('The seller changed the payment amount; do not pay')
|
||||
if(!paymentOperations.selected(operation))return
|
||||
onchainAttempt.value=attempt.retired_unallocated?null:attempt
|
||||
if(attempt.retired_unallocated){onchainPaying.value=false;return}
|
||||
if(attempt.paid){await recoverOnchainDownload(item,onion,attempt,paymentGeneration.value);return}
|
||||
if(!attempt.address || !attempt.external_exposure) throw Error('Original address allocation is unresolved; recover this operation without requesting another address')
|
||||
const image=await QRCode.toDataURL(`bitcoin:${attempt.address}?amount=${(attempt.price_sats/1e8).toFixed(8)}`,{margin:1,width:240})
|
||||
if(!paymentOperations.selected(operation))return
|
||||
onchainData.value={address:attempt.address,amount_sats:attempt.price_sats};onchainQr.value=image
|
||||
onchainPaying.value=true;void pollOnchain(attempt.operation_id)
|
||||
}catch(error){if(paymentOperations.selected(operation))onchainError.value=error instanceof Error?error.message:'Recover the original on-chain operation; do not pay again'}
|
||||
finally {if(paymentOperations.finish(operation))onchainWaiting.value=false}
|
||||
}
|
||||
|
||||
async function copyOnchain() {
|
||||
@@ -1382,71 +1410,63 @@ async function copyOnchain() {
|
||||
} catch { /* clipboard denied */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
|
||||
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
|
||||
* the payment and release the file (address is the gate token). Slower than LN
|
||||
* because the seller waits for the tx to appear/confirm.
|
||||
*/
|
||||
/** Review and confirm the node's saved transaction; never call generic sendcoins. */
|
||||
async function payOnchain() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || paymentActionBusy.value) return
|
||||
if (!await permitFreshOtherRail(item, onion)) return
|
||||
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
|
||||
if (getItemPrice(item.access) < 546) { lnError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
|
||||
const operation = paymentOperations.begin('onchain-send', onion, item.id)
|
||||
if (!operation) return
|
||||
onchainPaying.value = true
|
||||
lnError.value = ''
|
||||
let polling = false
|
||||
const item=payItem.value,onion=props.peerId || currentPeer.value?.onion
|
||||
if(!item || !onion || paymentActionBusy.value || !await permitFreshOtherRail(item,onion,false,true))return
|
||||
if(!onchainAttempt.value && getItemPrice(item.access)<546){lnError.value='On-chain payment requires at least 546 sats.';return}
|
||||
const operation=paymentOperations.begin('onchain-send',onion,item.id);if(!operation)return
|
||||
const selected=()=>paymentOperations.selected(operation)
|
||||
onchainPaying.value=true;lnError.value='';let polling=false
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ method: 'content.request-onchain', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1 })
|
||||
if (!req?.address || !req?.amount_sats) throw new Error(req?.error || 'The seller could not provide an on-chain address.')
|
||||
if (!Number.isSafeInteger(req.amount_sats) || req.amount_sats !== getItemPrice(item.access) || req.amount_sats < 546) throw new Error('The seller changed the payment amount. Refresh the file before paying.')
|
||||
if (!paymentOperations.selected(operation)) return
|
||||
const send = await rpcClient.call<{ txid?: string; error?: string }>({ method: 'lnd.sendcoins', params: { addr: req.address, amount: req.amount_sats }, timeout: 60000, maxRetries: 1 })
|
||||
if (!send?.txid) throw new Error(send?.error || 'The on-chain result is unconfirmed. Check this wallet transaction before sending again.')
|
||||
if (!paymentOperations.selected(operation)) return
|
||||
polling = true
|
||||
void pollOnchain(req.address)
|
||||
} catch (error) {
|
||||
if (paymentOperations.selected(operation)) lnError.value = error instanceof Error ? error.message : 'Could not confirm on-chain payment'
|
||||
} finally {
|
||||
if (paymentOperations.finish(operation) && !polling) onchainPaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
type OnchainPollScope = { item: CatalogItem; onion: string; address: string; generation: number }
|
||||
function onchainScopeSelected(scope: OnchainPollScope) {
|
||||
return paymentGeneration.value === scope.generation && activePaymentMatches(scope.onion, scope.item.id)
|
||||
}
|
||||
async function pollOnchain(address: string, original?: OnchainPollScope) {
|
||||
const item = original?.item || payItem.value
|
||||
const onion = original?.onion || props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
const scope = original || { item, onion, address, generation: paymentGeneration.value }
|
||||
if (!onchainScopeSelected(scope)) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean; status?: string; error?: string }>({ method: 'content.onchain-status', params: { onion, content_id: item.id, address: scope.address }, timeout: 30000 })
|
||||
if (!onchainScopeSelected(scope)) return
|
||||
if (res?.error) lnError.value = res.error
|
||||
if (res?.paid === true) {
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-onchain', params: { onion, content_id: item.id, address: scope.address, filename: item.filename, price_sats: getItemPrice(item.access), cache_only: true }, timeout: 960000, maxRetries: 1,
|
||||
})
|
||||
if (!onchainScopeSelected(scope)) return
|
||||
onchainPaying.value = false
|
||||
if (dl?.data !== undefined || dl?.owned === true) {
|
||||
openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
|
||||
} else lnError.value = dl?.error || 'Payment is confirmed; delivery is still recoverable with this address.'
|
||||
return
|
||||
if(onchainLookupError.value)throw Error('Original on-chain lookup failed. Reopen this file to recover before paying.')
|
||||
let attempt=onchainAttempt.value
|
||||
if(attempt?.paid){await recoverOnchainDownload(item,onion,attempt,paymentGeneration.value);return}
|
||||
if(attempt?.external_exposure) {polling=true;void pollOnchain(attempt.operation_id);return}
|
||||
if(!attempt || attempt.phase==='address_requested') {
|
||||
attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-create',params:{onion,content_id:item.id,price_sats:attempt?.price_sats ?? getItemPrice(item.access),...(attempt?{operation_id:attempt.operation_id}:{})},timeout:60000,maxRetries:1}))
|
||||
if(!onchainAttempt.value && attempt.price_sats!==getItemPrice(item.access))throw Error('The seller changed the payment amount; do not pay')
|
||||
if(!selected())return
|
||||
onchainAttempt.value=attempt.retired_unallocated?null:attempt
|
||||
if(attempt.retired_unallocated)return
|
||||
if(attempt.phase==='address_requested')throw Error('The original seller offer is unresolved. Preserve this operation; no replacement address will be requested.')
|
||||
}
|
||||
} catch {
|
||||
if (onchainScopeSelected(scope)) lnError.value = 'Payment verification is unavailable. Keep the original address and do not pay again.'
|
||||
// Retry read-only status for the original item only.
|
||||
}
|
||||
if (onchainScopeSelected(scope) && onchainPaying.value) onchainPollTimer = setTimeout(() => pollOnchain(scope.address, scope), 5000)
|
||||
if(!attempt.plan_sha256 && !attempt.template_sha256) {
|
||||
const cap=Number(onchainFeeCap.value)
|
||||
if(!Number.isSafeInteger(cap)||cap<=0)throw Error('Enter a positive whole-sat maximum fee')
|
||||
attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-prepare',params:{onion,content_id:item.id,operation_id:attempt.operation_id,max_fee_sats:cap},timeout:60000,maxRetries:1}))
|
||||
if(selected())onchainAttempt.value=attempt.retired_unallocated?null:attempt
|
||||
return // A separate click confirms the actual saved fee and transaction.
|
||||
}
|
||||
if(!selected())return
|
||||
attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-pay',params:{onion,content_id:item.id,operation_id:attempt.operation_id,template_sha256:attempt.template_sha256,plan_sha256:attempt.plan_sha256},timeout:120000,maxRetries:1}))
|
||||
if(!selected())return
|
||||
onchainAttempt.value=attempt.retired_unallocated?null:attempt;if(attempt.retired_unallocated)return;polling=true;void pollOnchain(attempt.operation_id)
|
||||
}catch(error){if(selected())lnError.value=error instanceof Error?error.message:'Original on-chain result is unresolved; do not pay again'}
|
||||
finally{if(paymentOperations.finish(operation)&&!polling)onchainPaying.value=false}
|
||||
}
|
||||
type OnchainPollScope={item:CatalogItem;onion:string;operationId:string;generation:number}
|
||||
function onchainScopeSelected(scope:OnchainPollScope){return paymentGeneration.value===scope.generation&&activePaymentMatches(scope.onion,scope.item.id)}
|
||||
async function recoverOnchainDownload(item:CatalogItem,onion:string,attempt:OnchainAttempt,generation:number) {
|
||||
const dl=await rpcClient.call<{owned?:boolean;owned_content_id?:string;mime_type?:string}>({method:'content.onchain-download',params:{onion,content_id:item.id,operation_id:attempt.operation_id},timeout:960000,maxRetries:1})
|
||||
if(generation!==paymentGeneration.value||!activePaymentMatches(onion,item.id))return
|
||||
onchainPaying.value=false
|
||||
if(dl.owned===true)openPurchased(item,undefined,dl.mime_type,onion,dl.owned_content_id)
|
||||
else lnError.value='Payment is confirmed; recover its original download without paying again.'
|
||||
}
|
||||
async function pollOnchain(operationId:string,original?:OnchainPollScope) {
|
||||
const item=original?.item||payItem.value,onion=original?.onion||props.peerId||currentPeer.value?.onion
|
||||
if(!item||!onion)return
|
||||
const scope=original||{item,onion,operationId,generation:paymentGeneration.value}
|
||||
if(!onchainScopeSelected(scope))return
|
||||
try {
|
||||
const attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-recover',params:{onion,content_id:item.id,operation_id:scope.operationId},timeout:60000,maxRetries:1}))
|
||||
if(!onchainScopeSelected(scope))return
|
||||
onchainAttempt.value=attempt.retired_unallocated?null:attempt
|
||||
if(attempt.retired_unallocated){onchainPaying.value=false;return}
|
||||
if(attempt.paid){await recoverOnchainDownload(item,onion,attempt,scope.generation);return}
|
||||
}catch(error){if(onchainScopeSelected(scope))lnError.value=error instanceof Error?error.message:'Original payment verification unavailable; do not pay again'}
|
||||
if(onchainScopeSelected(scope)&&onchainPaying.value)onchainPollTimer=setTimeout(()=>pollOnchain(scope.operationId,scope),5000)
|
||||
}
|
||||
|
||||
/** Spendable balance for a given ecash backend in the current plan. */
|
||||
@@ -1469,6 +1489,7 @@ async function prepareEcashPay() {
|
||||
await cashuLookup
|
||||
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
|
||||
if (hasBlockingLightningReceipt.value) {lnError.value='Recover or cancel the original invoice before choosing another method.';return}
|
||||
if(onchainAttempt.value || onchainLookupError.value){lnError.value='Recover the original on-chain purchase first.';return}
|
||||
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
|
||||
if (!operation) return
|
||||
const price = getItemPrice(item.access)
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
|
||||
const hash = 'a'.repeat(64)
|
||||
const onchainFixture = { operation_id:'22222222-2222-4222-8222-222222222222',price_sats:546,phase:'quoted',network:'mainnet',external_exposure:false,address:null,fee_sats:null,max_fee_sats:null,template_sha256:null,txid:null,paid:false,change_allocation_ambiguous:false,can_switch_method:false,retired_unallocated:false }
|
||||
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning', 'ecash'] } } }
|
||||
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
|
||||
const cashuQuoteFixture = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', operation_id: '12345678-1234-4234-8234-123456789abc', envelope_sha256: 'b'.repeat(64), gross_token_sats: 6, seller_net_sats: 5, wallet_debit_sats: 7, expires_at: 2_000_000_000 }
|
||||
@@ -39,16 +40,16 @@ beforeEach(() => {
|
||||
describe('Lightning file delivery recovery', () => {
|
||||
it('opens a confirmed on-chain delivery from HTTP cache without another payment', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.onchain-status') return { paid: true }
|
||||
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
|
||||
if (method === 'content.onchain-recover') return { ...onchainFixture, paid: true }
|
||||
if (method === 'content.onchain-download') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
|
||||
return { items: [], attempts: [], attempt: null }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.pollOnchain('bc1test')
|
||||
await vm.pollOnchain(onchainFixture.operation_id)
|
||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||
expect(vm.viewerMime).toBe('video/mp4')
|
||||
const calls = vi.mocked(rpcClient.call).mock.calls.map(([call]) => call)
|
||||
expect(calls.find(call => call.method === 'content.download-peer-onchain')?.params).toMatchObject({ cache_only: true, address: 'bc1test', filename: 'bought.txt' })
|
||||
expect(calls.find(call => call.method === 'content.onchain-download')?.params).toMatchObject({ operation_id: onchainFixture.operation_id, content_id: item.id })
|
||||
expect(calls.some(call => ['lnd.sendcoins', 'content.request-onchain'].includes(call.method))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -353,7 +354,7 @@ it('retains already dispatched Lightning evidence after unmount without opening
|
||||
vm.openPayModal({ ...item, access: { paid: { price_sats: 545, accepted: ['onchain', 'lightning', 'ecash'] } } })
|
||||
await vm.payOnchain()
|
||||
await vm.loadOnchainQr()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-onchain', 'lnd.sendcoins'].includes(call.method))).toBe(false)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.onchain-create', 'content.onchain-expose', 'content.onchain-pay', 'lnd.sendcoins'].includes(call.method))).toBe(false)
|
||||
expect(vm.paymentActionBusy).toBe(false)
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(false)
|
||||
expect(vm.lnError).toContain('546')
|
||||
@@ -361,13 +362,13 @@ it('retains already dispatched Lightning evidence after unmount without opening
|
||||
})
|
||||
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
|
||||
if (method === 'content.onchain-create') return { ...onchainFixture, price_sats: 547 }
|
||||
return { items: [], attempts: [], attempt: null }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
|
||||
await vm.payOnchain()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-onchain')).toHaveLength(1)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.onchain-create')).toHaveLength(1)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'lnd.sendcoins')).toBe(false)
|
||||
expect(vm.lnError).toContain('changed the payment amount')
|
||||
expect(vm.paymentActionBusy).toBe(false)
|
||||
@@ -593,7 +594,7 @@ describe('External invoice recovery retains terminal settlement', () => {
|
||||
describe('Durable external invoice ownership', () => {
|
||||
it('recovers a browser-lost invoice from node storage and blocks other rails', async()=>{
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false}}}:original(args))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
|
||||
const {wrapper,vm}=await open();await flushPromises();await vm.prepareEcashPay();await vm.payOnchain()
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(true)
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({bolt11:'ln-original',external_exposure:true})
|
||||
@@ -603,7 +604,7 @@ describe('Durable external invoice ownership', () => {
|
||||
it('local LND failure cannot release an externally displayed invoice',async()=>{
|
||||
localStorage.setItem(receiptKey,JSON.stringify({bolt11:'ln-external',payment_hash:hash,price_sats:5,origin:'native',external_exposure:true,state:'pending'}))
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='lnd.paymentstatus'?{status:'failed'}:args.method==='content.invoice-status'?{paid:false,state:'open',can_switch_method:false}:original(args))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='lnd.paymentstatus'?{status:'failed'}:args.method==='content.invoice-status'?{paid:false,state:'open',can_switch_method:false,retired_unallocated:false}:original(args))
|
||||
const {wrapper,vm}=await open();await vm.payWithLightning()
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(true);expect(nativePay).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
@@ -611,7 +612,7 @@ describe('Durable external invoice ownership', () => {
|
||||
it('settlement wins a cancellation reply and keeps paid-file recovery',async()=>{
|
||||
localStorage.setItem(receiptKey,JSON.stringify({operation_id:'11111111-1111-4111-8111-111111111111',bolt11:'ln-external',payment_hash:hash,price_sats:5,origin:'external',external_exposure:true,state:'pending'}))
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-cancel'?{paid:true,state:'settled',can_switch_method:false}:original(args))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-cancel'?{paid:true,state:'settled',can_switch_method:false,retired_unallocated:false}:original(args))
|
||||
const {wrapper,vm}=await open();await vm.cancelExternalInvoice()
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({state:'succeeded'})
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(true);expect(nativePay).not.toHaveBeenCalled()
|
||||
@@ -625,7 +626,7 @@ describe('Succeeded invoice reconciliation',()=>{
|
||||
const operation='11111111-1111-4111-8111-111111111111'
|
||||
localStorage.setItem(receiptKey,JSON.stringify({operation_id:operation,payment_hash:hash,price_sats:5,origin:'native',external_exposure:false,state:'succeeded'}))
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false}}}:original(args))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
|
||||
const {wrapper,vm}=await open()
|
||||
expect(vm.lnReceipt.state).toBe('succeeded')
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({state:'succeeded'})
|
||||
@@ -635,7 +636,7 @@ describe('Succeeded invoice reconciliation',()=>{
|
||||
})
|
||||
it('restores node-proven native success after browser storage is lost',async()=>{
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:false,native_succeeded:true,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false}}}:original(args))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:false,native_succeeded:true,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
|
||||
const {wrapper,vm}=await open()
|
||||
expect(vm.lnReceipt.state).toBe('succeeded')
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(true)
|
||||
@@ -649,7 +650,7 @@ describe('Damaged supplemental invoice receipt',()=>{
|
||||
it('reconciles only from an authoritative saved node operation and preserves the damaged bytes',async()=>{
|
||||
localStorage.setItem(receiptKey,'{damaged receipt')
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false}}}:original(args))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
|
||||
const {wrapper,vm}=await open()
|
||||
expect(localStorage.getItem(`${receiptKey}:unreadable`)).toBe('{damaged receipt')
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({payment_hash:hash,state:'pending',external_exposure:true})
|
||||
@@ -677,7 +678,7 @@ it('recovers a saved incomplete invoice request without paying or exposing its B
|
||||
const operation='11111111-1111-4111-8111-111111111111'
|
||||
const original=vi.mocked(rpcClient.call).getMockImplementation()!
|
||||
vi.mocked(rpcClient.call).mockImplementation(async args=>{
|
||||
if(args.method==='content.invoice-attempt')return {attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:recovered?{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false}:null}}
|
||||
if(args.method==='content.invoice-attempt')return {attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:recovered?{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false,retired_unallocated:false}:null}}
|
||||
if(args.method==='content.invoice-create'){recovered=true;return {operation_id:operation,state:'open',payment_hash:hash}}
|
||||
return original(args)
|
||||
})
|
||||
@@ -759,7 +760,7 @@ describe('Supported peer-file ecash choices', () => {
|
||||
describe('Unknown on-chain verification', () => {
|
||||
it('shows verification errors without downloading, paying or creating another address', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.onchain-status') return { paid: false, status: 'unknown', error: 'Exact outputs unavailable; do not pay again.' }
|
||||
if (method === 'content.onchain-recover') throw Error('Exact outputs unavailable; do not pay again.')
|
||||
return { items: [], attempts: [], attempt: null }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
@@ -772,3 +773,112 @@ describe('Unknown on-chain verification', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Original on-chain transaction confirmation and callback ownership',()=>{
|
||||
const chainItem={...item,access:{paid:{price_sats:546,accepted:['onchain','lightning','ecash']}}}
|
||||
const prepared={...onchainFixture,phase:'plan_prepared',fee_sats:142,max_fee_sats:1000,plan_sha256:'c'.repeat(64),template_sha256:null}
|
||||
it('reviews the saved fee before a separate confirmation and never calls sendcoins',async()=>{
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
|
||||
if(method==='content.onchain-create')return {...onchainFixture,phase:'offer_prepared'}
|
||||
if(method==='content.onchain-prepare')return prepared
|
||||
if(method==='content.onchain-pay')throw Error('Lost publish reply; recover original')
|
||||
return {items:[],attempts:[],attempt:null}
|
||||
})
|
||||
const {wrapper,vm}=await open();vm.openPayModal(chainItem);await flushPromises()
|
||||
await vm.payOnchain()
|
||||
expect(vm.onchainAttempt.fee_sats).toBe(142)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='content.onchain-pay')).toBe(false)
|
||||
await vm.payOnchain()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([c])=>c.method==='content.onchain-pay')![0].params).toMatchObject({operation_id:prepared.operation_id,plan_sha256:prepared.plan_sha256})
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='lnd.sendcoins')).toBe(false)
|
||||
expect(vm.onchainAttempt.operation_id).toBe(prepared.operation_id);wrapper.unmount()
|
||||
})
|
||||
it('does not prepare or send after a delayed create reply outlives its modal',async()=>{
|
||||
let finish!:(v:unknown)=>void
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method})=>method==='content.onchain-create'?await new Promise(resolve=>{finish=resolve}):{items:[],attempts:[],attempt:null})
|
||||
const {wrapper,vm}=await open();vm.openPayModal(chainItem);await flushPromises()
|
||||
const pending=vm.payOnchain();await flushPromises();expect(finish).toBeTypeOf('function')
|
||||
vm.closePayModal();vm.openPayModal({...chainItem,id:'other'});await flushPromises();finish(onchainFixture);await pending
|
||||
expect(vm.onchainAttempt).toBeNull()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>['content.onchain-prepare','content.onchain-pay','lnd.sendcoins'].includes(c.method))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reloads original state and blocks replacement rails without paying automatically',async()=>{
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method})=>method==='content.onchain-attempt'?{attempt:prepared}:{items:[],attempts:[],attempt:null})
|
||||
const {wrapper,vm}=await open();vm.openPayModal(chainItem);await flushPromises()
|
||||
expect(vm.onchainAttempt.operation_id).toBe(prepared.operation_id)
|
||||
await vm.payWithLightning();await vm.prepareEcashPay()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>['content.invoice-create','content.purchase','content.onchain-pay','lnd.sendcoins'].includes(c.method))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel only provably unallocated on-chain operations',()=>{
|
||||
const unallocated={...onchainFixture,phase:'address_requested',network:null}
|
||||
const retired={...unallocated,retired_unallocated:true,can_switch_method:true}
|
||||
it('unlocks other rails only after matching terminal seller acknowledgement',async()=>{
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
|
||||
if(method==='content.onchain-attempt')return {attempt:unallocated}
|
||||
if(method==='content.onchain-cancel')return retired
|
||||
if(method==='wallet.ecash-balance')return {cashu_sats:10,fedimint_sats:0}
|
||||
return {items:[],attempts:[],attempt:null}
|
||||
})
|
||||
const {wrapper,vm}=await open()
|
||||
expect(vm.onchainAttempt.operation_id).toBe(unallocated.operation_id)
|
||||
await vm.cancelOriginalOnchain()
|
||||
expect(vm.onchainAttempt).toBeNull()
|
||||
await vm.prepareEcashPay()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='wallet.ecash-balance')).toBe(true)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>['content.onchain-create','content.onchain-pay','lnd.sendcoins'].includes(c.method))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps an unknown allocation blocked after cancellation fails',async()=>{
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
|
||||
if(method==='content.onchain-attempt')return {attempt:unallocated}
|
||||
if(method==='content.onchain-cancel')throw Error('Original address allocation was dispatched; recover it')
|
||||
return {items:[],attempts:[],attempt:null}
|
||||
})
|
||||
const {wrapper,vm}=await open();await vm.cancelOriginalOnchain();await vm.payWithLightning()
|
||||
expect(vm.onchainAttempt.operation_id).toBe(unallocated.operation_id)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='content.invoice-create')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('cannot clear another selection after an old cancellation reply arrives',async()=>{
|
||||
let finish!:(v:unknown)=>void
|
||||
const other={...unallocated,operation_id:'33333333-3333-4333-8333-333333333333'}
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method,params})=>{
|
||||
if(method==='content.onchain-attempt')return {attempt:(params as {content_id:string}).content_id==='other'?other:unallocated}
|
||||
if(method==='content.onchain-cancel')return await new Promise(resolve=>{finish=resolve})
|
||||
return {items:[],attempts:[],attempt:null}
|
||||
})
|
||||
const {wrapper,vm}=await open();const cancel=vm.cancelOriginalOnchain();await flushPromises()
|
||||
expect(finish).toBeTypeOf('function');vm.closePayModal();vm.openPayModal({...item,id:'other'});await flushPromises()
|
||||
finish(retired);await cancel
|
||||
expect(vm.onchainAttempt.operation_id).toBe(other.operation_id)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Unallocated two-phase on-chain review',()=>{
|
||||
it('lets an insufficient-funds review cancel before any Pay or address exposure',async()=>{
|
||||
const offer={...onchainFixture,phase:'offer_prepared'}
|
||||
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
|
||||
if(method==='content.onchain-create')return offer
|
||||
if(method==='content.onchain-prepare')throw Error('Insufficient confirmed funds; no seller address or inputs allocated')
|
||||
if(method==='content.onchain-cancel')return {...offer,retired_unallocated:true,can_switch_method:true}
|
||||
if(method==='wallet.ecash-balance')return {cashu_sats:1000,fedimint_sats:0}
|
||||
return {items:[],attempts:[],attempt:null}
|
||||
})
|
||||
const {wrapper,vm}=await open();vm.openPayModal({...item,access:{paid:{price_sats:546,accepted:['onchain','ecash']}}});await flushPromises()
|
||||
await vm.payOnchain();expect(vm.lnError).toContain('Insufficient confirmed funds')
|
||||
expect(vm.onchainAttempt.operation_id).toBe(offer.operation_id)
|
||||
await vm.cancelOriginalOnchain();expect(vm.onchainAttempt).toBeNull()
|
||||
await vm.prepareEcashPay()
|
||||
const calls=vi.mocked(rpcClient.call).mock.calls.map(([call])=>call.method)
|
||||
expect(calls).toContain('wallet.ecash-balance')
|
||||
for(const forbidden of ['content.onchain-pay','content.onchain-expose','lnd.sendcoins'])expect(calls).not.toContain(forbidden)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user