Skip to main content

cashu/nuts/
nut16.rs

1//! NUT-16: Animated QR codes
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/16.md>
4//!
5//! Tokens that are too large for a single QR code are shared as an animated
6//! QR code based on the [UR](https://developer.blockchaincommons.com/ur/)
7//! protocol. The sender splits the token into a fountain-coded sequence of UR
8//! fragments ([`TokenUrEncoder`]) and displays each fragment as one QR frame.
9//! The receiver scans the frames and feeds them into a [`TokenUrDecoder`]
10//! until the token is reassembled.
11//!
12//! Fragments are `ur:bytes` URs whose payload is the serialized token
13//! (`cashuB…`) encoded as a CBOR byte string, matching the de-facto standard
14//! used by existing NUT-16 implementations (e.g. cashu.me).
15//!
16//! # Example
17//!
18//! ```
19//! use std::str::FromStr;
20//!
21//! use cashu::nuts::nut16::DEFAULT_MAX_FRAGMENT_LENGTH;
22//! use cashu::nuts::{Token, TokenUrDecoder};
23//!
24//! let token = Token::from_str("cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ=")?;
25//!
26//! // Sender: display each fragment as one QR frame
27//! let mut encoder = token.ur_encoder(DEFAULT_MAX_FRAGMENT_LENGTH)?;
28//! let mut decoder = TokenUrDecoder::default();
29//!
30//! // Receiver: feed scanned frames until the token is reassembled
31//! while !decoder.complete() {
32//!     decoder.receive(&encoder.next_part()?)?;
33//! }
34//!
35//! assert_eq!(decoder.token()?, Some(token));
36//! # Ok::<(), Box<dyn std::error::Error>>(())
37//! ```
38
39use std::fmt;
40use std::str::FromStr;
41use std::string::FromUtf8Error;
42
43use thiserror::Error;
44
45use crate::nuts::nut00::{Token, TokenV4};
46
47/// UR type used for Cashu tokens: `ur:bytes`
48///
49/// `bytes` is the de-facto standard used by existing NUT-16 wallets: the UR
50/// payload is the serialized token encoded as a CBOR byte string.
51pub const TOKEN_UR_TYPE: &str = "bytes";
52
53/// Default maximum fragment length, in payload bytes per QR frame
54///
55/// 200 bytes is the largest fragment length recommended for broad QR scanner
56/// compatibility; smaller fragments (50–100 bytes) produce less dense frames
57/// that scan more reliably at the cost of a longer animation.
58pub const DEFAULT_MAX_FRAGMENT_LENGTH: usize = 200;
59
60/// Maximum encoded length accepted for one UR part
61///
62/// This is the maximum alphanumeric capacity of a version 40 QR code. Checking
63/// it before decoding bounds allocations caused by untrusted scanner or FFI
64/// input.
65pub const MAX_UR_PART_LENGTH: usize = 4_296;
66
67/// Maximum number of source fragments accepted by the decoder
68///
69/// Fountain decoding work can grow with the declared fragment count, so this
70/// limit is checked before a part is passed to the underlying decoder.
71pub const MAX_UR_FRAGMENT_COUNT: usize = 4_096;
72
73/// Maximum reconstructed UR message length accepted by the decoder
74///
75/// One mebibyte leaves ample room for large Cashu tokens while bounding memory
76/// retained during reconstruction.
77pub const MAX_UR_MESSAGE_LENGTH: usize = 1024 * 1024;
78
79/// NUT-16 Error
80#[derive(Debug, Error)]
81pub enum Error {
82    /// UR encoding or decoding error
83    #[error("UR error: {0}")]
84    Ur(ur::ur::Error),
85    /// CBOR serialization error
86    #[error(transparent)]
87    CiboriumSer(#[from] ciborium::ser::Error<std::io::Error>),
88    /// CBOR deserialization error
89    #[error(transparent)]
90    CiboriumDe(#[from] ciborium::de::Error<std::io::Error>),
91    /// UR payload is not valid UTF-8
92    #[error(transparent)]
93    Utf8(#[from] FromUtf8Error),
94    /// Token error
95    #[error(transparent)]
96    Token(#[from] crate::nuts::nut00::Error),
97    /// Received a UR of an unexpected type
98    #[error("unexpected UR type: expected `{TOKEN_UR_TYPE}`, got `{0}`")]
99    UnexpectedUrType(String),
100    /// An encoded UR part exceeds the decoder limit
101    #[error("UR part too large: {actual} bytes, maximum is {max}")]
102    PartTooLarge {
103        /// Actual encoded part length
104        actual: usize,
105        /// Maximum accepted encoded part length
106        max: usize,
107    },
108    /// A multipart UR declares too many source fragments
109    #[error("too many UR fragments: {actual}, maximum is {max}")]
110    TooManyFragments {
111        /// Declared source fragment count
112        actual: usize,
113        /// Maximum accepted source fragment count
114        max: usize,
115    },
116    /// A UR declares a reconstructed message that is too large
117    #[error("UR message too large: {actual} bytes, maximum is {max}")]
118    MessageTooLarge {
119        /// Declared reconstructed message length
120        actual: usize,
121        /// Maximum accepted reconstructed message length
122        max: usize,
123    },
124}
125
126impl From<ur::ur::Error> for Error {
127    fn from(err: ur::ur::Error) -> Self {
128        Self::Ur(err)
129    }
130}
131
132/// Encodes a [`Token`] into UR fragments for display as an animated QR code
133///
134/// Each call to [`next_part`](Self::next_part) returns one fragment
135/// (`ur:bytes/…`) to be displayed as a single QR frame. The first
136/// [`fragment_count`](Self::fragment_count) frames cover the whole token; the
137/// stream is unbounded and frames beyond that are redundant fountain parts,
138/// so a receiver can complete from any sufficiently large subset of frames.
139/// The sender typically loops the frames until the receiver signals
140/// completion.
141///
142/// If the token fits into a single frame, the single-part form
143/// (`ur:bytes/<payload>`, without fragment indices) is returned, matching
144/// the reference implementations.
145///
146/// V3 tokens are normalized to V4 before serialization, so the encoded token
147/// payload always uses the `cashuB…` format.
148pub struct TokenUrEncoder {
149    encoder: ur::Encoder<'static>,
150    /// Full CBOR payload, used to emit the single-part form
151    cbor: Vec<u8>,
152}
153
154impl TokenUrEncoder {
155    /// Creates a new encoder for `token`
156    ///
157    /// `max_fragment_length` is the maximum number of payload bytes per QR
158    /// frame; [`DEFAULT_MAX_FRAGMENT_LENGTH`] is a sane default.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if `max_fragment_length` is zero or the token cannot
163    /// be converted to V4 or serialized.
164    pub fn new(token: &Token, max_fragment_length: usize) -> Result<Self, Error> {
165        let cbor = token_to_cbor(token)?;
166        let encoder = ur::Encoder::bytes(&cbor, max_fragment_length)?;
167        Ok(Self { encoder, cbor })
168    }
169
170    /// Returns the next UR fragment to display as a QR frame
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the fragment cannot be encoded.
175    pub fn next_part(&mut self) -> Result<String, Error> {
176        if self.fragment_count() == 1 {
177            // Advance the index, then emit the single-part form, matching the
178            // reference implementations. The type is the constant
179            // `TOKEN_UR_TYPE`, which is always valid.
180            let _part = self.encoder.next_part()?;
181            return Ok(ur::encode(&self.cbor, &ur::Type::Bytes));
182        }
183
184        Ok(self.encoder.next_part()?)
185    }
186
187    /// Returns the number of fragments emitted so far
188    pub fn current_index(&self) -> usize {
189        self.encoder.current_index()
190    }
191
192    /// Returns the number of fragments the token was split into
193    pub fn fragment_count(&self) -> usize {
194        self.encoder.fragment_count()
195    }
196
197    /// Returns whether the token fits into a single QR frame
198    pub fn is_single_fragment(&self) -> bool {
199        self.fragment_count() == 1
200    }
201}
202
203impl fmt::Debug for TokenUrEncoder {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.debug_struct("TokenUrEncoder")
206            .field("current_index", &self.current_index())
207            .field("fragment_count", &self.fragment_count())
208            .finish()
209    }
210}
211
212impl Token {
213    /// Creates a [`TokenUrEncoder`] for displaying this token as an animated
214    /// QR code
215    ///
216    /// `max_fragment_length` is the maximum number of payload bytes per QR
217    /// frame; [`DEFAULT_MAX_FRAGMENT_LENGTH`] is a sane default. Each
218    /// [`TokenUrEncoder::next_part`] fragment is displayed as one QR frame.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if `max_fragment_length` is zero or the token cannot
223    /// be converted to V4 or serialized.
224    pub fn ur_encoder(&self, max_fragment_length: usize) -> Result<TokenUrEncoder, Error> {
225        TokenUrEncoder::new(self, max_fragment_length)
226    }
227}
228
229/// Reassembles a [`Token`] from scanned UR fragments
230///
231/// QR frames are fed with [`receive`](Self::receive) as they are scanned, in
232/// any order; [`complete`](Self::complete) reports when enough frames have
233/// been seen and [`token`](Self::token) returns the reassembled token.
234#[derive(Default)]
235pub struct TokenUrDecoder {
236    decoder: ur::Decoder,
237    /// Payload of a received single-part UR, if any
238    single_part: Option<Vec<u8>>,
239}
240
241impl TokenUrDecoder {
242    /// Feeds one scanned QR frame into the decoder
243    ///
244    /// Accepts both multi-part fragments (`ur:bytes/<seq>-<len>/<payload>`)
245    /// and the single-part form (`ur:bytes/<payload>`).
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if the frame is not a well-formed `ur:bytes` UR, is
250    /// inconsistent with previously received frames, exceeds the decoder
251    /// resource limits, or fails checksum validation.
252    pub fn receive(&mut self, part: &str) -> Result<(), Error> {
253        if part.len() > MAX_UR_PART_LENGTH {
254            return Err(Error::PartTooLarge {
255                actual: part.len(),
256                max: MAX_UR_PART_LENGTH,
257            });
258        }
259
260        let ur_type = parse_ur_type(part)?;
261        if !ur_type.eq_ignore_ascii_case(TOKEN_UR_TYPE) {
262            return Err(Error::UnexpectedUrType(ur_type.to_string()));
263        }
264
265        let (kind, payload) = ur::decode(part)?;
266        match kind {
267            ur::ur::Kind::MultiPart => {
268                validate_multi_part_metadata(&payload)?;
269                self.decoder.receive(part)?;
270            }
271            ur::ur::Kind::SinglePart => {
272                if payload.len() > MAX_UR_MESSAGE_LENGTH {
273                    return Err(Error::MessageTooLarge {
274                        actual: payload.len(),
275                        max: MAX_UR_MESSAGE_LENGTH,
276                    });
277                }
278                self.single_part = Some(payload);
279            }
280        }
281
282        Ok(())
283    }
284
285    /// Returns whether the token has been fully reassembled
286    pub fn complete(&self) -> bool {
287        self.single_part.is_some() || self.decoder.complete()
288    }
289
290    /// Returns the reassembled [`Token`] once [`complete`](Self::complete)
291    ///
292    /// Returns `None` while decoding is incomplete.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the reassembled payload is not a valid token.
297    pub fn token(&self) -> Result<Option<Token>, Error> {
298        let message = match &self.single_part {
299            Some(payload) => Some(payload.clone()),
300            None => self.decoder.message()?,
301        };
302
303        message.map(|m| token_from_cbor(&m)).transpose()
304    }
305
306    /// Returns the total number of fragments the token was split into
307    ///
308    /// This is `0` until the first multi-part fragment is received.
309    pub fn fragment_count(&self) -> usize {
310        self.decoder.fragment_count()
311    }
312
313    /// Returns the number of fragments resolved so far, either received
314    /// directly or recovered via the fountain code
315    ///
316    /// Useful for progress indication. Returns `None` before any fragment
317    /// has been received.
318    pub fn resolved_fragment_count(&self) -> Option<usize> {
319        match &self.single_part {
320            Some(_) => Some(1),
321            None => self.decoder.resolved_fragment_count(),
322        }
323    }
324}
325
326impl fmt::Debug for TokenUrDecoder {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        f.debug_struct("TokenUrDecoder")
329            .field("complete", &self.complete())
330            .field("fragment_count", &self.fragment_count())
331            .field("resolved_fragment_count", &self.resolved_fragment_count())
332            .finish()
333    }
334}
335
336/// Serializes a token to the CBOR payload carried by the UR fragments: the
337/// serialized token (`cashuB…`) as a CBOR byte string
338fn token_to_cbor(token: &Token) -> Result<Vec<u8>, Error> {
339    let serialized = match token {
340        Token::TokenV3(token) => TokenV4::try_from(token.clone())?.to_string(),
341        Token::TokenV4(token) => token.to_string(),
342    };
343    let mut cbor = Vec::new();
344    ciborium::ser::into_writer(&serde_bytes::Bytes::new(serialized.as_bytes()), &mut cbor)?;
345    Ok(cbor)
346}
347
348/// Deserializes a token from the CBOR payload of a reassembled UR
349fn token_from_cbor(cbor: &[u8]) -> Result<Token, Error> {
350    let bytes: serde_bytes::ByteBuf = ciborium::de::from_reader(cbor)?;
351    let token_str = String::from_utf8(bytes.into_vec())?;
352    Ok(Token::from_str(&token_str)?)
353}
354
355/// Extracts the type component of a UR string (`ur:<type>/…`)
356fn parse_ur_type(part: &str) -> Result<&str, Error> {
357    let (scheme, without_scheme) = part.split_once(':').ok_or(ur::ur::Error::InvalidScheme)?;
358    if !scheme.eq_ignore_ascii_case("ur") {
359        return Err(ur::ur::Error::InvalidScheme.into());
360    }
361
362    let (ur_type, _) = without_scheme
363        .split_once('/')
364        .ok_or(ur::ur::Error::TypeUnspecified)?;
365    Ok(ur_type)
366}
367
368/// Validates the resource dimensions declared by a CBOR-encoded fountain part
369fn validate_multi_part_metadata(payload: &[u8]) -> Result<(), Error> {
370    type FountainPart = (u32, u32, u32, u32, serde_bytes::ByteBuf);
371
372    let (_, fragment_count, message_length, _, _): FountainPart =
373        ciborium::de::from_reader(payload)?;
374    let fragment_count = fragment_count as usize;
375    let message_length = message_length as usize;
376
377    if fragment_count > MAX_UR_FRAGMENT_COUNT {
378        return Err(Error::TooManyFragments {
379            actual: fragment_count,
380            max: MAX_UR_FRAGMENT_COUNT,
381        });
382    }
383    if message_length > MAX_UR_MESSAGE_LENGTH {
384        return Err(Error::MessageTooLarge {
385            actual: message_length,
386            max: MAX_UR_MESSAGE_LENGTH,
387        });
388    }
389
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    /// Token from the NUT-00 test vectors
398    const TOKEN_STR: &str = "cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ=";
399    const TOKEN_V3_STR: &str = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9";
400    const TOKEN_V4_FROM_V3_STR: &str = "cashuBpGFtd2h0dHBzOi8vODMzMy5zcGFjZTozMzM4YXVjc2F0YWRqVGhhbmsgeW91LmF0gaJhaUgAmh8pMlPkHmFwgqRhYQJhc3hANDA3OTE1YmMyMTJiZTYxYTc3ZTNlNmQyYWViNGM3Mjc5ODBiZGE1MWNkMDZhNmFmYzI5ZTI4NjE3NjhhNzgzN2FjWCECvJCXmX2Br7LMc0a15DRak0a9KlBut5WFmKcvDPhRY-phZPakYWEIYXN4QGZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmVhY1ghAp6OUFC4kKfWwJaNsWvB1dX6BA6h3ihPbsadYSmfZxBZYWT2";
401
402    fn test_token() -> Token {
403        Token::from_str(TOKEN_STR).expect("valid test token")
404    }
405
406    fn multi_part(sequence: u32, fragment_count: u32, message_length: u32, data: &[u8]) -> String {
407        let metadata = (
408            sequence,
409            fragment_count,
410            message_length,
411            0_u32,
412            serde_bytes::Bytes::new(data),
413        );
414        let mut cbor = Vec::new();
415        ciborium::ser::into_writer(&metadata, &mut cbor).expect("valid fountain part CBOR");
416        let encoded = ur::encode(&cbor, &ur::Type::Bytes);
417        let payload = encoded
418            .strip_prefix("ur:bytes/")
419            .expect("encoded bytes UR has expected prefix");
420        format!("ur:bytes/{sequence}-{fragment_count}/{payload}")
421    }
422
423    #[test]
424    fn test_single_fragment_roundtrip() {
425        let token = test_token();
426        let mut encoder = TokenUrEncoder::new(&token, 1000).expect("valid encoder");
427        assert!(encoder.is_single_fragment());
428
429        let part = encoder.next_part().expect("valid part");
430        assert!(part.starts_with("ur:bytes/"));
431        // Single-part form carries no fragment indices (`ur:bytes/<payload>`)
432        assert_eq!(part.matches('/').count(), 1);
433
434        let mut decoder = TokenUrDecoder::default();
435        decoder.receive(&part).expect("valid receive");
436        assert!(decoder.complete());
437        assert_eq!(decoder.token().expect("valid token"), Some(token));
438    }
439
440    #[test]
441    fn test_v3_token_is_encoded_as_v4() {
442        let token = Token::from_str(TOKEN_V3_STR).expect("valid V3 token");
443        let expected = Token::from_str(TOKEN_V4_FROM_V3_STR).expect("valid V4 token");
444        let mut encoder = token.ur_encoder(1000).expect("valid encoder");
445        let part = encoder.next_part().expect("valid part");
446
447        let mut decoder = TokenUrDecoder::default();
448        decoder.receive(&part).expect("valid receive");
449
450        assert_eq!(decoder.token().expect("valid token"), Some(expected));
451    }
452
453    #[test]
454    fn test_token_ur_encoder_method() {
455        let token = Token::from_str(TOKEN_STR).expect("valid token");
456        let mut encoder = token.ur_encoder(100).expect("encoder");
457
458        let mut decoder = TokenUrDecoder::default();
459        while !decoder.complete() {
460            let part = encoder.next_part().expect("part");
461            decoder.receive(&part).expect("receive");
462        }
463
464        assert_eq!(decoder.token().expect("token"), Some(token));
465    }
466
467    #[test]
468    fn test_multi_fragment_roundtrip() {
469        let token = test_token();
470        let mut encoder = TokenUrEncoder::new(&token, 20).expect("valid encoder");
471        assert!(encoder.fragment_count() > 1);
472
473        let mut decoder = TokenUrDecoder::default();
474        let mut parts = 0;
475        while !decoder.complete() {
476            let part = encoder.next_part().expect("valid part");
477            assert!(part.starts_with("ur:bytes/"));
478            // Multi-part form carries `<seq>-<len>` fragment indices
479            // (`ur:bytes/<seq>-<len>/<payload>`)
480            assert_eq!(part.matches('/').count(), 2);
481            decoder.receive(&part).expect("valid receive");
482            parts += 1;
483            assert!(parts <= 100, "decoder should complete");
484        }
485
486        assert!(parts >= encoder.fragment_count());
487        assert_eq!(decoder.token().expect("valid token"), Some(token));
488    }
489
490    #[test]
491    fn test_multi_fragment_with_dropped_frames() {
492        let token = test_token();
493        let mut encoder = TokenUrEncoder::new(&token, 20).expect("valid encoder");
494        let mut decoder = TokenUrDecoder::default();
495
496        // Drop every other frame; the fountain code must recover
497        for _ in 0..200 {
498            if decoder.complete() {
499                break;
500            }
501            let part = encoder.next_part().expect("valid part");
502            if encoder.current_index() % 2 == 0 {
503                decoder.receive(&part).expect("valid receive");
504            }
505        }
506
507        assert!(decoder.complete(), "decoder must tolerate dropped frames");
508        assert_eq!(decoder.token().expect("valid token"), Some(token));
509    }
510
511    #[test]
512    fn test_rejects_wrong_ur_type() {
513        let part = ur::encode(b"not a token", &ur::Type::Custom("crypto-psbt"));
514        let mut decoder = TokenUrDecoder::default();
515        let err = decoder.receive(&part).expect_err("must reject wrong type");
516        assert!(matches!(err, Error::UnexpectedUrType(t) if t == "crypto-psbt"));
517    }
518
519    #[test]
520    fn test_rejects_non_ur_frame() {
521        let mut decoder = TokenUrDecoder::default();
522        assert!(decoder.receive(TOKEN_STR).is_err());
523    }
524
525    #[test]
526    fn test_accepts_uppercase_ur() {
527        let token = test_token();
528        let mut encoder = TokenUrEncoder::new(&token, 1000).expect("valid encoder");
529        let part = encoder
530            .next_part()
531            .expect("valid part")
532            .to_ascii_uppercase();
533
534        let mut decoder = TokenUrDecoder::default();
535        decoder.receive(&part).expect("valid uppercase receive");
536        assert_eq!(decoder.token().expect("valid token"), Some(token));
537    }
538
539    #[test]
540    fn test_rejects_oversized_part_before_decoding() {
541        let part = "x".repeat(MAX_UR_PART_LENGTH + 1);
542        let mut decoder = TokenUrDecoder::default();
543        let err = decoder.receive(&part).expect_err("oversized part");
544
545        assert!(matches!(
546            err,
547            Error::PartTooLarge {
548                actual,
549                max: MAX_UR_PART_LENGTH,
550            } if actual == MAX_UR_PART_LENGTH + 1
551        ));
552    }
553
554    #[test]
555    fn test_rejects_excessive_fragment_count() {
556        let fragment_count =
557            u32::try_from(MAX_UR_FRAGMENT_COUNT + 1).expect("fragment limit fits in u32");
558        let part = multi_part(fragment_count + 1, fragment_count, 1, &[0]);
559        let mut decoder = TokenUrDecoder::default();
560        let err = decoder.receive(&part).expect_err("too many fragments");
561
562        assert!(matches!(
563            err,
564            Error::TooManyFragments {
565                actual,
566                max: MAX_UR_FRAGMENT_COUNT,
567            } if actual == MAX_UR_FRAGMENT_COUNT + 1
568        ));
569    }
570
571    #[test]
572    fn test_rejects_excessive_message_length() {
573        let message_length =
574            u32::try_from(MAX_UR_MESSAGE_LENGTH + 1).expect("message limit fits in u32");
575        let part = multi_part(1, 1, message_length, &[0]);
576        let mut decoder = TokenUrDecoder::default();
577        let err = decoder.receive(&part).expect_err("message too large");
578
579        assert!(matches!(
580            err,
581            Error::MessageTooLarge {
582                actual,
583                max: MAX_UR_MESSAGE_LENGTH,
584            } if actual == MAX_UR_MESSAGE_LENGTH + 1
585        ));
586    }
587
588    #[test]
589    fn test_payload_is_cbor_byte_string_of_token() {
590        // The UR payload must be the serialized token as a CBOR byte string
591        // (de-facto NUT-16 encoding used by e.g. cashu.me)
592        let token = test_token();
593        let cbor = token_to_cbor(&token).expect("valid cbor");
594        let bytes: serde_bytes::ByteBuf =
595            ciborium::de::from_reader(&cbor[..]).expect("valid cbor byte string");
596        assert_eq!(bytes.into_vec(), token.to_string().as_bytes());
597    }
598}