diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 509a2da5..fa77de01 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -57,6 +57,31 @@ pub struct SwapResult { pub new_proofs: Vec, } +/// Exact private request material to persist before a remote swap. Debug +/// deliberately omits bearer secrets and blinding factors. +#[derive(Clone, Serialize, Deserialize)] +pub struct PreparedSwap { + mint_url: String, + inputs: Vec, + keyset: MintKeyset, + outputs: Vec, + blinding: Vec, +} +#[derive(Clone, Serialize, Deserialize)] +struct PreparedBlinding { + secret: Vec, + factor: [u8; 32], + amount: u64, +} +impl std::fmt::Debug for PreparedSwap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreparedSwap") + .field("input_count", &self.inputs.len()) + .field("output_count", &self.outputs.len()) + .finish_non_exhaustive() + } +} + /// Result of a mint operation. pub struct MintResult { pub proofs: Vec, @@ -537,6 +562,19 @@ impl MintClient { target_amounts: &[u64], minimum: u64, ) -> Result { + let prepared = self + .prepare_swap_at_least(inputs, target_amounts, minimum) + .await?; + self.execute_prepared_swap(&prepared).await + } + + /// Read mint metadata and derive outputs, but do not consume any input. + pub async fn prepare_swap_at_least( + &self, + inputs: &[Proof], + target_amounts: &[u64], + minimum: u64, + ) -> Result { // V4 tokens carry short keyset IDs. Every swap path (including paid // files and streams) must expand these, not only wallet imports. let resolved = self.resolve_truncated_keyset_ids(inputs).await?; @@ -599,13 +637,102 @@ impl MintClient { let (blinded_messages, blinding_data) = self.blinded_outputs(&keyset.id, target_amounts).await?; + let prepared = PreparedSwap { + mint_url: self.url.clone(), + inputs: inputs.to_vec(), + keyset, + outputs: blinded_messages, + blinding: blinding_data + .into_iter() + .map(|(secret, factor, amount)| PreparedBlinding { + secret, + factor: factor.secret_bytes(), + amount, + }) + .collect(), + }; + self.validate_prepared_swap(&prepared)?; + Ok(prepared) + } + + fn validate_prepared_swap(&self, prepared: &PreparedSwap) -> Result<()> { + anyhow::ensure!( + prepared.mint_url == self.url, + "Prepared swap belongs to a different mint" + ); + anyhow::ensure!( + !prepared.inputs.is_empty() + && !prepared.outputs.is_empty() + && prepared.outputs.len() == prepared.blinding.len(), + "Invalid prepared swap structure" + ); + let commitments: std::collections::HashSet<_> = prepared + .outputs + .iter() + .map(|output| output.b_prime.as_str()) + .collect(); + anyhow::ensure!( + commitments.len() == prepared.outputs.len(), + "Prepared swap contains duplicate outputs" + ); + let input_secrets: std::collections::HashSet<_> = prepared + .inputs + .iter() + .map(|proof| proof.secret.as_str()) + .collect(); + anyhow::ensure!( + input_secrets.len() == prepared.inputs.len(), + "Prepared swap contains duplicate inputs" + ); + anyhow::ensure!( + prepared.keyset.unit == "sat", + "Prepared swap is not denominated in sats" + ); + let input_total = prepared + .inputs + .iter() + .try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)) + .context("Prepared input amount overflow")?; + let output_total = prepared + .outputs + .iter() + .try_fold(0u64, |sum, output| sum.checked_add(output.amount)) + .context("Prepared output amount overflow")?; + anyhow::ensure!( + output_total <= input_total, + "Prepared swap output value exceeds its inputs" + ); + for (output, secret) in prepared.outputs.iter().zip(&prepared.blinding) { + anyhow::ensure!( + output.id == prepared.keyset.id + && output.amount == secret.amount + && output.amount.is_power_of_two(), + "Prepared swap output metadata changed" + ); + let factor = secp256k1::SecretKey::from_slice(&secret.factor) + .context("Invalid prepared blinding factor")?; + let blinded = bdhke::blind_message(&secret.secret, &factor)?; + anyhow::ensure!( + output.b_prime == hex::encode(blinded.b_prime.serialize()), + "Prepared swap output commitment changed" + ); + std::str::from_utf8(&secret.secret).context("Invalid prepared secret encoding")?; + prepared.keyset.key_for_amount(output.amount)?; + } + Ok(()) + } + + /// Execute only an already prepared request. Callers implementing recovery + /// must save it before invoking this method, and retain it on any error. + pub async fn execute_prepared_swap(&self, prepared: &PreparedSwap) -> Result { + self.validate_prepared_swap(prepared)?; let url = format!("{}/v1/swap", self.url); let res = self .client .post(&url) .json(&serde_json::json!({ - "inputs": inputs, - "outputs": blinded_messages, + "inputs": prepared.inputs, + "outputs": prepared.outputs, })) .send() .await @@ -625,39 +752,84 @@ impl MintClient { ) .context("Failed to parse swap signatures")?; - if signatures.len() != blinding_data.len() { - anyhow::bail!( - "Swap returned {} signatures, expected {}", - signatures.len(), - blinding_data.len() + self.unblind_prepared_swap(prepared, &signatures) + } + + fn unblind_prepared_swap( + &self, + prepared: &PreparedSwap, + signatures: &[BlindSignature], + ) -> Result { + anyhow::ensure!( + signatures.len() == prepared.blinding.len(), + "Swap returned an incomplete signature set; preserve the prepared operation" + ); + let mut new_proofs = Vec::with_capacity(signatures.len()); + for (sig, secret) in signatures.iter().zip(&prepared.blinding) { + anyhow::ensure!( + sig.amount == secret.amount && sig.id == prepared.keyset.id, + "Mint returned a swap signature for an unexpected amount or keyset" ); - } - - let mut new_proofs = Vec::new(); - for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) { - if sig.amount != *amount || sig.id != keyset.id { - anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset"); - } - let c_prime = sig.c_prime_as_pubkey()?; - let mint_key = keyset.key_for_amount(*amount)?; - let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?; - + let factor = secp256k1::SecretKey::from_slice(&secret.factor)?; + let c = bdhke::unblind_signature( + &sig.c_prime_as_pubkey()?, + &factor, + &prepared.keyset.key_for_amount(secret.amount)?, + )?; new_proofs.push(Proof { - amount: *amount, - id: keyset.id.clone(), - secret: String::from_utf8_lossy(secret).to_string(), + amount: secret.amount, + id: prepared.keyset.id.clone(), + secret: std::str::from_utf8(&secret.secret)?.to_owned(), c: hex::encode(c.serialize()), }); } - - debug!( - "Swapped {} inputs for {} new proofs", - inputs.len(), - new_proofs.len() - ); Ok(SwapResult { new_proofs }) } + /// Recover signatures for the exact persisted outputs after a lost reply. + /// No result is not proof of failure; callers must retain the reservation. + pub async fn restore_prepared_swap( + &self, + prepared: &PreparedSwap, + ) -> Result> { + self.validate_prepared_swap(prepared)?; + let returned = self.restore(&prepared.outputs).await?; + if returned.is_empty() { + return Ok(None); + } + let mut by_output = std::collections::HashMap::new(); + for (commitment, signature) in returned { + // Hex case is not part of the curve-point identity. + let commitment = hex::encode( + commitment + .parse::() + .context("Mint restored an invalid output commitment")? + .serialize(), + ); + anyhow::ensure!( + prepared + .outputs + .iter() + .any(|output| output.b_prime == commitment), + "Mint restored an unknown output" + ); + anyhow::ensure!( + by_output.insert(commitment, signature).is_none(), + "Mint restored duplicate outputs" + ); + } + let ordered: Vec<_> = prepared + .outputs + .iter() + .map(|output| { + by_output + .remove(&output.b_prime) + .context("Mint restored only part of the prepared swap") + }) + .collect::>()?; + Ok(Some(self.unblind_prepared_swap(prepared, &ordered)?)) + } + // ── Check state (NUT-07) ── /// Check whether proofs are spent, unspent, or pending. diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index 95e40500..e0b5c21e 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -20,6 +20,8 @@ struct Mint { requests: Arc>>, task: tokio::task::JoinHandle<()>, failure: Arc, + lose_swap_reply: Arc, + restore_reply: Arc>>, } impl Drop for Mint { fn drop(&mut self) { @@ -55,15 +57,26 @@ impl Mint { 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 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(); 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(); async move { let mut status = 200; let body = match req.uri().path() { @@ -119,10 +132,46 @@ impl Mint { .unwrap() .insert(p["secret"].as_str().unwrap().into()); } - json!({"signatures":outputs.iter().map(|o| json!({ - "amount":o["amount"],"id":ACTIVE, - "C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap()) - })).collect::>()}) + 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/restore" => { + 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}) } } _ => { @@ -150,6 +199,8 @@ impl Mint { requests, task, failure, + lose_swap_reply, + restore_reply, } } async fn wallet(&self) -> tempfile::TempDir { @@ -460,3 +511,108 @@ async fn simultaneous_receipts_and_revenue_writes_preserve_every_payment() { 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 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()); +} diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 0852e1bb..3ad9f21a 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -170,3 +170,26 @@ prepared outputs, quote/change handling and seller receipts, interrupted-operati recovery and complete timed-playback integration. Higher-level Minibits claim and purchase flows must pin their network/terms across their entire business operation; serializing individual wallet calls alone does not provide that contract. + +### Recoverable prepared Cashu swaps + +MintClient now separates preparation from execution. Prepared requests contain +the exact outputs and private unblinding material, can survive serialization, and +validate their mint, commitments, values and keyset before execution. Debug output +omits bearer secrets. Recovery uses the original outputs, matches returned curve +points independently of response ordering/hex case, and rejects partial, +duplicate, unknown or mismatched signatures. An empty restore response remains +uncertain; it is never interpreted as permission to spend again. + +Real HTTP/curve fixtures simulate a mint consuming inputs and returning500instead +of its successful response. A reconstructed client recovers the original valid +proofs without a second swap. Negative tests cover changed preparation and +malformed recovery. The first run failed an overly strict test comparing JSON +bytes with unordered map keys; the corrected test compares semantic contents. +Original log: /tmp/archy-prepared-swap-tests.log. Final complete isolated backend +suite: **1,767 pass, zero failures, five existing skips**, in +/tmp/archy-prepared-swap-final-full-tests.log. + +This introduces the request/recovery primitive. Existing swap callers still +execute immediately; durable wallet reservations and the correlated purchase +journal are not wired yet. No live money, wallet state or app deployment changed.