Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/cashu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ serde_with.workspace = true
regex = { workspace = true, optional = true }
strum = { workspace = true, optional = true }
strum_macros = { workspace = true, optional = true }
unicode-normalization = "0.1"
zeroize = "1"

[target.'cfg(target_arch = "wasm32")'.dependencies]
Expand Down
6 changes: 3 additions & 3 deletions crates/cashu/src/nuts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ pub use auth::{
ClearAuthSettings, Method, MintAuthRequest, ProtectedEndpoint, RoutePath,
};
pub use nut00::{
BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proof, Proofs, ProofsMethods,
Token, TokenV3, TokenV4, Witness,
BlindSignature, BlindedMessage, PaymentMethod, Proof, Proofs, ProofsMethods, Token, TokenV3,
TokenV4, Witness,
};
#[cfg(feature = "wallet")]
pub use nut00::{PreMint, PreMintSecrets};
pub use nut01::{Keys, KeysResponse, PublicKey, SecretKey};
pub use nut01::{CurrencyUnit, Keys, KeysResponse, PublicKey, SecretKey};
#[cfg(feature = "mint")]
pub use nut02::MintKeySet;
pub use nut02::{Id, KeySet, KeySetInfo, KeysetResponse};
Expand Down
95 changes: 0 additions & 95 deletions crates/cashu/src/nuts/nut00/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,93 +553,6 @@ where
PublicKey::from_slice(&bytes).map_err(serde::de::Error::custom)
}

/// Currency Unit
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub enum CurrencyUnit {
/// Sat
#[default]
Sat,
/// Msat
Msat,
/// Usd
Usd,
/// Euro
Eur,
/// Auth
Auth,
/// Custom currency unit
Custom(String),
}

#[cfg(feature = "mint")]
impl CurrencyUnit {
/// Derivation index mint will use for unit
pub fn derivation_index(&self) -> Option<u32> {
match self {
Self::Sat => Some(0),
Self::Msat => Some(1),
Self::Usd => Some(2),
Self::Eur => Some(3),
Self::Auth => Some(4),
_ => None,
}
}
}

impl FromStr for CurrencyUnit {
type Err = Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let upper_value = value.to_uppercase();
match upper_value.as_str() {
"SAT" => Ok(Self::Sat),
"MSAT" => Ok(Self::Msat),
"USD" => Ok(Self::Usd),
"EUR" => Ok(Self::Eur),
"AUTH" => Ok(Self::Auth),
_ => Ok(Self::Custom(value.to_string())),
}
}
}

impl fmt::Display for CurrencyUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
CurrencyUnit::Sat => "SAT",
CurrencyUnit::Msat => "MSAT",
CurrencyUnit::Usd => "USD",
CurrencyUnit::Eur => "EUR",
CurrencyUnit::Auth => "AUTH",
CurrencyUnit::Custom(unit) => unit,
};
if let Some(width) = f.width() {
write!(f, "{:width$}", s.to_lowercase(), width = width)
} else {
write!(f, "{}", s.to_lowercase())
}
}
}

impl Serialize for CurrencyUnit {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}

impl<'de> Deserialize<'de> for CurrencyUnit {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let currency: String = String::deserialize(deserializer)?;
Self::from_str(&currency).map_err(|_| serde::de::Error::custom("Unsupported unit"))
}
}

/// Payment Method
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
Expand Down Expand Up @@ -978,14 +891,6 @@ mod tests {
assert_eq!(b.len(), 1);
}

#[test]
fn custom_unit_ser_der() {
let unit = CurrencyUnit::Custom(String::from("test"));
let serialized = serde_json::to_string(&unit).unwrap();
let deserialized: CurrencyUnit = serde_json::from_str(&serialized).unwrap();
assert_eq!(unit, deserialized)
}

#[test]
fn test_payment_method_parsing() {
// Test standard variants
Expand Down
163 changes: 163 additions & 0 deletions crates/cashu/src/nuts/nut01/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
use std::collections::BTreeMap;
use std::fmt;
use std::ops::{Deref, DerefMut};
use std::str::FromStr;
use unicode_normalization::UnicodeNormalization;

use bitcoin::hashes::sha256::Hash as Sha256;
use bitcoin::hashes::Hash;
use bitcoin::secp256k1;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
Expand Down Expand Up @@ -192,6 +196,130 @@ impl MintKeyPair {
}
}

