Skip to main content

cashu/
quote_id.rs

1//! Quote ID. The specifications only define a string but CDK uses Uuid, so we use an enum to port compatibility.
2use std::fmt;
3use std::str::FromStr;
4
5use bitcoin::base64::engine::general_purpose;
6use bitcoin::base64::Engine as _;
7use serde::{de, Deserialize, Deserializer, Serialize};
8use thiserror::Error;
9use uuid::Uuid;
10
11/// Invalid UUID
12#[derive(Debug, Error)]
13pub enum QuoteIdError {
14    /// UUID Error
15    #[error("invalid UUID: {0}")]
16    Uuid(#[from] uuid::Error),
17    /// Invalid base64
18    #[error("invalid base64")]
19    Base64,
20    /// Invalid quote ID
21    #[error("neither a valid UUID nor a valid base64 string")]
22    InvalidQuoteId,
23}
24
25/// Mint Quote ID
26#[derive(Serialize, Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash)]
27#[serde(untagged)]
28pub enum QuoteId {
29    /// (Nutshell) base64 quote ID
30    BASE64(String),
31    /// UUID quote ID
32    UUID(Uuid),
33}
34
35impl QuoteId {
36    /// Create a new UUID-based MintQuoteId
37    pub fn new() -> Self {
38        Self::UUID(Uuid::now_v7())
39    }
40}
41
42impl Default for QuoteId {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl From<Uuid> for QuoteId {
49    fn from(uuid: Uuid) -> Self {
50        Self::UUID(uuid)
51    }
52}
53
54impl fmt::Display for QuoteId {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            QuoteId::BASE64(s) => write!(f, "{s}"),
58            QuoteId::UUID(u) => write!(f, "{}", u.hyphenated()),
59        }
60    }
61}
62
63impl FromStr for QuoteId {
64    type Err = QuoteIdError;
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        // Try UUID first
68        if let Ok(u) = Uuid::parse_str(s) {
69            return Ok(QuoteId::UUID(u));
70        }
71
72        // Try base64: decode, then re-encode and compare to ensure canonical form
73        // Use the standard (URL/filename safe or standard) depending on your needed alphabet.
74        // Here we use standard base64.
75        match general_purpose::URL_SAFE.decode(s) {
76            Ok(_bytes) => Ok(QuoteId::BASE64(s.to_string())),
77            Err(_) => Err(QuoteIdError::InvalidQuoteId),
78        }
79    }
80}
81
82impl<'de> Deserialize<'de> for QuoteId {
83    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84    where
85        D: Deserializer<'de>,
86    {
87        // Deserialize as plain string first
88        let s = String::deserialize(deserializer)?;
89
90        // Try UUID first
91        if let Ok(u) = Uuid::parse_str(&s) {
92            return Ok(QuoteId::UUID(u));
93        }
94
95        if general_purpose::URL_SAFE.decode(&s).is_ok() {
96            return Ok(QuoteId::BASE64(s));
97        }
98
99        // Neither matched — return a helpful error
100        Err(de::Error::custom(format!(
101            "QuoteId must be either a UUID (e.g. {}) or a valid base64 string; got: {}",
102            Uuid::nil(),
103            s
104        )))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_quote_id_display_uuid() {
114        // Test UUID display - should be hyphenated format
115        let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
116        let quote_id = QuoteId::UUID(uuid);
117        let displayed = quote_id.to_string();
118        assert_eq!(displayed, "550e8400-e29b-41d4-a716-446655440000");
119        assert!(!displayed.is_empty());
120
121        // Verify roundtrip works for UUID
122        let parsed: QuoteId = displayed.parse().unwrap();
123        assert_eq!(quote_id, parsed);
124    }
125
126    #[test]
127    fn test_quote_id_new_uses_uuid_v7() {
128        let QuoteId::UUID(uuid) = QuoteId::new() else {
129            panic!("new should create a UUID quote ID");
130        };
131
132        assert_eq!(uuid.get_version(), Some(uuid::Version::SortRand));
133    }
134
135    #[test]
136    fn test_quote_id_from_uuid_preserves_uuid() {
137        let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
138
139        assert_eq!(QuoteId::from(uuid), QuoteId::UUID(uuid));
140    }
141
142    #[test]
143    fn test_quote_id_display_base64() {
144        // Test BASE64 display - should output the string as-is
145        let base64_str = "SGVsbG8gV29ybGQh"; // "Hello World!" with proper padding
146        let base64_id = QuoteId::BASE64(base64_str.to_string());
147        let displayed = base64_id.to_string();
148        assert_eq!(displayed, base64_str);
149        assert!(!displayed.is_empty());
150
151        // Verify roundtrip works for base64
152        let parsed: QuoteId = displayed.parse().unwrap();
153        assert_eq!(base64_id, parsed);
154    }
155
156    #[test]
157    fn test_quote_id_deserialize_uuid_preserves_uuid() {
158        let quote_id: QuoteId =
159            serde_json::from_str(r#""550e8400-e29b-41d4-a716-446655440000""#).unwrap();
160
161        assert_eq!(
162            quote_id,
163            QuoteId::UUID(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap())
164        );
165    }
166
167    #[test]
168    fn test_quote_id_deserialize_base64_preserves_base64() {
169        let quote_id: QuoteId = serde_json::from_str(r#""SGVsbG8gV29ybGQh""#).unwrap();
170
171        assert_eq!(quote_id, QuoteId::BASE64("SGVsbG8gV29ybGQh".to_string()));
172    }
173
174    #[test]
175    fn test_quote_id_deserialize_rejects_invalid_id() {
176        let err = serde_json::from_str::<QuoteId>(r#""not a quote id""#).unwrap_err();
177
178        assert!(err.to_string().contains("QuoteId must be either a UUID"));
179    }
180}