From 95d6bf5dace35730c58068c7b2b02fcff95cb1db Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 1 Jul 2026 20:50:19 +0000 Subject: [PATCH] not sure --- core/Cargo.lock | 1 + core/archipelago/Cargo.toml | 1 + core/archipelago/src/wallet/cashu.rs | 178 +++++++++++++++++++++++---- 3 files changed, 159 insertions(+), 21 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 4596ff06..957a8cfd 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -139,6 +139,7 @@ dependencies = [ "reqwest 0.11.27", "sd-notify", "serde", + "serde_bytes", "serde_json", "serde_yaml", "serial2-tokio", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 157c1d00..ddbf0964 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -109,6 +109,7 @@ hkdf = "0.12.4" # Transport abstraction (Phase 2: mesh as federation transport) ciborium = "0.2.2" +serde_bytes = "0.11" reed-solomon-erasure = "6.0" mdns-sd = "0.18" diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 17865853..5a4fc8ed 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -1,6 +1,6 @@ //! Cashu token format (NUT-00) — serialization and deserialization. //! -//! Supports the cashuA (V3) token format: +//! Emits the cashuA (V3) token format: //! cashuA //! //! Token JSON structure: @@ -8,13 +8,54 @@ //! "token": [{ "mint": "", "proofs": [{ "amount": u64, "id": "", "secret": "", "C": "" }] }], //! "memo": "" //! } +//! +//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit +//! by default now: +//! cashuB +//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w), +//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw +//! bytes on the wire; we hex-encode them into `Proof` to match the V3 +//! convention so the rest of the wallet doesn't need to know which version +//! a token arrived in. use anyhow::{Context, Result}; use bitcoin::secp256k1::PublicKey; use serde::{Deserialize, Serialize}; -/// Prefix for V3 tokens. +/// Prefix for V3 (JSON) tokens. const CASHU_A_PREFIX: &str = "cashuA"; +/// Prefix for V4 (CBOR) tokens. +const CASHU_B_PREFIX: &str = "cashuB"; + +/// Raw CBOR shape of a V4 proof — field names are the spec's map keys. +#[derive(Debug, Deserialize)] +struct ProofV4 { + a: u64, + s: String, + #[serde(with = "serde_bytes")] + c: Vec, + // DLEQ proof ("d") and witness ("w") aren't verified or stored by this + // wallet; accept and discard them rather than fail on the field. +} + +/// Raw CBOR shape of a V4 token entry (one keyset's worth of proofs). +#[derive(Debug, Deserialize)] +struct TokenEntryV4 { + #[serde(with = "serde_bytes")] + i: Vec, + p: Vec, +} + +/// Raw CBOR shape of a full V4 token — single mint per token, unlike V3. +#[derive(Debug, Deserialize)] +struct TokenV4 { + t: Vec, + m: String, + #[serde(default)] + u: Option, + #[serde(default, rename = "d")] + memo: Option, +} /// A single Cashu proof (a signed token for a specific denomination). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -100,31 +141,65 @@ impl CashuToken { Ok(format!("{}{}", CASHU_A_PREFIX, encoded)) } - /// Decode a cashuA token string. + /// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) 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))?; + if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) { + return Self::deserialize_v4(payload); + } - 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 payload = token_str.strip_prefix(CASHU_A_PREFIX).ok_or_else(|| { + anyhow::anyhow!( + "Token must start with '{}' or '{}'", + CASHU_A_PREFIX, + CASHU_B_PREFIX + ) + })?; + let decoded = decode_token_base64(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() { + token.validate()?; + Ok(token) + } + + /// Decode a cashuB (V4 CBOR) token payload (prefix already stripped). + fn deserialize_v4(payload: &str) -> Result { + let decoded = decode_token_base64(payload).context("Invalid base64 in cashuB token")?; + + let v4: TokenV4 = + ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?; + + let proofs = v4 + .t + .into_iter() + .flat_map(|entry| { + let keyset_id = hex::encode(&entry.i); + entry.p.into_iter().map(move |p| Proof { + amount: p.a, + id: keyset_id.clone(), + secret: p.s, + c: hex::encode(&p.c), + }) + }) + .collect(); + + let token = CashuToken { + token: vec![TokenEntry { mint: v4.m, proofs }], + memo: v4.memo, + unit: v4.u, + }; + token.validate()?; + Ok(token) + } + + /// Structural validation shared by both token versions. + fn validate(&self) -> Result<()> { + if self.token.is_empty() { anyhow::bail!("Token has no entries"); } - for entry in &token.token { + for entry in &self.token { if entry.mint.is_empty() { anyhow::bail!("Token entry has empty mint URL"); } @@ -143,11 +218,20 @@ impl CashuToken { } } } - - Ok(token) + Ok(()) } } +/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec +/// default) and falling back to other alphabets some implementations use. +fn decode_token_base64(payload: &str) -> Result, base64::DecodeError> { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload)) +} + /// Keyset info returned by a mint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeysetInfo { @@ -303,11 +387,63 @@ mod tests { } #[test] - fn test_deserialize_rejects_invalid_prefix() { + fn test_deserialize_rejects_unknown_prefix() { + let result = CashuToken::deserialize("cashuZabc123"); + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rejects_malformed_v4_cbor() { let result = CashuToken::deserialize("cashuBabc123"); assert!(result.is_err()); } + #[test] + fn test_deserialize_v4_cbor_token() { + // Hand-built (not via our own encoder) to verify we actually match + // the NUT-00 V4 wire format: single-letter CBOR map keys, raw-byte + // keyset id ("i") and signature ("c"). + use base64::Engine; + use ciborium::value::Value; + + let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e]; + let sig = hex::decode( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24", + ) + .unwrap(); + + let proof = Value::Map(vec![ + (Value::from("a"), Value::from(8u64)), + (Value::from("s"), Value::from("abcdef1234567890")), + (Value::from("c"), Value::from(sig.clone())), + ]); + let entry = Value::Map(vec![ + (Value::from("i"), Value::from(keyset_id.clone())), + (Value::from("p"), Value::Array(vec![proof])), + ]); + let token = Value::Map(vec![ + (Value::from("t"), Value::Array(vec![entry])), + (Value::from("m"), Value::from("http://127.0.0.1:8175")), + (Value::from("u"), Value::from("sat")), + (Value::from("d"), Value::from("test token")), + ]); + + let mut buf = Vec::new(); + ciborium::into_writer(&token, &mut buf).unwrap(); + let encoded = format!( + "cashuB{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf) + ); + + 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.token[0].proofs[0].id, hex::encode(&keyset_id)); + assert_eq!(decoded.token[0].proofs[0].c, hex::encode(&sig)); + assert_eq!(decoded.memo, Some("test token".to_string())); + } + #[test] fn test_amount_to_denominations() { assert_eq!(amount_to_denominations(0), Vec::::new());