/// Currency Unit
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub enum CurrencyUnit {
/// Sat
#[default]
Sat,
/// Msat
Msat,
/// Usd
Usd,
/// Euro
Eur,
/// Auth
Auth,
/// Custom currency unit
Custom(String),
}

#[cfg(feature = "mint")]
impl CurrencyUnit {
/// Deterministic derivation index for each currency unit
///
/// - use SHA-256 so results are identical across platforms
/// and rust versions
/// - NFC normalization, lowercasing, and trim to ensure logically
/// equivalent strings map to the same index.
/// - reserve a low integer range for known variants
pub fn derivation_index(&self) -> Option<u32> {
// reserved to ensure backward compatibility for hardcoded derivation paths
const RESERVED: u32 = 5;

match self {
Self::Sat => Some(0),
Self::Msat => Some(1),
Self::Usd => Some(2),
Self::Eur => Some(3),
Self::Auth => Some(4),
Self::Custom(s) => Self::custom_derivation_index(s, RESERVED),
}
}

/// Generate deterministic derivation index for custom currency units
fn custom_derivation_index(s: &str, reserved: u32) -> Option<u32> {
// 1) NFC normalization: composes equivalent Unicode sequences (e.g., "e" + U+0301) into a single
// canonical code point (e.g., "é") so visually/equivalently identical strings hash the same
// 2) lowercase: avoids case-induced divergence ("USD" vs "usd")
// 3) trim: removes accidental leading/trailing whitespace (" usd " vs "usd")
let norm = s.nfc().collect::<String>().to_lowercase();
let norm = norm.trim();

// use SHA-256 so that the same normalized string always yields the same digest
let digest = Sha256::hash(norm.as_bytes());

// take 4 bytes in a fixed endianness to get a u32
let x = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]) as u64;

// map x into the inclusive interval [RESERVED, u32::MAX].
// compute the size of that interval:
// size = (u32::MAX - RESERVED + 1)
// use u64 math to avoid overflow.
let interval_size = (u32::MAX as u64) - (reserved as u64) + 1;

// Fold x uniformly into [0, interval_size - 1].
let r = (x % interval_size) as u32;

// shift into [RESERVED, u32::MAX], guaranteeing no overlap with reserved band [0, RESERVED-1].
Some(reserved + r)
}
}

impl FromStr for CurrencyUnit {
type Err = Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let upper_value = value.to_uppercase();
match upper_value.as_str() {
"SAT" => Ok(Self::Sat),
"MSAT" => Ok(Self::Msat),
"USD" => Ok(Self::Usd),
"EUR" => Ok(Self::Eur),
"AUTH" => Ok(Self::Auth),
_ => Ok(Self::Custom(value.to_string())),
}
}
}

impl fmt::Display for CurrencyUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
CurrencyUnit::Sat => "SAT",
CurrencyUnit::Msat => "MSAT",
CurrencyUnit::Usd => "USD",
CurrencyUnit::Eur => "EUR",
CurrencyUnit::Auth => "AUTH",
CurrencyUnit::Custom(unit) => unit,
};
if let Some(width) = f.width() {
write!(f, "{:width$}", s.to_lowercase(), width = width)
} else {
write!(f, "{}", s.to_lowercase())
}
}
}

impl Serialize for CurrencyUnit {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}

impl<'de> Deserialize<'de> for CurrencyUnit {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let currency: String = String::deserialize(deserializer)?;
Self::from_str(&currency).map_err(|_| serde::de::Error::custom("Unsupported unit"))
}
}

