Skip to main content

cashu/nuts/nut00/
token.rs

1//! Cashu Token
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/00.md>
4
5use std::collections::{BTreeSet, HashMap, HashSet};
6use std::fmt;
7use std::str::FromStr;
8
9use bitcoin::base64::engine::{general_purpose, GeneralPurpose};
10use bitcoin::base64::{alphabet, Engine as _};
11use bitcoin::hashes::sha256;
12use serde::{Deserialize, Serialize};
13
14use super::{Error, Proof, ProofV3, ProofV4, Proofs};
15use crate::mint_url::MintUrl;
16use crate::nut02::ShortKeysetId;
17use crate::nuts::nut10::SpendingConditions;
18use crate::nuts::{CurrencyUnit, Id, Kind, PublicKey};
19use crate::{ensure_cdk, Amount, KeySetInfo};
20
21/// Token Enum
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(untagged)]
24pub enum Token {
25    /// Token V3
26    TokenV3(TokenV3),
27    /// Token V4
28    TokenV4(TokenV4),
29}
30
31impl fmt::Display for Token {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        let token = match self {
34            Self::TokenV3(token) => token.to_string(),
35            Self::TokenV4(token) => token.to_string(),
36        };
37
38        write!(f, "{token}")
39    }
40}
41
42impl Token {
43    /// Create new [`Token`]
44    pub fn new(
45        mint_url: MintUrl,
46        proofs: Proofs,
47        memo: Option<String>,
48        unit: CurrencyUnit,
49    ) -> Self {
50        let proofs = proofs
51            .into_iter()
52            .fold(HashMap::new(), |mut acc, val| {
53                acc.entry(val.keyset_id)
54                    .and_modify(|p: &mut Vec<Proof>| p.push(val.clone()))
55                    .or_insert(vec![val.clone()]);
56                acc
57            })
58            .into_iter()
59            .map(|(id, proofs)| TokenV4Token::new(id, proofs))
60            .collect();
61
62        Token::TokenV4(TokenV4 {
63            mint_url,
64            unit,
65            memo,
66            token: proofs,
67        })
68    }
69
70    /// Proofs in [`Token`]
71    pub fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result<Proofs, Error> {
72        match self {
73            Self::TokenV3(token) => token.proofs(mint_keysets),
74            Self::TokenV4(token) => token.proofs(mint_keysets),
75        }
76    }
77
78    /// Total value of [`Token`]
79    pub fn value(&self) -> Result<Amount, Error> {
80        match self {
81            Self::TokenV3(token) => token.value(),
82            Self::TokenV4(token) => token.value(),
83        }
84    }
85
86    /// [`Token`] memo
87    pub fn memo(&self) -> &Option<String> {
88        match self {
89            Self::TokenV3(token) => token.memo(),
90            Self::TokenV4(token) => token.memo(),
91        }
92    }
93
94    /// Unit
95    pub fn unit(&self) -> Option<CurrencyUnit> {
96        match self {
97            Self::TokenV3(token) => token.unit().clone(),
98            Self::TokenV4(token) => Some(token.unit().clone()),
99        }
100    }
101
102    /// Mint url
103    pub fn mint_url(&self) -> Result<MintUrl, Error> {
104        match self {
105            Self::TokenV3(token) => {
106                let mint_urls = token.mint_urls();
107
108                ensure_cdk!(mint_urls.len() == 1, Error::UnsupportedToken);
109
110                mint_urls.first().ok_or(Error::UnsupportedToken).cloned()
111            }
112            Self::TokenV4(token) => Ok(token.mint_url.clone()),
113        }
114    }
115
116    /// To v3 string
117    pub fn to_v3_string(&self) -> String {
118        let v3_token = match self {
119            Self::TokenV3(token) => token.clone(),
120            Self::TokenV4(token) => token.clone().into(),
121        };
122
123        v3_token.to_string()
124    }
125
126    /// Serialize the token to raw binary
127    pub fn to_raw_bytes(&self) -> Result<Vec<u8>, Error> {
128        match self {
129            Self::TokenV3(_) => Err(Error::UnsupportedToken),
130            Self::TokenV4(token) => token.to_raw_bytes(),
131        }
132    }
133
134    /// Return all proof secrets in this token without keyset-id mapping, across V3/V4
135    /// This is intended for spending-condition inspection where only the secret matters.
136    pub fn token_secrets(&self) -> Vec<&crate::secret::Secret> {
137        match self {
138            Token::TokenV3(t) => t
139                .token
140                .iter()
141                .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret))
142                .collect(),
143            Token::TokenV4(t) => t
144                .token
145                .iter()
146                .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret))
147                .collect(),
148        }
149    }
150
151    /// Extract unique spending conditions across all proofs
152    pub fn spending_conditions(&self) -> Result<HashSet<SpendingConditions>, Error> {
153        let mut set = HashSet::new();
154        for secret in self.token_secrets().into_iter() {
155            if let Ok(cond) = SpendingConditions::try_from(secret) {
156                set.insert(cond);
157            }
158        }
159        Ok(set)
160    }
161
162    /// Collect pubkeys for P2PK-locked ecash
163    pub fn p2pk_pubkeys(&self) -> Result<HashSet<PublicKey>, Error> {
164        let mut keys: HashSet<PublicKey> = HashSet::new();
165        for secret in self.token_secrets().into_iter() {
166            if let Ok(cond) = SpendingConditions::try_from(secret) {
167                if cond.kind() == Kind::P2PK {
168                    if let Some(ps) = cond.pubkeys() {
169                        keys.extend(ps);
170                    }
171                }
172            }
173        }
174        Ok(keys)
175    }
176
177    /// Collect refund pubkeys from P2PK conditions
178    pub fn p2pk_refund_pubkeys(&self) -> Result<HashSet<PublicKey>, Error> {
179        let mut keys: HashSet<PublicKey> = HashSet::new();
180        for secret in self.token_secrets().into_iter() {
181            if let Ok(cond) = SpendingConditions::try_from(secret) {
182                if cond.kind() == Kind::P2PK {
183                    if let Some(ps) = cond.refund_keys() {
184                        keys.extend(ps);
185                    }
186                }
187            }
188        }
189        Ok(keys)
190    }
191
192    /// Collect HTLC hashes
193    pub fn htlc_hashes(&self) -> Result<HashSet<sha256::Hash>, Error> {
194        let mut hashes: HashSet<sha256::Hash> = HashSet::new();
195        for secret in self.token_secrets().into_iter() {
196            if let Ok(SpendingConditions::HTLCConditions { data, .. }) =
197                SpendingConditions::try_from(secret)
198            {
199                hashes.insert(data);
200            }
201        }
202        Ok(hashes)
203    }
204
205    /// Collect unique locktimes from spending conditions
206    pub fn locktimes(&self) -> Result<BTreeSet<u64>, Error> {
207        let mut set: BTreeSet<u64> = BTreeSet::new();
208        for secret in self.token_secrets().into_iter() {
209            if let Ok(cond) = SpendingConditions::try_from(secret) {
210                if let Some(lt) = cond.locktime() {
211                    set.insert(lt);
212                }
213            }
214        }
215        Ok(set)
216    }
217}
218
219impl FromStr for Token {
220    type Err = Error;
221
222    fn from_str(s: &str) -> Result<Self, Self::Err> {
223        let (is_v3, s) = match (s.strip_prefix("cashuA"), s.strip_prefix("cashuB")) {
224            (Some(s), None) => (true, s),
225            (None, Some(s)) => (false, s),
226            _ => return Err(Error::UnsupportedToken),
227        };
228
229        let decode_config = general_purpose::GeneralPurposeConfig::new()
230            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
231        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
232
233        match is_v3 {
234            true => {
235                let decoded_str = String::from_utf8(decoded)?;
236                let token: TokenV3 = serde_json::from_str(&decoded_str)?;
237                Ok(Token::TokenV3(token))
238            }
239            false => {
240                let token: TokenV4 = ciborium::from_reader(&decoded[..])?;
241                Ok(Token::TokenV4(token))
242            }
243        }
244    }
245}
246
247impl TryFrom<&Vec<u8>> for Token {
248    type Error = Error;
249
250    fn try_from(bytes: &Vec<u8>) -> Result<Self, Self::Error> {
251        ensure_cdk!(bytes.len() >= 5, Error::UnsupportedToken);
252
253        let prefix = String::from_utf8(bytes[..5].to_vec())?;
254
255        match prefix.as_str() {
256            "crawB" => {
257                let token: TokenV4 = ciborium::from_reader(&bytes[5..])?;
258                Ok(Token::TokenV4(token))
259            }
260            _ => Err(Error::UnsupportedToken),
261        }
262    }
263}
264
265/// Token V3 Token
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct TokenV3Token {
268    /// Url of mint
269    pub mint: MintUrl,
270    /// [`Vec<ProofV3>`]
271    pub proofs: Vec<ProofV3>,
272}
273
274impl TokenV3Token {
275    /// Create new [`TokenV3Token`]
276    pub fn new(mint_url: MintUrl, proofs: Proofs) -> Self {
277        Self {
278            mint: mint_url,
279            proofs: proofs.into_iter().map(ProofV3::from).collect(),
280        }
281    }
282}
283
284/// Token
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct TokenV3 {
287    /// Proofs in [`Token`] by mint
288    pub token: Vec<TokenV3Token>,
289    /// Memo for token
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub memo: Option<String>,
292    /// Token Unit
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub unit: Option<CurrencyUnit>,
295}
296
297impl TokenV3 {
298    /// Create new [`Token`]
299    pub fn new(
300        mint_url: MintUrl,
301        proofs: Proofs,
302        memo: Option<String>,
303        unit: Option<CurrencyUnit>,
304    ) -> Result<Self, Error> {
305        ensure_cdk!(!proofs.is_empty(), Error::ProofsRequired);
306
307        Ok(Self {
308            token: vec![TokenV3Token::new(mint_url, proofs)],
309            memo,
310            unit,
311        })
312    }
313
314    /// Proofs
315    pub fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result<Proofs, Error> {
316        let mut proofs: Proofs = vec![];
317        for t in self.token.iter() {
318            for p in t.proofs.iter() {
319                let long_id = Id::from_short_keyset_id(&p.keyset_id, mint_keysets)?;
320                proofs.push(p.into_proof(&long_id));
321            }
322        }
323        Ok(proofs)
324    }
325
326    /// Value - errors if duplicate proofs are found
327    #[inline]
328    pub fn value(&self) -> Result<Amount, Error> {
329        let proof_count = self.token.iter().map(|t| t.proofs.len()).sum::<usize>();
330        let unique_count = self
331            .token
332            .iter()
333            .flat_map(|t| t.proofs.iter().map(|p| &p.secret))
334            .collect::<std::collections::HashSet<_>>()
335            .len();
336
337        // Check if there are any duplicate proofs
338        if unique_count != proof_count {
339            return Err(Error::DuplicateProofs);
340        }
341
342        Ok(Amount::try_sum(
343            self.token
344                .iter()
345                .map(|t| Amount::try_sum(t.proofs.iter().map(|p| p.amount)))
346                .collect::<Result<Vec<Amount>, _>>()?,
347        )?)
348    }
349
350    /// Memo
351    #[inline]
352    pub fn memo(&self) -> &Option<String> {
353        &self.memo
354    }
355
356    /// Unit
357    #[inline]
358    pub fn unit(&self) -> &Option<CurrencyUnit> {
359        &self.unit
360    }
361
362    /// Mint Url
363    pub fn mint_urls(&self) -> Vec<MintUrl> {
364        let mut mint_urls = Vec::new();
365
366        for token in self.token.iter() {
367            mint_urls.push(token.mint.clone());
368        }
369
370        mint_urls
371    }
372
373    /// Checks if a token has multiple mints
374    ///
375    /// These tokens are not supported by this crate
376    pub fn is_multi_mint(&self) -> bool {
377        self.token.len() > 1
378    }
379}
380
381impl FromStr for TokenV3 {
382    type Err = Error;
383
384    fn from_str(s: &str) -> Result<Self, Self::Err> {
385        let s = s.strip_prefix("cashuA").ok_or(Error::UnsupportedToken)?;
386
387        let decode_config = general_purpose::GeneralPurposeConfig::new()
388            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
389        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
390        let decoded_str = String::from_utf8(decoded)?;
391        let token: TokenV3 = serde_json::from_str(&decoded_str)?;
392        Ok(token)
393    }
394}
395
396impl fmt::Display for TokenV3 {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        let json_string = serde_json::to_string(self).map_err(|_| fmt::Error)?;
399        let encoded = general_purpose::URL_SAFE.encode(json_string);
400        write!(f, "cashuA{encoded}")
401    }
402}
403
404impl From<TokenV4> for TokenV3 {
405    fn from(token: TokenV4) -> Self {
406        let proofs: Vec<ProofV3> = token
407            .token
408            .into_iter()
409            .flat_map(|token| {
410                token.proofs.into_iter().map(move |p| ProofV3 {
411                    amount: p.amount,
412                    keyset_id: token.keyset_id.clone(),
413                    secret: p.secret,
414                    c: p.c,
415                    witness: p.witness,
416                    dleq: p.dleq,
417                })
418            })
419            .collect();
420
421        let token_v3_token = TokenV3Token {
422            mint: token.mint_url,
423            proofs,
424        };
425        TokenV3 {
426            token: vec![token_v3_token],
427            memo: token.memo,
428            unit: Some(token.unit),
429        }
430    }
431}
432
433/// Token V4
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct TokenV4 {
436    /// Mint Url
437    #[serde(rename = "m")]
438    pub mint_url: MintUrl,
439    /// Token Unit
440    #[serde(rename = "u")]
441    pub unit: CurrencyUnit,
442    /// Memo for token
443    #[serde(rename = "d", skip_serializing_if = "Option::is_none")]
444    pub memo: Option<String>,
445    /// Proofs grouped by keyset_id
446    #[serde(rename = "t")]
447    pub token: Vec<TokenV4Token>,
448}
449
450impl TokenV4 {
451    /// Proofs from token
452    pub fn proofs(&self, mint_keysets: &[KeySetInfo]) -> Result<Proofs, Error> {
453        let mut proofs: Proofs = vec![];
454        for t in self.token.iter() {
455            let long_id = Id::from_short_keyset_id(&t.keyset_id, mint_keysets)?;
456            proofs.extend(t.proofs.iter().map(|p| p.into_proof(&long_id)));
457        }
458        Ok(proofs)
459    }
460
461    /// Value - errors if duplicate proofs are found
462    #[inline]
463    pub fn value(&self) -> Result<Amount, Error> {
464        let proof_count = self.token.iter().map(|t| t.proofs.len()).sum::<usize>();
465        let unique_count = self
466            .token
467            .iter()
468            .flat_map(|t| t.proofs.iter().map(|p| &p.secret))
469            .collect::<std::collections::HashSet<_>>()
470            .len();
471
472        // Check if there are any duplicate proofs
473        if unique_count != proof_count {
474            return Err(Error::DuplicateProofs);
475        }
476
477        Ok(Amount::try_sum(
478            self.token
479                .iter()
480                .map(|t| Amount::try_sum(t.proofs.iter().map(|p| p.amount)))
481                .collect::<Result<Vec<Amount>, _>>()?,
482        )?)
483    }
484
485    /// Memo
486    #[inline]
487    pub fn memo(&self) -> &Option<String> {
488        &self.memo
489    }
490
491    /// Unit
492    #[inline]
493    pub fn unit(&self) -> &CurrencyUnit {
494        &self.unit
495    }
496
497    /// Serialize the token to raw binary
498    pub fn to_raw_bytes(&self) -> Result<Vec<u8>, Error> {
499        let mut prefix = b"crawB".to_vec();
500        let mut data = Vec::new();
501        ciborium::into_writer(self, &mut data).map_err(Error::CiboriumSerError)?;
502        prefix.extend(data);
503        Ok(prefix)
504    }
505}
506
507impl fmt::Display for TokenV4 {
508    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509        use serde::ser::Error;
510        let mut data = Vec::new();
511        ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
512        let encoded = general_purpose::URL_SAFE.encode(data);
513        write!(f, "cashuB{encoded}")
514    }
515}
516
517impl FromStr for TokenV4 {
518    type Err = Error;
519
520    fn from_str(s: &str) -> Result<Self, Self::Err> {
521        let s = s.strip_prefix("cashuB").ok_or(Error::UnsupportedToken)?;
522
523        let decode_config = general_purpose::GeneralPurposeConfig::new()
524            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
525        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
526        let token: TokenV4 = ciborium::from_reader(&decoded[..])?;
527        Ok(token)
528    }
529}
530
531impl TryFrom<&Vec<u8>> for TokenV4 {
532    type Error = Error;
533
534    fn try_from(bytes: &Vec<u8>) -> Result<Self, Self::Error> {
535        ensure_cdk!(bytes.len() >= 5, Error::UnsupportedToken);
536
537        let prefix = String::from_utf8(bytes[..5].to_vec())?;
538        ensure_cdk!(prefix.as_str() == "crawB", Error::UnsupportedToken);
539
540        Ok(ciborium::from_reader(&bytes[5..])?)
541    }
542}
543
544impl TryFrom<TokenV3> for TokenV4 {
545    type Error = Error;
546    fn try_from(token: TokenV3) -> Result<Self, Self::Error> {
547        let mint_urls = token.mint_urls();
548        let proofs: Vec<ProofV3> = token.token.into_iter().flat_map(|t| t.proofs).collect();
549
550        ensure_cdk!(mint_urls.len() == 1, Error::UnsupportedToken);
551
552        let mint_url = mint_urls.first().ok_or(Error::UnsupportedToken)?;
553
554        let proofs = proofs
555            .into_iter()
556            .fold(
557                HashMap::<ShortKeysetId, Vec<ProofV4>>::new(),
558                |mut acc, val| {
559                    acc.entry(val.keyset_id.clone())
560                        .and_modify(|p: &mut Vec<ProofV4>| p.push(val.clone().into()))
561                        .or_insert(vec![val.clone().into()]);
562                    acc
563                },
564            )
565            .into_iter()
566            .map(|(id, proofs)| TokenV4Token {
567                keyset_id: id,
568                proofs,
569            })
570            .collect();
571
572        Ok(TokenV4 {
573            mint_url: mint_url.clone(),
574            token: proofs,
575            memo: token.memo,
576            unit: token.unit.ok_or(Error::UnsupportedUnit)?,
577        })
578    }
579}
580
581/// Token V4 Token
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct TokenV4Token {
584    /// `Keyset id`
585    #[serde(
586        rename = "i",
587        serialize_with = "serialize_v4_keyset_id",
588        deserialize_with = "deserialize_v4_keyset_id"
589    )]
590    pub keyset_id: ShortKeysetId,
591    /// Proofs
592    #[serde(rename = "p")]
593    pub proofs: Vec<ProofV4>,
594}
595
596fn serialize_v4_keyset_id<S>(keyset_id: &ShortKeysetId, serializer: S) -> Result<S::Ok, S::Error>
597where
598    S: serde::Serializer,
599{
600    serializer.serialize_bytes(&keyset_id.to_bytes())
601}
602
603fn deserialize_v4_keyset_id<'de, D>(deserializer: D) -> Result<ShortKeysetId, D::Error>
604where
605    D: serde::Deserializer<'de>,
606{
607    let bytes = Vec::<u8>::deserialize(deserializer)?;
608    ShortKeysetId::from_bytes(&bytes).map_err(serde::de::Error::custom)
609}
610
611impl TokenV4Token {
612    /// Create new [`TokenV4Token`]
613    pub fn new(keyset_id: Id, proofs: Proofs) -> Self {
614        // Create a short keyset id from id
615        let short_id = ShortKeysetId::from(keyset_id);
616        Self {
617            keyset_id: short_id,
618            proofs: proofs.into_iter().map(Into::into).collect(),
619        }
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use std::str::FromStr;
626
627    use bip39::rand::{self, RngCore};
628    use bitcoin::hashes::sha256::Hash as Sha256Hash;
629    use bitcoin::hashes::Hash;
630
631    use super::*;
632    use crate::dhke::hash_to_curve;
633    use crate::mint_url::MintUrl;
634    use crate::nuts::nut10::{Conditions, SpendingConditions};
635    use crate::nuts::nut11::SigFlag;
636    use crate::secret::Secret;
637    use crate::util::hex;
638
639    #[test]
640    fn test_token_padding() {
641        let token_str_with_padding = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91IHZlcnkgbXVjaC4ifQ==";
642
643        let token = TokenV3::from_str(token_str_with_padding).unwrap();
644
645        let token_str_without_padding = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91IHZlcnkgbXVjaC4ifQ";
646
647        let token_without = TokenV3::from_str(token_str_without_padding).unwrap();
648
649        assert_eq!(token, token_without);
650    }
651
652    #[test]
653    fn test_token_v4_str_round_trip() {
654        let token_str = "cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ=";
655        let token = TokenV4::from_str(token_str).unwrap();
656
657        assert_eq!(
658            token.mint_url,
659            MintUrl::from_str("http://localhost:3338").unwrap()
660        );
661        assert_eq!(
662            token.token[0].keyset_id,
663            ShortKeysetId::from_str("00ad268c4d1f5826").unwrap()
664        );
665
666        let encoded = &token.to_string();
667
668        let token_data = TokenV4::from_str(encoded).unwrap();
669
670        assert_eq!(token_data, token);
671    }
672
673    #[test]
674    fn test_token_v4_multi_keyset() {
675        let token_str_multi_keysets = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA==";
676
677        let token = Token::from_str(token_str_multi_keysets).unwrap();
678        let amount = token.value().expect("valid amount");
679
680        assert_eq!(amount, Amount::from(4));
681
682        let unit = token.unit().unwrap();
683        assert_eq!(CurrencyUnit::Sat, unit);
684
685        match token {
686            Token::TokenV4(token) => {
687                let tokens: Vec<ShortKeysetId> =
688                    token.token.iter().map(|t| t.keyset_id.clone()).collect();
689
690                assert_eq!(tokens.len(), 2);
691
692                assert!(tokens.contains(&ShortKeysetId::from_str("00ffd48b8f5ecf80").unwrap()));
693                assert!(tokens.contains(&ShortKeysetId::from_str("00ad268c4d1f5826").unwrap()));
694
695                let mint_url = token.mint_url;
696
697                assert_eq!("http://localhost:3338", &mint_url.to_string());
698            }
699            _ => {
700                panic!("Token should be a v4 token")
701            }
702        }
703    }
704
705    #[test]
706    fn test_tokenv4_from_tokenv3() {
707        let token_v3_str = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9";
708        let token_v3 =
709            TokenV3::from_str(token_v3_str).expect("TokenV3 should be created from string");
710        let token_v4 = TokenV4::try_from(token_v3).expect("TokenV3 should be converted to TokenV4");
711        let token_v4_expected = "cashuBpGFtd2h0dHBzOi8vODMzMy5zcGFjZTozMzM4YXVjc2F0YWRqVGhhbmsgeW91LmF0gaJhaUgAmh8pMlPkHmFwgqRhYQJhc3hANDA3OTE1YmMyMTJiZTYxYTc3ZTNlNmQyYWViNGM3Mjc5ODBiZGE1MWNkMDZhNmFmYzI5ZTI4NjE3NjhhNzgzN2FjWCECvJCXmX2Br7LMc0a15DRak0a9KlBut5WFmKcvDPhRY-phZPakYWEIYXN4QGZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmVhY1ghAp6OUFC4kKfWwJaNsWvB1dX6BA6h3ihPbsadYSmfZxBZYWT2";
712        assert_eq!(token_v4.to_string(), token_v4_expected);
713    }
714
715    #[test]
716    fn test_token_str_round_trip() {
717        let token_str = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9";
718
719        let token = TokenV3::from_str(token_str).unwrap();
720        assert_eq!(
721            token.token[0].mint,
722            MintUrl::from_str("https://8333.space:3338").unwrap()
723        );
724        assert_eq!(
725            token.token[0].proofs[0].clone().keyset_id,
726            ShortKeysetId::from_str("009a1f293253e41e").unwrap()
727        );
728        assert_eq!(token.unit.clone().unwrap(), CurrencyUnit::Sat);
729
730        let encoded = &token.to_string();
731
732        let token_data = TokenV3::from_str(encoded).unwrap();
733
734        assert_eq!(token_data, token);
735    }
736
737    #[test]
738    fn incorrect_tokens() {
739        let incorrect_prefix = "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9";
740
741        let incorrect_prefix_token = TokenV3::from_str(incorrect_prefix);
742
743        assert!(incorrect_prefix_token.is_err());
744
745        let no_prefix = "eyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9";
746
747        let no_prefix_token = TokenV3::from_str(no_prefix);
748
749        assert!(no_prefix_token.is_err());
750
751        let correct_token = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9";
752
753        let correct_token = TokenV3::from_str(correct_token);
754
755        assert!(correct_token.is_ok());
756    }
757
758    #[test]
759    fn test_token_v4_raw_roundtrip() {
760        let token_raw = hex::decode("6372617742a4617481a261694800ad268c4d1f5826617081a3616101617378403961366462623834376264323332626137366462306466313937323136623239643362386363313435353363643237383237666331636339343266656462346561635821038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d4721267926164695468616e6b20796f75616d75687474703a2f2f6c6f63616c686f73743a33333338617563736174").unwrap();
761        let token = TokenV4::try_from(&token_raw).expect("Token deserialization error");
762        let token_raw_ = token.to_raw_bytes().expect("Token serialization error");
763        let token_ = TokenV4::try_from(&token_raw_).expect("Token deserialization error");
764        assert!(token_ == token)
765    }
766
767    #[test]
768    fn test_token_generic_raw_roundtrip() {
769        let tokenv4_raw = hex::decode("6372617742a4617481a261694800ad268c4d1f5826617081a3616101617378403961366462623834376264323332626137366462306466313937323136623239643362386363313435353363643237383237666331636339343266656462346561635821038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d4721267926164695468616e6b20796f75616d75687474703a2f2f6c6f63616c686f73743a33333338617563736174").unwrap();
770        let tokenv4 = Token::try_from(&tokenv4_raw).expect("Token deserialization error");
771        let tokenv4_ = TokenV4::try_from(&tokenv4_raw).expect("Token deserialization error");
772        let tokenv4_bytes = tokenv4.to_raw_bytes().expect("Serialization error");
773        let tokenv4_bytes_ = tokenv4_.to_raw_bytes().expect("Serialization error");
774        assert!(tokenv4_bytes_ == tokenv4_bytes);
775    }
776
777    #[test]
778    fn test_token_with_duplicate_proofs() {
779        // Create a token with duplicate proofs
780        let mint_url = MintUrl::from_str("https://example.com").unwrap();
781        let keyset_id = Id::from_str("009a1f293253e41e").unwrap();
782
783        let secret = Secret::generate();
784        // Create two identical proofs
785        let proof1 = Proof {
786            amount: Amount::from(10),
787            keyset_id,
788            secret: secret.clone(),
789            c: "02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea"
790                .parse()
791                .unwrap(),
792            witness: None,
793            dleq: None,
794            p2pk_e: None,
795        };
796
797        let proof2 = proof1.clone(); // Duplicate proof
798
799        // Create a token with the duplicate proofs
800        let proofs = vec![proof1.clone(), proof2].into_iter().collect();
801        let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat);
802
803        // Verify that value() returns an error
804        let result = token.value();
805        assert!(result.is_err());
806
807        // Proofs with the same secret are duplicates even if another field differs.
808        let proof2 = Proof {
809            amount: Amount::from(10_000),
810            ..proof1.clone()
811        };
812
813        let proofs = vec![proof1.clone(), proof2.clone()].into_iter().collect();
814        let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat);
815
816        let result = token.value();
817        assert!(result.is_err());
818
819        let proofs = vec![proof1.clone(), proof2].into_iter().collect();
820        let token = TokenV3::new(mint_url.clone(), proofs, None, Some(CurrencyUnit::Sat))
821            .expect("token should be created");
822
823        let result = token.value();
824        assert!(result.is_err());
825
826        // Create a token with unique proofs
827        let proof3 = Proof {
828            amount: Amount::from(10),
829            keyset_id,
830            secret: Secret::generate(),
831            c: "03bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea"
832                .parse()
833                .unwrap(), // Different C value
834            witness: None,
835            dleq: None,
836            p2pk_e: None,
837        };
838
839        let proofs = vec![proof1, proof3].into_iter().collect();
840        let token = Token::new(mint_url, proofs, None, CurrencyUnit::Sat);
841
842        // Verify that value() succeeds with unique proofs
843        let result = token.value();
844        assert!(result.is_ok());
845        assert_eq!(result.unwrap(), Amount::from(20));
846    }
847
848    #[test]
849    fn test_token_from_proofs_with_idv2_round_trip() {
850        let mint_url = MintUrl::from_str("https://example.com").unwrap();
851
852        let keysets_info: Vec<KeySetInfo> = (0..10)
853            .map(|_| {
854                let mut bytes: [u8; 33] = [0u8; 33];
855                bytes[0] = 1u8;
856                rand::thread_rng().fill_bytes(&mut bytes[1..]);
857                let id = Id::from_bytes(&bytes).unwrap();
858                KeySetInfo {
859                    id,
860                    unit: CurrencyUnit::Sat,
861                    active: true,
862                    input_fee_ppk: 0,
863                    final_expiry: None,
864                }
865            })
866            .collect();
867
868        let chosen_keyset_id = keysets_info[0].id;
869        // Make up a bunch of fake proofs
870        let proofs = (0..5)
871            .map(|_| {
872                let mut c_preimage: [u8; 33] = [0u8; 33];
873                c_preimage[0] = 1u8;
874                rand::thread_rng().fill_bytes(&mut c_preimage[1..]);
875                Proof::new(
876                    Amount::from(1),
877                    chosen_keyset_id,
878                    Secret::generate(),
879                    hash_to_curve(&c_preimage).unwrap(),
880                )
881            })
882            .collect();
883
884        let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat);
885        let token_str = token.to_string();
886
887        let token1 = Token::from_str(&token_str);
888        assert!(token1.is_ok());
889
890        let proofs1 = token1.unwrap().proofs(&keysets_info);
891        assert!(proofs1.is_ok());
892
893        //println!("{:?}", proofs1);
894    }
895
896    #[test]
897    fn test_token_proofs_with_unknown_short_keyset_id() {
898        let mint_url = MintUrl::from_str("https://example.com").unwrap();
899
900        let keysets_info: Vec<KeySetInfo> = (0..10)
901            .map(|_| {
902                let mut bytes: [u8; 33] = [0u8; 33];
903                bytes[0] = 1u8;
904                rand::thread_rng().fill_bytes(&mut bytes[1..]);
905                let id = Id::from_bytes(&bytes).unwrap();
906                KeySetInfo {
907                    id,
908                    unit: CurrencyUnit::Sat,
909                    active: true,
910                    input_fee_ppk: 0,
911                    final_expiry: None,
912                }
913            })
914            .collect();
915
916        let chosen_keyset_id =
917            Id::from_str("01c352c0b47d42edb764bddf8c53d77b85f057157d92084d9d05e876251ecd8422")
918                .unwrap();
919
920        // Make up a bunch of fake proofs
921        let proofs = (0..5)
922            .map(|_| {
923                let mut c_preimage: [u8; 33] = [0u8; 33];
924                c_preimage[0] = 1u8;
925                rand::thread_rng().fill_bytes(&mut c_preimage[1..]);
926                Proof::new(
927                    Amount::from(1),
928                    chosen_keyset_id,
929                    Secret::generate(),
930                    hash_to_curve(&c_preimage).unwrap(),
931                )
932            })
933            .collect();
934
935        let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat);
936        let token_str = token.to_string();
937
938        let token1 = Token::from_str(&token_str);
939        assert!(token1.is_ok());
940
941        let proofs1 = token1.unwrap().proofs(&keysets_info);
942        assert!(proofs1.is_err());
943    }
944    #[test]
945    fn test_token_spending_condition_helpers_p2pk_htlc_v4() {
946        let mint_url = MintUrl::from_str("https://example.com").unwrap();
947        let keyset_id = Id::from_str("009a1f293253e41e").unwrap();
948
949        // P2PK: base pubkey plus an extra pubkey via tags, refund key, and locktime
950        let sk1 = crate::nuts::SecretKey::generate();
951        let pk1 = sk1.public_key();
952        let sk2 = crate::nuts::SecretKey::generate();
953        let pk2 = sk2.public_key();
954        let refund_sk = crate::nuts::SecretKey::generate();
955        let refund_pk = refund_sk.public_key();
956
957        let cond_p2pk = Conditions {
958            locktime: Some(1_700_000_000),
959            pubkeys: Some(vec![pk2]),
960            refund_keys: Some(vec![refund_pk]),
961            num_sigs: Some(1),
962            sig_flag: SigFlag::SigInputs,
963            num_sigs_refund: None,
964        };
965
966        let nut10_p2pk = crate::nuts::Nut10Secret::new(
967            crate::nuts::Kind::P2PK,
968            crate::nuts::SecretData::new(pk1.to_string(), Some(cond_p2pk.clone())),
969        );
970        let secret_p2pk: Secret = nut10_p2pk.try_into().unwrap();
971
972        // HTLC: use a known preimage hash and its own locktime
973        let preimage = b"cdk-test-preimage";
974        let htlc_hash = Sha256Hash::hash(preimage);
975        let cond_htlc = Conditions {
976            locktime: Some(1_800_000_000),
977            ..Default::default()
978        };
979        let nut10_htlc = crate::nuts::Nut10Secret::new(
980            crate::nuts::Kind::HTLC,
981            crate::nuts::SecretData::new(htlc_hash.to_string(), Some(cond_htlc.clone())),
982        );
983        let secret_htlc: Secret = nut10_htlc.try_into().unwrap();
984
985        // Build two proofs (one P2PK, one HTLC)
986        let proof_p2pk = Proof::new(Amount::from(1), keyset_id, secret_p2pk.clone(), pk1);
987        let proof_htlc = Proof::new(Amount::from(2), keyset_id, secret_htlc.clone(), pk2);
988        let token = Token::new(
989            mint_url,
990            vec![proof_p2pk, proof_htlc].into_iter().collect(),
991            None,
992            CurrencyUnit::Sat,
993        );
994
995        // token_secrets should see both
996        assert_eq!(token.token_secrets().len(), 2);
997
998        // spending_conditions should contain both kinds with their conditions
999        let sc = token.spending_conditions().unwrap();
1000        assert!(sc.contains(&SpendingConditions::P2PKConditions {
1001            data: pk1,
1002            conditions: Some(cond_p2pk.clone())
1003        }));
1004        assert!(sc.contains(&SpendingConditions::HTLCConditions {
1005            data: htlc_hash,
1006            conditions: Some(cond_htlc.clone())
1007        }));
1008
1009        // p2pk_pubkeys should include base pk1 and extra pk2 from tags (deduped)
1010        let pks = token.p2pk_pubkeys().unwrap();
1011        assert!(pks.contains(&pk1));
1012        assert!(pks.contains(&pk2));
1013        assert_eq!(pks.len(), 2);
1014
1015        // p2pk_refund_pubkeys should include refund_pk only
1016        let refund = token.p2pk_refund_pubkeys().unwrap();
1017        assert!(refund.contains(&refund_pk));
1018        assert_eq!(refund.len(), 1);
1019
1020        // htlc_hashes should include exactly our hash
1021        let hashes = token.htlc_hashes().unwrap();
1022        assert!(hashes.contains(&htlc_hash));
1023        assert_eq!(hashes.len(), 1);
1024
1025        // locktimes should include both unique locktimes
1026        let lts = token.locktimes().unwrap();
1027        assert!(lts.contains(&1_700_000_000));
1028        assert!(lts.contains(&1_800_000_000));
1029        assert_eq!(lts.len(), 2);
1030    }
1031
1032    #[test]
1033    fn test_token_spending_condition_helpers_dedup_and_v3() {
1034        let mint_url = MintUrl::from_str("https://example.org").unwrap();
1035        let id = Id::from_str("00ad268c4d1f5826").unwrap();
1036
1037        // Same P2PK conditions duplicated across two proofs
1038        let sk = crate::nuts::SecretKey::generate();
1039        let pk = sk.public_key();
1040
1041        let cond = Conditions {
1042            locktime: Some(1_650_000_000),
1043            pubkeys: Some(vec![pk]), // include itself to test dedup inside pubkeys()
1044            refund_keys: Some(vec![pk]), // deliberate duplicate
1045            num_sigs: Some(1),
1046            sig_flag: SigFlag::SigInputs,
1047            num_sigs_refund: None,
1048        };
1049
1050        let nut10 = crate::nuts::Nut10Secret::new(
1051            crate::nuts::Kind::P2PK,
1052            crate::nuts::SecretData::new(pk.to_string(), Some(cond.clone())),
1053        );
1054        let secret: Secret = nut10.try_into().unwrap();
1055
1056        let p1 = Proof::new(Amount::from(1), id, secret.clone(), pk);
1057        let p2 = Proof::new(Amount::from(2), id, secret.clone(), pk);
1058
1059        // Build a V3 token explicitly and wrap into Token::TokenV3
1060        let token_v3 = TokenV3::new(
1061            mint_url,
1062            vec![p1, p2].into_iter().collect(),
1063            None,
1064            Some(CurrencyUnit::Sat),
1065        )
1066        .unwrap();
1067        let token = Token::TokenV3(token_v3);
1068
1069        // Helpers should dedup
1070        let sc = token.spending_conditions().unwrap();
1071        assert_eq!(sc.len(), 1); // identical conditions across proofs
1072
1073        let pks = token.p2pk_pubkeys().unwrap();
1074        assert!(pks.contains(&pk));
1075        assert_eq!(pks.len(), 1); // duplicates removed
1076
1077        let refunds = token.p2pk_refund_pubkeys().unwrap();
1078        assert!(refunds.contains(&pk));
1079        assert_eq!(refunds.len(), 1);
1080
1081        let lts = token.locktimes().unwrap();
1082        assert!(lts.contains(&1_650_000_000));
1083        assert_eq!(lts.len(), 1);
1084
1085        // No HTLC here
1086        let hashes = token.htlc_hashes().unwrap();
1087        assert!(hashes.is_empty());
1088
1089        // token_secrets length equals number of proofs even if conditions identical
1090        assert_eq!(token.token_secrets().len(), 2);
1091    }
1092}