//! Real HTTP/curve-signature regressions for paid Cashu redemption. use super::*; use crate::wallet::{bdhke, cashu::Proof}; use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; use hyper::{ service::{make_service_fn, service_fn}, Body, Request, Response, Server, }; use serde_json::{json, Value}; use std::{ convert::Infallible, sync::{Arc, Mutex}, }; const ACTIVE: &str = "0011223344556677"; const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111"; struct Mint { url: String, requests: Arc>>, task: tokio::task::JoinHandle<()>, failure: Arc, lose_swap_reply: Arc, restore_reply: Arc>>, state_reply: Arc>>, // Per-fixture clock advancement proves the spend deadline is checked after // mint preflight; no process-global clock or timing-sensitive sleep. restore_clock: Arc, i64)>>>, } impl Drop for Mint { fn drop(&mut self) { self.task.abort(); } } fn signing_key() -> SecretKey { SecretKey::from_slice(&[7; 32]).unwrap() } fn signed_point(point: PublicKey) -> String { point .mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key())) .unwrap() .to_string() } fn proof(id: &str, amount: u64) -> Proof { let secret = format!("test-{id}-{amount}"); Proof { amount, id: id.into(), c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()), secret, } } impl Mint { async fn start(fee: u64, failure: Option) -> Self { 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 requests = Arc::new(Mutex::new(Vec::new())); let seen = requests.clone(); let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0))); let rejection = failure.clone(); let spent = Arc::new(Mutex::new(std::collections::HashSet::::new())); let issued = Arc::new(Mutex::new(std::collections::HashMap::::new())); let lose_swap_reply = Arc::new(std::sync::atomic::AtomicBool::new(false)); let lose_reply = lose_swap_reply.clone(); let restore_reply = Arc::new(Mutex::new(None::)); let restore_override = restore_reply.clone(); let state_reply = Arc::new(Mutex::new(None::)); let state_override = state_reply.clone(); let restore_clock = Arc::new(Mutex::new(None::<(Arc, i64)>)); let advance_clock = restore_clock.clone(); let service = make_service_fn(move |_| { let seen = seen.clone(); let rejection = rejection.clone(); let spent = spent.clone(); let issued = issued.clone(); let lose_reply = lose_reply.clone(); let restore_override = restore_override.clone(); let state_override = state_override.clone(); let advance_clock = advance_clock.clone(); async move { Ok::<_, Infallible>(service_fn(move |req: Request| { let seen = seen.clone(); let rejection = rejection.clone(); let spent = spent.clone(); let issued = issued.clone(); let lose_reply = lose_reply.clone(); let restore_override = restore_override.clone(); let state_override = state_override.clone(); let advance_clock = advance_clock.clone(); async move { let mut status = 200; let body = match req.uri().path() { "/v1/keysets" => json!({"keysets":[ {"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee}, {"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee} ]}), path if path == "/v1/keys" || path.starts_with("/v1/keys/") => { let public = PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()) .to_string(); let keys: serde_json::Map = (0..16) .map(|i| ((1u64 << i).to_string(), json!(public))) .collect(); let id = path.strip_prefix("/v1/keys/").unwrap_or(ACTIVE); json!({"keysets":[{"id": id,"unit":"sat","keys":keys}]}) } "/v1/swap" => { let body: Value = serde_json::from_slice( &hyper::body::to_bytes(req.into_body()).await.unwrap(), ) .unwrap(); seen.lock().unwrap().push(body.clone()); let inputs = body["inputs"].as_array().unwrap(); let outputs = body["outputs"].as_array().unwrap(); let code = rejection.load(std::sync::atomic::Ordering::SeqCst); if code != 0 { status = code; json!({"detail":"mock mint rejection"}) } else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE) { status = 422; json!({"detail":[{"msg":"NUT02: ID length invalid"}]}) } else if inputs.iter().any(|p| { spent .lock() .unwrap() .contains(p["secret"].as_str().unwrap()) }) { status = 400; json!({"code":11001,"detail":"Token Already Spent"}) } else { let total: u64 = inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum(); let out: u64 = outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum(); assert_eq!( out, total - (inputs.len() as u64 * fee).div_ceil(1000) ); for p in inputs { spent .lock() .unwrap() .insert(p["secret"].as_str().unwrap().into()); } let signatures: Vec<_> = outputs.iter().map(|o| { let signature = json!({"amount":o["amount"],"id":ACTIVE, "C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())}); issued.lock().unwrap().insert(o["B_"].as_str().unwrap().into(), signature.clone()); signature }).collect(); if lose_reply.load(std::sync::atomic::Ordering::SeqCst) { status = 500; json!({"detail":"Fixture lost the reply after consuming inputs"}) } else { json!({"signatures":signatures}) } } } "/v1/checkstate" => { let body: Value = serde_json::from_slice( &hyper::body::to_bytes(req.into_body()).await.unwrap(), ) .unwrap(); let overridden = state_override.lock().unwrap().clone(); overridden.unwrap_or_else(|| { let spent_points: std::collections::HashSet<_> = spent.lock().unwrap().iter().map(|secret| hex::encode(bdhke::hash_to_curve(secret.as_bytes()).unwrap().serialize())).collect(); json!({"states": body["Ys"].as_array().unwrap().iter().map(|y| json!({"Y":y,"state":if spent_points.contains(y.as_str().unwrap()) {"SPENT"} else {"UNSPENT"}})).collect::>()}) }) } "/v1/restore" => { if let Some((clock, deadline)) = advance_clock.lock().unwrap().as_ref() { clock.store(*deadline, std::sync::atomic::Ordering::SeqCst); } let body: Value = serde_json::from_slice( &hyper::body::to_bytes(req.into_body()).await.unwrap(), ) .unwrap(); let override_reply = restore_override.lock().unwrap().clone(); if let Some(reply) = override_reply { reply } else { let issued = issued.lock().unwrap(); let mut outputs = Vec::new(); let mut signatures = Vec::new(); for output in body["outputs"].as_array().unwrap().iter().rev() { if let Some(signature) = issued.get(output["B_"].as_str().unwrap()) { let mut echoed = output.clone(); echoed["B_"] = json!(output["B_"] .as_str() .unwrap() .to_uppercase()); outputs.push(echoed); signatures.push(signature.clone()); } } json!({"outputs":outputs,"signatures":signatures}) } } _ => { status = 404; json!({}) } }; Ok::<_, Infallible>( Response::builder() .status(status) .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(), ) } })) } }); let server = Server::from_tcp(listener).unwrap().serve(service); let task = tokio::spawn(async move { server.await.unwrap(); }); Self { url, requests, task, failure, lose_swap_reply, restore_reply, state_reply, restore_clock, } } async fn wallet(&self) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); save_accepted_mints( dir.path(), &AcceptedMints { mints: vec![format!("{}/", self.url)], }, ) .await .unwrap(); dir } } #[tokio::test] async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() { let mint = Mint::start(0, None).await; let dir = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]) .serialize_v4() .unwrap(); let decoded = CashuToken::deserialize(&token).unwrap(); assert_eq!( decoded.token[0].proofs[0].id.len(), 16, "reproduce the short V4 ID" ); assert_eq!( verify_and_receive_payment(dir.path(), &token, 100) .await .unwrap(), 100 ); let wallet = load_wallet(dir.path()).await.unwrap(); assert_eq!(wallet.balance(), 100); for p in wallet.proofs { assert_eq!( p.proof.c, signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap()) ); } assert!(mint.requests.lock().unwrap()[0]["inputs"] .as_array() .unwrap() .iter() .all(|p| p["id"] == V2)); assert!(verify_and_receive_payment(dir.path(), &token, 100) .await .is_err()); assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100); } #[tokio::test] async fn paid_v3_full_v2_and_v1_ids_work() { for id in [V2, ACTIVE] { let mint = Mint::start(0, None).await; let dir = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(id, 128)]) .serialize() .unwrap(); assert_eq!( verify_and_receive_payment(dir.path(), &token, 100) .await .unwrap(), 128 ); } } #[tokio::test] async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() { let mint = Mint::start(1000, None).await; let dir = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) .serialize_v4() .unwrap(); assert!(verify_and_receive_payment(dir.path(), &token, 128) .await .unwrap_err() .to_string() .contains("after mint fees")); assert!(mint.requests.lock().unwrap().is_empty()); assert_eq!( verify_and_receive_payment(dir.path(), &token, 127) .await .unwrap(), 127 ); assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127); } #[tokio::test] async fn rejected_mint_response_does_not_credit_wallet() { for status in [200, 400, 422, 500, 503] { let mint = Mint::start(0, Some(status)).await; let dir = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) .serialize_v4() .unwrap(); assert!(verify_and_receive_payment(dir.path(), &token, 100) .await .is_err()); assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0); } } #[tokio::test] async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() { let mint = Mint::start(0, None).await; let dir = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]); let mut invalid = vec![ "cashuSend_500_abc_1700000000".into(), "cashuBinvalid".into(), ]; let mut wrong_unit = token.clone(); wrong_unit.unit = Some("usd".into()); invalid.push(wrong_unit.serialize().unwrap()); let mut multi = token.clone(); multi.token.push(token.token[0].clone()); invalid.push(multi.serialize().unwrap()); let mut untrusted = token.clone(); untrusted.token[0].mint = "http://127.0.0.1:1".into(); invalid.push(untrusted.serialize().unwrap()); for id in ["00ffffffffffffff", "01ffffffffffffff"] { invalid.push( CashuToken::new(&mint.url, vec![proof(id, 128)]) .serialize() .unwrap(), ); } for value in invalid { assert!(verify_and_receive_payment(dir.path(), &value, 100) .await .is_err()); } assert!( verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129) .await .is_err() ); assert!(mint.requests.lock().unwrap().is_empty()); assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0); } #[tokio::test] async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() { let mint = Mint::start(0, Some(422)).await; let buyer = mint.wallet().await; let seller = mint.wallet().await; let mut wallet = load_wallet(buyer.path()).await.unwrap(); wallet.mint_url = mint.url.clone(); wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]); save_wallet(buyer.path(), &wallet).await.unwrap(); let token = send_token(buyer.path(), 100).await.unwrap(); assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0); assert!(verify_and_receive_payment(seller.path(), &token, 100) .await .is_err()); mint.failure.store(0, std::sync::atomic::Ordering::SeqCst); assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100); assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100); assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0); assert!(receive_token(buyer.path(), &token).await.is_err()); assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100); } #[tokio::test] async fn unreachable_mint_does_not_credit_seller() { let mint = Mint::start(0, None).await; let dir = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) .serialize_v4() .unwrap(); mint.task.abort(); tokio::task::yield_now().await; assert!(verify_and_receive_payment(dir.path(), &token, 100) .await .is_err()); assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0); } #[tokio::test] async fn send_with_fees_preserves_payment_denominations_and_saves_change() { // 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1, // which is needed for a 65-sat payment, after consuming the inputs. let mint = Mint::start(1000, None).await; let buyer = mint.wallet().await; let mut wallet = load_wallet(buyer.path()).await.unwrap(); wallet.mint_url = mint.url.clone(); let first = proof(V2, 64); let mut second = first.clone(); second.secret.push_str("-second"); second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap()); wallet.add_proofs(&mint.url, vec![first, second]); save_wallet(buyer.path(), &wallet).await.unwrap(); let encoded = send_token(buyer.path(), 65).await.unwrap(); assert_eq!( CashuToken::deserialize(&encoded).unwrap().total_amount(), 65 ); assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61); } #[tokio::test] async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() { use crate::content_server::{ self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult, }; for (exists, accepts_cashu, price) in [ (true, true, 100), (true, false, 100), (false, true, 100), (true, true, 129), ] { let mint = Mint::start(0, None).await; let seller = mint.wallet().await; let item = ContentItem { id: "paid-test".into(), filename: "test.txt".into(), mime_type: "text/plain".into(), size_bytes: 5, description: String::new(), added_at: String::new(), availability: Availability::AllPeers, access: AccessControl::Paid { price_sats: price, accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()], }, }; content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] }) .await .unwrap(); if exists { tokio::fs::create_dir_all(seller.path().join("content/files")) .await .unwrap(); tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello") .await .unwrap(); } let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) .serialize_v4() .unwrap(); let result = content_server::serve_content( seller.path(), "paid-test", Some(&token), None, None, None, false, ) .await .unwrap(); if exists && accepts_cashu && price <= 128 { match result { ServeResult::Ok(bytes, mime) => { assert_eq!(bytes, b"hello"); assert_eq!(mime, "text/plain"); } _ => panic!("paid content was not delivered"), } assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128); } else { if !exists { assert!(matches!(result, ServeResult::Unavailable)); assert!(content_server::load_catalog(seller.path()) .await .unwrap() .items .iter() .any(|item| item.id == "paid-test")); } else { assert!(matches!(result, ServeResult::PaymentRequired(_))); } assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0); assert!(mint.requests.lock().unwrap().is_empty()); } } } #[tokio::test] async fn simultaneous_receipts_and_revenue_writes_preserve_every_payment() { let mint = Mint::start(0, None).await; let wallet_dir = mint.wallet().await; let mut tasks = tokio::task::JoinSet::new(); for exponent in 0..8 { let amount = 1u64 << exponent; let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, amount)]) .serialize() .unwrap(); let data_dir = wallet_dir.path().to_path_buf(); tasks.spawn(async move { verify_and_receive_payment(&data_dir, &token, amount) .await .unwrap(); }); } for index in 0..16 { let data_dir = wallet_dir.path().to_path_buf(); tasks.spawn(async move { record_streaming_revenue(&data_dir, 1, "fixture-service", &format!("peer-{index}")) .await .unwrap(); }); } while let Some(result) = tasks.join_next().await { result.unwrap(); } let wallet = load_wallet(wallet_dir.path()).await.unwrap(); assert_eq!(wallet.balance(), 255); assert_eq!(wallet.transactions.len(), 24); assert_eq!(mint.requests.lock().unwrap().len(), 8); } #[tokio::test] async fn prepared_swap_recovers_a_lost_reply_without_a_second_spend() { let mint = Mint::start(0, None).await; let client = MintClient::new(&mint.url).unwrap(); let prepared = client .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) .await .unwrap(); assert!( mint.requests.lock().unwrap().is_empty(), "Preparation must not consume inputs" ); assert!(client .restore_prepared_swap(&prepared) .await .unwrap() .is_none()); let stored = serde_json::to_vec(&prepared).unwrap(); mint.lose_swap_reply .store(true, std::sync::atomic::Ordering::SeqCst); assert!(client.execute_prepared_swap(&prepared).await.is_err()); let reconstructed: crate::wallet::mint_client::PreparedSwap = serde_json::from_slice(&stored).unwrap(); let restarted = MintClient::new(&mint.url).unwrap(); let restored = restarted .restore_prepared_swap(&reconstructed) .await .unwrap() .unwrap(); assert_eq!(restored.new_proofs.iter().map(|p| p.amount).sum::(), 8); for proof in &restored.new_proofs { assert_eq!( proof.c, signed_point(bdhke::hash_to_curve(proof.secret.as_bytes()).unwrap()) ); } assert_eq!(mint.requests.lock().unwrap().len(), 1); assert_eq!( serde_json::to_value(&reconstructed).unwrap(), serde_json::from_slice::(&stored).unwrap() ); assert!(!format!("{:?}", reconstructed).contains(&restored.new_proofs[0].secret)); } #[tokio::test] async fn proof_state_checks_reject_foreign_duplicate_missing_and_unknown_states() { let mint = Mint::start(0, None).await; let client = MintClient::new(&mint.url).unwrap(); let proofs = vec![proof(ACTIVE, 4), proof(ACTIVE, 8)]; let states = client.check_state(&proofs).await.unwrap(); assert_eq!(states.len(), 2); let valid: Vec<_> = states .iter() .map(|state| json!({"Y":state.y,"state":state.state})) .collect(); let mut duplicate = valid.clone(); duplicate[1] = duplicate[0].clone(); let mut foreign = valid.clone(); foreign[0]["Y"] = json!("00".repeat(33)); let mut unknown = valid.clone(); unknown[0]["state"] = json!("UNKNOWN"); let mut reversed = valid.clone(); reversed.reverse(); for response in [ json!({}), json!({"states":[valid[0].clone()]}), json!({"states":duplicate}), json!({"states":foreign}), json!({"states":unknown}), json!({"states":reversed}), ] { *mint.state_reply.lock().unwrap() = Some(response); assert!(client.check_state(&proofs).await.is_err()); } let mut mixed = valid; mixed[0]["Y"] = json!(states[0].y.to_uppercase()); mixed[0]["state"] = json!("PENDING"); mixed[1]["state"] = json!("SPENT"); *mint.state_reply.lock().unwrap() = Some(json!({"states":mixed})); let result = client.check_state(&proofs).await.unwrap(); assert_eq!(result[0].state, "PENDING"); assert_eq!(result[1].state, "SPENT"); assert!(client .check_state(&[proofs[0].clone(), proofs[0].clone()]) .await .is_err()); assert!(client.check_state(&[]).await.unwrap().is_empty()); } #[tokio::test] async fn journal_recovers_lost_swap_reply_and_commits_change_once() { use crate::wallet::{ mutation, send_journal::{Binding, Journal, Outcome, Request as SendRequest}, }; let mint = Mint::start(0, None).await; let root = tempfile::tempdir().unwrap(); let client = MintClient::new(&mint.url).unwrap(); let input = proof(ACTIVE, 8); 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 held = mutation::guard(root.path()).await.unwrap(); let journal = Journal::new(&held); let mut wallet = WalletState::default(); wallet.mint_url = mint.url.clone(); wallet.add_proofs(&mint.url, vec![input.clone()]); save_wallet(root.path(), &wallet).await.unwrap(); let prepared = client .prepare_swap_at_least(&[input], &[4, 4], 4) .await .unwrap(); let mut impossible = binding.clone(); impossible.id = uuid::Uuid::new_v4().to_string(); impossible.amount_sats = 16; assert!(journal .prepare(impossible, SendRequest::Swap(prepared.clone())) .await .is_err()); journal .prepare(binding.clone(), SendRequest::Swap(prepared.clone())) .await .unwrap(); journal.reserve_wallet(&binding).await.unwrap(); mint.lose_swap_reply .store(true, std::sync::atomic::Ordering::SeqCst); assert!(client.execute_prepared_swap(&prepared).await.is_err()); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); } let held = mutation::guard(root.path()).await.unwrap(); let journal = Journal::new(&held); let record = journal.load(&binding.id).await.unwrap().unwrap(); let SendRequest::Swap(prepared) = record.request else { panic!("Lost prepared swap") }; let mut restored = MintClient::new(&mint.url) .unwrap() .restore_prepared_swap(&prepared) .await .unwrap() .unwrap() .new_proofs; let send = restored.remove(0); let token = CashuToken::new(&mint.url, vec![send.clone()]) .serialize() .unwrap(); journal .record_result( &binding, Outcome { token: token.clone(), change: restored, }, ) .await .unwrap(); assert_eq!(journal.commit_wallet(&binding).await.unwrap(), token); assert_eq!(journal.commit_wallet(&binding).await.unwrap(), token); let wallet = load_wallet(root.path()).await.unwrap(); assert_eq!(wallet.balance(), 4); assert_eq!(wallet.transactions.len(), 1); assert_eq!(wallet.transactions[0].id, binding.id); assert_eq!(wallet.proofs.len(), 3); assert!( wallet .proofs .iter() .find(|stored| stored.proof.secret == send.secret) .unwrap() .spent ); assert!(wallet .proofs .iter() .all(|stored| !stored.reserved && stored.reserved_by.is_none())); assert_eq!( mint.requests.lock().unwrap().len(), 1, "Recovery must not send another swap" ); } #[tokio::test] async fn recoverable_send_rejects_broken_recovery_before_reserving_or_spending() { 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(); *mint.restore_reply.lock().unwrap() = Some(json!({})); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); let error = send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context, ) .await .unwrap_err(); assert!(error.to_string().contains("recovery support")); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); assert!(mint.requests.lock().unwrap().is_empty()); // Once the mint responds correctly the same unspent operation can proceed. *mint.restore_reply.lock().unwrap() = None; assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context, ) .await .is_ok()); assert_eq!(mint.requests.lock().unwrap().len(), 1); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4); } #[tokio::test] async fn recoverable_send_reuses_original_operation_after_ambiguous_mint_response() { 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 id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); mint.lose_swap_reply .store(true, std::sync::atomic::Ordering::SeqCst); assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context ) .await .is_err()); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); assert_eq!(mint.requests.lock().unwrap().len(), 1); // A missing restore response is not permission to swap spent inputs again. *mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[],"signatures":[]})); assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context ) .await .is_err()); assert_eq!(mint.requests.lock().unwrap().len(), 1); *mint.restore_reply.lock().unwrap() = None; let token = send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context, ) .await .unwrap(); assert_eq!(CashuToken::deserialize(&token).unwrap().total_amount(), 4); let purse = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); assert_eq!( send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context ) .await .unwrap(), token ); assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 8, &context ) .await .is_err()); assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &"cd".repeat(32) ) .await .is_err()); assert_eq!( std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), purse ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4); assert_eq!( load_wallet(root.path()).await.unwrap().transactions.len(), 1 ); assert_eq!(mint.requests.lock().unwrap().len(), 1); } #[tokio::test] async fn recoverable_exact_send_supports_compact_v2_and_keeps_full_wallet_id() { 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(V2, 8)]); save_wallet(root.path(), &wallet).await.unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); let token = send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context) .await .unwrap(); assert_eq!( CashuToken::deserialize(&token).unwrap().token[0].proofs[0].id, &V2[..16] ); assert_eq!( send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context) .await .unwrap(), token ); let wallet = load_wallet(root.path()).await.unwrap(); assert_eq!(wallet.balance(), 0); assert_eq!(wallet.proofs[0].proof.id, V2); assert_eq!(wallet.transactions.len(), 1); } #[tokio::test] async fn seed_restore_refuses_to_credit_when_counter_persistence_fails() { let mint = Mint::start(0, None).await; let root = tempfile::tempdir().unwrap(); crate::wallet::nut13::establish_independent(root.path()) .await .unwrap(); let client = MintClient::new(&mint.url).unwrap().with_recovery( crate::wallet::nut13::RecoverySource::load(root.path()) .await .unwrap(), ); let prepared = client .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) .await .unwrap(); client.execute_prepared_swap(&prepared).await.unwrap(); let counter = root.path().join("wallet/cashu_counters.json"); std::fs::rename(&counter, root.path().join("counter-fixture-backup")).unwrap(); std::fs::create_dir(&counter).unwrap(); let error = restore_from_seed(root.path(), &mint.url).await.unwrap_err(); assert!(error.to_string().contains("derivation position")); assert!(counter.is_dir()); assert!(!root.path().join("wallet/ecash.json").exists()); std::fs::remove_dir(&counter).unwrap(); std::fs::rename(root.path().join("counter-fixture-backup"), &counter).unwrap(); assert_eq!( restore_from_seed(root.path(), &mint.url) .await .unwrap() .recovered_sats, 8 ); assert_eq!( restore_from_seed(root.path(), &mint.url) .await .unwrap() .recovered_sats, 0 ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); assert_eq!(mint.requests.lock().unwrap().len(), 1); } #[tokio::test] async fn seed_restore_matches_points_and_rejects_foreign_or_duplicated_metadata() { let mint = Mint::start(0, None).await; let client = MintClient::new(&mint.url).unwrap(); let prepared = client .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) .await .unwrap(); client.execute_prepared_swap(&prepared).await.unwrap(); let encoded = serde_json::to_value(&prepared).unwrap(); let outputs: Vec = serde_json::from_value(encoded["outputs"].clone()).unwrap(); let restored = client.restore(&outputs).await.unwrap(); assert_eq!(restored.len(), 2); assert!(restored .iter() .all(|(point, _)| outputs.iter().any(|output| &output.b_prime == point))); let output = encoded["outputs"][0].clone(); let signature = json!({"id":ACTIVE,"amount":4,"C_":signed_point(output["B_"].as_str().unwrap().parse().unwrap())}); // A seed scan legitimately receives only the outputs the mint signed. *mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[output.clone()],"signatures":[signature.clone()]})); assert_eq!(client.restore(&outputs).await.unwrap().len(), 1); let mut foreign = output.clone(); foreign["B_"] = json!(PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()).to_string()); let mut wrong_keyset = signature.clone(); wrong_keyset["id"] = json!(V2); let mut wrong_amount = signature.clone(); wrong_amount["amount"] = json!(8); for response in [ json!({}), json!({"outputs":[]}), json!({"signatures":[]}), json!({"outputs":null,"signatures":[]}), json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}), json!({"outputs":[foreign],"signatures":[signature.clone()]}), json!({"outputs":[output.clone()],"signatures":[wrong_keyset]}), json!({"outputs":[output],"signatures":[wrong_amount]}), ] { *mint.restore_reply.lock().unwrap() = Some(response); assert!(client.restore(&outputs).await.is_err()); } } #[tokio::test] async fn damaged_or_wrong_mint_preparation_fails_before_spending() { let mint = Mint::start(0, None).await; let client = MintClient::new(&mint.url).unwrap(); let prepared = client .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) .await .unwrap(); for field in ["mint_url", "outputs", "blinding", "duplicate"] { let mut data = serde_json::to_value(&prepared).unwrap(); match field { "mint_url" => data["mint_url"] = json!("https://different.invalid"), "outputs" => data["outputs"][0]["amount"] = json!(2), "duplicate" => { data["outputs"][1] = data["outputs"][0].clone(); data["blinding"][1] = data["blinding"][0].clone(); } _ => data["blinding"][0]["secret"] = json!([1, 2, 3]), } let corrupted = serde_json::from_value(data).unwrap(); assert!(client.execute_prepared_swap(&corrupted).await.is_err()); } assert!(mint.requests.lock().unwrap().is_empty()); } #[tokio::test] async fn incomplete_duplicate_or_unknown_restoration_never_completes_a_swap() { let mint = Mint::start(0, None).await; let client = MintClient::new(&mint.url).unwrap(); let prepared = client .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) .await .unwrap(); let data = serde_json::to_value(&prepared).unwrap(); let output = data["outputs"][0].clone(); let signature = json!({"amount":4,"id":ACTIVE, "C_":signed_point(output["B_"].as_str().unwrap().parse().unwrap())}); let mut unknown = output.clone(); unknown["B_"] = json!(PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()).to_string()); let second = data["outputs"][1].clone(); let second_signature = json!({"amount":4,"id":ACTIVE, "C_":signed_point(second["B_"].as_str().unwrap().parse().unwrap())}); let mut wrong_amount = signature.clone(); wrong_amount["amount"] = json!(2); let mut wrong_keyset = signature.clone(); wrong_keyset["id"] = json!(V2); for reply in [ json!({"outputs":[output.clone(),second.clone()],"signatures":[wrong_amount,second_signature.clone()]}), json!({"outputs":[output.clone(),second],"signatures":[wrong_keyset,second_signature]}), json!({"outputs":[output.clone()],"signatures":[signature.clone()]}), json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}), json!({"outputs":[unknown],"signatures":[signature.clone()]}), json!({"outputs":[output],"signatures":[]}), ] { *mint.restore_reply.lock().unwrap() = Some(reply); assert!(client.restore_prepared_swap(&prepared).await.is_err()); } assert!(mint.requests.lock().unwrap().is_empty()); } #[tokio::test] async fn recoverable_receive_lost_reply_claims_inputs_and_recovers_once() { let mint = Mint::start(0, None).await; let root = mint.wallet().await; let incoming = CashuToken::new(&mint.url, vec![proof(V2, 8), proof(ACTIVE, 4)]); let token = incoming.serialize_v4().unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); mint.lose_swap_reply .store(true, std::sync::atomic::Ordering::SeqCst); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 12, &context ) .await .is_err()); assert_eq!(mint.requests.lock().unwrap().len(), 1); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); assert!(restore_from_seed(root.path(), &mint.url) .await .unwrap_err() .to_string() .contains("pending receipts")); for duplicate in [ token.clone(), incoming.serialize().unwrap(), CashuToken::new(&mint.url, vec![proof(ACTIVE, 4), proof(ACTIVE, 2)]) .serialize() .unwrap(), ] { let other = uuid::Uuid::new_v4().to_string(); assert!(receive_token_recoverable( root.path(), &other, EcashNetwork::Mainnet, &mint.url, &duplicate, 1, &context ) .await .unwrap_err() .to_string() .contains("another settlement")); } assert!(receive_token(root.path(), &token) .await .unwrap_err() .to_string() .contains("another settlement")); *mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[],"signatures":[]})); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 12, &context ) .await .unwrap_err() .to_string() .contains("pending at the mint")); assert_eq!(mint.requests.lock().unwrap().len(), 1); *mint.restore_reply.lock().unwrap() = None; assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 12, &context ) .await .unwrap(), 12 ); let wallet = load_wallet(root.path()).await.unwrap(); assert_eq!(wallet.balance(), 12); assert_eq!(wallet.transactions.len(), 1); assert_eq!(wallet.transactions[0].id, format!("received:{id}")); assert_eq!(wallet.receive_commits.len(), 1); for output in &wallet.proofs { assert_eq!( output.proof.c, signed_point(bdhke::hash_to_curve(output.proof.secret.as_bytes()).unwrap()) ); assert!(!incoming.token[0] .proofs .iter() .any(|input| input.secret == output.proof.secret)); } let before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 12, &context ) .await .unwrap(), 12 ); assert_eq!( std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), before ); assert_eq!(mint.requests.lock().unwrap().len(), 1); for (network, price, terms) in [ (EcashNetwork::Testnet, 12, context.clone()), (EcashNetwork::Mainnet, 11, context.clone()), (EcashNetwork::Mainnet, 12, "cd".repeat(32)), ] { assert!(receive_token_recoverable( root.path(), &id, network, &mint.url, &token, price, &terms ) .await .is_err()); } // Completed receipts remain valid without re-crediting a pruned wallet. std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap(); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 12, &context ) .await .unwrap(), 12 ); assert!(!root.path().join("wallet/ecash.json").exists()); assert!(receive_token_recoverable( root.path(), &uuid::Uuid::new_v4().to_string(), EcashNetwork::Mainnet, &mint.url, &incoming.serialize().unwrap(), 12, &context ) .await .is_err()); } #[tokio::test] async fn recoverable_receive_recovery_support_fees_and_terms_fail_before_spend() { let mint = Mint::start(1000, None).await; let root = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]) .serialize() .unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); *mint.restore_reply.lock().unwrap() = Some(json!({})); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 7, &context ) .await .unwrap_err() .to_string() .contains("recovery support")); assert!(mint.requests.lock().unwrap().is_empty()); assert!(!root.path().join("wallet/receive-operations").exists()); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); let mut foreign = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]); foreign.unit = Some("usd".into()); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &foreign.serialize().unwrap(), 7, &context ) .await .is_err()); foreign.unit = Some("sat".into()); foreign.token.push(foreign.token[0].clone()); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &foreign.serialize().unwrap(), 7, &context ) .await .is_err()); *mint.restore_reply.lock().unwrap() = None; assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &format!("{}/", mint.url), &token, 7, &context ) .await .unwrap(), 7 ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 7); assert_eq!(mint.requests.lock().unwrap().len(), 1); } fn rewrite_receive_phase(root: &std::path::Path, id: &str, phase: Value) { use sha2::{Digest, Sha256}; let path = root.join(format!("wallet/receive-operations/{id}.json")); let mut envelope: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); let mut record: Value = serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap(); record["phase"] = phase; let payload = serde_json::to_string(&record).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(); } #[tokio::test] async fn recoverable_receive_commit_boundaries_survive_history_pruning_without_recredit() { use sha2::{Digest, Sha256}; let mint = Mint::start(0, None).await; let root = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]) .serialize() .unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap(), 8 ); let mut wallet = load_wallet(root.path()).await.unwrap(); let proofs: Vec<_> = wallet.proofs.iter().map(|p| p.proof.clone()).collect(); let committing = json!({"Committing":{"proofs":proofs,"before":hex::encode(Sha256::digest(b"missing"))}}); let journal_path = root .path() .join(format!("wallet/receive-operations/{id}.json")); let committed_record = std::fs::read(&journal_path).unwrap(); // Crash after purse save but before phase save; unrelated history pruning // preserves the durable marker and must not restore already-spent outputs. rewrite_receive_phase(root.path(), &id, committing.clone()); wallet.transactions.clear(); wallet.proofs.clear(); save_wallet(root.path(), &wallet).await.unwrap(); let purse = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap(), 8 ); assert_eq!( std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), purse ); // Old writers dropping the marker cause a recovery hold, never a guessed credit. rewrite_receive_phase(root.path(), &id, committing.clone()); wallet.receive_commits.clear(); save_wallet(root.path(), &wallet).await.unwrap(); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap_err() .to_string() .contains("manual recovery")); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); // Crash before the purse save: exact absent pre-image authorizes first commit. std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap(); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap(), 8 ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); assert_eq!(mint.requests.lock().unwrap().len(), 1); // Completed receipt survives a missing purse without rebuilding its proofs. std::fs::write(journal_path, committed_record).unwrap(); std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap(); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap(), 8 ); assert!(!root.path().join("wallet/ecash.json").exists()); } #[tokio::test] async fn recoverable_receive_corrupt_claims_and_write_failures_preserve_funds() { let mint = Mint::start(0, None).await; let root = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]) .serialize() .unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); // A directory at the target blocks the private journal replacement before POST. let path = root .path() .join(format!("wallet/receive-operations/{id}.json")); std::fs::create_dir_all(&path).unwrap(); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); assert!(mint.requests.lock().unwrap().is_empty()); std::fs::remove_dir(&path).unwrap(); mint.lose_swap_reply .store(true, std::sync::atomic::Ordering::SeqCst); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); let saved = std::fs::read(&path).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600 ); assert_eq!( std::fs::metadata(path.parent().unwrap()) .unwrap() .permissions() .mode() & 0o777, 0o700 ); } std::fs::write(&path, b"damaged").unwrap(); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); assert!(receive_token_recoverable( root.path(), &uuid::Uuid::new_v4().to_string(), EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); assert!(receive_token(root.path(), &token).await.is_err()); assert_eq!(mint.requests.lock().unwrap().len(), 1); std::fs::write(&path, saved).unwrap(); assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap(), 8 ); } #[tokio::test] async fn recoverable_receive_unspent_retry_reuses_exact_request_and_waits_for_verified_state() { let mint = Mint::start(0, Some(503)).await; let root = mint.wallet().await; let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]) .serialize() .unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); assert_eq!(mint.requests.lock().unwrap().len(), 1); // Legacy paid-content redemption must respect claims even while mint inputs // remain unspent; otherwise it could steal the pending operation's proofs. assert!(verify_and_receive_payment(root.path(), &token, 8) .await .unwrap_err() .to_string() .contains("another settlement")); assert_eq!(mint.requests.lock().unwrap().len(), 1); let original = mint.requests.lock().unwrap()[0].clone(); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); // An empty state response must not authorize a second POST. *mint.state_reply.lock().unwrap() = Some(json!({"states":[]})); mint.failure.store(0, std::sync::atomic::Ordering::SeqCst); assert!(receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .is_err()); assert_eq!(mint.requests.lock().unwrap().len(), 1); *mint.state_reply.lock().unwrap() = None; assert_eq!( receive_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, &token, 8, &context ) .await .unwrap(), 8 ); let requests = mint.requests.lock().unwrap(); assert_eq!(requests.len(), 2); assert_eq!(requests[1], original); drop(requests); let wallet = load_wallet(root.path()).await.unwrap(); assert_eq!(wallet.balance(), 8); assert_eq!(wallet.transactions.len(), 1); assert_eq!(wallet.receive_commits.len(), 1); } #[tokio::test] async fn purchase_deadline_rejects_fresh_exact_send_but_recovers_prior_committed_token() { 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 id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); assert!(send_token_recoverable_before( root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context, 1 ) .await .is_err()); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); // Fixture a prior completed send; an expired caller must recover its result, // not require another payment or credit the old proofs back to the purse. let original = send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context) .await .unwrap(); assert_eq!( send_token_recoverable_before( root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context, 1 ) .await .unwrap(), original ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); } #[tokio::test] async fn expired_purchase_recovers_issued_swap_but_never_posts_still_unspent_inputs() { for issued in [false, true] { let mint = Mint::start(0, if issued { None } else { Some(503) }).await; let root = mint.wallet().await; let mut wallet = load_wallet(root.path()).await.unwrap(); wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); save_wallet(root.path(), &wallet).await.unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); mint.lose_swap_reply .store(issued, std::sync::atomic::Ordering::SeqCst); assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context ) .await .is_err()); mint.failure.store(0, std::sync::atomic::Ordering::SeqCst); let result = send_token_recoverable_before( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context, 1, ) .await; if issued { assert_eq!( CashuToken::deserialize(&result.unwrap()) .unwrap() .total_amount(), 4 ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4); } else { assert!(result.unwrap_err().to_string().contains("expired")); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); assert!(load_wallet(root.path()) .await .unwrap() .proofs .iter() .any(|p| p.reserved)); } assert_eq!(mint.requests.lock().unwrap().len(), 1); } } #[tokio::test] async fn purchase_executor_recovers_prior_buyer_spend_and_delayed_accepted_seller_settlement() { use crate::content_purchase::{BuyerPhase, Contract, Journal}; use crate::content_purchase_executor::{prepare_buyer_token, settle_seller_token}; let mint = Mint::start(0, None).await; let buyer = mint.wallet().await; let seller = mint.wallet().await; let contract = Contract { version: 1, id: uuid::Uuid::new_v4().to_string(), buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([1; 32])).unwrap(), seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([2; 32])).unwrap(), content_id: "film-1".into(), content_sha256: "ab".repeat(32), content_size: 1024, terms_sha256: "cd".repeat(32), network: EcashNetwork::Mainnet, mint_url: mint.url.clone(), gross_token_sats: 8, minimum_net_sats: 8, offered_at: 1000, expires_at: 2000, }; let mut wallet = load_wallet(buyer.path()).await.unwrap(); wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); save_wallet(buyer.path(), &wallet).await.unwrap(); // An expired offer without durable seller acceptance cannot redeem a token // or create settlement state, even when the token and buyer are otherwise valid. let unaccepted_token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]) .serialize() .unwrap(); let seller_wallet_before = std::fs::read(seller.path().join("wallet/ecash.json")).ok(); let rejected = settle_seller_token( seller.path(), &contract, &unaccepted_token, &contract.buyer_did, ) .await .err() .unwrap(); assert!(rejected .to_string() .contains("Seller intent is not durable")); assert!(mint.requests.lock().unwrap().is_empty()); assert_eq!( std::fs::read(seller.path().join("wallet/ecash.json")).ok(), seller_wallet_before ); { let journal = Journal::open(seller.path()).await.unwrap(); assert!(journal.seller(&contract.id).await.unwrap().is_none()); } // Deterministically fixture durable acceptance before the historical deadline. let accepted = { let journal = Journal::open(seller.path()).await.unwrap(); journal .prepare_seller(&contract, 1500) .await .unwrap() .acceptance() .unwrap() }; { let journal = Journal::open(buyer.path()).await.unwrap(); journal.prepare_buyer(&contract, 1500).await.unwrap(); assert!(journal .record_acceptance(&contract, &accepted, &contract.buyer_did) .await .is_err()); journal .record_acceptance(&contract, &accepted, &contract.seller_did) .await .unwrap(); } // Fixture a prior wallet commit, interrupted before the buyer journal saved // its token. The actual executor must recover that result after expiry. let original = send_token_recoverable( buyer.path(), &contract.id, contract.network, &mint.url, 8, &contract.context_hash().unwrap(), ) .await .unwrap(); assert_eq!( prepare_buyer_token(buyer.path(), &contract).await.unwrap(), original ); assert!(mint.requests.lock().unwrap().is_empty()); mint.lose_swap_reply .store(true, std::sync::atomic::Ordering::SeqCst); assert!( settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did) .await .is_err() ); assert_eq!(mint.requests.lock().unwrap().len(), 1); let receipt = settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did) .await .unwrap(); assert!( settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did) .await .unwrap() == receipt ); assert!( settle_seller_token(seller.path(), &contract, &original, &contract.seller_did) .await .is_err() ); let altered = CashuToken::new(&mint.url, vec![proof(V2, 8)]) .serialize() .unwrap(); assert!( settle_seller_token(seller.path(), &contract, &altered, &contract.buyer_did) .await .is_err() ); 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); let journal = Journal::open(buyer.path()).await.unwrap(); journal.record_receipt(&contract, &receipt).await.unwrap(); assert_eq!( journal.buyer(&contract.id).await.unwrap().unwrap().phase, BuyerPhase::ReceiptSaved ); } #[tokio::test] async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap() { use std::sync::atomic::{AtomicI64, Ordering}; let mint = Mint::start(0, None).await; let root = mint.wallet().await; let mut wallet = load_wallet(root.path()).await.unwrap(); wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); save_wallet(root.path(), &wallet).await.unwrap(); let wallet_before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); let clock = Arc::new(AtomicI64::new(1000)); *mint.restore_clock.lock().unwrap() = Some((clock.clone(), 2000)); let id = uuid::Uuid::new_v4().to_string(); let error = send_token_recoverable_with_deadline( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &"ab".repeat(32), Some(2000), || clock.load(Ordering::SeqCst), None, ) .await .unwrap_err(); assert_eq!( clock.load(Ordering::SeqCst), 2000, "preflight must have completed" ); assert!(error.to_string().contains("expired")); assert!( mint.requests.lock().unwrap().is_empty(), "no swap POST after expiry" ); assert_eq!( std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), wallet_before ); assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); let held = crate::wallet::mutation::guard(root.path()).await.unwrap(); assert!(crate::wallet::send_journal::Journal::new(&held) .load(&id) .await .unwrap() .is_none()); } // Append to wallet/payment_tests.rs when the next payment-plan batch is applied. #[tokio::test] async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() { use crate::wallet::{mutation, send_journal}; let mint = Mint::start(0, None).await; let root = tempfile::tempdir().unwrap(); let mut wallet = WalletState::default(); wallet.mint_url = mint.url.clone(); let original = proof(ACTIVE, 8); wallet.add_proofs(&mint.url, vec![original.clone()]); save_wallet(root.path(), &wallet).await.unwrap(); let id = uuid::Uuid::new_v4().to_string(); let context = "ab".repeat(32); let prepared = MintClient::new(&mint.url) .unwrap() .prepare_swap_at_least(&[original], &[4, 4], 4) .await .unwrap(); { let guard = mutation::guard(root.path()).await.unwrap(); send_journal::Journal::new(&guard) .prepare( send_journal::Binding { id: id.clone(), network: EcashNetwork::Mainnet, mint_url: mint.url.clone(), amount_sats: 4, context_hash: context.clone(), }, send_journal::Request::Swap(prepared), ) .await .unwrap(); } // Simulate a selected proof becoming unavailable before reservation. Other // sufficient coins exist, but the accepted immutable plan cannot select them. wallet.proofs.clear(); let mut replacement = proof(ACTIVE, 8); replacement.secret = "replacement-not-in-plan".into(); wallet.add_proofs(&mint.url, vec![replacement]); save_wallet(root.path(), &wallet).await.unwrap(); let before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); assert!(send_token_recoverable( root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context ) .await .is_err()); assert_eq!( std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), before ); 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>, } 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 prepare_offer( &self, content_id: &str, expected: Option<&crate::content_purchase_caller::ExpectedRental>, ) -> anyhow::Result> { if let Some(expected) = expected { expected.verify(&self.template)?; } // A registered movie still being hashed, without asking the fake mint. Ok(content_id.starts_with("registered_").then_some((3, 16))) } async fn offer( &self, id: &str, content_id: &str, ) -> anyhow::Result { 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 { 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::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::cancel( &self.seller_root, envelope, &envelope.offer.buyer_did, ) .await } async fn settle( &self, request: &crate::content_purchase_protocol::Settlement, ) -> anyhow::Result { 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(), }; let preparing_id = format!("registered_{}", uuid::Uuid::new_v4()); for _ in 0..3 { assert!(matches!( purchase( buyer.path(), &buyer_did, &preparing_id, None, 8, None, &transport ) .await .unwrap(), ReadyPurchase::Preparing { completed_bytes: 3, total_bytes: 16 } )); } assert!(transport.offers.lock().unwrap().is_empty()); assert!(mint.requests.lock().unwrap().is_empty()); assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8); assert!(crate::content_purchase::Journal::open(buyer.path()) .await .unwrap() .find_buyers(&buyer_did, &transport.template.seller_did, &preparing_id) .await .unwrap() .is_empty()); 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, }; // Preparation checks already-verified signed metadata, without scanning bytes // or allocating a quote. Keep this independent of the fake offer response. let metadata: crate::media_registration::Receipt = serde_json::from_value(json!({ "version":1,"request_id":uuid::Uuid::new_v4().to_string(),"nonce":"fixture", "app_audience":"indeedhub","node_did":expected.seller_did, "producer":"fixture","project_id":"fixture","price_sats":8, "viewing_seconds":60,"expires_at":now+300,"content_id":"registered_film", "sha256":"ab".repeat(32),"size_bytes":"16","payment_methods":["cashu"], "issued_at":now,"signature":"verified by registration boundary" })) .unwrap(); expected .verify_metadata(&expected.seller_did, &metadata) .unwrap(); 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] { assert!(wrong .verify_metadata(&expected.seller_did, &metadata) .is_err()); 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()); }