#[cfg(test)]
mod tests {
use std::str::FromStr;
Expand Down Expand Up @@ -247,4 +375,39 @@ mod tests {
let response: Result<Keys, serde_json::Error> = serde_json::from_str(incorrect_1);
assert!(response.is_ok());
}

#[test]
fn custom_unit_ser_der() {
let unit = CurrencyUnit::Custom(String::from("test"));
let serialized = serde_json::to_string(&unit).unwrap();
let deserialized: CurrencyUnit = serde_json::from_str(&serialized).unwrap();
assert_eq!(unit, deserialized)
}

#[test]
fn currency_unit_unicode_equivalents_match() {
// "é" as composed vs decomposed; both should normalize and hash to same index
let composed = CurrencyUnit::Custom("café".into());
let decomposed = CurrencyUnit::Custom("cafe\u{0301}".into());
assert_eq!(composed.derivation_index(), decomposed.derivation_index());
}

#[test]
fn currency_unit_case_and_whitespace_ignored() {
let a = CurrencyUnit::Custom(" UsD ".into());
let b = CurrencyUnit::Custom("usd".into());
assert_eq!(a.derivation_index(), b.derivation_index());
}

#[cfg(all(test, feature = "mint"))]
#[test]
fn currency_unit_custom_unit_derivation_index() {
let unit = CurrencyUnit::Custom("nuts".into());

let idx = unit
.derivation_index()
.expect("custom units should always produce an index");

assert_eq!(idx, 2425615040);
}
}
2 changes: 1 addition & 1 deletion crates/cashu/src/nuts/nut02.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use thiserror::Error;
use super::nut01::Keys;
#[cfg(feature = "mint")]
use super::nut01::{MintKeyPair, MintKeys};
use crate::nuts::nut00::CurrencyUnit;
use crate::nuts::nut01::CurrencyUnit;
use crate::util::hex;
use crate::{ensure_cdk, Amount};

Expand Down
3 changes: 2 additions & 1 deletion crates/cashu/src/nuts/nut04.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ use thiserror::Error;
#[cfg(feature = "mint")]
use uuid::Uuid;

use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod};
use super::nut00::{BlindSignature, BlindedMessage, PaymentMethod};
use super::nut01::CurrencyUnit;
use crate::Amount;

/// NUT04 Error
Expand Down
3 changes: 2 additions & 1 deletion crates/cashu/src/nuts/nut05.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use thiserror::Error;
#[cfg(feature = "mint")]
use uuid::Uuid;

use super::nut00::{BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
use super::nut00::{BlindedMessage, PaymentMethod, Proofs};
use super::nut01::CurrencyUnit;
use super::ProofsMethods;
use crate::Amount;

Expand Down
2 changes: 1 addition & 1 deletion crates/cashu/src/nuts/nut06.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use super::nut19::CachedEndpoint;
use super::{nut04, nut05, nut15, nut19, MppMethodSettings};
#[cfg(feature = "auth")]
use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint};
use crate::CurrencyUnit;
use crate::nuts::CurrencyUnit;

/// Mint Version
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
Expand Down
5 changes: 5 additions & 0 deletions crates/cdk-payment-processor/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ pub enum Error {
/// NUT00 Error
#[error(transparent)]
NUT00(#[from] cdk_common::nuts::nut00::Error),
/// NUT01 Error
#[error(transparent)]
NUT01(#[from] cdk_common::nuts::nut01::Error),
/// NUT05 error
#[error(transparent)]
NUT05(#[from] cdk_common::nuts::nut05::Error),
Expand All @@ -53,6 +56,7 @@ impl From<Error> for Status {
Error::Hex(err) => Status::invalid_argument(format!("Hex decode error: {err}")),
Error::Bolt12Parse => Status::invalid_argument("BOLT12 parse error"),
Error::NUT00(err) => Status::internal(format!("NUT00 error: {err}")),
Error::NUT01(err) => Status::internal(format!("NUT01 error: {err}")),
Error::NUT05(err) => Status::internal(format!("NUT05 error: {err}")),
Error::Payment(err) => Status::internal(format!("Payment error: {err}")),
}
Expand All @@ -74,6 +78,7 @@ impl From<Error> for cdk_common::payment::Error {
Error::Hex(err) => Self::Custom(format!("Hex decode error: {err}")),
Error::Bolt12Parse => Self::Custom("BOLT12 parse error".to_string()),
Error::NUT00(err) => Self::Custom(format!("NUT00 error: {err}")),
Error::NUT01(err) => Self::Custom(format!("NUT01 error: {err}")),
Error::NUT05(err) => err.into(),
Error::Payment(err) => err,
}
Expand Down
Loading