//! Cashu token format (NUT-00) — serialization and deserialization. //! //! Supports the cashuA (V3) token format: //! cashuA //! //! Token JSON structure: //! { //! "token": [{ "mint": "", "proofs": [{ "amount": u64, "id": "", "secret": "", "C": "" }] }], //! "memo": "" //! } use anyhow::{Context, Result}; use bitcoin::secp256k1::PublicKey; use serde::{Deserialize, Serialize}; /// Prefix for V3 tokens. const CASHU_A_PREFIX: &str = "cashuA"; /// A single Cashu proof (a signed token for a specific denomination). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Proof { /// Denomination in the mint's unit (sats). pub amount: u64, /// Keyset ID (hex string, e.g. "009a1f293253e41e"). pub id: String, /// The secret (random hex string or NUT-10 structured secret). pub secret: String, /// The unblinded signature C as hex-encoded compressed public key. #[serde(rename = "C")] pub c: String, } impl Proof { /// Parse the C field as a secp256k1 PublicKey. pub fn c_as_pubkey(&self) -> Result { let bytes = hex::decode(&self.c).context("Invalid hex in proof C field")?; PublicKey::from_slice(&bytes).context("Invalid public key in proof C field") } } /// A group of proofs from a single mint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenEntry { /// Mint URL. pub mint: String, /// Proofs from this mint. pub proofs: Vec, } /// The full cashuA token envelope. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CashuToken { /// Token entries grouped by mint. pub token: Vec, /// Optional memo. #[serde(skip_serializing_if = "Option::is_none")] pub memo: Option, /// Optional unit (e.g. "sat"). #[serde(skip_serializing_if = "Option::is_none")] pub unit: Option, } impl CashuToken { /// Create a new token with proofs from a single mint. pub fn new(mint_url: &str, proofs: Vec) -> Self { Self { token: vec![TokenEntry { mint: mint_url.to_string(), proofs, }], memo: None, unit: Some("sat".to_string()), } } /// Total value of all proofs across all mints. pub fn total_amount(&self) -> u64 { self.token .iter() .flat_map(|e| &e.proofs) .map(|p| p.amount) .sum() } /// All proofs across all mint entries. pub fn all_proofs(&self) -> Vec<&Proof> { self.token.iter().flat_map(|e| &e.proofs).collect() } /// All unique mint URLs in this token. pub fn mint_urls(&self) -> Vec<&str> { self.token.iter().map(|e| e.mint.as_str()).collect() } /// Encode as a cashuA token string. pub fn serialize(&self) -> Result { let json = serde_json::to_string(self).context("Failed to serialize token JSON")?; use base64::Engine; let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes()); Ok(format!("{}{}", CASHU_A_PREFIX, encoded)) } /// Decode a cashuA token string. pub fn deserialize(token_str: &str) -> Result { let payload = token_str .strip_prefix(CASHU_A_PREFIX) .ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?; use base64::Engine; let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload) .or_else(|_| { // Try standard base64 as fallback (some implementations use it) base64::engine::general_purpose::URL_SAFE.decode(payload) }) .or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload)) .context("Invalid base64 in cashuA token")?; let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?; let token: CashuToken = serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?; // Structural validation if token.token.is_empty() { anyhow::bail!("Token has no entries"); } for entry in &token.token { if entry.mint.is_empty() { anyhow::bail!("Token entry has empty mint URL"); } if entry.proofs.is_empty() { anyhow::bail!("Token entry has no proofs"); } for proof in &entry.proofs { if proof.amount == 0 { anyhow::bail!("Proof has zero amount"); } if proof.secret.is_empty() { anyhow::bail!("Proof has empty secret"); } if proof.c.is_empty() { anyhow::bail!("Proof has empty C"); } } } Ok(token) } } /// Keyset info returned by a mint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeysetInfo { pub id: String, pub unit: String, pub active: bool, } /// Mint keyset: maps denomination amounts to public keys. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MintKeyset { pub id: String, /// Map of amount (as string) to hex-encoded public key. pub keys: std::collections::HashMap, } impl MintKeyset { /// Get the mint's public key for a given denomination amount. pub fn key_for_amount(&self, amount: u64) -> Result { let amount_str = amount.to_string(); let hex_key = self .keys .get(&amount_str) .ok_or_else(|| anyhow::anyhow!("No key for amount {} in keyset {}", amount, self.id))?; let bytes = hex::decode(hex_key).context("Invalid hex in mint pubkey")?; PublicKey::from_slice(&bytes).context("Invalid pubkey in mint keyset") } } /// Blinded message sent to the mint during mint/swap. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlindedMessageRequest { /// Amount for this output. pub amount: u64, /// Keyset ID to use. pub id: String, /// Blinded secret B_ as hex-encoded compressed pubkey. #[serde(rename = "B_")] pub b_prime: String, } /// Blind signature returned by the mint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlindSignature { /// Amount signed. pub amount: u64, /// Keyset ID. pub id: String, /// Blind signature C_ as hex-encoded compressed pubkey. #[serde(rename = "C_")] pub c_prime: String, } impl BlindSignature { /// Parse C_ as a secp256k1 PublicKey. pub fn c_prime_as_pubkey(&self) -> Result { let bytes = hex::decode(&self.c_prime).context("Invalid hex in blind signature C_")?; PublicKey::from_slice(&bytes).context("Invalid pubkey in blind signature C_") } } /// Split a target amount into powers of 2 (Cashu denomination scheme). /// E.g., 13 -> [1, 4, 8] pub fn amount_to_denominations(mut amount: u64) -> Vec { let mut denoms = Vec::new(); let mut bit = 0; while amount > 0 { if amount & 1 == 1 { denoms.push(1u64 << bit); } amount >>= 1; bit += 1; } denoms } #[cfg(test)] mod tests { use super::*; #[test] fn test_serialize_deserialize_roundtrip() { let token = CashuToken { token: vec![TokenEntry { mint: "http://127.0.0.1:8175".to_string(), proofs: vec![Proof { amount: 8, id: "009a1f293253e41e".to_string(), secret: "abcdef1234567890".to_string(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24" .to_string(), }], }], memo: Some("test token".to_string()), unit: Some("sat".to_string()), }; let encoded = token.serialize().unwrap(); assert!(encoded.starts_with("cashuA")); let decoded = CashuToken::deserialize(&encoded).unwrap(); assert_eq!(decoded.total_amount(), 8); assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175"); assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890"); assert_eq!(decoded.memo, Some("test token".to_string())); } #[test] fn test_total_amount_multi_proof() { let token = CashuToken { token: vec![TokenEntry { mint: "http://mint".to_string(), proofs: vec![ Proof { amount: 1, id: "id1".into(), secret: "s1".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24" .into(), }, Proof { amount: 4, id: "id1".into(), secret: "s2".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24" .into(), }, Proof { amount: 8, id: "id1".into(), secret: "s3".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24" .into(), }, ], }], memo: None, unit: None, }; assert_eq!(token.total_amount(), 13); } #[test] fn test_deserialize_rejects_empty_token() { let bad = CashuToken { token: vec![], memo: None, unit: None, }; let encoded = bad.serialize().unwrap(); let result = CashuToken::deserialize(&encoded); assert!(result.is_err()); } #[test] fn test_deserialize_rejects_invalid_prefix() { let result = CashuToken::deserialize("cashuBabc123"); assert!(result.is_err()); } #[test] fn test_amount_to_denominations() { assert_eq!(amount_to_denominations(0), Vec::::new()); assert_eq!(amount_to_denominations(1), vec![1]); assert_eq!(amount_to_denominations(13), vec![1, 4, 8]); assert_eq!(amount_to_denominations(21), vec![1, 4, 16]); assert_eq!(amount_to_denominations(64), vec![64]); assert_eq!( amount_to_denominations(255), vec![1, 2, 4, 8, 16, 32, 64, 128] ); } #[test] fn test_amount_to_denominations_large() { let denoms = amount_to_denominations(1_000_000); let sum: u64 = denoms.iter().sum(); assert_eq!(sum, 1_000_000); } #[test] fn test_proof_c_as_pubkey() { let proof = Proof { amount: 1, id: "test".into(), secret: "s".into(), c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".to_string(), }; assert!(proof.c_as_pubkey().is_ok()); } }