From d737edb26fbbad167a258e6519b7de4a73ab19e2 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 23:12:44 -0300 Subject: [PATCH 1/3] =?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/3] =?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/3] 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();