Merge cashu-v4-token-support: Cashu v4 (CBOR) token support in the wallet

Same kiosk.service conflict resolution as the previous merge (branch carried
rebased duplicates of the kiosk commits): CPUQuota=200% + main's memory tuning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-10 20:20:47 +01:00
commit 2ca7e9cad2
3 changed files with 159 additions and 21 deletions

1
core/Cargo.lock generated
View File

@ -140,6 +140,7 @@ dependencies = [
"reqwest 0.11.27", "reqwest 0.11.27",
"sd-notify", "sd-notify",
"serde", "serde",
"serde_bytes",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"serial2-tokio", "serial2-tokio",

View File

@ -110,6 +110,7 @@ hkdf = "0.12.4"
# Transport abstraction (Phase 2: mesh as federation transport) # Transport abstraction (Phase 2: mesh as federation transport)
ciborium = "0.2.2" ciborium = "0.2.2"
serde_bytes = "0.11"
reed-solomon-erasure = "6.0" reed-solomon-erasure = "6.0"
mdns-sd = "0.18" mdns-sd = "0.18"

View File

@ -1,6 +1,6 @@
//! Cashu token format (NUT-00) — serialization and deserialization. //! Cashu token format (NUT-00) — serialization and deserialization.
//! //!
//! Supports the cashuA (V3) token format: //! Emits the cashuA (V3) token format:
//! cashuA<base64url_encoded_json> //! cashuA<base64url_encoded_json>
//! //!
//! Token JSON structure: //! Token JSON structure:
@ -8,13 +8,54 @@
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }], //! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
//! "memo": "<optional>" //! "memo": "<optional>"
//! } //! }
//!
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
//! by default now:
//! cashuB<base64url_encoded_cbor>
//! 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 anyhow::{Context, Result};
use bitcoin::secp256k1::PublicKey; use bitcoin::secp256k1::PublicKey;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Prefix for V3 tokens. /// Prefix for V3 (JSON) tokens.
const CASHU_A_PREFIX: &str = "cashuA"; 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<u8>,
// 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<u8>,
p: Vec<ProofV4>,
}
/// Raw CBOR shape of a full V4 token — single mint per token, unlike V3.
#[derive(Debug, Deserialize)]
struct TokenV4 {
t: Vec<TokenEntryV4>,
m: String,
#[serde(default)]
u: Option<String>,
#[serde(default, rename = "d")]
memo: Option<String>,
}
/// A single Cashu proof (a signed token for a specific denomination). /// A single Cashu proof (a signed token for a specific denomination).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -100,31 +141,65 @@ impl CashuToken {
Ok(format!("{}{}", CASHU_A_PREFIX, encoded)) 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<Self> { pub fn deserialize(token_str: &str) -> Result<Self> {
let payload = token_str if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
.strip_prefix(CASHU_A_PREFIX) return Self::deserialize_v4(payload);
.ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?; }
use base64::Engine; let payload = token_str.strip_prefix(CASHU_A_PREFIX).ok_or_else(|| {
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD anyhow::anyhow!(
.decode(payload) "Token must start with '{}' or '{}'",
.or_else(|_| { CASHU_A_PREFIX,
// Try standard base64 as fallback (some implementations use it) CASHU_B_PREFIX
base64::engine::general_purpose::URL_SAFE.decode(payload) )
}) })?;
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
.context("Invalid base64 in cashuA token")?;
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 json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?;
let token: CashuToken = let token: CashuToken =
serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?; serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?;
// Structural validation token.validate()?;
if token.token.is_empty() { Ok(token)
}
/// Decode a cashuB (V4 CBOR) token payload (prefix already stripped).
fn deserialize_v4(payload: &str) -> Result<Self> {
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"); anyhow::bail!("Token has no entries");
} }
for entry in &token.token { for entry in &self.token {
if entry.mint.is_empty() { if entry.mint.is_empty() {
anyhow::bail!("Token entry has empty mint URL"); anyhow::bail!("Token entry has empty mint URL");
} }
@ -143,11 +218,20 @@ impl CashuToken {
} }
} }
} }
Ok(())
Ok(token)
} }
} }
/// 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<Vec<u8>, 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. /// Keyset info returned by a mint.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeysetInfo { pub struct KeysetInfo {
@ -303,11 +387,63 @@ mod tests {
} }
#[test] #[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"); let result = CashuToken::deserialize("cashuBabc123");
assert!(result.is_err()); 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] #[test]
fn test_amount_to_denominations() { fn test_amount_to_denominations() {
assert_eq!(amount_to_denominations(0), Vec::<u64>::new()); assert_eq!(amount_to_denominations(0), Vec::<u64>::new());