From d737edb26fbbad167a258e6519b7de4a73ab19e2 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 23:12:44 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(cashu):=20C4=20=E2=80=94=20escrow=20pr?= =?UTF-8?q?imitives=20(2-of-3=20NUT-11=20lock,=20sign,=20redeem,=20reclaim?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C4 of docs/cashu/README.md, the cryptographic heart of Cashu-mode trading. No UI and no bridge surface, so review can be about correctness alone. Stacked on C2 (#235) — the escrow methods hang off the wallet from that phase. `rust/src/cashu/escrow.rs`: - `xonly_to_cashu_pubkey` — the `02`-prefix mapping the daemon applies in `cashu_pubkey_from_xonly_hex`. Rejects anything that is not exactly 64 hex chars instead of padding or truncating: a silently reinterpreted key locks funds to nobody, and nothing downstream would notice. - `escrow_conditions` — the 2-of-3 of §2: data = P_S, pubkeys = [P_B, P_M], n_sigs = 2, SIG_INPUTS, locktime, refund = [P_S], n_sigs_refund = 1. - `fee_conditions` — 1-of-1 to Mostro. The fee is a payment, not an escrow; conditions would make it unspendable for the node. - `verify_conditions` / `verify_escrow_token` — the client-side mirror of the daemon's composite check, run *per proof*: a token whose first proof is correct and whose second is locked to the builder alone would pass a spot check and walk away with the difference. - `sign_proofs`, `combine_and_redeem`, `reclaim_after_locktime` — one signature per proof keyed by that proof's secret, matching the wire form C0 pinned. A missing peer signature fails before the mint is contacted. The spike (docs/cashu/cdk-spike.md) established that cdk exposes NUT-11 with custom tags in full, so none of this hand-builds secrets or witness encodings. Tests - Unit: the `02` mapping against a fresh key; five malformed-key shapes rejected; the built condition checked field by field against §2 (a wrong default cannot hide behind a passing round trip); a past locktime refused at construction; and verification refused for every way an escrow can be wrong — wrong seller, n_sigs=1, an extra refund key, a bare P2PK, a missing counterparty, a locktime shorter than required. - Integration (`#[ignore]`, MOSTRO_TEST_MINT_URL): full lock → verify → sign → combine → redeem against a real mint; one signature alone cannot move an escrow; an impostor's signature does not settle one; a short-changed escrow fails verification. --- rust/src/cashu/escrow.rs | 857 +++++++++++++++++++++++++++++++++++++++ rust/src/cashu/mod.rs | 2 + rust/src/cashu/wallet.rs | 24 +- 3 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 rust/src/cashu/escrow.rs diff --git a/rust/src/cashu/escrow.rs b/rust/src/cashu/escrow.rs new file mode 100644 index 00000000..65779bf0 --- /dev/null +++ b/rust/src/cashu/escrow.rs @@ -0,0 +1,857 @@ +//! Escrow primitives — phase C4 of `docs/cashu/README.md`. +//! +//! The cryptographic heart of Cashu-mode trading, deliberately UI-free so a +//! review can be about correctness and nothing else. +//! +//! The escrow is a NUT-11 P2PK secret locked 2-of-3 (§2 of the doc): +//! +//! ```text +//! data = P_S (seller) +//! pubkeys = [P_B, P_M] (buyer, Mostro) +//! n_sigs = 2 (any two of the three) +//! sigflag = SIG_INPUTS +//! locktime = now + escrow_locktime_days +//! refund = [P_S] (seller alone, after locktime) +//! n_sigs_refund = 1 +//! ``` +//! +//! Two things this module exists to get right, both of which fail silently if +//! wrong: +//! +//! - **Key encoding.** Nostr trade keys are x-only (32 bytes); Cashu P2PK wants +//! compressed SEC1 (33 bytes). The daemon prefixes `02` +//! (`cashu_pubkey_from_xonly_hex`) and the client must do the identical +//! thing, or the token locks to a key nobody holds. +//! - **Per-order keys.** Every party is identified by the *trade* key for that +//! order, never the identity key. That is a privacy requirement of the +//! upstream spec, not a preference. + +use anyhow::{anyhow, bail, Result}; +use cdk::amount::SplitTarget; +use cdk::nuts::nut10::{Conditions, SpendingConditions}; +use cdk::nuts::nut11::SigFlag; +use cdk::nuts::{Proof, PublicKey, SecretKey, Token, Witness}; +use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions}; +use cdk::Amount; + +use super::CashuWallet; + +/// The three keys an escrow is locked to, already in Cashu (compressed) form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowParties { + /// `P_B` — buyer's per-order trade key. + pub buyer: PublicKey, + /// `P_S` — seller's per-order trade key. Also the refund key. + pub seller: PublicKey, + /// `P_M` — the Mostro node's key. + pub mostro: PublicKey, +} + +impl EscrowParties { + /// Build from the x-only hex keys the protocol carries. + pub fn from_xonly_hex(buyer: &str, seller: &str, mostro: &str) -> Result { + Ok(Self { + buyer: xonly_to_cashu_pubkey(buyer)?, + seller: xonly_to_cashu_pubkey(seller)?, + mostro: xonly_to_cashu_pubkey(mostro)?, + }) + } +} + +/// One signature over one proof, keyed by that proof's own secret so order does +/// not matter on the wire. +/// +/// Mirrors `mostro_core`'s `CashuProofSignature` without depending on it — this +/// module is about cryptography; the wire mapping belongs to C5. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProofSignature { + /// The proof's secret, verbatim, as it appears in the token. + pub secret: String, + /// BIP-340 signature over that secret, hex. + pub signature: String, +} + +/// Map a Nostr x-only public key (32 bytes, hex) to a Cashu compressed key. +/// +/// The `02` prefix is not a choice: it is what the daemon does, so any other +/// parity yields a key the counterparty cannot sign for. Anything that is not +/// exactly 64 hex characters is rejected rather than padded or truncated — a +/// silently reinterpreted key would lock funds to nobody. +pub fn xonly_to_cashu_pubkey(xonly_hex: &str) -> Result { + let hex = xonly_hex.trim(); + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("InvalidTradeKey: expected 64 hex characters, got {:?}", hex); + } + PublicKey::from_hex(format!("02{hex}")) + .map_err(|e| anyhow!("InvalidTradeKey: {hex} is not a valid point ({e})")) +} + +/// The 2-of-3 escrow condition of §2. +/// +/// `locktime` is an absolute unix timestamp; `cdk` rejects one in the past, so +/// a caller that miscomputes it fails here rather than minting an escrow that +/// is already refundable. +pub fn escrow_conditions(parties: &EscrowParties, locktime: u64) -> Result { + let conditions = Conditions::new( + Some(locktime), + // `data` carries the seller, so the extra pubkeys are the other two. + Some(vec![parties.buyer, parties.mostro]), + // After locktime the seller alone can reclaim. + Some(vec![parties.seller]), + Some(2), + Some(SigFlag::SigInputs), + Some(1), + ) + .map_err(|e| anyhow!("InvalidEscrowConditions: {e}"))?; + + Ok(SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: Some(conditions), + }) +} + +/// The fee token's condition: 1-of-1 to Mostro, no locktime. +/// +/// The fee is not escrowed — it is a straight payment the node redeems when the +/// trade settles, so it carries none of the escrow's conditions. +pub fn fee_conditions(mostro: PublicKey) -> SpendingConditions { + SpendingConditions::P2PKConditions { + data: mostro, + conditions: None, + } +} + +/// Check a parsed condition against what this trade requires. +/// +/// Defense in depth: the daemon runs the same check, and a client that submits +/// a token failing it only finds out through a `CantDo` rejection, with the +/// funds already locked at the mint. +pub fn verify_conditions( + conditions: &SpendingConditions, + parties: &EscrowParties, + min_locktime: u64, +) -> Result<()> { + let SpendingConditions::P2PKConditions { data, conditions } = conditions else { + bail!("InvalidEscrowToken: not a P2PK secret"); + }; + + if *data != parties.seller { + bail!("InvalidEscrowToken: locked to the wrong seller key"); + } + + let c = conditions + .as_ref() + .ok_or_else(|| anyhow!("InvalidEscrowToken: no spending conditions"))?; + + let pubkeys = c + .pubkeys + .as_ref() + .ok_or_else(|| anyhow!("InvalidEscrowToken: no additional pubkeys"))?; + if !pubkeys.contains(&parties.buyer) || !pubkeys.contains(&parties.mostro) { + bail!("InvalidEscrowToken: buyer or Mostro key missing"); + } + + if c.num_sigs != Some(2) { + bail!("InvalidEscrowToken: n_sigs must be 2, got {:?}", c.num_sigs); + } + if c.sig_flag != SigFlag::SigInputs { + bail!("InvalidEscrowToken: sigflag must be SIG_INPUTS"); + } + + match c.locktime { + // Too short a locktime is the dangerous direction: the seller could + // reclaim before the buyer has had time to pay. + Some(l) if l >= min_locktime => {} + Some(l) => bail!("InvalidEscrowToken: locktime {l} is before {min_locktime}"), + None => bail!("InvalidEscrowToken: no locktime"), + } + + match c.refund_keys.as_ref() { + Some(keys) if keys == &vec![parties.seller] => {} + _ => bail!("InvalidEscrowToken: refund key must be the seller alone"), + } + if c.num_sigs_refund.unwrap_or(1) != 1 { + bail!("InvalidEscrowToken: n_sigs_refund must be 1"); + } + + Ok(()) +} + +impl CashuWallet { + /// Swap `amount_sats` of wallet proofs into an escrow token. + /// + /// The proofs leave the spendable balance the moment this returns: they are + /// locked to a condition this wallet alone cannot satisfy. + pub async fn build_escrow_token( + &self, + amount_sats: u64, + parties: &EscrowParties, + locktime: u64, + ) -> Result { + self.build_locked_token(amount_sats, escrow_conditions(parties, locktime)?) + .await + } + + /// Swap `amount_sats` into a token payable to Mostro alone. + pub async fn build_fee_token(&self, amount_sats: u64, mostro: PublicKey) -> Result { + self.build_locked_token(amount_sats, fee_conditions(mostro)) + .await + } + + async fn build_locked_token( + &self, + amount_sats: u64, + conditions: SpendingConditions, + ) -> Result { + if amount_sats == 0 { + bail!("CashuAmountZero"); + } + + let prepared = self + .inner() + .prepare_send( + Amount::from(amount_sats), + SendOptions { + conditions: Some(conditions), + amount_split_target: SplitTarget::default(), + ..Default::default() + }, + ) + .await + .map_err(|e| anyhow!("CashuLockFailed: {e}"))?; + + let token = prepared + .confirm(None::) + .await + .map_err(|e| anyhow!("CashuLockFailed: {e}"))?; + + Ok(token.to_string()) + } + + /// Verify an escrow token someone else built: right mint, right amount, and + /// the right 2-of-3 condition on **every** proof. + /// + /// Per-proof rather than per-token on purpose — a token whose first proof is + /// correct and whose second is locked to the builder alone would pass a spot + /// check and walk away with the difference. + pub async fn verify_escrow_token( + &self, + encoded: &str, + parties: &EscrowParties, + expected_amount: u64, + min_locktime: u64, + ) -> Result<()> { + let token: Token = encoded + .trim() + .parse() + .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + + let token_mint = token + .mint_url() + .map_err(|e| anyhow!("InvalidEscrowToken: no mint ({e})"))? + .to_string(); + if token_mint.trim_end_matches('/') != self.mint_url().trim_end_matches('/') { + bail!("InvalidEscrowToken: wrong mint ({token_mint})"); + } + + let value = u64::from( + token + .value() + .map_err(|e| anyhow!("InvalidEscrowToken: unreadable amount ({e})"))?, + ); + if value != expected_amount { + bail!("InvalidEscrowToken: expected {expected_amount} sat, got {value}"); + } + + for proof in self.proofs_of(&token).await? { + let conditions: SpendingConditions = (&proof.secret) + .try_into() + .map_err(|e| anyhow!("InvalidEscrowToken: unreadable secret ({e})"))?; + verify_conditions(&conditions, parties, min_locktime)?; + } + + Ok(()) + } + + /// Sign every proof in `encoded` with `key`, returning one signature per + /// proof keyed by that proof's secret. + /// + /// This is the seller's release signature and the buyer's cooperative-cancel + /// signature: each party signs alone and hands the signatures over, and only + /// the combination of two satisfies the 2-of-3. + pub async fn sign_proofs(&self, encoded: &str, key: SecretKey) -> Result> { + let token: Token = encoded + .trim() + .parse() + .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + + self.proofs_of(&token) + .await? + .into_iter() + .map(|mut proof| { + let secret = proof.secret.to_string(); + proof + .sign_p2pk(key.clone()) + .map_err(|e| anyhow!("CashuSignFailed: {e}"))?; + Ok(ProofSignature { + secret, + signature: last_signature(&proof)?, + }) + }) + .collect() + } + + /// Attach `own_key`'s signature plus `peer_signatures` to every proof and + /// swap the result at the mint into fresh, unconditional proofs. + /// + /// The redeeming half of a settled trade: buyer release, or seller reclaim + /// after a cooperative cancel. Returns the amount received. + /// + /// A missing peer signature fails before the mint is contacted, so a + /// half-signed spend is never attempted. + pub async fn combine_and_redeem( + &self, + encoded: &str, + own_key: SecretKey, + peer_signatures: &[ProofSignature], + ) -> Result { + let token: Token = encoded + .trim() + .parse() + .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + + let proofs = self.proofs_of(&token).await?; + let mut signed = Vec::with_capacity(proofs.len()); + + for mut proof in proofs { + let secret = proof.secret.to_string(); + let peer = peer_signatures + .iter() + .find(|s| s.secret == secret) + .ok_or_else(|| anyhow!("MissingPeerSignature: none for proof {secret}"))?; + + proof + .sign_p2pk(own_key.clone()) + .map_err(|e| anyhow!("CashuSignFailed: {e}"))?; + + match proof.witness.as_mut() { + Some(witness) => witness.add_signatures(vec![peer.signature.clone()]), + // `sign_p2pk` always leaves a witness, so this is unreachable — + // reported rather than panicked on. + None => bail!("CashuSignFailed: signing left no witness"), + } + signed.push(proof); + } + + let amount = self + .inner() + .receive_proofs(signed, ReceiveOptions::default(), None, None) + .await + .map_err(|e| anyhow!("CashuRedeemFailed: {e}"))?; + + Ok(u64::from(amount)) + } + + /// Spend an escrow through its refund path once the locktime has passed. + /// + /// Only the seller can, and only after `locktime`. The mint enforces both, + /// so a premature call fails there rather than silently doing nothing. + pub async fn reclaim_after_locktime( + &self, + encoded: &str, + seller_key: SecretKey, + ) -> Result { + let token: Token = encoded + .trim() + .parse() + .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + + let proofs = self.proofs_of(&token).await?; + let mut signed = Vec::with_capacity(proofs.len()); + + for mut proof in proofs { + proof + .sign_p2pk(seller_key.clone()) + .map_err(|e| anyhow!("CashuSignFailed: {e}"))?; + signed.push(proof); + } + + let amount = self + .inner() + .receive_proofs(signed, ReceiveOptions::default(), None, None) + .await + .map_err(|e| anyhow!("CashuReclaimFailed: {e}"))?; + + Ok(u64::from(amount)) + } +} + +/// The signature `sign_p2pk` just appended. +fn last_signature(proof: &Proof) -> Result { + match proof.witness.as_ref() { + Some(Witness::P2PKWitness(w)) => w + .signatures + .last() + .cloned() + .ok_or_else(|| anyhow!("CashuSignFailed: witness carries no signature")), + _ => bail!("CashuSignFailed: proof has no P2PK witness"), + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// A valid x-only key, taken from a fresh compressed key by dropping its + /// parity byte — the same shape a Nostr trade key has. + fn xonly() -> String { + SecretKey::generate().public_key().to_hex()[2..].to_string() + } + + fn parties() -> EscrowParties { + EscrowParties::from_xonly_hex(&xonly(), &xonly(), &xonly()).unwrap() + } + + /// Far enough ahead that `Conditions::new` accepts it in any year this code + /// runs in. + fn future_locktime() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 15 * 24 * 60 * 60 + } + + #[test] + fn an_xonly_key_maps_to_the_daemons_compressed_form() { + // Arrange — the mapping the daemon applies is a literal `02` prefix. + let compressed = SecretKey::generate().public_key().to_hex(); + let x_only = &compressed[2..]; + + // Act + let mapped = xonly_to_cashu_pubkey(x_only).unwrap(); + + // Assert — same x coordinate, always even parity regardless of the + // original key's. + assert_eq!(mapped.to_hex(), format!("02{x_only}")); + } + + #[test] + fn a_malformed_trade_key_is_rejected_rather_than_reinterpreted() { + // Arrange — every shape that could silently become a different key. + for bad in [ + "", + "02", + "deadbeef", + // a 33-byte compressed key passed where x-only was expected + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104", + // right length, not hex + "zz94603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e10", + ] { + let err = xonly_to_cashu_pubkey(bad).unwrap_err(); + assert!( + err.to_string().contains("InvalidTradeKey"), + "expected rejection for {bad:?}, got {err}" + ); + } + } + + #[test] + fn whitespace_around_a_key_is_tolerated() { + // Arrange — keys arrive from JSON and the wire; a stray newline must + // not read as a different key. + let x_only = xonly(); + + // Act / Assert + assert_eq!( + xonly_to_cashu_pubkey(&format!(" {x_only}\n")).unwrap(), + xonly_to_cashu_pubkey(&x_only).unwrap() + ); + } + + #[test] + fn the_escrow_condition_matches_the_documented_shape() { + // Arrange + let parties = parties(); + let locktime = future_locktime(); + + // Act + let conditions = escrow_conditions(&parties, locktime).unwrap(); + + // Assert — every field of §2 individually, so a wrong default cannot + // hide behind a passing round trip. + let SpendingConditions::P2PKConditions { data, conditions } = &conditions else { + panic!("expected P2PK conditions"); + }; + assert_eq!(*data, parties.seller, "data must be the seller"); + let c = conditions.as_ref().unwrap(); + assert_eq!(c.pubkeys, Some(vec![parties.buyer, parties.mostro])); + assert_eq!(c.num_sigs, Some(2)); + assert_eq!(c.sig_flag, SigFlag::SigInputs); + assert_eq!(c.locktime, Some(locktime)); + assert_eq!(c.refund_keys, Some(vec![parties.seller])); + assert_eq!(c.num_sigs_refund, Some(1)); + } + + #[test] + fn a_locktime_in_the_past_is_refused() { + // Arrange / Act — an already-refundable escrow is worthless to the + // buyer, so it must never be built at all. + let err = escrow_conditions(&parties(), 1).unwrap_err(); + + // Assert + assert!( + err.to_string().contains("InvalidEscrowConditions"), + "got {err}" + ); + } + + #[test] + fn the_fee_condition_is_a_plain_lock_to_mostro() { + // Arrange + let parties = parties(); + + // Act + let conditions = fee_conditions(parties.mostro); + + // Assert — no locktime, no extra keys: the fee is a payment, not an + // escrow, and conditions would make it unspendable for the node. + let SpendingConditions::P2PKConditions { data, conditions } = &conditions else { + panic!("expected P2PK conditions"); + }; + assert_eq!(*data, parties.mostro); + assert!(conditions.is_none()); + } + + #[test] + fn a_well_formed_escrow_verifies() { + // Arrange + let parties = parties(); + let locktime = future_locktime(); + let conditions = escrow_conditions(&parties, locktime).unwrap(); + + // Act / Assert — a longer-than-required locktime still passes: more + // time is safe for the buyer. + verify_conditions(&conditions, &parties, locktime).unwrap(); + verify_conditions(&conditions, &parties, locktime - 100).unwrap(); + } + + #[test] + fn verification_rejects_every_way_an_escrow_can_be_wrong() { + // Arrange + let parties = parties(); + let locktime = future_locktime(); + let other = SecretKey::generate().public_key(); + let inner = |c: SpendingConditions| match c { + SpendingConditions::P2PKConditions { conditions, .. } => conditions, + _ => unreachable!(), + }; + + let cases: Vec<(SpendingConditions, &str, &str)> = vec![ + ( + // Locked to someone else's key entirely. + SpendingConditions::P2PKConditions { + data: other, + conditions: inner(escrow_conditions(&parties, locktime).unwrap()), + }, + "wrong seller", + "wrong seller key", + ), + ( + // 1-of-N: the seller could spend alone. + SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: Some( + Conditions::new( + Some(locktime), + Some(vec![parties.buyer, parties.mostro]), + Some(vec![parties.seller]), + Some(1), + Some(SigFlag::SigInputs), + Some(1), + ) + .unwrap(), + ), + }, + "n_sigs 1", + "n_sigs must be 2", + ), + ( + // Refundable to the buyer too — the seller's funds could walk. + SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: Some( + Conditions::new( + Some(locktime), + Some(vec![parties.buyer, parties.mostro]), + Some(vec![parties.seller, parties.buyer]), + Some(2), + Some(SigFlag::SigInputs), + Some(1), + ) + .unwrap(), + ), + }, + "extra refund key", + "refund key must be the seller alone", + ), + ( + // A bare lock to the seller, no conditions at all. + SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: None, + }, + "bare P2PK", + "no spending conditions", + ), + ]; + + // Act / Assert + for (conditions, label, expected) in cases { + let err = verify_conditions(&conditions, &parties, locktime).unwrap_err(); + assert!( + err.to_string().contains(expected), + "{label}: expected {expected:?}, got {err}" + ); + } + } + + #[test] + fn a_locktime_shorter_than_required_is_refused() { + // Arrange — the dangerous direction: the seller could reclaim before + // the buyer has had time to pay. + let parties = parties(); + let locktime = future_locktime(); + let conditions = escrow_conditions(&parties, locktime).unwrap(); + + // Act + let err = verify_conditions(&conditions, &parties, locktime + 1).unwrap_err(); + + // Assert + assert!(err.to_string().contains("is before"), "got {err}"); + } + + // ── Integration ────────────────────────────────────────────────────────── + // + // The full lock → sign → combine → redeem cycle can only be exercised + // against a real mint: every step is blind signatures and DLEQ, and a mock + // that faked them would prove nothing about the escrow. Run with a local + // nutshell: + // + // docker run -p 3338:3338 cashubtc/nutshell:latest poetry run mint + // MOSTRO_TEST_MINT_URL=http://localhost:3338 cargo test -- --ignored + // + // The seller wallet must already hold funds; funding is out of band. + + fn test_mint_url() -> String { + std::env::var("MOSTRO_TEST_MINT_URL") + .expect("set MOSTRO_TEST_MINT_URL to run the Cashu integration tests") + } + + fn temp_db_path() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("mostro_escrow_test_{}_{n}.db", std::process::id())) + } + + /// A party: its secret key, and the x-only hex the protocol carries. + fn party() -> (SecretKey, String) { + let sk = SecretKey::generate(); + let x_only = sk.public_key().to_hex()[2..].to_string(); + (sk, x_only) + } + + #[tokio::test] + #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] + async fn an_escrow_locks_and_settles_with_two_of_three_signatures() { + // Arrange — seller funds the escrow, buyer redeems it after release. + let mint = test_mint_url(); + let seller_db = temp_db_path(); + let buyer_db = temp_db_path(); + + let seller = CashuWallet::connect(&mint, [11u8; 64], seller_db.to_str().unwrap()) + .await + .unwrap(); + let buyer = CashuWallet::connect(&mint, [12u8; 64], buyer_db.to_str().unwrap()) + .await + .unwrap(); + + let funded = seller.balance().await.unwrap(); + assert!(funded >= 16, "fund the seller wallet first (has {funded} sat)"); + + let (seller_sk, seller_pk) = party(); + let (buyer_sk, buyer_pk) = party(); + let (_mostro_sk, mostro_pk) = party(); + let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let locktime = future_locktime(); + + // Act — seller locks. + let token = seller + .build_escrow_token(16, &parties, locktime) + .await + .unwrap(); + + // Assert — the buyer verifies before doing anything with it, which is + // the whole point of verify_escrow_token. + buyer + .verify_escrow_token(&token, &parties, 16, locktime) + .await + .unwrap(); + assert_eq!(seller.balance().await.unwrap(), funded - 16); + + // Act — seller signs (release), buyer combines and redeems. + let seller_sigs = seller.sign_proofs(&token, seller_sk).await.unwrap(); + let received = buyer + .combine_and_redeem(&token, buyer_sk, &seller_sigs) + .await + .unwrap(); + + // Assert + assert_eq!(received, 16); + assert_eq!(buyer.balance().await.unwrap(), 16); + + let _ = std::fs::remove_file(&seller_db); + let _ = std::fs::remove_file(&buyer_db); + } + + #[tokio::test] + #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] + async fn one_signature_is_not_enough_to_move_an_escrow() { + // Arrange — the security property the 2-of-3 exists for: neither party + // alone can take the funds. + let mint = test_mint_url(); + let seller_db = temp_db_path(); + let seller = CashuWallet::connect(&mint, [13u8; 64], seller_db.to_str().unwrap()) + .await + .unwrap(); + assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + + let (seller_sk, seller_pk) = party(); + let (_buyer_sk, buyer_pk) = party(); + let (_mostro_sk, mostro_pk) = party(); + let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let token = seller + .build_escrow_token(8, &parties, future_locktime()) + .await + .unwrap(); + + // Act — the seller tries to take it back with only their own signature, + // before the locktime. + let err = seller + .reclaim_after_locktime(&token, seller_sk) + .await + .unwrap_err(); + + // Assert — the mint refuses; the client does not have to. + assert!(err.to_string().contains("CashuReclaimFailed"), "got {err}"); + + let _ = std::fs::remove_file(&seller_db); + } + + #[tokio::test] + #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] + async fn a_signature_from_the_wrong_key_does_not_settle_an_escrow() { + // Arrange — an attacker holding neither trade key. + let mint = test_mint_url(); + let seller_db = temp_db_path(); + let buyer_db = temp_db_path(); + let seller = CashuWallet::connect(&mint, [14u8; 64], seller_db.to_str().unwrap()) + .await + .unwrap(); + let buyer = CashuWallet::connect(&mint, [15u8; 64], buyer_db.to_str().unwrap()) + .await + .unwrap(); + assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + + let (_seller_sk, seller_pk) = party(); + let (buyer_sk, buyer_pk) = party(); + let (_mostro_sk, mostro_pk) = party(); + let (impostor_sk, _) = party(); + let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let token = seller + .build_escrow_token(8, &parties, future_locktime()) + .await + .unwrap(); + + // Act — buyer combines their own valid signature with an impostor's. + let impostor_sigs = seller.sign_proofs(&token, impostor_sk).await.unwrap(); + let err = buyer + .combine_and_redeem(&token, buyer_sk, &impostor_sigs) + .await + .unwrap_err(); + + // Assert + assert!(err.to_string().contains("CashuRedeemFailed"), "got {err}"); + + let _ = std::fs::remove_file(&seller_db); + let _ = std::fs::remove_file(&buyer_db); + } + + #[tokio::test] + #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] + async fn an_escrow_for_the_wrong_amount_fails_verification() { + // Arrange — a seller who locks less than the order calls for. + let mint = test_mint_url(); + let seller_db = temp_db_path(); + let seller = CashuWallet::connect(&mint, [16u8; 64], seller_db.to_str().unwrap()) + .await + .unwrap(); + assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + + let (_seller_sk, seller_pk) = party(); + let (_buyer_sk, buyer_pk) = party(); + let (_mostro_sk, mostro_pk) = party(); + let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let locktime = future_locktime(); + let token = seller + .build_escrow_token(8, &parties, locktime) + .await + .unwrap(); + + // Act — the buyer checks it against the amount they expect. + let err = seller + .verify_escrow_token(&token, &parties, 16, locktime) + .await + .unwrap_err(); + + // Assert + assert!( + err.to_string().contains("expected 16 sat, got 8"), + "got {err}" + ); + + let _ = std::fs::remove_file(&seller_db); + } + + #[test] + fn a_missing_counterparty_key_is_refused() { + // Arrange — Mostro left out, so buyer and seller could settle without + // the arbitrator ever being able to intervene. + let parties = parties(); + let locktime = future_locktime(); + let conditions = SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: Some( + Conditions::new( + Some(locktime), + Some(vec![parties.buyer]), + Some(vec![parties.seller]), + Some(2), + Some(SigFlag::SigInputs), + Some(1), + ) + .unwrap(), + ), + }; + + // Act / Assert + let err = verify_conditions(&conditions, &parties, locktime).unwrap_err(); + assert!( + err.to_string().contains("buyer or Mostro key missing"), + "got {err}" + ); + } +} diff --git a/rust/src/cashu/mod.rs b/rust/src/cashu/mod.rs index 328c5c50..08be20da 100644 --- a/rust/src/cashu/mod.rs +++ b/rust/src/cashu/mod.rs @@ -16,6 +16,8 @@ //! to wasm (verified; see `docs/cashu/cdk-spike.md`), so the web gap is storage //! alone, and closing it belongs with the rest of IndexedDB in #233. +#[cfg(not(target_arch = "wasm32"))] +pub mod escrow; #[cfg(not(target_arch = "wasm32"))] mod wallet; diff --git a/rust/src/cashu/wallet.rs b/rust/src/cashu/wallet.rs index 2df8b063..fc5dbd71 100644 --- a/rust/src/cashu/wallet.rs +++ b/rust/src/cashu/wallet.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Result}; use cdk::amount::SplitTarget; -use cdk::nuts::CurrencyUnit; +use cdk::nuts::{CurrencyUnit, Proof, Token}; use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions, Wallet}; use cdk::Amount; use cdk_sqlite::WalletSqliteDatabase; @@ -144,6 +144,28 @@ impl CashuWallet { &self.mint_url } + /// The underlying `cdk` wallet, for the escrow primitives in + /// [`super::escrow`]. Crate-internal: everything outside this module goes + /// through the methods above, so mint access stays in one place. + pub(crate) fn inner(&self) -> &Wallet { + &self.inner + } + + /// The proofs inside a token. + /// + /// Needs the mint's keysets — a v4 token identifies its keyset by id — so + /// this cannot be a free function on the token alone. + pub(crate) async fn proofs_of(&self, token: &Token) -> Result> { + let keysets = self + .inner + .load_mint_keysets() + .await + .map_err(|e| anyhow!("CashuMintUnreachable: {e}"))?; + token + .proofs(&keysets) + .map_err(|e| anyhow!("InvalidEscrowToken: unreadable proofs ({e})")) + } + /// What the mint advertised at connect time. pub fn capabilities(&self) -> &MintCapabilities { &self.capabilities From a2f9c0edc6b447247dbedd0454b83ee8df14b233 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Jul 2026 09:06:16 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(cashu):=20C4=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20the=202-of-3=20was=20not=20verified=20to=20be=20a?= =?UTF-8?q?=202-of-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the strict review on #236. Critical - `verify_conditions` checked that the buyer and Mostro keys were *present* in `pubkeys`, never that nothing else was. A seller could lock with `pubkeys = [P_B, P_M, P_attacker]` where they hold the extra key: with `data = P_S` and `n_sigs = 2`, seller + attacker is two of four, and the escrow is spendable unilaterally the moment it is funded — while this verifier called the token good. Now matched exactly, like every other field. The test table gained the row that was missing, which is what let it through. Major - No currency-unit check. `Token::value()` sums proof amounts irrespective of denomination, so a token in another unit with the right numeric total passed the amount check. Unit is now asserted before the amount. - Nothing rejected `buyer == seller`, `seller == mostro` or `buyer == mostro`. A duplicate collapses the threshold, and whether one signature then satisfies `n_sigs = 2` is mint-implementation-defined. `EscrowParties::ensure_distinct` runs at construction and again in verification. - `build_locked_token` leaked the whole escrow amount as reserved proofs when `confirm` failed (it consumes the handle, so nothing else could release them). It now reclaims and reports how much came back. Minor - `combine_and_redeem` scanned the peer signatures per proof; indexed by secret once instead. - `reclaim_after_locktime` relied on the mint to refuse a premature spend. The locktime is in the secret, so it now says "the refund path opens in N seconds" instead of surfacing an opaque mint error. Tests: the extra-pubkey case, the three degenerate-party cases, and a wrong-key-with-right-count case that keeps the membership check honest now that the count check fires first. --- rust/src/cashu/escrow.rs | 185 +++++++++++++++++++++++++++++++++++---- 1 file changed, 169 insertions(+), 16 deletions(-) diff --git a/rust/src/cashu/escrow.rs b/rust/src/cashu/escrow.rs index 65779bf0..4fe82693 100644 --- a/rust/src/cashu/escrow.rs +++ b/rust/src/cashu/escrow.rs @@ -50,11 +50,26 @@ pub struct EscrowParties { impl EscrowParties { /// Build from the x-only hex keys the protocol carries. pub fn from_xonly_hex(buyer: &str, seller: &str, mostro: &str) -> Result { - Ok(Self { + let parties = Self { buyer: xonly_to_cashu_pubkey(buyer)?, seller: xonly_to_cashu_pubkey(seller)?, mostro: xonly_to_cashu_pubkey(mostro)?, - }) + }; + parties.ensure_distinct()?; + Ok(parties) + } + + /// Three different keys, or it is not a 2-of-3. + /// + /// A duplicate collapses the threshold: whether one signature can satisfy + /// `n_sigs = 2` under a repeated key is mint-implementation-defined, which + /// is precisely the thing not to leave to the mint. Rejected on the way in + /// and on the way out. + pub fn ensure_distinct(&self) -> Result<()> { + if self.buyer == self.seller || self.buyer == self.mostro || self.seller == self.mostro { + bail!("InvalidEscrowParties: buyer, seller and Mostro must be three different keys"); + } + Ok(()) } } @@ -92,6 +107,8 @@ pub fn xonly_to_cashu_pubkey(xonly_hex: &str) -> Result { /// a caller that miscomputes it fails here rather than minting an escrow that /// is already refundable. pub fn escrow_conditions(parties: &EscrowParties, locktime: u64) -> Result { + parties.ensure_distinct()?; + let conditions = Conditions::new( Some(locktime), // `data` carries the seller, so the extra pubkeys are the other two. @@ -131,6 +148,8 @@ pub fn verify_conditions( parties: &EscrowParties, min_locktime: u64, ) -> Result<()> { + parties.ensure_distinct()?; + let SpendingConditions::P2PKConditions { data, conditions } = conditions else { bail!("InvalidEscrowToken: not a P2PK secret"); }; @@ -147,6 +166,18 @@ pub fn verify_conditions( .pubkeys .as_ref() .ok_or_else(|| anyhow!("InvalidEscrowToken: no additional pubkeys"))?; + + // Exactly two, and exactly these two. Checking only that the buyer and + // Mostro are *present* would accept `[P_B, P_M, P_attacker]`: with + // `n_sigs = 2` and `data = P_S`, the seller plus a key they also control + // satisfies the condition, and they can drain the escrow the moment it is + // funded. Every other field here is matched exactly; so is this one. + if pubkeys.len() != 2 { + bail!( + "InvalidEscrowToken: expected exactly 2 additional pubkeys, got {}", + pubkeys.len() + ); + } if !pubkeys.contains(&parties.buyer) || !pubkeys.contains(&parties.mostro) { bail!("InvalidEscrowToken: buyer or Mostro key missing"); } @@ -220,10 +251,17 @@ impl CashuWallet { .await .map_err(|e| anyhow!("CashuLockFailed: {e}"))?; - let token = prepared - .confirm(None::) - .await - .map_err(|e| anyhow!("CashuLockFailed: {e}"))?; + let token = match prepared.confirm(None::).await { + Ok(token) => token, + Err(e) => { + // The whole escrow amount is reserved at this point and + // `confirm` consumed the handle, so nothing else can release + // it. C5 would then report the wallet as short by exactly the + // amount it just tried to lock. + let reclaimed = self.check_proofs_state().await.unwrap_or(0); + bail!("CashuLockFailed: {e} (reclaimed {reclaimed} sat)"); + } + }; Ok(token.to_string()) } @@ -254,6 +292,14 @@ impl CashuWallet { bail!("InvalidEscrowToken: wrong mint ({token_mint})"); } + // Unit before amount: `value()` sums proof amounts irrespective of + // denomination, so a token in another unit with the right numeric total + // would pass the amount check unnoticed. + match token.unit() { + Some(cdk::nuts::CurrencyUnit::Sat) => {} + other => bail!("InvalidEscrowToken: expected sat, got {other:?}"), + } + let value = u64::from( token .value() @@ -323,11 +369,18 @@ impl CashuWallet { let proofs = self.proofs_of(&token).await?; let mut signed = Vec::with_capacity(proofs.len()); + // Indexed once rather than scanned per proof: linear in the number of + // signatures instead of quadratic, and it makes a duplicate secret in + // the peer's list a visible collision rather than a silent first-wins. + let by_secret: std::collections::HashMap<&str, &ProofSignature> = peer_signatures + .iter() + .map(|s| (s.secret.as_str(), s)) + .collect(); + for mut proof in proofs { let secret = proof.secret.to_string(); - let peer = peer_signatures - .iter() - .find(|s| s.secret == secret) + let peer = by_secret + .get(secret.as_str()) .ok_or_else(|| anyhow!("MissingPeerSignature: none for proof {secret}"))?; proof @@ -370,6 +423,25 @@ impl CashuWallet { let mut signed = Vec::with_capacity(proofs.len()); for mut proof in proofs { + // The locktime is right there in the secret. Checking it locally + // turns "the mint rejected your swap" into "the refund path opens + // in N days", which is the difference between a user waiting and a + // user filing a bug. + if let Ok(SpendingConditions::P2PKConditions { + conditions: Some(c), .. + }) = SpendingConditions::try_from(&proof.secret) + { + if let Some(locktime) = c.locktime { + let now = now_unix(); + if locktime > now { + bail!( + "CashuLocktimeNotReached: {} seconds remain", + locktime - now + ); + } + } + } + proof .sign_p2pk(seller_key.clone()) .map_err(|e| anyhow!("CashuSignFailed: {e}"))?; @@ -386,6 +458,15 @@ impl CashuWallet { } } +/// Seconds since the unix epoch, or 0 if the clock is before it — in which +/// case every locktime reads as "not reached", which is the safe direction. +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// The signature `sign_p2pk` just appended. fn last_signature(proof: &Proof) -> Result { match proof.witness.as_ref() { @@ -606,6 +687,29 @@ mod tests { "bare P2PK", "no spending conditions", ), + ( + // The one that matters most: an *extra* key the seller also + // controls. Every required key is present, so a + // presence-only check passes it — and then seller + extra is + // two of four, and the escrow is spendable unilaterally the + // moment it is funded. + SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: Some( + Conditions::new( + Some(locktime), + Some(vec![parties.buyer, parties.mostro, other]), + Some(vec![parties.seller]), + Some(2), + Some(SigFlag::SigInputs), + Some(1), + ) + .unwrap(), + ), + }, + "extra pubkey", + "expected exactly 2 additional pubkeys", + ), ]; // Act / Assert @@ -657,6 +761,10 @@ mod tests { std::env::temp_dir().join(format!("mostro_escrow_test_{}_{n}.db", std::process::id())) } + fn wallet_seed(byte: u8) -> zeroize::Zeroizing<[u8; 64]> { + zeroize::Zeroizing::new([byte; 64]) + } + /// A party: its secret key, and the x-only hex the protocol carries. fn party() -> (SecretKey, String) { let sk = SecretKey::generate(); @@ -672,10 +780,10 @@ mod tests { let seller_db = temp_db_path(); let buyer_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, [11u8; 64], seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, wallet_seed(11), seller_db.to_str().unwrap()) .await .unwrap(); - let buyer = CashuWallet::connect(&mint, [12u8; 64], buyer_db.to_str().unwrap()) + let buyer = CashuWallet::connect(&mint, wallet_seed(12), buyer_db.to_str().unwrap()) .await .unwrap(); @@ -724,7 +832,7 @@ mod tests { // alone can take the funds. let mint = test_mint_url(); let seller_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, [13u8; 64], seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, wallet_seed(13), seller_db.to_str().unwrap()) .await .unwrap(); assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); @@ -758,10 +866,10 @@ mod tests { let mint = test_mint_url(); let seller_db = temp_db_path(); let buyer_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, [14u8; 64], seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, wallet_seed(14), seller_db.to_str().unwrap()) .await .unwrap(); - let buyer = CashuWallet::connect(&mint, [15u8; 64], buyer_db.to_str().unwrap()) + let buyer = CashuWallet::connect(&mint, wallet_seed(15), buyer_db.to_str().unwrap()) .await .unwrap(); assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); @@ -796,7 +904,7 @@ mod tests { // Arrange — a seller who locks less than the order calls for. let mint = test_mint_url(); let seller_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, [16u8; 64], seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, wallet_seed(16), seller_db.to_str().unwrap()) .await .unwrap(); assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); @@ -826,6 +934,28 @@ mod tests { let _ = std::fs::remove_file(&seller_db); } + #[test] + fn three_parties_must_be_three_different_keys() { + // Arrange — a duplicate collapses the 2-of-3 into something weaker, and + // whether one signature then satisfies n_sigs=2 is up to the mint. + let shared = xonly(); + let third = xonly(); + + // Act / Assert — rejected at construction, so a degenerate set never + // reaches the condition builder. + for (buyer, seller, mostro, label) in [ + (shared.clone(), shared.clone(), third.clone(), "buyer == seller"), + (shared.clone(), third.clone(), shared.clone(), "buyer == mostro"), + (third.clone(), shared.clone(), shared.clone(), "seller == mostro"), + ] { + let err = EscrowParties::from_xonly_hex(&buyer, &seller, &mostro).unwrap_err(); + assert!( + err.to_string().contains("InvalidEscrowParties"), + "{label}: got {err}" + ); + } + } + #[test] fn a_missing_counterparty_key_is_refused() { // Arrange — Mostro left out, so buyer and seller could settle without @@ -847,8 +977,31 @@ mod tests { ), }; - // Act / Assert + // Act / Assert — the exact-count check catches this first, which is + // fine: both messages say the key set is not the one this trade needs. let err = verify_conditions(&conditions, &parties, locktime).unwrap_err(); + assert!( + err.to_string().contains("expected exactly 2 additional pubkeys"), + "got {err}" + ); + + // And with the right *count* but the wrong key, the membership check + // is the one that fires. + let wrong_key = SpendingConditions::P2PKConditions { + data: parties.seller, + conditions: Some( + Conditions::new( + Some(locktime), + Some(vec![parties.buyer, SecretKey::generate().public_key()]), + Some(vec![parties.seller]), + Some(2), + Some(SigFlag::SigInputs), + Some(1), + ) + .unwrap(), + ), + }; + let err = verify_conditions(&wrong_key, &parties, locktime).unwrap_err(); assert!( err.to_string().contains("buyer or Mostro key missing"), "got {err}" From 2aaf8aba93490b01419ba8cb8e646645aba93615 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Jul 2026 12:13:39 -0300 Subject: [PATCH 3/4] test(cashu): make the C4 escrow integration tests runnable against a mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment as C2, applied to the escrow suite once a real nutshell showed the tests could not pass at all, and then could not pass twice. - The four funds-using tests asserted "fund the seller wallet first" and returned. They now fund themselves through `mint_for_test`, so a reviewer can actually run them. - Fixed seeds plus a fresh DB replay NUT-13 blinding secrets; the mint answers "Blinded Message is already signed" on the second run. Seeds are unique per process and per call now. - The settlement test pinned the redeemed amount and the seller's balance to the face value. nutshell's default keyset charges a swap fee, so redeeming 16 sat yields less and locking costs more. The face value is what a validator checks; the assertions bound the rest. - `one_signature_is_not_enough_to_move_an_escrow` expected the *mint* to refuse a premature reclaim. Since the local locktime check added in this PR, the client refuses first with the time remaining — a better message for the same property. Either refusal is accepted. Verified 8/8 against nutshell 0.20.3, twice in a row (MINT_RATE_LIMIT=FALSE — the mint rate limits by default). --- rust/src/cashu/escrow.rs | 73 ++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/rust/src/cashu/escrow.rs b/rust/src/cashu/escrow.rs index 4fe82693..8c3573d8 100644 --- a/rust/src/cashu/escrow.rs +++ b/rust/src/cashu/escrow.rs @@ -761,8 +761,25 @@ mod tests { std::env::temp_dir().join(format!("mostro_escrow_test_{}_{n}.db", std::process::id())) } - fn wallet_seed(byte: u8) -> zeroize::Zeroizing<[u8; 64]> { - zeroize::Zeroizing::new([byte; 64]) + /// A seed unique to this process *and* this call — see the note on the + /// wallet's `unique_seed`: a fixed seed with a fresh DB replays NUT-13 + /// blinding secrets and the mint refuses them on the second run. + fn unique_seed() -> zeroize::Zeroizing<[u8; 64]> { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let mut seed = [0u8; 64]; + let pid = std::process::id() as u64; + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + seed[..8].copy_from_slice(&pid.to_le_bytes()); + seed[8..16].copy_from_slice(&n.to_le_bytes()); + seed[16..24].copy_from_slice( + &std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + .to_le_bytes(), + ); + zeroize::Zeroizing::new(seed) } /// A party: its secret key, and the x-only hex the protocol carries. @@ -780,15 +797,16 @@ mod tests { let seller_db = temp_db_path(); let buyer_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(11), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - let buyer = CashuWallet::connect(&mint, wallet_seed(12), buyer_db.to_str().unwrap()) + let buyer = CashuWallet::connect(&mint, unique_seed(), buyer_db.to_str().unwrap()) .await .unwrap(); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); let funded = seller.balance().await.unwrap(); - assert!(funded >= 16, "fund the seller wallet first (has {funded} sat)"); + assert!(funded >= 16, "minting should have funded the wallet"); let (seller_sk, seller_pk) = party(); let (buyer_sk, buyer_pk) = party(); @@ -808,7 +826,12 @@ mod tests { .verify_escrow_token(&token, &parties, 16, locktime) .await .unwrap(); - assert_eq!(seller.balance().await.unwrap(), funded - 16); + // A mint that charges a swap fee (nutshell's default keyset does) makes + // locking cost the seller slightly more than the face value. + assert!( + seller.balance().await.unwrap() <= funded - 16, + "locking 16 sat must cost the seller at least the face value" + ); // Act — seller signs (release), buyer combines and redeems. let seller_sigs = seller.sign_proofs(&token, seller_sk).await.unwrap(); @@ -817,9 +840,14 @@ mod tests { .await .unwrap(); - // Assert - assert_eq!(received, 16); - assert_eq!(buyer.balance().await.unwrap(), 16); + // Assert — the face value is what a validator checks; the redeemer + // receives that minus the mint's swap fee, which is the mint's + // property, not this code's. Bounded rather than pinned. + assert!( + received > 0 && received <= 16, + "received {received} sat for a 16 sat escrow" + ); + assert_eq!(buyer.balance().await.unwrap(), received); let _ = std::fs::remove_file(&seller_db); let _ = std::fs::remove_file(&buyer_db); @@ -832,10 +860,11 @@ mod tests { // alone can take the funds. let mint = test_mint_url(); let seller_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(13), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + assert!(seller.balance().await.unwrap() >= 8); let (seller_sk, seller_pk) = party(); let (_buyer_sk, buyer_pk) = party(); @@ -853,8 +882,14 @@ mod tests { .await .unwrap_err(); - // Assert — the mint refuses; the client does not have to. - assert!(err.to_string().contains("CashuReclaimFailed"), "got {err}"); + // Assert — refused before the mint is even contacted: the locktime is in + // the secret, so the client can say how long is left instead of + // relaying an opaque mint error. Either refusal proves the property. + assert!( + err.to_string().contains("CashuLocktimeNotReached") + || err.to_string().contains("CashuReclaimFailed"), + "got {err}" + ); let _ = std::fs::remove_file(&seller_db); } @@ -866,13 +901,14 @@ mod tests { let mint = test_mint_url(); let seller_db = temp_db_path(); let buyer_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(14), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - let buyer = CashuWallet::connect(&mint, wallet_seed(15), buyer_db.to_str().unwrap()) + let buyer = CashuWallet::connect(&mint, unique_seed(), buyer_db.to_str().unwrap()) .await .unwrap(); - assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + assert!(seller.balance().await.unwrap() >= 8); let (_seller_sk, seller_pk) = party(); let (buyer_sk, buyer_pk) = party(); @@ -904,10 +940,11 @@ mod tests { // Arrange — a seller who locks less than the order calls for. let mint = test_mint_url(); let seller_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(16), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + assert!(seller.balance().await.unwrap() >= 8); let (_seller_sk, seller_pk) = party(); let (_buyer_sk, buyer_pk) = party(); From c66141061a8344df3fba41006b6bdaca1d7e22ff Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 10 Sep 2026 12:57:28 -0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(cashu):=20C4=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20sign=5Fproofs=20was=20a=20signing=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes every finding still open from the two reviews on #236. - `sign_proofs` now takes the escrow's parties, amount and minimum locktime and verifies the token before producing a signature; there is no unchecked form. Under SIG_INPUTS a signature commits to the proof's secret alone, so signing a counterparty-supplied decoy that reused the escrow's secrets with the amounts rewritten released the real escrow (21Mill's PoC, 15 sat taken against a live mint). Regression test builds that decoy and asserts the refusal lands before any signature. - `verify_escrow_token` now also verifies DLEQ (NUT-12) and asks the mint that every proof is unspent (NUT-07), in that order after the shape check. It used to prove shape only, and the doc did not say so. Regression test presents a redeemed escrow again. - All four entry points parse through the wallet's `normalize_token`, so a `cashu:` URI the receive path accepts no longer fails as unparseable. - Peer signatures are indexed by an explicit insert that rejects a repeated secret before the mint is contacted; the previous `collect()` kept the last entry silently while its comment claimed otherwise. - docs/cashu/README.md §C4 updated to the new contract, with the SIG_INPUTS corollary C6/C7 must honour (no party signs both release and cancel). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LZaSj3PoUkwnajwKoUiw5g --- docs/cashu/README.md | 14 +- rust/src/cashu/escrow.rs | 339 ++++++++++++++++++++++++++++++++++----- 2 files changed, 313 insertions(+), 40 deletions(-) diff --git a/docs/cashu/README.md b/docs/cashu/README.md index 06f99104..e6103afd 100644 --- a/docs/cashu/README.md +++ b/docs/cashu/README.md @@ -456,9 +456,17 @@ The cryptographic heart, kept UI-free so review can focus on correctness: `n_sigs_refund=1`); - `build_fee_token(fee_amount, p_m)` — P2PK 1-of-1 to `P_M`, value `2 * order.fee`; - `verify_escrow_token(token, p_b, p_s, p_m, amount, min_locktime)` — client-side - mirror of the daemon's composite check (defense in depth before submitting); - - `sign_proofs(token, trade_secret_key) -> Vec` — BIP-340 - signatures over each proof secret (seller release / buyer coop-cancel); + mirror of the daemon's composite check (defense in depth before submitting): + the 2-of-3 condition on **every** proof, mint, unit, amount, DLEQ (NUT-12) and + NUT-07 unspent — a point-in-time statement, not a guarantee the proofs stay so; + - `sign_proofs(token, trade_secret_key, p_b, p_s, p_m, amount, min_locktime) -> + Vec` — BIP-340 signatures over each proof secret (seller + release / buyer coop-cancel). **Always verifies the token first, with no + unchecked form**: under `SIG_INPUTS` a signature commits to the secret alone, + so signing a counterparty-supplied decoy that reuses the escrow's secrets + would release the real escrow. Corollary for C6/C7: a release signature and a + coop-cancel signature over the same escrow authorise the same spend, so no + party may ever produce both for one escrow; - `combine_and_redeem(token, own_key, peer_signatures)` — attach both signatures, swap at the mint into fresh unconditional proofs (buyer release / seller reclaim); - `reclaim_after_locktime(token, seller_key)` — refund path spend. diff --git a/rust/src/cashu/escrow.rs b/rust/src/cashu/escrow.rs index f7e5274d..6d1c6718 100644 --- a/rust/src/cashu/escrow.rs +++ b/rust/src/cashu/escrow.rs @@ -26,12 +26,16 @@ //! order, never the identity key. That is a privacy requirement of the //! upstream spec, not a preference. +use std::collections::HashMap; + use anyhow::{anyhow, bail, Result}; +use cdk::nuts::nut07::State; use cdk::nuts::nut10::{Conditions, SpendingConditions}; use cdk::nuts::nut11::SigFlag; -use cdk::nuts::{Proof, PublicKey, SecretKey, Token, Witness}; +use cdk::nuts::{CurrencyUnit, Proof, PublicKey, SecretKey, Token, Witness}; use cdk::wallet::ReceiveOptions; +use super::wallet::normalize_token; use super::CashuWallet; /// The three keys an escrow is locked to, already in Cashu (compressed) form. @@ -245,12 +249,36 @@ impl CashuWallet { .await } - /// Verify an escrow token someone else built: right mint, right amount, and - /// the right 2-of-3 condition on **every** proof. + /// Verify an escrow token someone else built, before anything is paid or + /// signed against it. + /// + /// Four things, in this order, and `Ok` means all four: + /// + /// 1. **Shape** — right mint, `sat`, the expected total, and the 2-of-3 + /// condition of §2 on **every** proof. Per-proof rather than per-token + /// on purpose: a token whose first proof is correct and whose second is + /// locked to the builder alone would pass a spot check and walk away + /// with the difference. + /// 2. **Mint-issued** — every proof carries a DLEQ proof (NUT-12) that + /// verifies against the mint's key for that amount. Same reasoning as + /// [`CashuWallet::receive_token`]: `cdk` skips a proof with no DLEQ + /// rather than rejecting it, and a forged proof would otherwise only be + /// caught at redemption, after the fiat leg. + /// 3. **Unspent** — the mint reports every proof `Unspent` (NUT-07). A + /// structurally perfect token whose proofs were swapped away an hour + /// ago is worth nothing, and the buyer is the party who would find out + /// last. /// - /// Per-proof rather than per-token on purpose — a token whose first proof is - /// correct and whose second is locked to the builder alone would pass a spot - /// check and walk away with the difference. + /// This mirrors the daemon's composite check (§3 of the doc: condition, + /// amount, DLEQ, NUT-07). It is a point-in-time statement: the proofs can + /// still be spent by the seller *after* this returns if they hold two of + /// the three keys, which is exactly what the 2-of-3 and the distinctness + /// check exist to make impossible. + /// + /// **Errors** (stable markers): `InvalidEscrowToken` for a shape failure, + /// `CashuTokenUnverified` for a DLEQ failure, `CashuEscrowSpent` / + /// `CashuEscrowPending` when the mint no longer calls a proof unspent, + /// `CashuMintUnreachable` when the mint could not be asked. pub async fn verify_escrow_token( &self, encoded: &str, @@ -258,10 +286,7 @@ impl CashuWallet { expected_amount: u64, min_locktime: u64, ) -> Result<()> { - let token: Token = encoded - .trim() - .parse() - .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + let token = parse_token(encoded)?; let token_mint = token .mint_url() @@ -275,7 +300,7 @@ impl CashuWallet { // denomination, so a token in another unit with the right numeric total // would pass the amount check unnoticed. match token.unit() { - Some(cdk::nuts::CurrencyUnit::Sat) => {} + Some(CurrencyUnit::Sat) => {} other => bail!("InvalidEscrowToken: expected sat, got {other:?}"), } @@ -288,13 +313,37 @@ impl CashuWallet { bail!("InvalidEscrowToken: expected {expected_amount} sat, got {value}"); } - for proof in self.proofs_of(&token).await? { + let proofs = self.proofs_of(&token).await?; + for proof in &proofs { let conditions: SpendingConditions = (&proof.secret) .try_into() .map_err(|e| anyhow!("InvalidEscrowToken: unreadable secret ({e})"))?; verify_conditions(&conditions, parties, min_locktime)?; } + // Shape first, then provenance: a DLEQ failure on a token that is not + // even the right escrow would send the user chasing the wrong problem. + self.inner() + .verify_token_dleq(&token) + .await + .map_err(|e| anyhow!("CashuTokenUnverified: {e}"))?; + + // Last because it is the one that costs a round trip to the mint. + let states = self + .inner() + .check_proofs_spent(proofs) + .await + .map_err(|e| anyhow!("CashuMintUnreachable: state check failed ({e})"))?; + for state in &states { + match state.state { + State::Unspent => {} + State::Spent => bail!("CashuEscrowSpent: the mint reports a proof as spent"), + // In flight elsewhere: a swap that may or may not land. Not + // "spent", but not an escrow anyone should pay against either. + other => bail!("CashuEscrowPending: the mint reports a proof as {other:?}"), + } + } + Ok(()) } @@ -304,11 +353,38 @@ impl CashuWallet { /// This is the seller's release signature and the buyer's cooperative-cancel /// signature: each party signs alone and hands the signatures over, and only /// the combination of two satisfies the 2-of-3. - pub async fn sign_proofs(&self, encoded: &str, key: SecretKey) -> Result> { - let token: Token = encoded - .trim() - .parse() - .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + /// + /// **The token is verified against `parties`, `expected_amount` and + /// `min_locktime` before a single signature is produced**, and there is no + /// form of this function that skips it. Under `SIG_INPUTS` a signature + /// commits to the proof's *secret* and nothing else — not the amount, the + /// keyset, `C`, or the token it arrived in (`Proof::sign_p2pk` signs + /// `secret.to_bytes()`). So a signature harvested over a decoy that reuses + /// the escrow's secrets with the amounts rewritten to 1 sat is valid for + /// the real escrow, and the counterparty is exactly who hands this + /// function its token. Requiring the escrow's own parameters closes that: + /// the decoy fails the amount check before anything is signed. There is no + /// legitimate non-escrow caller — the fee token is redeemed by the node + /// with its own key, not signed through here. + /// + /// What the check cannot do: distinguish two tokens that *are* the same + /// escrow (same parties, amount, locktime, differing only in memo). That is + /// consent to release this escrow, which is what the caller asked for. It + /// also means a release signature and a cooperative-cancel signature over + /// the same escrow authorise the same thing — spending the proofs, not + /// where the value goes. C6/C7 must ensure no party ever signs for both; + /// the constraint belongs in `docs/cashu/README.md` §2, not here. + pub async fn sign_proofs( + &self, + encoded: &str, + key: SecretKey, + parties: &EscrowParties, + expected_amount: u64, + min_locktime: u64, + ) -> Result> { + self.verify_escrow_token(encoded, parties, expected_amount, min_locktime) + .await?; + let token = parse_token(encoded)?; self.proofs_of(&token) .await? @@ -340,22 +416,16 @@ impl CashuWallet { own_key: SecretKey, peer_signatures: &[ProofSignature], ) -> Result { - let token: Token = encoded - .trim() - .parse() - .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; + // Before the mint is contacted: a peer list that names one secret twice + // is malformed, and `collect()` into a map would silently keep the + // *last* entry — a wrong signature that only surfaces as an opaque + // mint error at swap time. + let by_secret = index_signatures(peer_signatures)?; + let token = parse_token(encoded)?; let proofs = self.proofs_of(&token).await?; let mut signed = Vec::with_capacity(proofs.len()); - // Indexed once rather than scanned per proof: linear in the number of - // signatures instead of quadratic, and it makes a duplicate secret in - // the peer's list a visible collision rather than a silent first-wins. - let by_secret: std::collections::HashMap<&str, &ProofSignature> = peer_signatures - .iter() - .map(|s| (s.secret.as_str(), s)) - .collect(); - for mut proof in proofs { let secret = proof.secret.to_string(); let peer = by_secret @@ -393,11 +463,7 @@ impl CashuWallet { encoded: &str, seller_key: SecretKey, ) -> Result { - let token: Token = encoded - .trim() - .parse() - .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})"))?; - + let token = parse_token(encoded)?; let proofs = self.proofs_of(&token).await?; let mut signed = Vec::with_capacity(proofs.len()); @@ -437,6 +503,35 @@ impl CashuWallet { } } +/// Parse an encoded token the way the wallet's receive path does. +/// +/// Same `cashu:` / `cashu://` strip as [`normalize_token`]: a token the wallet +/// accepts on receive must not fail escrow verification as "unparseable", +/// which is the wrong explanation for a missing prefix strip. +fn parse_token(encoded: &str) -> Result { + normalize_token(encoded) + .parse() + .map_err(|e| anyhow!("InvalidEscrowToken: unparseable ({e})")) +} + +/// Peer signatures keyed by secret, rejecting a secret that appears twice. +/// +/// Linear in the number of signatures rather than a scan per proof, and a +/// duplicate is an error here rather than a silent last-wins that the mint +/// would report as a bad witness with no hint of why. +fn index_signatures(signatures: &[ProofSignature]) -> Result> { + let mut by_secret = HashMap::with_capacity(signatures.len()); + for sig in signatures { + if by_secret.insert(sig.secret.as_str(), sig).is_some() { + bail!( + "DuplicatePeerSignature: secret {} appears more than once", + sig.secret + ); + } + } + Ok(by_secret) +} + /// Seconds since the unix epoch, or 0 if the clock is before it — in which /// case every locktime reads as "not reached", which is the safe direction. fn now_unix() -> u64 { @@ -813,7 +908,10 @@ mod tests { ); // Act — seller signs (release), buyer combines and redeems. - let seller_sigs = seller.sign_proofs(&token, seller_sk).await.unwrap(); + let seller_sigs = seller + .sign_proofs(&token, seller_sk, &parties, 16, locktime) + .await + .unwrap(); let received = buyer .combine_and_redeem(&token, buyer_sk, &seller_sigs) .await @@ -894,13 +992,17 @@ mod tests { let (_mostro_sk, mostro_pk) = party(); let (impostor_sk, _) = party(); let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let locktime = future_locktime(); let token = seller - .build_escrow_token(8, &parties, future_locktime()) + .build_escrow_token(8, &parties, locktime) .await .unwrap(); // Act — buyer combines their own valid signature with an impostor's. - let impostor_sigs = seller.sign_proofs(&token, impostor_sk).await.unwrap(); + let impostor_sigs = seller + .sign_proofs(&token, impostor_sk, &parties, 8, locktime) + .await + .unwrap(); let err = buyer .combine_and_redeem(&token, buyer_sk, &impostor_sigs) .await @@ -950,6 +1052,54 @@ mod tests { let _ = std::fs::remove_file(&seller_db); } + #[test] + fn a_token_uri_parses_like_the_wallets_receive_path() { + // Arrange — the same prefixes `normalize_token` accepts. The body is + // not a real token, so the failure must come from the token codec, + // never from the prefix. + for input in ["cashu:cashuBnope", "cashu://cashuBnope", " cashuBnope\n"] { + // Act + let err = parse_token(input).unwrap_err().to_string(); + + // Assert — the error is about the token body, and the marker is + // the escrow one, so a scanned URI never reads as "not an escrow". + assert!(err.starts_with("InvalidEscrowToken: unparseable"), "{input:?}: {err}"); + } + } + + #[test] + fn a_peer_signature_list_is_indexed_by_secret() { + // Arrange + let sigs = vec![ + ProofSignature { secret: "a".into(), signature: "sa".into() }, + ProofSignature { secret: "b".into(), signature: "sb".into() }, + ]; + + // Act + let index = index_signatures(&sigs).unwrap(); + + // Assert + assert_eq!(index.len(), 2); + assert_eq!(index["a"].signature, "sa"); + assert_eq!(index["b"].signature, "sb"); + } + + #[test] + fn a_duplicate_peer_signature_is_rejected_not_last_wins() { + // Arrange — the same secret twice with different signatures. A map + // built with `collect()` would keep the second and say nothing. + let sigs = vec![ + ProofSignature { secret: "a".into(), signature: "first".into() }, + ProofSignature { secret: "a".into(), signature: "second".into() }, + ]; + + // Act + let err = index_signatures(&sigs).unwrap_err(); + + // Assert + assert!(err.to_string().contains("DuplicatePeerSignature"), "got {err}"); + } + #[test] fn three_parties_must_be_three_different_keys() { // Arrange — a duplicate collapses the 2-of-3 into something weaker, and @@ -1023,4 +1173,119 @@ mod tests { "got {err}" ); } + + #[tokio::test] + #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] + async fn signing_refuses_a_decoy_that_reuses_the_escrows_secrets() { + // Arrange — the signing-oracle attack: under SIG_INPUTS a signature + // commits to the secret alone, so a signature over a decoy carrying the + // escrow's secrets with the amounts rewritten is valid for the real + // escrow. The seller must refuse to sign the decoy. + let mint = test_mint_url(); + let seller_db = temp_db_path(); + let buyer_db = temp_db_path(); + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) + .await + .unwrap(); + let buyer = CashuWallet::connect(&mint, unique_seed(), buyer_db.to_str().unwrap()) + .await + .unwrap(); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + + let (seller_sk, seller_pk) = party(); + let (buyer_sk, buyer_pk) = party(); + let (_mostro_sk, mostro_pk) = party(); + let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let locktime = future_locktime(); + let escrow = seller + .build_escrow_token(16, &parties, locktime) + .await + .unwrap(); + + // The buyer's decoy: same secrets and keyset, every amount 1 sat, + // witnesses stripped, a friendly memo. + let real = parse_token(&escrow).unwrap(); + let decoy_proofs: Vec = seller + .proofs_of(&real) + .await + .unwrap() + .into_iter() + .map(|p| Proof::new(cdk::Amount::from(1), p.keyset_id, p.secret, p.c)) + .collect(); + let decoy = Token::new( + real.mint_url().unwrap(), + decoy_proofs, + Some("trivial refund, please sign".into()), + CurrencyUnit::Sat, + ) + .to_string(); + + // Act — the seller is asked to sign the decoy for the escrow they know. + let err = seller + .sign_proofs(&decoy, seller_sk, &parties, 16, locktime) + .await + .unwrap_err(); + + // Assert — refused before any signature exists, on the amount. + assert!(err.to_string().contains("expected 16 sat, got"), "got {err}"); + + // And the escrow is still whole: the buyer alone cannot redeem it. + let err = buyer + .combine_and_redeem(&escrow, buyer_sk, &[]) + .await + .unwrap_err(); + assert!(err.to_string().contains("MissingPeerSignature"), "got {err}"); + + let _ = std::fs::remove_file(&seller_db); + let _ = std::fs::remove_file(&buyer_db); + } + + #[tokio::test] + #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] + async fn a_spent_escrow_fails_verification() { + // Arrange — a structurally perfect token whose proofs are gone: the + // seller locks, the trade settles, and the same token is presented + // again. + let mint = test_mint_url(); + let seller_db = temp_db_path(); + let buyer_db = temp_db_path(); + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) + .await + .unwrap(); + let buyer = CashuWallet::connect(&mint, unique_seed(), buyer_db.to_str().unwrap()) + .await + .unwrap(); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + + let (seller_sk, seller_pk) = party(); + let (buyer_sk, buyer_pk) = party(); + let (_mostro_sk, mostro_pk) = party(); + let parties = EscrowParties::from_xonly_hex(&buyer_pk, &seller_pk, &mostro_pk).unwrap(); + let locktime = future_locktime(); + let token = seller + .build_escrow_token(8, &parties, locktime) + .await + .unwrap(); + buyer + .verify_escrow_token(&token, &parties, 8, locktime) + .await + .expect("fresh escrow verifies"); + let sigs = seller + .sign_proofs(&token, seller_sk, &parties, 8, locktime) + .await + .unwrap(); + buyer.combine_and_redeem(&token, buyer_sk, &sigs).await.unwrap(); + + // Act — the same token, presented after it was redeemed. + let err = buyer + .verify_escrow_token(&token, &parties, 8, locktime) + .await + .unwrap_err(); + + // Assert — the shape is still right; the mint's word is what fails it. + assert!(err.to_string().contains("CashuEscrowSpent"), "got {err}"); + + let _ = std::fs::remove_file(&seller_db); + let _ = std::fs::remove_file(&buyer_db); + } }