1use 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 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}