moksha_core/
token.rs

1//! This module defines the `Token` struct, which is used for representing tokens in Cashu as described in [Nut-00](https://github.com/cashubtc/nuts/blob/main/00.md)
2//!
3//! The `Token` struct represents a token, with an optional `mint` field for the URL of the Mint and a `proofs` field for the proofs associated with the token.
4
5use std::str::FromStr;
6
7use base64::{engine::general_purpose, Engine as _};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use serde_with::skip_serializing_none;
10use url::Url;
11
12use crate::{error::MokshaCoreError, primitives::CurrencyUnit, proof::Proofs};
13
14const TOKEN_PREFIX_V3: &str = "cashuA";
15
16#[skip_serializing_none]
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct Token {
19    #[serde(serialize_with = "serialize_url", deserialize_with = "deserialize_url")]
20    pub mint: Option<Url>,
21    pub proofs: Proofs,
22}
23
24fn deserialize_url<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
25where
26    D: Deserializer<'de>,
27{
28    let url_str: Option<String> = Option::deserialize(deserializer)?;
29    url_str.map_or_else(
30        || Ok(None),
31        |s| Url::parse(&s).map_err(serde::de::Error::custom).map(Some),
32    )
33}
34
35fn serialize_url<S>(url: &Option<Url>, serializer: S) -> Result<S::Ok, S::Error>
36where
37    S: Serializer,
38{
39    match url {
40        Some(url) => {
41            let mut url_str = url.as_str().to_owned();
42            if url_str.ends_with('/') {
43                url_str.pop();
44            }
45            serializer.serialize_str(&url_str)
46        }
47        None => serializer.serialize_none(),
48    }
49}
50
51#[skip_serializing_none]
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct TokenV3 {
54    #[serde(rename = "token")]
55    pub tokens: Vec<Token>,
56    pub unit: Option<CurrencyUnit>,
57    pub memo: Option<String>,
58}
59
60impl TokenV3 {
61    pub fn new(token: Token) -> Self {
62        Self {
63            tokens: vec![token],
64            memo: None,
65            unit: None,
66        }
67    }
68
69    pub const fn empty() -> Self {
70        Self {
71            tokens: vec![],
72            memo: None,
73            unit: None,
74        }
75    }
76
77    pub fn total_amount(&self) -> u64 {
78        self.tokens
79            .iter()
80            .map(|token| {
81                token
82                    .proofs
83                    .proofs()
84                    .iter()
85                    .map(|proof| proof.amount)
86                    .sum::<u64>()
87            })
88            .sum()
89    }
90
91    pub fn proofs(&self) -> Proofs {
92        Proofs::new(
93            self.tokens
94                .iter()
95                .flat_map(|token| token.proofs.proofs())
96                .collect(),
97        )
98    }
99
100    pub fn serialize(&self) -> Result<String, MokshaCoreError> {
101        let json = serde_json::to_string(&self)?;
102        Ok(format!(
103            "{}{}",
104            TOKEN_PREFIX_V3,
105            general_purpose::URL_SAFE.encode(json.as_bytes())
106        ))
107    }
108
109    pub fn deserialize(data: impl Into<String>) -> Result<Self, MokshaCoreError> {
110        let data = data.into();
111        let token = data
112            .strip_prefix(TOKEN_PREFIX_V3)
113            .ok_or(MokshaCoreError::InvalidTokenPrefix)?;
114
115        let json = general_purpose::URL_SAFE_NO_PAD
116            .decode(token.as_bytes())
117            .or_else(|_| general_purpose::URL_SAFE.decode(token.as_bytes()))
118            .map_err(|_| MokshaCoreError::InvalidToken)?;
119
120        Ok(serde_json::from_slice::<Self>(&json)?)
121    }
122
123    pub fn mint(&self) -> Option<Url> {
124        self.tokens
125            .first()
126            .and_then(|token| token.mint.as_ref())
127            .map(|url| url.to_owned())
128    }
129}
130
131impl TryFrom<TokenV3> for String {
132    type Error = MokshaCoreError;
133
134    fn try_from(token: TokenV3) -> Result<Self, Self::Error> {
135        token.serialize()
136    }
137}
138
139impl TryFrom<String> for TokenV3 {
140    type Error = MokshaCoreError;
141
142    fn try_from(value: String) -> Result<Self, Self::Error> {
143        Self::deserialize(value)
144    }
145}
146
147impl FromStr for TokenV3 {
148    type Err = MokshaCoreError;
149
150    fn from_str(s: &str) -> Result<Self, Self::Err> {
151        Self::deserialize(s)
152    }
153}
154
155impl From<(Url, Proofs)> for TokenV3 {
156    fn from(from: (Url, Proofs)) -> Self {
157        Self {
158            tokens: vec![Token {
159                mint: Some(from.0),
160                proofs: from.1,
161            }],
162            memo: None,
163            unit: None,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use serde_json::{json, Value};
171    use url::Url;
172
173    use crate::{
174        dhke,
175        fixture::read_fixture,
176        primitives::CurrencyUnit,
177        proof::Proof,
178        token::{Token, TokenV3},
179    };
180    use pretty_assertions::assert_eq;
181
182    #[test]
183    fn test_token_v3() -> anyhow::Result<()> {
184        let js = json!(
185        {
186          "token": [
187            {
188              "mint": "https://8333.space:3338",
189              "proofs": [
190                {
191                  "amount": 2,
192                  "id": "009a1f293253e41e",
193                  "secret": "407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837",
194                  "C": "02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea"
195                },
196                {
197                  "amount": 8,
198                  "id": "009a1f293253e41e",
199                  "secret": "fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be",
200                  "C": "029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059"
201                }
202              ]
203            }
204          ],
205          "unit": "sat",
206          "memo": "Thank you."
207        });
208
209        let token = serde_json::from_value::<super::TokenV3>(js)?;
210        assert_eq!(
211            token.tokens[0].mint,
212            Some(Url::parse("https://8333.space:3338")?)
213        );
214        assert_eq!(token.tokens[0].proofs.len(), 2);
215        assert_eq!(token.unit, Some(CurrencyUnit::Sat));
216
217        let token_serialized = token.serialize()?;
218        let fixture = read_fixture("token_nut_example.cashu")?;
219        assert_eq!(token_serialized, fixture);
220        Ok(())
221    }
222
223    #[test]
224    fn test_token() -> anyhow::Result<()> {
225        let js = json!(
226            {
227              "mint": "https://8333.space:3338",
228              "proofs": [
229                {
230                  "id": "DSAl9nvvyfva",
231                  "amount": 2,
232                  "secret": "EhpennC9qB3iFlW8FZ_pZw",
233                  "C": "02c020067db727d586bc3183aecf97fcb800c3f4cc4759f69c626c9db5d8f5b5d4"
234                },
235                {
236                  "id": "DSAl9nvvyfva",
237                  "amount": 8,
238                  "secret": "TmS6Cv0YT5PU_5ATVKnukw",
239                  "C": "02ac910bef28cbe5d7325415d5c263026f15f9b967a079ca9779ab6e5c2db133a7"
240                }
241              ]
242        });
243
244        let token = serde_json::from_value::<super::Token>(js)?;
245        assert_eq!(token.mint, Some(Url::parse("https://8333.space:3338")?));
246        assert_eq!(token.proofs.len(), 2);
247        Ok(())
248    }
249
250    #[test]
251    fn test_tokens_serialize() -> anyhow::Result<()> {
252        use base64::{engine::general_purpose, Engine as _};
253        let token = Token {
254            mint: Some(Url::parse("https://8333.space:3338/")?),
255            proofs: Proof {
256                amount: 21,
257                secret: "secret".to_string(),
258                c: dhke::public_key_from_hex(
259                    "02c020067db727d586bc3183aecf97fcb800c3f4cc4759f69c626c9db5d8f5b5d4",
260                ),
261                keyset_id: "someid".to_string(),
262                script: None,
263            }
264            .into(),
265        };
266        let tokens = super::TokenV3 {
267            tokens: vec![token],
268            memo: Some("my memo".to_string()),
269            unit: None,
270        };
271
272        let serialized: String = tokens.try_into()?;
273        assert!(serialized.starts_with("cashuA"));
274
275        // check if mint is serialized without trailing slash
276        let json = general_purpose::URL_SAFE.decode(serialized.strip_prefix("cashuA").unwrap())?;
277        let deser = String::from_utf8(json)?;
278        let json: Value = serde_json::from_str(&deser)?;
279        let mint_value = json["token"][0]["mint"].as_str();
280        assert_eq!(mint_value, Some("https://8333.space:3338"));
281        Ok(())
282    }
283
284    #[test]
285    fn test_tokens_deserialize() -> anyhow::Result<()> {
286        let input = read_fixture("token_nut_example.cashu")?;
287        let tokens = TokenV3::deserialize(input)?;
288        assert_eq!(tokens.memo, Some("Thank you.".to_string()),);
289        assert_eq!(tokens.tokens.len(), 1);
290        Ok(())
291    }
292
293    #[test]
294    fn test_tokens_deserialize_no_pad() -> anyhow::Result<()> {
295        let input = read_fixture("token_no_pad60.cashu")?;
296        let tokens = TokenV3::deserialize(input)?;
297        assert_eq!(tokens.memo, None);
298        assert_eq!(tokens.tokens.len(), 1);
299        Ok(())
300    }
301
302    #[test]
303    fn test_tokens_deserialize_with_padding() -> anyhow::Result<()> {
304        let input = read_fixture("token_60.cashu")?;
305        let tokens = TokenV3::deserialize(input)?;
306        assert_eq!(tokens.tokens.len(), 1);
307        Ok(())
308    }
309
310    #[test]
311    fn test_tokens_deserialize_invalid() -> anyhow::Result<()> {
312        let input = read_fixture("token_invalid.cashu")?;
313        let tokens = TokenV3::deserialize(input);
314        assert!(tokens.is_err());
315        Ok(())
316    }
317}