Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
+64
View File
@@ -290,6 +290,10 @@ pub async fn load_network(data_dir: &Path) -> Result<EcashNetwork> {
/// disk untouched, so switching is reversible and loses nothing.
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
crate::content_purchase::Journal::open(data_dir)
.await?
.ensure_seller_policy_change(network, None)
.await?;
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
@@ -443,6 +447,10 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
/// Save accepted mints list.
pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
crate::content_purchase::Journal::open(data_dir)
.await?
.ensure_seller_policy_change(load_network(data_dir).await?, Some(&mints.mints))
.await?;
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
@@ -823,6 +831,7 @@ pub async fn send_token_recoverable(
context_hash,
None,
|| chrono::Utc::now().timestamp(),
None,
)
.await
}
@@ -849,6 +858,7 @@ pub async fn send_token_recoverable_before(
context_hash,
Some(expires_at),
|| chrono::Utc::now().timestamp(),
None,
)
.await
}
@@ -868,6 +878,7 @@ async fn send_token_recoverable_with_deadline(
context_hash: &str,
expires_at: Option<i64>,
now: impl Fn() -> i64 + Send + Sync,
plan: Option<&super::purchase_fee_plan::FeePlan>,
) -> Result<String> {
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
let held = super::mutation::guard(data_dir).await?;
@@ -884,6 +895,17 @@ async fn send_token_recoverable_with_deadline(
};
let journal = Journal::new(&held);
let previous = journal.load(operation_id).await?;
if let Some(plan) = plan {
plan.validate()?;
anyhow::ensure!(
previous.is_some(),
"Original planned inputs are missing; no new proof selection allowed"
);
anyhow::ensure!(
plan.mint_url == mint_url && plan.gross_sats == amount_sats,
"Planned amount or mint changed"
);
}
let recovering = previous.is_some();
let record = if let Some(record) = previous {
anyhow::ensure!(
@@ -927,12 +949,26 @@ async fn send_token_recoverable_with_deadline(
ensure_fresh_payment_allowed(expires_at, now())?;
journal.prepare(binding.clone(), request).await?
};
anyhow::ensure!(
!matches!(record.phase, Phase::Cancelled),
"Payment operation was cancelled"
);
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
return journal.commit_wallet(&binding).await;
}
// An exact Prepared record has never exposed a token: result durability
// precedes both wallet commit and return. Do not reserve fresh inputs merely
// to reject an already-expired accepted plan.
if matches!(&record.request, Request::Exact { .. }) {
ensure_fresh_payment_allowed(expires_at, now())?;
}
journal.reserve_wallet(&binding).await?;
let (send, change) = match &record.request {
Request::Exact { proofs } => {
if let Some(plan) = plan {
plan.validate_proofs(proofs)?;
plan.verify_mint_keysets(&MintClient::new(mint_url)?.get_keysets().await?, false)?;
}
ensure_fresh_payment_allowed(expires_at, now())?;
(proofs.clone(), vec![])
}
@@ -961,7 +997,11 @@ async fn send_token_recoverable_with_deadline(
"This payment is still pending at the mint; do not pay again"
);
}
if let Some(plan) = plan {
plan.verify_mint_keysets(&client.get_keysets().await?, true)?;
}
ensure_fresh_payment_allowed(expires_at, now())?;
journal.mark_dispatched(&binding).await?;
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
"The mint did not confirm this payment; retry this same operation to recover it"))?
};
@@ -985,6 +1025,9 @@ async fn send_token_recoverable_with_deadline(
};
let token = CashuToken::new(&binding.mint_url, send);
let encoded = token.serialize_v4().or_else(|_| token.serialize())?;
if let Some(plan) = plan {
plan.validate_token(&encoded)?;
}
journal
.record_result(
&binding,
@@ -997,6 +1040,27 @@ async fn send_token_recoverable_with_deadline(
journal.commit_wallet(&binding).await
}
/// Executes only a previously pinned private wallet plan. A missing send record
/// rejects instead of silently selecting another set of inputs.
pub(crate) async fn send_token_preplanned_before(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &super::purchase_fee_plan::FeePlan,
) -> Result<String> {
send_token_recoverable_with_deadline(
data_dir,
&contract.id,
contract.network,
&contract.mint_url,
contract.gross_token_sats,
&contract.context_hash()?,
Some(contract.expires_at),
|| chrono::Utc::now().timestamp(),
Some(plan),
)
.await
}
async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = mint_url.to_string();
@@ -83,6 +83,14 @@ impl std::fmt::Debug for PreparedSwap {
}
impl PreparedSwap {
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id }
pub(super) fn input_fee_sats(&self) -> Result<u64> {
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?;
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?;
inputs.checked_sub(outputs).context("Prepared outputs exceed input value")
}
pub(super) fn inputs(&self) -> &[Proof] {
&self.inputs
}
+3 -1
View File
@@ -8,10 +8,12 @@ pub mod ecash;
pub mod fedimint_client;
pub mod minibits;
pub mod mint_client;
mod mutation;
pub(crate) mod mutation;
pub mod nut13;
pub mod profits;
mod send_journal;
mod receive_journal;
pub(crate) mod purchase_fee_plan;
pub(crate) mod purchase_plan;
+2 -2
View File
@@ -30,12 +30,12 @@ async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc<Mutex<()>>)> {
Ok((canonical, lock))
}
pub(super) struct WalletMutation {
pub(crate) struct WalletMutation {
pub(super) data_dir: PathBuf,
_held: OwnedMutexGuard<()>,
}
pub(super) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
pub(crate) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
let (data_dir, lock) = canonical_lock(data_dir).await?;
Ok(WalletMutation {
data_dir,
@@ -1849,6 +1849,7 @@ async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap()
&"ab".repeat(32),
Some(2000),
|| clock.load(Ordering::SeqCst),
None,
)
.await
.unwrap_err();
@@ -1933,3 +1934,785 @@ async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() {
);
assert!(mint.requests.lock().unwrap().is_empty());
}
// Add within wallet payment_tests, using its existing fake proof fixtures.
#[tokio::test]
async fn expired_saved_exact_plan_never_reserves_spendable_inputs() {
let root = tempfile::tempdir().unwrap();
let mint = "https://unused-mint.invalid";
let mut wallet = WalletState::default();
wallet.mint_url = mint.into();
wallet.add_proofs(mint, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
{
let guard = crate::wallet::mutation::guard(root.path()).await.unwrap();
let binding = crate::wallet::send_journal::Binding {
id: id.clone(),
network: EcashNetwork::Mainnet,
mint_url: mint.into(),
amount_sats: 8,
context_hash: context.clone(),
};
crate::wallet::send_journal::Journal::new(&guard)
.prepare(
binding,
crate::wallet::send_journal::Request::Exact {
proofs: vec![proof(ACTIVE, 8)],
},
)
.await
.unwrap();
}
assert!(send_token_recoverable_before(
root.path(),
&id,
EcashNetwork::Mainnet,
mint,
8,
&context,
chrono::Utc::now().timestamp() - 1
)
.await
.is_err());
assert_eq!(
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
original
);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
}
// Append to wallet/payment_tests.rs after integrating dispatch/cancellation APIs.
#[tokio::test]
async fn cancelled_exact_plan_cannot_later_send_and_releases_only_its_reservation() {
use crate::wallet::{
mutation,
send_journal::{Binding, Journal, Request},
};
let root = tempfile::tempdir().unwrap();
let mint = "https://unused-mint.invalid";
let mut wallet = WalletState::default();
wallet.mint_url = mint.into();
wallet.add_proofs(mint, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]);
save_wallet(root.path(), &wallet).await.unwrap();
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: mint.into(),
amount_sats: 8,
context_hash: "ab".repeat(32),
};
{
let guard = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&guard);
journal
.prepare(
binding.clone(),
Request::Exact {
proofs: vec![proof(ACTIVE, 8)],
},
)
.await
.unwrap();
journal.reserve_wallet(&binding).await.unwrap();
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
journal.cancel_unspent(&binding).await.unwrap();
journal.cancel_unspent(&binding).await.unwrap();
}
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
assert!(send_token_recoverable(
root.path(),
&binding.id,
binding.network,
mint,
8,
&binding.context_hash
)
.await
.is_err());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
assert!(load_wallet(root.path())
.await
.unwrap()
.transactions
.is_empty());
}
#[tokio::test]
async fn dispatched_or_legacy_unknown_swap_cannot_cancel_despite_unspent_mint_inputs() {
use crate::wallet::{
mutation,
send_journal::{Binding, Journal, Request},
};
use sha2::{Digest, Sha256};
for legacy in [false, true] {
let mint = Mint::start(0, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
amount_sats: 4,
context_hash: "ab".repeat(32),
};
let prepared = MintClient::new(&mint.url)
.unwrap()
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
.await
.unwrap();
{
let guard = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&guard);
journal
.prepare(binding.clone(), Request::Swap(prepared))
.await
.unwrap();
journal.reserve_wallet(&binding).await.unwrap();
if !legacy {
journal.mark_dispatched(&binding).await.unwrap();
}
}
if legacy {
let path = root
.path()
.join("wallet/send-operations")
.join(format!("{}.json", binding.id));
let mut envelope: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let mut payload: serde_json::Value =
serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap();
payload.as_object_mut().unwrap().remove("dispatch");
let payload = serde_json::to_string(&payload).unwrap();
envelope["checksum"] = json!(hex::encode(Sha256::digest(payload.as_bytes())));
envelope["payload"] = json!(payload);
std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap();
}
let guard = mutation::guard(root.path()).await.unwrap();
assert!(Journal::new(&guard).cancel_unspent(&binding).await.is_err());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
assert!(mint.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn planner_rejects_wrong_network_before_creating_intent_or_reservation() {
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = "http://127.0.0.1:1".into();
wallet.add_proofs("http://127.0.0.1:1", vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
let error = crate::wallet::purchase_plan::prepare(
root.path(),
"http://127.0.0.1:1",
EcashNetwork::Testnet,
4,
8,
)
.await
.err()
.unwrap();
assert!(error.to_string().contains("another wallet network"));
assert_eq!(
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
original
);
assert!(!root.path().join("content-purchases").exists());
assert!(!root.path().join("wallet/send-operations").exists());
}
#[tokio::test]
async fn planner_skips_unfundable_swaps_and_finds_later_exact_shape_within_budget() {
let mint = Mint::start(2000, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]);
save_wallet(root.path(), &wallet).await.unwrap();
let plan =
crate::wallet::purchase_plan::prepare(root.path(), &mint.url, EcashNetwork::Mainnet, 7, 12)
.await
.unwrap();
assert_eq!(plan.fee_plan.gross_sats, 12);
assert_eq!(plan.fee_plan.fee_sats, 4);
assert_eq!(plan.fee_plan.net_sats, 8);
assert_eq!(plan.wallet_debit_sats, 12);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
assert!(mint.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn cancellation_seal_wins_against_an_already_waiting_fresh_swap_executor() {
use crate::wallet::{
mutation,
send_journal::{Binding, Journal, Request},
};
let mint = Mint::start(0, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
amount_sats: 4,
context_hash: "ab".repeat(32),
};
let prepared = MintClient::new(&mint.url)
.unwrap()
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
.await
.unwrap();
let guard = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&guard);
journal
.prepare(binding.clone(), Request::Swap(prepared))
.await
.unwrap();
journal.reserve_wallet(&binding).await.unwrap();
let waiting = send_token_recoverable(
root.path(),
&binding.id,
binding.network,
&binding.mint_url,
binding.amount_sats,
&binding.context_hash,
);
tokio::pin!(waiting);
assert!(futures_util::poll!(&mut waiting).is_pending());
journal.cancel_unspent(&binding).await.unwrap();
drop(journal);
drop(guard);
assert!(waiting.await.is_err());
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
assert!(load_wallet(root.path())
.await
.unwrap()
.transactions
.is_empty());
}
// Append under wallet/payment_tests.rs: real local journals + fake mint, no real funds.
struct PurchaseTestTransport {
seller_root: std::path::PathBuf,
template: crate::content_purchase_protocol::Offer,
lose_offer: std::sync::atomic::AtomicBool,
lose_accept: std::sync::atomic::AtomicBool,
lose_settle: std::sync::atomic::AtomicBool,
offers: std::sync::Mutex<Vec<String>>,
}
impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport {
fn seller_did(&self) -> &str {
&self.template.seller_did
}
fn seller_onion(&self) -> &str {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"
}
async fn offer(
&self,
id: &str,
content_id: &str,
) -> anyhow::Result<crate::content_purchase_protocol::Offer> {
self.offers.lock().unwrap().push(id.into());
let mut offer = self.template.clone();
offer.id = id.into();
offer.content_id = content_id.into();
let saved = crate::content_purchase_protocol::save_offer(
&self.seller_root,
&offer,
&offer.buyer_did,
chrono::Utc::now().timestamp(),
)
.await?;
anyhow::ensure!(
!self
.lose_offer
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Lost offer response"
);
Ok(saved)
}
async fn accept(
&self,
envelope: &crate::content_purchase_protocol::Envelope,
) -> anyhow::Result<crate::content_purchase_protocol::Accepted> {
let reply = crate::content_purchase_protocol::accept(
&self.seller_root,
envelope,
&envelope.offer.buyer_did,
|| chrono::Utc::now().timestamp(),
)
.await?;
anyhow::ensure!(
!self
.lose_accept
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Lost acceptance response"
);
Ok(reply)
}
async fn status(
&self,
envelope: &crate::content_purchase_protocol::Envelope,
) -> anyhow::Result<crate::content_purchase_protocol::SellerStatus> {
crate::content_purchase_protocol::status(
&self.seller_root,
envelope,
&envelope.offer.buyer_did,
)
.await
}
async fn cancel(
&self,
envelope: &crate::content_purchase_protocol::Envelope,
) -> anyhow::Result<crate::content_purchase_protocol::Cancelled> {
crate::content_purchase_protocol::cancel(
&self.seller_root,
envelope,
&envelope.offer.buyer_did,
)
.await
}
async fn settle(
&self,
request: &crate::content_purchase_protocol::Settlement,
) -> anyhow::Result<crate::content_purchase::Receipt> {
let reply = crate::content_purchase_protocol::settle(
&self.seller_root,
request,
&request.envelope.offer.buyer_did,
)
.await?;
anyhow::ensure!(
!self
.lose_settle
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Invalid purchase response after settlement"
);
Ok(reply)
}
}
#[tokio::test]
async fn full_caller_recovers_lost_acceptance_and_settlement_without_another_payment() {
use crate::content_purchase_caller::{purchase, PurchaseConsent, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering};
let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await;
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(buyer.path(), &wallet).await.unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
save_wallet(seller.path(), &wallet).await.unwrap();
let now = chrono::Utc::now().timestamp();
let transport = PurchaseTestTransport {
seller_root: seller.path().into(),
template: crate::content_purchase_protocol::Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer_did.clone(),
seller_did,
content_id: "paid-film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 16,
viewing_seconds: None,
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
seller_net_sats: 8,
offered_at: now,
expires_at: now + 300,
},
lose_offer: AtomicBool::new(true),
lose_accept: AtomicBool::new(true),
lose_settle: AtomicBool::new(true),
offers: Default::default(),
};
assert!(purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport
)
.await
.is_err());
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
let quote = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
let consent = match quote {
ReadyPurchase::AwaitingConfirmation {
operation_id,
envelope_sha256,
wallet_debit_sats,
..
} => PurchaseConsent {
operation_id,
envelope_sha256,
wallet_debit_sats,
},
_ => panic!("Fresh purchase spent before confirmation"),
};
let reopened = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
9_007_199_254_740_991,
None,
&transport,
)
.await
.unwrap();
match reopened {
ReadyPurchase::AwaitingConfirmation {
operation_id,
wallet_debit_sats,
..
} => {
assert_eq!(operation_id, consent.operation_id);
assert_eq!(wallet_debit_sats, consent.wallet_debit_sats);
}
_ => panic!("A broad quote budget initiated spending without consent"),
}
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
// A quote alone does not pin seller policy. Removing acceptance must stop
// the buyer before any token is exposed; the same quote can resume later.
save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] })
.await
.unwrap();
let rejected = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
Some(&consent),
&transport,
)
.await
.err()
.unwrap();
assert!(rejected
.to_string()
.contains("does not accept the quoted mint"));
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
assert!(mint.requests.lock().unwrap().is_empty());
save_accepted_mints(
seller.path(),
&AcceptedMints {
mints: vec![mint.url.clone()],
},
)
.await
.unwrap();
let error = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
Some(&consent),
&transport,
)
.await
.err()
.expect("The simulated lost response must surface");
assert!(
error.to_string().contains("Lost acceptance response"),
"unexpected purchase failure: {error:#}"
);
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
// Durable acceptance now pins redemption policy until cancellation or
// settlement, including when the acceptance reply was lost.
assert!(
save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] })
.await
.is_err()
);
assert!(save_network(seller.path(), EcashNetwork::Testnet)
.await
.is_err());
assert_eq!(
load_network(seller.path()).await.unwrap(),
EcashNetwork::Mainnet
);
let error = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
Some(&consent),
&transport,
)
.await
.err()
.expect("The simulated lost response must surface");
assert!(
error
.to_string()
.contains("Invalid purchase response after settlement"),
"unexpected purchase failure: {error:#}"
);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
let recovered = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
let original = match recovered {
ReadyPurchase::Entitlement { contract, receipt } => {
assert_eq!(contract.id, consent.operation_id);
receipt
}
_ => panic!("Original entitlement was not recovered"),
};
let again = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
match again {
ReadyPurchase::Entitlement { receipt, .. } => assert!(receipt == original),
_ => panic!("Receipt replay changed"),
}
assert_eq!(transport.offers.lock().unwrap().len(), 2);
assert_ne!(transport.offers.lock().unwrap()[0], consent.operation_id);
assert_eq!(transport.offers.lock().unwrap()[1], consent.operation_id);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 8);
assert_eq!(
load_wallet(buyer.path()).await.unwrap().transactions.len(),
1
);
assert_eq!(
load_wallet(seller.path()).await.unwrap().transactions.len(),
1
);
assert!(!transport.lose_accept.load(Ordering::SeqCst));
}
#[tokio::test]
async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
use crate::content_purchase_caller::{purchase_bound, ExpectedRental};
use std::sync::atomic::AtomicBool;
let buyer = tempfile::tempdir().unwrap();
let seller = tempfile::tempdir().unwrap();
save_accepted_mints(
seller.path(),
&AcceptedMints {
mints: vec!["http://127.0.0.1:1".into()],
},
)
.await
.unwrap();
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let now = chrono::Utc::now().timestamp();
let transport = PurchaseTestTransport {
seller_root: seller.path().into(),
template: crate::content_purchase_protocol::Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer_did.clone(),
seller_did: seller_did.clone(),
content_id: "registered_film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 16,
viewing_seconds: Some(60),
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: "http://127.0.0.1:1".into(),
seller_net_sats: 8,
offered_at: now,
expires_at: now + 300,
},
lose_offer: AtomicBool::new(false),
lose_accept: AtomicBool::new(false),
lose_settle: AtomicBool::new(false),
offers: Default::default(),
};
let expected = ExpectedRental {
seller_did,
content_id: "registered_film".into(),
sha256: "ab".repeat(32),
price_sats: 8,
viewing_seconds: 60,
};
let mut changed_hash = expected.clone();
changed_hash.sha256 = "ef".repeat(32);
let mut changed_price = expected.clone();
changed_price.price_sats = 9;
let mut changed_duration = expected.clone();
changed_duration.viewing_seconds = 120;
for wrong in [changed_hash, changed_price, changed_duration] {
let error = purchase_bound(
buyer.path(),
&buyer_did,
"registered_film",
None,
20,
None,
&transport,
Some(&wrong),
)
.await
.err()
.unwrap();
assert!(error.to_string().contains("Published rental"), "{error:#}");
assert!(!buyer.path().join("wallet/ecash.json").exists());
assert!(!buyer.path().join("wallet/send-operations").exists());
assert!(crate::content_purchase::Journal::open(buyer.path())
.await
.unwrap()
.find_buyers(&buyer_did, &expected.seller_did, "registered_film")
.await
.unwrap()
.is_empty());
}
}
#[tokio::test]
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
use crate::content_purchase_caller::{purchase, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering};
let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await;
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(buyer.path(), &wallet).await.unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
save_wallet(seller.path(), &wallet).await.unwrap();
let now = chrono::Utc::now().timestamp();
let transport = PurchaseTestTransport {
seller_root: seller.path().into(),
template: crate::content_purchase_protocol::Offer {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer_did.clone(),
seller_did,
content_id: "paid-film".into(),
filename: "film.mp4".into(),
mime_type: "video/mp4".into(),
content_sha256: "ab".repeat(32),
content_size: 16,
viewing_seconds: None,
terms_sha256: "cd".repeat(32),
network: EcashNetwork::Mainnet,
mint_url: mint.url.clone(),
seller_net_sats: 8,
offered_at: now,
expires_at: now + 300,
},
lose_offer: AtomicBool::new(false),
lose_accept: AtomicBool::new(false),
lose_settle: AtomicBool::new(false),
offers: Default::default(),
};
let quote = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
let id = match quote {
ReadyPurchase::AwaitingConfirmation { operation_id, .. } => operation_id,
_ => panic!("Quote spent funds"),
};
assert!(!buyer
.path()
.join("wallet/send-operations")
.join(format!("{id}.json"))
.exists());
let (envelope, plan) = {
let journal = crate::content_purchase::Journal::open(buyer.path())
.await
.unwrap();
(
journal
.protocol_envelope("buyer", &id)
.await
.unwrap()
.unwrap(),
journal.buyer_plan(&id).await.unwrap().unwrap(),
)
};
crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport)
.await
.unwrap();
crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport)
.await
.unwrap();
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
assert!(load_wallet(buyer.path())
.await
.unwrap()
.transactions
.is_empty());
assert!(mint.requests.lock().unwrap().is_empty());
let next = purchase(
buyer.path(),
&buyer_did,
"paid-film",
Some("film.mp4"),
8,
None,
&transport,
)
.await
.unwrap();
match next {
ReadyPurchase::AwaitingConfirmation { operation_id, .. } => assert_ne!(operation_id, id),
_ => panic!("Replacement quote spent funds"),
}
assert!(mint.requests.lock().unwrap().is_empty());
}
@@ -0,0 +1,264 @@
//! Local-only deterministic payment planning. No swap POST or reservation here.
//! Serialize this object only into the private buyer journal, never onto the wire.
use super::{
cashu::{KeysetInfo, Proof},
ecash,
mint_client::MintClient,
mutation,
purchase_fee_plan::{FeePlan, KeysetPlan},
send_journal::{Binding, Journal, Request},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::Path};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct PreparedPayment {
pub fee_plan: FeePlan,
/// Includes any buyer funding-swap input fee, distinct from seller redemption.
pub wallet_debit_sats: u64,
request: Request,
}
fn exact_shape(proofs: &[Proof], keysets: &[KeysetInfo]) -> Result<Vec<KeysetPlan>> {
let mut groups: BTreeMap<String, KeysetPlan> = BTreeMap::new();
for proof in proofs {
let matches: Vec<_> = keysets
.iter()
.filter(|keyset| super::cashu::matches_stored_keyset_id(&proof.id, &keyset.id))
.collect();
anyhow::ensure!(matches.len() == 1, "Missing or ambiguous payment keyset");
let keyset = matches[0];
anyhow::ensure!(keyset.unit == "sat", "Payment keyset has another unit");
groups
.entry(keyset.id.to_ascii_lowercase())
.or_insert_with(|| KeysetPlan {
keyset_id: keyset.id.to_ascii_lowercase(),
denominations: vec![],
input_fee_ppk: keyset.input_fee_ppk,
})
.denominations
.push(proof.amount);
}
Ok(groups.into_values().collect())
}
/// Quote at most 1024 gross amounts. Failure is explicit; never silently increase
/// the user-approved debit limit or substitute another mint/network.
pub(crate) async fn prepare(
data_dir: &Path,
mint: &str,
expected_network: ecash::EcashNetwork,
seller_net_sats: u64,
max_wallet_debit: u64,
) -> Result<PreparedPayment> {
anyhow::ensure!(
seller_net_sats > 0 && max_wallet_debit >= seller_net_sats,
"Invalid payment budget"
);
let _held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == expected_network,
"Offer uses another wallet network; no intent was created"
);
let wallet = ecash::load_wallet(data_dir).await?;
anyhow::ensure!(
wallet.select_proofs(mint, seller_net_sats).is_some(),
"No spendable balance at the seller's mint; no intent was created"
);
let client =
MintClient::new(mint)?.with_recovery(super::nut13::RecoverySource::load(data_dir).await?);
let keysets = client.get_keysets().await?;
let active = client.get_active_sat_keyset().await?;
let active_fee: Vec<_> = keysets
.iter()
.filter(|keyset| keyset.id == active.id && keyset.active && keyset.unit == "sat")
.collect();
anyhow::ensure!(
active_fee.len() == 1,
"Active payment keyset is not uniquely bound"
);
for additional in 0..1024u64 {
let gross = seller_net_sats
.checked_add(additional)
.context("Payment amount overflow")?;
if gross > max_wallet_debit {
break;
}
let Some((indices, excess)) = wallet.select_proofs(mint, gross) else {
break;
};
let proofs: Vec<_> = indices
.iter()
.map(|&index| wallet.proofs[index].proof.clone())
.collect();
let input_shape = exact_shape(&proofs, &keysets)?;
let input_ppk = input_shape
.iter()
.try_fold(0u64, |total, group| {
total.checked_add(
group
.input_fee_ppk
.checked_mul(group.denominations.len() as u64)?,
)
})
.context("Funding fee overflow")?;
let quoted_funding_fee = input_ppk.checked_add(999).context("Funding fee overflow")? / 1000;
if excess > 0
&& (quoted_funding_fee > excess
|| gross
.checked_add(quoted_funding_fee)
.is_none_or(|debit| debit > max_wallet_debit))
{
continue;
}
let shape = if excess == 0 {
input_shape
} else {
vec![KeysetPlan {
keyset_id: active.id.to_ascii_lowercase(),
denominations: super::cashu::amount_to_denominations(gross),
input_fee_ppk: active_fee[0].input_fee_ppk,
}]
};
let ppk = shape
.iter()
.try_fold(0u64, |total, group| {
total.checked_add(
group
.input_fee_ppk
.checked_mul(group.denominations.len() as u64)?,
)
})
.context("Quoted fee overflow")?;
let fee = ppk.checked_add(999).context("Quoted fee overflow")? / 1000;
if gross <= fee || gross - fee < seller_net_sats {
continue;
}
let fee_plan = FeePlan::from_shape(mint, shape)?;
if fee_plan.net_sats < seller_net_sats {
continue;
}
let (request, funding_fee) = if excess == 0 {
(Request::Exact { proofs }, 0)
} else {
let mut amounts = super::cashu::amount_to_denominations(gross);
amounts.extend(super::cashu::amount_to_denominations(excess));
let prepared = client
.prepare_swap_at_least(&proofs, &amounts, gross)
.await?;
anyhow::ensure!(
prepared.payment_keyset_id() == active.id,
"Mint rotated while planning; refresh the offer"
);
let fee = prepared.input_fee_sats()?;
anyhow::ensure!(
fee == quoted_funding_fee,
"Mint funding fee changed while planning; refresh before acceptance"
);
anyhow::ensure!(
client.restore_prepared_swap(&prepared).await?.is_none(),
"Planned outputs already exist; recover the previous operation"
);
(Request::Swap(prepared), fee)
};
let debit = gross
.checked_add(funding_fee)
.context("Payment debit overflow")?;
anyhow::ensure!(
debit <= max_wallet_debit,
"Funding fee exceeds the approved debit limit"
);
return Ok(PreparedPayment {
fee_plan,
wallet_debit_sats: debit,
request,
});
}
anyhow::bail!("No bounded payment plan covers the seller price within the approved debit limit")
}
/// Must precede authenticated seller acceptance. This durably pins the exact
/// inputs/outputs without reserving or spending; stale inputs later reject.
pub(crate) async fn persist(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &PreparedPayment,
) -> Result<()> {
contract.validate()?;
anyhow::ensure!(
plan.fee_plan.mint_url == contract.mint_url
&& plan.fee_plan.gross_sats == contract.gross_token_sats
&& plan.fee_plan.net_sats >= contract.minimum_net_sats,
"Wallet plan changed purchase amounts"
);
let held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == contract.network,
"Purchase wallet network changed"
);
let binding = Binding {
id: contract.id.clone(),
network: contract.network,
mint_url: contract.mint_url.clone(),
amount_sats: contract.gross_token_sats,
context_hash: contract.context_hash()?,
};
let journal = Journal::new(&held);
if let Some(existing) = journal.load(&contract.id).await? {
anyhow::ensure!(
existing.binding == binding
&& serde_json::to_value(&existing.request)? == serde_json::to_value(&plan.request)?,
"Original wallet preparation changed"
);
}
if journal.load(&contract.id).await?.is_none() {
let buyer = crate::content_purchase::Journal::open(data_dir)
.await?
.buyer(&contract.id)
.await?
.context("Buyer intent is missing")?;
anyhow::ensure!(buyer.phase == crate::content_purchase::BuyerPhase::Intent,
"Accepted operation lost its wallet journal; no new preparation or cancellation is allowed");
}
journal.prepare(binding, plan.request.clone()).await?;
Ok(())
}
/// Seal before contacting seller. Repeating after a lost seller reply is safe.
pub(crate) async fn cancel_unspent(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &PreparedPayment,
) -> Result<()> {
persist(data_dir, contract, plan).await?;
let held = mutation::guard(data_dir).await?;
let binding = Binding {
id: contract.id.clone(),
network: contract.network,
mint_url: contract.mint_url.clone(),
amount_sats: contract.gross_token_sats,
context_hash: contract.context_hash()?,
};
Journal::new(&held).cancel_unspent(&binding).await
}
/// Publish the buyer intent while holding the wallet network mutation guard, so
/// a concurrent network switch cannot strand a newly published wrong-network row.
pub(crate) async fn persist_intent(
data_dir: &Path,
envelope: &crate::content_purchase_protocol::Envelope,
plan: &PreparedPayment,
) -> Result<()> {
let contract = envelope.contract()?;
anyhow::ensure!(plan.fee_plan == envelope.fee_plan, "Buyer fee plan changed");
let _held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == contract.network,
"Offer uses another wallet network; no intent was created"
);
let journal = crate::content_purchase::Journal::open(data_dir).await?;
journal.save_buyer_plan(&contract.id, plan).await?;
journal.save_protocol_envelope("buyer", envelope).await?;
journal
.prepare_buyer(&contract, chrono::Utc::now().timestamp())
.await?;
Ok(())
}
+72 -1
View File
@@ -450,16 +450,26 @@ pub(super) struct Outcome {
#[derive(Clone, Serialize, Deserialize)]
pub(super) enum Phase {
Prepared,
Cancelled,
Result(Outcome),
Committed(Outcome),
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(super) enum DispatchState {
#[default]
Unknown,
NotDispatched,
Started,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Record {
pub binding: Binding,
pub request: Request,
pub phase: Phase,
#[serde(default)]
pub dispatch: DispatchState,
}
impl std::fmt::Debug for Record {
@@ -528,6 +538,9 @@ impl<'a> Journal<'a> {
{
continue;
}
if matches!(record.phase, Phase::Cancelled) {
continue;
}
let Phase::Committed(outcome) = record.phase else {
anyhow::bail!(
"Recover pending payments before restoring this mint from the backup phrase"
@@ -634,6 +647,7 @@ impl<'a> Journal<'a> {
use super::ecash::TransactionType;
let record = self.bound_record(binding).await?;
let (outcome, committed) = match &record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) => (outcome, false),
Phase::Committed(outcome) => (outcome, true),
@@ -867,7 +881,7 @@ impl<'a> Journal<'a> {
Self::validate_binding(&record.binding)?;
Self::validate_request(&record.binding, &record.request)?;
match &record.phase {
Phase::Prepared => (),
Phase::Prepared | Phase::Cancelled => (),
Phase::Result(outcome) | Phase::Committed(outcome) => {
Self::validate_outcome(&record, outcome)?
}
@@ -895,6 +909,7 @@ impl<'a> Journal<'a> {
binding,
request,
phase: Phase::Prepared,
dispatch: DispatchState::NotDispatched,
};
self.write(&record).await?;
Ok(record)
@@ -911,6 +926,7 @@ impl<'a> Journal<'a> {
);
Self::validate_outcome(&record, &outcome)?;
match &record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => record.phase = Phase::Result(outcome),
Phase::Result(previous) | Phase::Committed(previous) => {
anyhow::ensure!(
@@ -935,6 +951,7 @@ impl<'a> Journal<'a> {
"Payment operation terms changed"
);
record.phase = match record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome),
};
@@ -942,6 +959,60 @@ impl<'a> Journal<'a> {
Ok(record)
}
/// Must finish durably immediately before every fresh mint POST, under the
/// same wallet mutation guard as cancellation and input reservation.
pub async fn mark_dispatched(&self, binding: &Binding) -> Result<()> {
let mut record = self.bound_record(binding).await?;
anyhow::ensure!(
matches!(record.phase, Phase::Prepared),
"Payment cannot be dispatched in this phase"
);
record.dispatch = DispatchState::Started;
self.write(&record).await
}
/// A terminal local seal precedes release. No mint state query can prove an
/// ambiguous old POST will not finish later, so such swaps are never released.
pub async fn cancel_unspent(&self, binding: &Binding) -> Result<()> {
let mut record = self.bound_record(binding).await?;
if !matches!(record.phase, Phase::Cancelled) {
anyhow::ensure!(
matches!(record.phase, Phase::Prepared),
"A prepared token/result cannot be cancelled as unspent"
);
anyhow::ensure!(
matches!(record.request, Request::Exact { .. })
|| record.dispatch == DispatchState::NotDispatched,
"Mint dispatch is possible; recover original results instead of cancelling"
);
record.phase = Phase::Cancelled;
self.write(&record).await?;
}
let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?;
let mut changed = false;
for stored in &mut wallet.proofs {
if stored.reserved_by.as_deref() != Some(binding.id.as_str()) {
continue;
}
anyhow::ensure!(
Self::inputs(&record.request)
.iter()
.any(|input| input.secret == stored.proof.secret
&& input.amount == stored.proof.amount
&& input.id == stored.proof.id
&& input.c == stored.proof.c),
"Cancellation reservation differs from original inputs"
);
anyhow::ensure!(!stored.spent, "Cancellation cannot restore spent inputs");
stored.reserved = false;
stored.reserved_by = None;
changed = true;
}
if changed {
super::ecash::save_wallet(&self.guard.data_dir, &wallet).await?;
}
Ok(())
}
async fn write(&self, record: &Record) -> Result<()> {
let payload = serde_json::to_string(record)?;
let checksum = hex::encode(Sha256::digest(payload.as_bytes()));