Integrate two-phase on-chain purchase recovery

This commit is contained in:
archipelago
2026-10-07 07:49:02 -04:00
parent 558f097fd6
commit 6547ae05fa
18 changed files with 6084 additions and 113 deletions
@@ -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,
+1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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, &params.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,
&params.content_id,
)
.await?;
let original = if let Some(id) = &params.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, &params.content_id)?
};
if action == "lookup" {
return Ok(json!({"attempt":original.as_ref().map(public).transpose()?}));
}
if let Some(id) = &params.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, &params.content_id)
.await?;
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
anyhow::ensure!(
cashu
.find_buyers(&buyer, &peer.did, &params.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,
&params.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, &params.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), &params.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());
}
}
+2 -2
View File
@@ -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();
}
}
+85
View File
@@ -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
}
+3
View File
@@ -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;
+270
View File
@@ -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
}
+116 -95
View File
@@ -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()
})
})