Skip to main content

bitcoin_payment_instructions/
cashu.rs

1//! NUT-26: Bech32m encoding for payment requests
2//!
3//! This module provides bech32m encoding and decoding functionality for Cashu payment requests,
4//! implementing the CREQ-B format using TLV (Tag-Length-Value) encoding as specified in NUT-26.
5
6use alloc::string::{String, ToString};
7use alloc::vec;
8use alloc::vec::Vec;
9use core::fmt;
10use core::ops::Deref;
11use core::str::FromStr;
12
13use bitcoin::bech32::{self, Bech32m, Hrp};
14
15/// Human-readable part for CREQ-B bech32m encoding
16pub const CREQ_B_HRP: &str = "creqb";
17
18/// Maximum number of bytes that can be stored inline in a `UnitString`.
19/// Set to 11 so that `UnitString` fits in 12 bytes (matching `String` on 32-bit systems).
20const INLINE_UNIT_BYTES: usize = 11;
21
22/// Errors that can occur during parsing and encoding of Cashu payment requests
23#[derive(Debug, PartialEq, Eq)]
24pub enum Error {
25	/// Invalid HRP prefix (must be `creqb`)
26	InvalidPrefix,
27	/// Invalid length of a TLV field or the overall structure
28	InvalidLength,
29	/// Invalid UTF-8 encoding in a string field
30	InvalidUtf8,
31	/// Unknown NUT-10 spending condition kind
32	UnknownKind(u8),
33	/// Bech32 encoding/decoding error
34	Bech32,
35	/// Invalid TLV structure (missing required fields, unexpected values, malformed TLV)
36	InvalidStructure,
37}
38
39/// A string type optimized for short currency unit names.
40///
41/// Stores strings of 11 bytes or less inline without heap allocation.
42/// Longer strings fall back to heap allocation.
43#[derive(Clone, PartialEq, Eq)]
44pub struct UnitString(UnitStringInner);
45
46#[derive(Clone, PartialEq, Eq)]
47enum UnitStringInner {
48	Inline { bytes: [u8; INLINE_UNIT_BYTES], len: u8 },
49	Heap(String),
50}
51
52impl UnitString {
53	/// Creates a new `UnitString` from a string slice.
54	pub fn new(s: &str) -> Self {
55		let bytes = s.as_bytes();
56		if bytes.len() <= INLINE_UNIT_BYTES {
57			let mut arr = [0u8; INLINE_UNIT_BYTES];
58			arr[..bytes.len()].copy_from_slice(bytes);
59			Self(UnitStringInner::Inline { bytes: arr, len: bytes.len() as u8 })
60		} else {
61			Self(UnitStringInner::Heap(s.to_string()))
62		}
63	}
64
65	/// Creates a `UnitString` from a byte slice, returning `None` if the bytes are not valid UTF-8.
66	pub fn from_utf8(bytes: &[u8]) -> Option<Self> {
67		core::str::from_utf8(bytes).ok().map(Self::new)
68	}
69
70	/// Returns the string as a string slice.
71	pub fn as_str(&self) -> &str {
72		match &self.0 {
73			UnitStringInner::Inline { bytes, len } => {
74				// We only store valid UTF-8 in the inline buffer via the
75				// public constructors, and UnitStringInner is private.
76				core::str::from_utf8(&bytes[..*len as usize])
77					.expect("UnitString contains valid UTF-8")
78			},
79			UnitStringInner::Heap(s) => s.as_str(),
80		}
81	}
82
83	/// Returns the string as a byte slice.
84	pub fn as_bytes(&self) -> &[u8] {
85		self.as_str().as_bytes()
86	}
87}
88
89impl Deref for UnitString {
90	type Target = str;
91
92	fn deref(&self) -> &Self::Target {
93		self.as_str()
94	}
95}
96
97impl AsRef<str> for UnitString {
98	fn as_ref(&self) -> &str {
99		self.as_str()
100	}
101}
102
103impl fmt::Display for UnitString {
104	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105		fmt::Display::fmt(self.as_str(), f)
106	}
107}
108
109impl fmt::Debug for UnitString {
110	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111		fmt::Display::fmt(self.as_str(), f)
112	}
113}
114
115impl PartialEq<str> for UnitString {
116	fn eq(&self, other: &str) -> bool {
117		self.as_str() == other
118	}
119}
120
121impl PartialEq<&str> for UnitString {
122	fn eq(&self, other: &&str) -> bool {
123		self.as_str() == *other
124	}
125}
126
127impl From<&str> for UnitString {
128	fn from(s: &str) -> Self {
129		Self::new(s)
130	}
131}
132
133impl From<String> for UnitString {
134	fn from(s: String) -> Self {
135		if s.len() <= INLINE_UNIT_BYTES {
136			Self::new(&s)
137		} else {
138			Self(UnitStringInner::Heap(s))
139		}
140	}
141}
142
143/// Supported Currency Units
144///
145/// A mint may support any currency unit(s) they can mint and melt, either directly or indirectly.
146/// Defined in [NUT-01](https://github.com/cashubtc/nuts/blob/main/01.md#supported-currency-units).
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum CurrencyUnit {
149	/// Bitcoin's Minor Unit (satoshis).
150	Sat,
151	/// Millisatoshis.
152	Msat,
153	/// US Dollars (ISO 4217 code `usd`).
154	/// Amounts represent cents (0.01 USD).
155	Usd,
156	/// Euro (ISO 4217 code `eur`).
157	/// Amounts represent cents (0.01 EUR).
158	Eur,
159	/// Reserved for Blind Authentication.
160	///
161	/// These are special tokens (BATs) used to access protected mint endpoints while maintaining privacy.
162	/// They function similarly to regular ecash but with a fixed amount of 1 and the unit `auth`.
163	///
164	/// In a payment request, this can be used to request access rights to a mint.
165	/// The sender authenticates with the mint, gets BATs, and transfers them to the receiver,
166	/// allowing the receiver to perform actions (like minting) on that mint without their own credentials.
167	///
168	/// See [NUT-22](https://github.com/cashubtc/nuts/blob/main/22.md).
169	Auth,
170	/// Custom unit (e.g., other ISO 4217 codes like `gbp`, `jpy`).
171	/// Note: There is no length limit for the unit string according to the spec.
172	Custom(UnitString),
173}
174
175impl CurrencyUnit {
176	/// Creates a custom currency unit from a string slice.
177	pub fn custom(s: &str) -> Self {
178		Self::Custom(UnitString::new(s))
179	}
180}
181
182/// The mechanism used to deliver the ecash token
183///
184/// Note: If the transport list is empty, it is implicitly assumed that the payment
185/// will be delivered in-band (e.g., in an HTTP response header as in
186/// [X-Cashu/NUT-24](https://github.com/cashubtc/nuts/blob/main/24.md)).
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum TransportType {
189	/// Send via Nostr Direct Message (NIP-17)
190	Nostr,
191	/// Send via HTTP POST request
192	HttpPost,
193}
194
195/// A value in a TagTuple, stored inline to avoid heap allocation.
196/// Maximum length is 255 bytes (enforced by the TLV encoding).
197#[derive(Copy, Clone, Eq)]
198pub struct TagValue {
199	bytes: [u8; 255],
200	len: u8,
201}
202
203impl TagValue {
204	/// Create a new TagValue from a string slice.
205	pub fn new(s: &str) -> Result<Self, Error> {
206		let bytes = s.as_bytes();
207		if bytes.len() > u8::MAX as usize {
208			return Err(Error::InvalidLength);
209		}
210		let mut arr = [0u8; 255];
211		arr[..bytes.len()].copy_from_slice(bytes);
212		Ok(Self { bytes: arr, len: bytes.len() as u8 })
213	}
214
215	/// Returns the string slice.
216	pub fn as_str(&self) -> &str {
217		// We only construct from valid str in new()
218		core::str::from_utf8(&self.bytes[..self.len as usize])
219			.expect("TagValue contains valid UTF-8")
220	}
221
222	/// Returns the byte slice.
223	pub fn as_bytes(&self) -> &[u8] {
224		self.as_str().as_bytes()
225	}
226}
227
228impl Deref for TagValue {
229	type Target = str;
230
231	fn deref(&self) -> &Self::Target {
232		self.as_str()
233	}
234}
235
236impl AsRef<str> for TagValue {
237	fn as_ref(&self) -> &str {
238		self.as_str()
239	}
240}
241
242impl fmt::Display for TagValue {
243	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244		f.write_str(self.as_str())
245	}
246}
247
248impl fmt::Debug for TagValue {
249	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250		f.write_str(self.as_str())
251	}
252}
253
254impl PartialEq for TagValue {
255	fn eq(&self, other: &Self) -> bool {
256		self.as_str() == other.as_str()
257	}
258}
259
260impl PartialEq<str> for TagValue {
261	fn eq(&self, other: &str) -> bool {
262		self.as_str() == other
263	}
264}
265
266impl PartialEq<&str> for TagValue {
267	fn eq(&self, other: &&str) -> bool {
268		self.as_str() == *other
269	}
270}
271
272impl PartialEq<String> for TagValue {
273	fn eq(&self, other: &String) -> bool {
274		self.as_str() == other.as_str()
275	}
276}
277
278/// A tag tuple containing a key and zero or more values.
279///
280/// This represents the generic tag format used in NUT-18/NUT-26 for both
281/// transport tags and NUT-10 spending condition tags.
282/// In JSON, this is represented as `["key", "value1", "value2", ...]`.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct TagTuple {
285	/// The tag key (e.g., "n" for NIPs, "locktime" for timelocks)
286	key: TagValue,
287	/// The tag values
288	values: Vec<TagValue>,
289}
290
291impl TagTuple {
292	/// Create a new tag tuple with a key and values.
293	///
294	/// Returns an error if the key or any value exceeds 255 bytes.
295	pub fn new<I, S>(key: &str, values: I) -> Result<Self, Error>
296	where
297		I: IntoIterator<Item = S>,
298		S: AsRef<str>,
299	{
300		let key = TagValue::new(key)?;
301		let values_iter = values.into_iter();
302		let mut tag_values = Vec::with_capacity(values_iter.size_hint().0);
303		for value in values_iter {
304			tag_values.push(TagValue::new(value.as_ref())?);
305		}
306		Ok(Self { key, values: tag_values })
307	}
308
309	/// Create a tag tuple with a single value.
310	///
311	/// Returns an error if the key or value exceeds 255 bytes.
312	pub fn single(key: &str, value: &str) -> Result<Self, Error> {
313		let key = TagValue::new(key)?;
314		Ok(Self { key, values: vec![TagValue::new(value)?] })
315	}
316
317	/// Returns the tag key.
318	pub fn key(&self) -> &str {
319		&self.key
320	}
321
322	/// Returns the tag values.
323	pub fn values(&self) -> &[TagValue] {
324		&self.values
325	}
326}
327
328/// Transport configuration for sending ecash
329///
330/// Defines how and where the wallet should send the proofs (ecash) to fulfill the payment request.
331/// This allows the receiver to specify their preferred method of receiving the payment,
332/// such as via a Nostr direct message or an HTTP POST request.
333///
334/// The transport can be empty. If the transport is empty, it is implicitly assumed that the payment will be in-band.
335/// An example is [X-Cashu](https://github.com/cashubtc/nuts/blob/main/24.md) where the payment is expected in the HTTP header of a request.
336/// We can only hope that the protocol being used has a well-defined transport.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct Transport {
339	/// The method of transport to use (e.g. Nostr, HTTP)
340	pub kind: TransportType,
341	/// The target destination (e.g. nostr profile string, HTTP URL)
342	pub target: String,
343	/// Additional parameters for the transport (e.g. relays, specific NIPs)
344	///
345	/// For Nostr transports (`kind=0`), generic tag tuples are used.
346	/// - Key `"n"`: Specifies the NIPs the receiver supports (e.g., `TagTuple::single("n", "17")`).
347	///
348	/// Note that relays are *not* stored here (though are encoded as tags in the bech32 encoding).
349	/// Instead, they are included in the nostr profile string in [`Self::target`].
350	pub tags: Vec<TagTuple>,
351}
352
353impl Transport {
354	/// If [`Self::kind`] is [`TransportType::Nostr`], this returns the relays which are encoded as
355	/// a part of the [`Self::target`] (nostr profile string). Otherwise, returns
356	/// Err([`Error::InvalidStructure`])
357	pub fn nostr_relays(&self) -> Result<Vec<String>, Error> {
358		if self.kind == TransportType::Nostr {
359			Ok(CashuPaymentRequest::decode_nprofile(&self.target)?.1)
360		} else {
361			Err(Error::InvalidStructure)
362		}
363	}
364}
365
366/// NUT-10 Spending Condition Kind
367///
368/// Specifies the type of spending condition required for the token.
369/// These correspond to the "kind" field in NUT-10 spending conditions.
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum Kind {
372	/// Pay to Public Key (P2PK)
373	///
374	/// Tokens are locked to a public key and require a valid signature to spend.
375	/// Defined in [NUT-11](https://github.com/cashubtc/nuts/blob/main/11.md).
376	P2PK,
377	/// Hash Time Locked Contract (HTLC)
378	///
379	/// Tokens are locked with a hash and/or a timelock.
380	/// Defined in [NUT-14](https://github.com/cashubtc/nuts/blob/main/14.md).
381	HTLC,
382}
383
384/// NUT-10 Spending Condition
385///
386/// Represents a requested spending condition for the ecash token.
387/// The payee can specify requirements for the token secret, such as locking it to a public key (P2PK) or a hash (HTLC).
388/// Defined in [NUT-10](https://github.com/cashubtc/nuts/blob/main/10.md).
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct Nut10SecretRequest {
391	/// The type of spending condition (e.g., P2PK or HTLC).
392	pub kind: Kind,
393	/// The data required for the spending condition.
394	///
395	/// - For P2PK, this is the 33-byte public key (hex-encoded).
396	/// - For HTLC, this is the 32-byte hash of the preimage (hex-encoded).
397	pub data: String,
398	/// Optional tags for additional conditions.
399	///
400	/// Common tags include:
401	/// - `TagTuple::single("locktime", "<timestamp>")`: Unix timestamp for time locks.
402	/// - `TagTuple::single("refund", "<pubkey>")`: Public key for refund spending condition.
403	/// - `TagTuple::single("sig", "<signature>")`: Signature for P2PK authorization.
404	pub tags: Vec<TagTuple>,
405}
406
407impl Nut10SecretRequest {
408	/// Create a new NUT-10 secret request
409	pub fn new(kind: Kind, data: &str, tags: Vec<TagTuple>) -> Self {
410		Self { kind, data: data.to_string(), tags }
411	}
412}
413
414/// Cashu Payment Request
415///
416/// A standardised format for payment requests that supply a sending wallet with all information necessary to complete the transaction.
417/// Defined in [NUT-18](https://github.com/cashubtc/nuts/blob/main/18.md).
418/// The bech32 encoding is defined in [NUT-26](https://github.com/cashubtc/nuts/blob/main/26.md).
419/// Note: This crate currently only supports the bech32 encoding format (CREQ-B).
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct CashuPaymentRequest {
422	/// Payment ID to be included in the payment payload.
423	pub payment_id: Option<String>,
424	/// The amount of the requested payment.
425	pub amount: Option<u64>,
426	/// The unit of the requested payment.
427	/// MUST be set if `amount` is set.
428	pub unit: Option<CurrencyUnit>,
429	/// Whether the payment request is for single use.
430	pub single_use: Option<bool>,
431	/// A set of mints from which the payment is requested.
432	pub mints: Option<Vec<String>>,
433	/// A human readable description that the sending wallet will display after scanning the request.
434	pub description: Option<String>,
435	/// The method of `Transport` chosen to transmit the payment.
436	/// Can be multiple, sorted by preference.
437	pub transports: Vec<Transport>,
438	/// The required NUT-10 spending conditions.
439	pub nut10: Option<Nut10SecretRequest>,
440}
441
442/// TLV reader helper for parsing binary TLV data
443struct TlvReader<'a> {
444	data: &'a [u8],
445	position: usize,
446}
447
448impl<'a> TlvReader<'a> {
449	fn new(data: &'a [u8]) -> Self {
450		Self { data, position: 0 }
451	}
452
453	fn read_tlv(&mut self) -> Result<Option<(u8, &'a [u8])>, Error> {
454		if self.position + 3 > self.data.len() {
455			return Ok(None);
456		}
457
458		let tag = self.data[self.position];
459		let len = u16::from_be_bytes([self.data[self.position + 1], self.data[self.position + 2]])
460			as usize;
461		self.position += 3;
462
463		if self.position + len > self.data.len() {
464			return Err(Error::InvalidLength);
465		}
466
467		let value = &self.data[self.position..self.position + len];
468		self.position += len;
469
470		Ok(Some((tag, value)))
471	}
472}
473
474/// Helper to write TLV (Tag-Length-Value) data directly to a buffer.
475///
476/// For nested TLVs, use [`SingleTlvWriter`] which provides RAII-based length patching.
477/// This avoids intermediate allocations by writing directly to a single buffer and
478/// patching the length field when the wrapper is dropped.
479struct TlvWriter {
480	data: Vec<u8>,
481}
482
483impl TlvWriter {
484	fn with_capacity(capacity: usize) -> Self {
485		Self { data: Vec::with_capacity(capacity) }
486	}
487
488	fn write_tlv(&mut self, tag: u8, value: &[u8]) {
489		self.data.push(tag);
490		let len = value.len() as u16;
491		self.data.extend_from_slice(&len.to_be_bytes());
492		self.data.extend_from_slice(value);
493	}
494
495	/// Write raw bytes directly to the buffer (used for tag tuple encoding).
496	fn write_raw(&mut self, bytes: &[u8]) {
497		self.data.extend_from_slice(bytes);
498	}
499
500	/// Write a single byte directly to the buffer.
501	fn write_byte(&mut self, byte: u8) {
502		self.data.push(byte);
503	}
504
505	fn into_bytes(self) -> Vec<u8> {
506		self.data
507	}
508}
509
510/// Wrapper for writing a single nested TLV structure.
511/// Writes the tag and length placeholder on creation, patches the length on drop.
512struct SingleTlvWriter<'a> {
513	writer: &'a mut TlvWriter,
514	len_pos: usize,
515}
516
517impl<'a> SingleTlvWriter<'a> {
518	fn new(writer: &'a mut TlvWriter, tag: u8) -> Self {
519		writer.data.push(tag);
520		let len_pos = writer.data.len();
521		writer.data.extend_from_slice(&[0, 0]); // Placeholder for length
522		Self { writer, len_pos }
523	}
524
525	/// Create a nested TLV writer within this one.
526	fn nested(&mut self, tag: u8) -> SingleTlvWriter<'_> {
527		SingleTlvWriter::new(self.writer, tag)
528	}
529
530	fn write_tlv(&mut self, tag: u8, value: &[u8]) {
531		self.writer.write_tlv(tag, value);
532	}
533
534	fn write_raw(&mut self, bytes: &[u8]) {
535		self.writer.write_raw(bytes);
536	}
537
538	fn write_byte(&mut self, byte: u8) {
539		self.writer.write_byte(byte);
540	}
541}
542
543impl Drop for SingleTlvWriter<'_> {
544	fn drop(&mut self) {
545		let value_len = self.writer.data.len() - (self.len_pos + 2);
546		let len_bytes = (value_len as u16).to_be_bytes();
547		self.writer.data[self.len_pos] = len_bytes[0];
548		self.writer.data[self.len_pos + 1] = len_bytes[1];
549	}
550}
551
552impl FromStr for CashuPaymentRequest {
553	type Err = Error;
554
555	fn from_str(s: &str) -> Result<Self, Self::Err> {
556		Self::from_bech32_string(s)
557	}
558}
559
560impl fmt::Display for CashuPaymentRequest {
561	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562		let s = self.to_bech32_string().map_err(|_| fmt::Error)?;
563		f.write_str(&s)
564	}
565}
566
567impl CashuPaymentRequest {
568	/// Encodes a payment request to CREQB1 bech32m format.
569	///
570	/// # Example
571	///
572	/// ```
573	/// use bitcoin_payment_instructions::cashu::{CashuPaymentRequest, CurrencyUnit};
574	///
575	/// let request = CashuPaymentRequest {
576	///     payment_id: Some("demo123".to_string()),
577	///     amount: Some(1000),
578	///     unit: Some(CurrencyUnit::Sat),
579	///     single_use: Some(true),
580	///     mints: Some(vec!["https://mint.example.com".to_string()]),
581	///     description: Some("Coffee payment".to_string()),
582	///     transports: vec![],
583	///     nut10: None,
584	/// };
585	///
586	/// let encoded = request.to_bech32_string().unwrap();
587	/// assert!(encoded.starts_with("CREQB1"));
588	/// ```
589	pub fn to_bech32_string(&self) -> Result<String, Error> {
590		let tlv_bytes = self.encode_tlv()?;
591		let hrp = Hrp::parse(CREQ_B_HRP).map_err(|_| Error::InvalidPrefix)?;
592
593		// Always emit uppercase for QR compatibility
594		let encoded =
595			bech32::encode_upper::<Bech32m>(hrp, &tlv_bytes).map_err(|_| Error::Bech32)?;
596		Ok(encoded)
597	}
598
599	/// Decodes a payment request from CREQB1 bech32m format.
600	pub fn from_bech32_string(s: &str) -> Result<Self, Error> {
601		// If s contains ':', assume it might be a URI and try to extract the bech32 part?
602		// But the caller usually handles URIs. We assume s is the bech32 string.
603		let (hrp, data) = bech32::decode(s).map_err(|_| Error::Bech32)?;
604		if !hrp.as_str().eq_ignore_ascii_case(CREQ_B_HRP) {
605			return Err(Error::InvalidPrefix);
606		}
607
608		Self::from_bech32_bytes(&data)
609	}
610
611	#[cfg(fuzzing)]
612	/// Decode from a byte array so the fuzzer can bypass bech32
613	pub fn from_bytes_fuzzy(bytes: &[u8]) -> Result<CashuPaymentRequest, Error> {
614		Self::from_bech32_bytes(bytes)
615	}
616
617	/// Decode from TLV bytes
618	fn from_bech32_bytes(bytes: &[u8]) -> Result<CashuPaymentRequest, Error> {
619		let mut reader = TlvReader::new(bytes);
620
621		let mut id: Option<String> = None;
622		let mut amount: Option<u64> = None;
623		let mut unit: Option<CurrencyUnit> = None;
624		let mut single_use: Option<bool> = None;
625		let mut mints: Vec<String> = Vec::new();
626		let mut description: Option<String> = None;
627		let mut transports: Vec<Transport> = Vec::new();
628		let mut nut10: Option<Nut10SecretRequest> = None;
629
630		while let Some((tag, value)) = reader.read_tlv()? {
631			match tag {
632				0x01 => {
633					// id: string
634					if id.is_some() {
635						return Err(Error::InvalidStructure);
636					}
637					id = Some(String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidUtf8)?);
638				},
639				0x02 => {
640					// amount: u64
641					if amount.is_some() {
642						return Err(Error::InvalidStructure);
643					}
644					if value.len() != 8 {
645						return Err(Error::InvalidLength);
646					}
647					let amount_val = u64::from_be_bytes([
648						value[0], value[1], value[2], value[3], value[4], value[5], value[6],
649						value[7],
650					]);
651					amount = Some(amount_val);
652				},
653				0x03 => {
654					// unit: u8 or string
655					if unit.is_some() {
656						return Err(Error::InvalidStructure);
657					}
658					if value.len() == 1 && value[0] == 0 {
659						unit = Some(CurrencyUnit::Sat);
660					} else {
661						match value {
662							b"msat" => unit = Some(CurrencyUnit::Msat),
663							b"usd" => unit = Some(CurrencyUnit::Usd),
664							b"eur" => unit = Some(CurrencyUnit::Eur),
665							b"auth" => unit = Some(CurrencyUnit::Auth),
666							_ => {
667								let unit_str =
668									UnitString::from_utf8(value).ok_or(Error::InvalidUtf8)?;
669								unit = Some(CurrencyUnit::Custom(unit_str));
670							},
671						}
672					}
673				},
674				0x04 => {
675					// single_use: u8 (0 or 1)
676					if single_use.is_some() {
677						return Err(Error::InvalidStructure);
678					}
679					if !value.is_empty() {
680						single_use = Some(value[0] != 0);
681					}
682				},
683				0x05 => {
684					// mint: string (repeatable)
685					let mint_str =
686						String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidUtf8)?;
687					mints.push(mint_str);
688				},
689				0x06 => {
690					// description: string
691					if description.is_some() {
692						return Err(Error::InvalidStructure);
693					}
694					description =
695						Some(String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidUtf8)?);
696				},
697				0x07 => {
698					// transport: sub-TLV (repeatable)
699					let transport = Self::decode_transport(value)?;
700					transports.push(transport);
701				},
702				0x08 => {
703					// nut10: sub-TLV
704					if nut10.is_some() {
705						return Err(Error::InvalidStructure);
706					}
707					nut10 = Some(Self::decode_nut10(value)?);
708				},
709				_ => {
710					// Unknown tags are ignored
711				},
712			}
713		}
714
715		Ok(CashuPaymentRequest {
716			payment_id: id,
717			amount,
718			unit,
719			single_use,
720			mints: if mints.is_empty() { None } else { Some(mints) },
721			description,
722			transports,
723			nut10,
724		})
725	}
726
727	/// Encode to TLV bytes
728	fn encode_tlv(&self) -> Result<Vec<u8>, Error> {
729		// Estimate capacity to minimize reallocations:
730		// - Each TLV header is 3 bytes (1 tag + 2 length)
731		// - id: ~10-50 bytes typical
732		// - amount: 8 bytes
733		// - unit: 1-10 bytes
734		// - single_use: 1 byte
735		// - mints: variable, ~50 bytes each
736		// - description: variable
737		// - transports: ~100-200 bytes each
738		// - nut10: ~100 bytes
739		let estimated_capacity = 64
740			+ self.payment_id.as_ref().map_or(0, |s| s.len() + 3)
741			+ self.amount.map_or(0, |_| 11)
742			+ self.unit.as_ref().map_or(0, |_| 10)
743			+ self.mints.as_ref().map_or(0, |m| m.iter().map(|s| s.len() + 3).sum())
744			+ self.description.as_ref().map_or(0, |s| s.len() + 3)
745			+ self.transports.len() * 150
746			+ self.nut10.as_ref().map_or(0, |_| 100);
747
748		let mut writer = TlvWriter::with_capacity(estimated_capacity);
749
750		// 0x01 id: string
751		if let Some(ref id) = self.payment_id {
752			writer.write_tlv(0x01, id.as_bytes());
753		}
754
755		// 0x02 amount: u64
756		if let Some(amount) = self.amount {
757			let amount_bytes = amount.to_be_bytes();
758			writer.write_tlv(0x02, &amount_bytes);
759		}
760
761		// 0x03 unit: u8 or string
762		if let Some(ref unit) = self.unit {
763			match unit {
764				CurrencyUnit::Sat => writer.write_tlv(0x03, &[0]),
765				CurrencyUnit::Msat => writer.write_tlv(0x03, b"msat"),
766				CurrencyUnit::Usd => writer.write_tlv(0x03, b"usd"),
767				CurrencyUnit::Eur => writer.write_tlv(0x03, b"eur"),
768				CurrencyUnit::Auth => writer.write_tlv(0x03, b"auth"),
769				CurrencyUnit::Custom(s) => writer.write_tlv(0x03, s.as_bytes()),
770			}
771		}
772
773		// 0x04 single_use: u8 (0 or 1)
774		if let Some(single_use) = self.single_use {
775			writer.write_tlv(0x04, &[if single_use { 1 } else { 0 }]);
776		}
777
778		// 0x05 mint: string (repeatable)
779		if let Some(ref mints) = self.mints {
780			for mint in mints {
781				writer.write_tlv(0x05, mint.as_bytes());
782			}
783		}
784
785		// 0x06 description: string
786		if let Some(ref description) = self.description {
787			writer.write_tlv(0x06, description.as_bytes());
788		}
789
790		// 0x07 transport: sub-TLV (repeatable, order = priority)
791		for transport in &self.transports {
792			let mut w = SingleTlvWriter::new(&mut writer, 0x07);
793			Self::encode_transport_into(transport, &mut w)?;
794		}
795
796		// 0x08 nut10: sub-TLV
797		if let Some(ref nut10) = self.nut10 {
798			let mut w = SingleTlvWriter::new(&mut writer, 0x08);
799			Self::encode_nut10_into(nut10, &mut w)?;
800		}
801
802		Ok(writer.into_bytes())
803	}
804
805	/// Decode transport sub-TLV
806	fn decode_transport(bytes: &[u8]) -> Result<Transport, Error> {
807		let mut reader = TlvReader::new(bytes);
808
809		let mut kind: Option<u8> = None;
810		let mut raw_target: Option<&[u8]> = None;
811		let mut tags: Vec<(&str, Vec<&str>)> = Vec::new();
812
813		while let Some((tag, value)) = reader.read_tlv()? {
814			match tag {
815				0x01 => {
816					// kind: u8
817					if kind.is_some() {
818						return Err(Error::InvalidStructure);
819					}
820					if value.len() != 1 {
821						return Err(Error::InvalidLength);
822					}
823					kind = Some(value[0]);
824				},
825				0x02 => {
826					// target: bytes (store raw, interpret after loop based on kind)
827					if raw_target.is_some() {
828						return Err(Error::InvalidStructure);
829					}
830					raw_target = Some(value);
831				},
832				0x03 => {
833					// tag_tuple: generic tuple (repeatable)
834					let tag_tuple = Self::decode_tag_tuple(value)?;
835					tags.push(tag_tuple);
836				},
837				_ => {
838					// Unknown sub-TLV tags are ignored
839				},
840			}
841		}
842
843		let transport_type = match kind.ok_or(Error::InvalidStructure)? {
844			0x00 => TransportType::Nostr,
845			0x01 => TransportType::HttpPost,
846			_ => return Err(Error::InvalidStructure),
847		};
848
849		let relays: Vec<&str> =
850			tags.iter().filter(|(k, _)| *k == "r").flat_map(|(_, v)| v.iter().copied()).collect();
851
852		// Interpret raw target bytes based on kind
853		let raw_target = raw_target.ok_or(Error::InvalidStructure)?;
854		let target = match transport_type {
855			TransportType::Nostr => {
856				// nostr: 32-byte x-only pubkey
857				if raw_target.len() != 32 {
858					return Err(Error::InvalidLength);
859				}
860				Self::encode_nprofile(raw_target, &relays)?
861			},
862			TransportType::HttpPost => {
863				// http_post: UTF-8 URL string
864				String::from_utf8(raw_target.to_vec()).map_err(|_| Error::InvalidUtf8)?
865			},
866		};
867
868		let mut final_tags: Vec<TagTuple> = Vec::new();
869		for (key, values) in tags {
870			if key != "r" {
871				final_tags.push(TagTuple::new(key, values)?);
872			}
873		}
874
875		Ok(Transport { kind: transport_type, target, tags: final_tags })
876	}
877
878	/// Encode transport body directly into the provided writer to avoid intermediate allocations.
879	fn encode_transport_into(
880		transport: &Transport, writer: &mut SingleTlvWriter<'_>,
881	) -> Result<(), Error> {
882		let kind = match transport.kind {
883			TransportType::Nostr => 0x00u8,
884			TransportType::HttpPost => 0x01u8,
885		};
886		writer.write_tlv(0x01, &[kind]);
887
888		match transport.kind {
889			TransportType::Nostr => {
890				let (pubkey, relays) = Self::decode_nprofile(&transport.target)?;
891
892				writer.write_tlv(0x02, &pubkey);
893
894				for tag in transport.tags.iter() {
895					Self::encode_tag_tuple_into(tag, writer);
896				}
897
898				for relay in relays {
899					Self::encode_tag_tuple_into(&TagTuple::single("r", &relay)?, writer);
900				}
901			},
902			TransportType::HttpPost => {
903				writer.write_tlv(0x02, transport.target.as_bytes());
904
905				for tag in transport.tags.iter() {
906					Self::encode_tag_tuple_into(tag, writer);
907				}
908			},
909		}
910
911		Ok(())
912	}
913
914	/// Decode NUT-10 sub-TLV
915	fn decode_nut10(bytes: &[u8]) -> Result<Nut10SecretRequest, Error> {
916		let mut reader = TlvReader::new(bytes);
917
918		let mut kind: Option<u8> = None;
919		let mut data: Option<Vec<u8>> = None;
920		let mut tags: Vec<TagTuple> = Vec::new();
921
922		while let Some((tag, value)) = reader.read_tlv()? {
923			match tag {
924				0x01 => {
925					// kind: u8
926					if kind.is_some() {
927						return Err(Error::InvalidStructure);
928					}
929					if value.len() != 1 {
930						return Err(Error::InvalidLength);
931					}
932					kind = Some(value[0]);
933				},
934				0x02 => {
935					// data: bytes
936					if data.is_some() {
937						return Err(Error::InvalidStructure);
938					}
939					data = Some(value.to_vec());
940				},
941				0x03 | 0x05 => {
942					// tag_tuple: generic tuple (repeatable)
943					let (key, values) = Self::decode_tag_tuple(value)?;
944					tags.push(TagTuple::new(key, values)?);
945				},
946				_ => {
947					// Unknown tags are ignored
948				},
949			}
950		}
951
952		let kind_val = kind.ok_or(Error::InvalidStructure)?;
953		let data_val = data.unwrap_or_default();
954
955		let data_str = String::from_utf8(data_val).map_err(|_| Error::InvalidUtf8)?;
956
957		let kind_enum = match kind_val {
958			0 => Kind::P2PK,
959			1 => Kind::HTLC,
960			_ => return Err(Error::UnknownKind(kind_val)),
961		};
962
963		Ok(Nut10SecretRequest::new(kind_enum, &data_str, tags))
964	}
965
966	/// Encode NUT-10 body directly into the provided writer to avoid intermediate allocations.
967	fn encode_nut10_into(
968		nut10: &Nut10SecretRequest, writer: &mut SingleTlvWriter<'_>,
969	) -> Result<(), Error> {
970		let kind_val = match nut10.kind {
971			Kind::P2PK => 0u8,
972			Kind::HTLC => 1u8,
973		};
974		writer.write_tlv(0x01, &[kind_val]);
975		writer.write_tlv(0x02, nut10.data.as_bytes());
976
977		for tag in nut10.tags.iter() {
978			Self::encode_tag_tuple_into(tag, writer);
979		}
980
981		Ok(())
982	}
983
984	/// Decode tag tuple, returning borrowed strings to avoid intermediate allocations.
985	fn decode_tag_tuple(bytes: &[u8]) -> Result<(&str, Vec<&str>), Error> {
986		if bytes.is_empty() {
987			return Err(Error::InvalidLength);
988		}
989
990		let key_len = bytes[0] as usize;
991		if bytes.len() < 1 + key_len {
992			return Err(Error::InvalidLength);
993		}
994
995		let key = core::str::from_utf8(&bytes[1..1 + key_len]).map_err(|_| Error::InvalidUtf8)?;
996
997		let mut values = Vec::new();
998		let mut pos = 1 + key_len;
999
1000		while pos < bytes.len() {
1001			let val_len = bytes[pos] as usize;
1002			pos += 1;
1003
1004			if pos + val_len > bytes.len() {
1005				return Err(Error::InvalidLength);
1006			}
1007
1008			let value =
1009				core::str::from_utf8(&bytes[pos..pos + val_len]).map_err(|_| Error::InvalidUtf8)?;
1010			values.push(value);
1011			pos += val_len;
1012		}
1013
1014		Ok((key, values))
1015	}
1016
1017	/// Encode tag tuple directly into the provided writer to avoid intermediate allocations.
1018	/// Writes as a 0x03 sub-TLV (tag + length + key/values).
1019	fn encode_tag_tuple_into(tag: &TagTuple, writer: &mut SingleTlvWriter<'_>) {
1020		let mut w = writer.nested(0x03);
1021
1022		// Key length + key
1023		w.write_byte(tag.key().len() as u8);
1024		w.write_raw(tag.key().as_bytes());
1025
1026		// Values
1027		for value in tag.values() {
1028			w.write_byte(value.len() as u8);
1029			w.write_raw(value.as_bytes());
1030		}
1031	}
1032
1033	/// Decode nprofile bech32 string to (pubkey, relays)
1034	fn decode_nprofile(nprofile: &str) -> Result<([u8; 32], Vec<String>), Error> {
1035		let (hrp, data) = bech32::decode(nprofile).map_err(|_| Error::Bech32)?;
1036		if hrp.as_str() != "nprofile" {
1037			return Err(Error::InvalidPrefix);
1038		}
1039
1040		let mut pos = 0;
1041		let mut pubkey: Option<[u8; 32]> = None;
1042		let mut relays: Vec<String> = Vec::new();
1043
1044		while pos < data.len() {
1045			if pos + 2 > data.len() {
1046				break;
1047			}
1048
1049			let tag = data[pos];
1050			let len = data[pos + 1] as usize;
1051			pos += 2;
1052
1053			if pos + len > data.len() {
1054				return Err(Error::InvalidLength);
1055			}
1056
1057			let value = &data[pos..pos + len];
1058			pos += len;
1059
1060			match tag {
1061				0 => {
1062					// pubkey: 32 bytes
1063					if value.len() != 32 {
1064						return Err(Error::InvalidLength);
1065					}
1066					pubkey = Some(value.try_into().expect("len is 32"));
1067				},
1068				1 => {
1069					// relay: UTF-8 string
1070					let relay =
1071						String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidUtf8)?;
1072					relays.push(relay);
1073				},
1074				_ => {
1075					// Unknown TLV types are ignored
1076				},
1077			}
1078		}
1079
1080		let pubkey = pubkey.ok_or(Error::InvalidStructure)?;
1081		Ok((pubkey, relays))
1082	}
1083
1084	/// Encode pubkey and relays to nprofile bech32 string
1085	fn encode_nprofile(pubkey: &[u8], relays: &[&str]) -> Result<String, Error> {
1086		if pubkey.len() != 32 {
1087			return Err(Error::InvalidLength);
1088		}
1089
1090		let capacity = 34 + relays.iter().map(|r| 2 + r.len()).sum::<usize>();
1091		let mut tlv_bytes = Vec::with_capacity(capacity);
1092
1093		// Type 0: pubkey (32 bytes)
1094		tlv_bytes.push(0);
1095		tlv_bytes.push(32);
1096		tlv_bytes.extend_from_slice(pubkey);
1097
1098		// Type 1: relays
1099		for relay in relays {
1100			if relay.len() > 255 {
1101				return Err(Error::InvalidLength);
1102			}
1103			tlv_bytes.push(1);
1104			tlv_bytes.push(relay.len() as u8);
1105			tlv_bytes.extend_from_slice(relay.as_bytes());
1106		}
1107
1108		let hrp = Hrp::parse("nprofile").map_err(|_| Error::InvalidPrefix)?;
1109		bech32::encode::<bech32::Bech32>(hrp, &tlv_bytes).map_err(|_| Error::Bech32)
1110	}
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115	use super::*;
1116	use alloc::string::ToString;
1117	use bitcoin::hex::FromHex;
1118
1119	#[test]
1120	fn test_bech32_basic_round_trip() {
1121		let transport = Transport {
1122			kind: TransportType::HttpPost,
1123			target: "https://api.example.com/payment".to_string(),
1124			tags: Vec::new(),
1125		};
1126
1127		let payment_request = CashuPaymentRequest {
1128			payment_id: Some("test123".to_string()),
1129			amount: Some(100),
1130			unit: Some(CurrencyUnit::Sat),
1131			single_use: Some(true),
1132			mints: Some(vec!["https://mint.example.com".to_string()]),
1133			description: Some("Test payment".to_string()),
1134			transports: vec![transport],
1135			nut10: None,
1136		};
1137
1138		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1139
1140		// Verify it starts with CREQB1
1141		assert!(encoded.starts_with("CREQB1"));
1142
1143		// Round-trip test
1144		let decoded =
1145			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1146		assert_eq!(decoded.payment_id, payment_request.payment_id);
1147		assert_eq!(decoded.amount, payment_request.amount);
1148		assert_eq!(decoded.unit, payment_request.unit);
1149		assert_eq!(decoded.single_use, payment_request.single_use);
1150		assert_eq!(decoded.description, payment_request.description);
1151	}
1152
1153	#[test]
1154	fn test_bech32_minimal() {
1155		let payment_request = CashuPaymentRequest {
1156			payment_id: Some("minimal".to_string()),
1157			amount: None,
1158			unit: Some(CurrencyUnit::Sat),
1159			single_use: None,
1160			mints: Some(vec!["https://mint.example.com".to_string()]),
1161			description: None,
1162			transports: vec![],
1163			nut10: None,
1164		};
1165
1166		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1167
1168		let decoded =
1169			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1170		assert_eq!(decoded.payment_id, payment_request.payment_id);
1171		assert_eq!(decoded.mints, payment_request.mints);
1172	}
1173
1174	#[test]
1175	fn test_bech32_with_nut10() {
1176		let nut10 = Nut10SecretRequest::new(
1177			Kind::P2PK,
1178			"026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
1179			vec![TagTuple::single("timeout", "3600").unwrap()],
1180		);
1181
1182		let payment_request = CashuPaymentRequest {
1183			payment_id: Some("nut10test".to_string()),
1184			amount: Some(500),
1185			unit: Some(CurrencyUnit::Sat),
1186			single_use: None,
1187			mints: Some(vec!["https://mint.example.com".to_string()]),
1188			description: Some("P2PK locked payment".to_string()),
1189			transports: vec![],
1190			nut10: Some(nut10.clone()),
1191		};
1192
1193		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1194
1195		let decoded =
1196			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1197		assert_eq!(decoded.nut10.as_ref().unwrap().kind, nut10.kind);
1198		assert_eq!(decoded.nut10.as_ref().unwrap().data, nut10.data);
1199	}
1200
1201	#[test]
1202	fn test_parse_creq_param_bech32() {
1203		let payment_request = CashuPaymentRequest {
1204			payment_id: Some("test123".to_string()),
1205			amount: Some(100),
1206			unit: Some(CurrencyUnit::Sat),
1207			single_use: None,
1208			mints: Some(vec!["https://mint.example.com".to_string()]),
1209			description: None,
1210			transports: vec![],
1211			nut10: None,
1212		};
1213
1214		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1215
1216		let decoded_payment_request =
1217			CashuPaymentRequest::from_bech32_string(&encoded).expect("should parse bech32");
1218		assert_eq!(decoded_payment_request.payment_id, payment_request.payment_id);
1219	}
1220
1221	#[test]
1222	fn test_from_bech32_string_errors_on_wrong_encoding() {
1223		// Test that from_bech32_string errors if given a non-CREQ-B string
1224		let legacy_creq = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U=";
1225
1226		// Should error because it's not bech32m encoded
1227		assert!(CashuPaymentRequest::from_bech32_string(legacy_creq).is_err());
1228
1229		// Test with a string that's not CREQ-B
1230		assert!(CashuPaymentRequest::from_bech32_string("not_a_creq").is_err());
1231
1232		// Test with wrong HRP (nprofile instead of creqb)
1233		let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
1234		let pubkey_bytes = Vec::<u8>::from_hex(pubkey_hex).unwrap();
1235		let nprofile = CashuPaymentRequest::encode_nprofile(&pubkey_bytes, &[])
1236			.expect("should encode nprofile");
1237		assert!(CashuPaymentRequest::from_bech32_string(&nprofile).is_err());
1238	}
1239
1240	#[test]
1241	fn test_unit_encoding_bech32() {
1242		// Test default sat unit
1243		let payment_request = CashuPaymentRequest {
1244			payment_id: Some("unit_test".to_string()),
1245			amount: Some(100),
1246			unit: Some(CurrencyUnit::Sat),
1247			single_use: None,
1248			mints: Some(vec!["https://mint.example.com".to_string()]),
1249			description: None,
1250			transports: vec![],
1251			nut10: None,
1252		};
1253
1254		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1255
1256		let decoded =
1257			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1258		assert_eq!(decoded.unit, Some(CurrencyUnit::Sat));
1259
1260		// Test custom unit
1261		let payment_request_usd = CashuPaymentRequest {
1262			payment_id: Some("unit_test_usd".to_string()),
1263			amount: Some(100),
1264			unit: Some(CurrencyUnit::Usd),
1265			single_use: None,
1266			mints: Some(vec!["https://mint.example.com".to_string()]),
1267			description: None,
1268			transports: vec![],
1269			nut10: None,
1270		};
1271
1272		let encoded_usd = payment_request_usd.to_bech32_string().expect("encoding should work");
1273
1274		let decoded_usd =
1275			CashuPaymentRequest::from_bech32_string(&encoded_usd).expect("decoding should work");
1276		assert_eq!(decoded_usd.unit, Some(CurrencyUnit::Usd));
1277	}
1278
1279	#[test]
1280	fn test_nprofile_no_relays() {
1281		// Test vector: a known 32-byte pubkey
1282		let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
1283		let pubkey_bytes = Vec::<u8>::from_hex(pubkey_hex).unwrap();
1284
1285		// Encode to nprofile with empty relay list
1286		let nprofile = CashuPaymentRequest::encode_nprofile(&pubkey_bytes, &[])
1287			.expect("should encode nprofile");
1288		assert!(nprofile.starts_with("nprofile"));
1289
1290		// Decode back
1291		let decoded =
1292			CashuPaymentRequest::decode_nprofile(&nprofile).expect("should decode nprofile");
1293		assert_eq!(&decoded.0[..], &pubkey_bytes[..]);
1294		assert!(decoded.1.is_empty());
1295	}
1296
1297	#[test]
1298	fn test_nostr_transport_with_nprofile_no_relays() {
1299		// Create a payment request with nostr transport using nprofile with empty relay list
1300		let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
1301		let pubkey_bytes = Vec::<u8>::from_hex(pubkey_hex).unwrap();
1302		let nprofile =
1303			CashuPaymentRequest::encode_nprofile(&pubkey_bytes, &[]).expect("encode nprofile");
1304
1305		let transport = Transport {
1306			kind: TransportType::Nostr,
1307			target: nprofile.clone(),
1308			tags: vec![TagTuple::single("n", "17").unwrap()],
1309		};
1310
1311		let payment_request = CashuPaymentRequest {
1312			payment_id: Some("nostr_test".to_string()),
1313			amount: Some(1000),
1314			unit: Some(CurrencyUnit::Sat),
1315			single_use: None,
1316			mints: Some(vec!["https://mint.example.com".to_string()]),
1317			description: Some("Nostr payment".to_string()),
1318			transports: vec![transport],
1319			nut10: None,
1320		};
1321
1322		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1323
1324		let decoded =
1325			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1326
1327		assert_eq!(decoded.payment_id, payment_request.payment_id);
1328		assert_eq!(decoded.transports.len(), 1);
1329		assert_eq!(decoded.transports[0].kind, TransportType::Nostr);
1330		assert!(decoded.transports[0].target.starts_with("nprofile"));
1331
1332		// Check that NIP-17 tag was preserved
1333		let mut tags_iter = decoded.transports[0].tags.iter();
1334		assert!(
1335			tags_iter.any(|t| t.key == "n" && t.values.first().map(|s| s.as_str()) == Some("17"))
1336		);
1337	}
1338
1339	#[test]
1340	fn test_nostr_transport_with_nprofile() {
1341		// Create a payment request with nostr transport using nprofile
1342		let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
1343		let pubkey_bytes = Vec::<u8>::from_hex(pubkey_hex).unwrap();
1344		let relays: Vec<&str> = vec!["wss://relay.example.com"];
1345		let nprofile =
1346			CashuPaymentRequest::encode_nprofile(&pubkey_bytes, &relays).expect("encode nprofile");
1347
1348		let transport = Transport {
1349			kind: TransportType::Nostr,
1350			target: nprofile.clone(),
1351			tags: vec![TagTuple::single("n", "17").unwrap()],
1352		};
1353
1354		let payment_request = CashuPaymentRequest {
1355			payment_id: Some("nprofile_test".to_string()),
1356			amount: Some(2100),
1357			unit: Some(CurrencyUnit::Sat),
1358			single_use: None,
1359			mints: Some(vec!["https://mint.example.com".to_string()]),
1360			description: Some("Nostr payment with relays".to_string()),
1361			transports: vec![transport],
1362			nut10: None,
1363		};
1364
1365		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1366
1367		let decoded =
1368			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1369
1370		assert_eq!(decoded.payment_id, payment_request.payment_id);
1371		assert_eq!(decoded.transports.len(), 1);
1372		assert_eq!(decoded.transports[0].kind, TransportType::Nostr);
1373
1374		// Should be encoded back as nprofile since it has relays
1375		assert!(decoded.transports[0].target.starts_with("nprofile"));
1376
1377		// Check that relay was preserved
1378		let relays = decoded.transports[0].nostr_relays().unwrap();
1379		assert_eq!(relays[0].as_str(), "wss://relay.example.com");
1380	}
1381
1382	#[test]
1383	fn test_spec_example_nostr_transport() {
1384		// Test a complete example as specified in the spec:
1385		// Payment request with nostr transport, NIP-17, pubkey, and one relay
1386		let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
1387		let pubkey_bytes = Vec::<u8>::from_hex(pubkey_hex).unwrap();
1388		let relays: Vec<&str> = vec!["wss://relay.damus.io"];
1389		let nprofile =
1390			CashuPaymentRequest::encode_nprofile(&pubkey_bytes, &relays).expect("encode nprofile");
1391
1392		let transport = Transport {
1393			kind: TransportType::Nostr,
1394			target: nprofile,
1395			tags: vec![TagTuple::single("n", "17").unwrap()],
1396		};
1397
1398		let payment_request = CashuPaymentRequest {
1399			payment_id: Some("spec_example".to_string()),
1400			amount: Some(10),
1401			unit: Some(CurrencyUnit::Sat),
1402			single_use: Some(true),
1403			mints: Some(vec!["https://mint.example.com".to_string()]),
1404			description: Some("Coffee".to_string()),
1405			transports: vec![transport],
1406			nut10: None,
1407		};
1408
1409		// Encode and decode
1410		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1411
1412		let decoded =
1413			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1414
1415		// Verify round-trip
1416		assert_eq!(decoded.payment_id, Some("spec_example".to_string()));
1417		assert_eq!(decoded.amount, Some(10));
1418		assert_eq!(decoded.unit, Some(CurrencyUnit::Sat));
1419		assert_eq!(decoded.single_use, Some(true));
1420		assert_eq!(decoded.description, Some("Coffee".to_string()));
1421		assert_eq!(decoded.transports.len(), 1);
1422		assert_eq!(decoded.transports[0].kind, TransportType::Nostr);
1423
1424		// Verify relay and NIP are preserved
1425		let mut tags_iter = decoded.transports[0].tags.iter();
1426		assert!(
1427			tags_iter.any(|t| t.key == "n" && t.values.first().map(|s| s.as_str()) == Some("17"))
1428		);
1429
1430		let nostr_relays = decoded.transports[0].nostr_relays().unwrap();
1431		assert_eq!(nostr_relays[0].as_str(), "wss://relay.damus.io");
1432	}
1433
1434	#[test]
1435	fn test_decode_valid_bech32_with_nostr_pubkeys_and_mints() {
1436		// First, create a payment request with multiple mints and nostr transports with different pubkeys
1437		let pubkey1_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
1438		let pubkey1_bytes = Vec::<u8>::from_hex(pubkey1_hex).unwrap();
1439		// Use nprofile with empty relay list instead of npub
1440		let nprofile1 =
1441			CashuPaymentRequest::encode_nprofile(&pubkey1_bytes, &[]).expect("encode nprofile1");
1442
1443		let pubkey2_hex = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
1444		let pubkey2_bytes = Vec::<u8>::from_hex(pubkey2_hex).unwrap();
1445		let relays2: Vec<&str> = vec!["wss://relay.damus.io", "wss://nos.lol"];
1446		let nprofile2 = CashuPaymentRequest::encode_nprofile(&pubkey2_bytes, &relays2)
1447			.expect("encode nprofile2");
1448
1449		let transport1 = Transport {
1450			kind: TransportType::Nostr,
1451			target: nprofile1.clone(),
1452			tags: vec![TagTuple::single("n", "17").unwrap()],
1453		};
1454
1455		let transport2 = Transport {
1456			kind: TransportType::Nostr,
1457			target: nprofile2.clone(),
1458			tags: vec![TagTuple::single("n", "17").unwrap(), TagTuple::single("n", "44").unwrap()],
1459		};
1460
1461		let payment_request = CashuPaymentRequest {
1462			payment_id: Some("multi_test".to_string()),
1463			amount: Some(5000),
1464			unit: Some(CurrencyUnit::Sat),
1465			single_use: Some(false),
1466			mints: Some(vec![
1467				"https://mint1.example.com".to_string(),
1468				"https://mint2.example.com".to_string(),
1469				"https://testnut.cashu.space".to_string(),
1470			]),
1471			description: Some("Payment with multiple transports and mints".to_string()),
1472			transports: vec![transport1, transport2],
1473			nut10: None,
1474		};
1475
1476		// Encode to bech32 string
1477		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1478
1479		// Now decode the bech32 string and verify contents
1480		let decoded = CashuPaymentRequest::from_bech32_string(&encoded)
1481			.expect("should decode valid bech32 string");
1482
1483		// Verify basic fields
1484		assert_eq!(decoded.payment_id, Some("multi_test".to_string()));
1485		assert_eq!(decoded.amount, Some(5000));
1486		assert_eq!(decoded.unit, Some(CurrencyUnit::Sat));
1487		assert_eq!(decoded.single_use, Some(false));
1488		assert_eq!(
1489			decoded.description,
1490			Some("Payment with multiple transports and mints".to_string())
1491		);
1492
1493		// Verify mints
1494		let mints = decoded.mints.as_ref().expect("should have mints");
1495		assert_eq!(mints.len(), 3);
1496
1497		// Verify transports
1498		assert_eq!(decoded.transports.len(), 2);
1499
1500		// Verify first transport (nprofile with no relays)
1501		let transport1_decoded = &decoded.transports[0];
1502		assert_eq!(transport1_decoded.kind, TransportType::Nostr);
1503		assert!(transport1_decoded.target.starts_with("nprofile"));
1504
1505		// Decode the nprofile to verify the pubkey
1506		let (decoded_pubkey1, decoded_relays1) =
1507			CashuPaymentRequest::decode_nprofile(&transport1_decoded.target)
1508				.expect("should decode nprofile");
1509		assert_eq!(&decoded_pubkey1[..], &pubkey1_bytes[..]);
1510		assert!(decoded_relays1.is_empty());
1511
1512		// Verify NIP-17 tag
1513		let mut tags1_iter = transport1_decoded.tags.iter();
1514		assert!(
1515			tags1_iter.any(|t| t.key == "n" && t.values.first().map(|s| s.as_str()) == Some("17"))
1516		);
1517
1518		// Verify second transport (nprofile)
1519		let transport2_decoded = &decoded.transports[1];
1520		assert_eq!(transport2_decoded.kind, TransportType::Nostr);
1521		assert!(transport2_decoded.target.starts_with("nprofile"));
1522
1523		// Decode the nprofile to verify the pubkey and relays
1524		let (decoded_pubkey2, decoded_relays2) =
1525			CashuPaymentRequest::decode_nprofile(&transport2_decoded.target)
1526				.expect("should decode nprofile");
1527		assert_eq!(&decoded_pubkey2[..], &pubkey2_bytes[..]);
1528		assert_eq!(decoded_relays2, relays2);
1529
1530		// Verify tags include both NIPs and relays
1531		let tags2 = &transport2_decoded.tags;
1532		assert!(tags2
1533			.iter()
1534			.any(|t| t.key == "n" && t.values.first().map(|s| s.as_str()) == Some("17")));
1535		assert!(tags2
1536			.iter()
1537			.any(|t| t.key == "n" && t.values.first().map(|s| s.as_str()) == Some("44")));
1538		let relays = transport2_decoded.nostr_relays().unwrap();
1539		assert!(relays.iter().any(|v| v == "wss://relay.damus.io"));
1540		assert!(relays.iter().any(|v| v == "wss://nos.lol"));
1541	}
1542
1543	#[test]
1544	fn test_basic_payment_request() {
1545		// Basic payment request with required fields
1546		// Original JSON:
1547		// {
1548		//     "i": "b7a90176",
1549		//     "a": 10,
1550		//     "u": "sat",
1551		//     "m": ["https://8333.space:3338"],
1552		//     "t": [
1553		//         {
1554		//             "t": "nostr",
1555		//             "a": "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n",
1556		//             "g": [["n", "17"]]
1557		//         }
1558		//     ]
1559		// }
1560
1561		let expected_encoded = "CREQB1QYQQSC3HVYUNQVFHXCPQQZQQQQQQQQQQQQ9QXQQPQQZSQ9MGW368QUE69UHNSVENXVH8XURPVDJN5VENXVUQWQREQYQQZQQZQQSGM6QFA3C8DTZ2FVZHVFQEACMWM0E50PE3K5TFMVPJJMN0VJ7M2TGRQQZSZMSZXYMSXQQHQ9EPGAMNWVAZ7TMJV4KXZ7FWV3SK6ATN9E5K7QCQRGQHY9MHWDEN5TE0WFJKCCTE9CURXVEN9EEHQCTRV5HSXQQSQ9EQ6AMNWVAZ7TMWDAEJUMR0DSRYDPGF";
1562
1563		// Construct the struct manually
1564		let transport = Transport {
1565			kind: TransportType::Nostr,
1566			target: "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n".to_string(),
1567			tags: vec![TagTuple::single("n", "17").unwrap()],
1568		};
1569
1570		let payment_request = CashuPaymentRequest {
1571			payment_id: Some("b7a90176".to_string()),
1572			amount: Some(10),
1573			unit: Some(CurrencyUnit::Sat),
1574			single_use: None,
1575			mints: Some(vec!["https://8333.space:3338".to_string()]),
1576			description: None,
1577			transports: vec![transport],
1578			nut10: None,
1579		};
1580
1581		// Test bech32m encoding (CREQ-B format)
1582		let encoded = payment_request.to_bech32_string().expect("Failed to encode to bech32");
1583
1584		// Verify exact encoding matches expected
1585		assert_eq!(encoded, expected_encoded);
1586
1587		// Test round-trip via bech32 format
1588		let decoded = CashuPaymentRequest::from_bech32_string(&encoded).unwrap();
1589
1590		// Verify decoded fields match original
1591		assert_eq!(decoded.payment_id.as_ref().unwrap(), "b7a90176");
1592		assert_eq!(decoded.amount.unwrap(), 10);
1593		assert_eq!(decoded.unit.unwrap(), CurrencyUnit::Sat);
1594		assert_eq!(decoded.mints.unwrap(), vec!["https://8333.space:3338".to_string()]);
1595
1596		// Verify transport type and that it has the NIP-17 tag
1597		assert_eq!(decoded.transports.len(), 1);
1598		assert_eq!(decoded.transports[0].kind, TransportType::Nostr);
1599		let mut tags_iter = decoded.transports[0].tags.iter();
1600		assert!(
1601			tags_iter.any(|t| t.key == "n" && t.values.first().map(|s| s.as_str()) == Some("17"))
1602		);
1603	}
1604
1605	#[test]
1606	fn test_nostr_transport_payment_request() {
1607		let expected_encoded = "CREQB1QYQQSE3EXFSN2VTZ8QPQQZQQQQQQQQQQQPJQXQQPQQZSQXTGW368QUE69UHK66TWWSCJUETCV9KHQMR99E3K7MG9QQVKSAR5WPEN5TE0D45KUAPJ9EJHSCTDWPKX2TNRDAKSWQPEQYQQZQQZQQSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQRQQZSZMSZXYMSXQQ8Q9HQGWFHXV6SCAGZ48";
1608
1609		let transport = Transport {
1610			kind: TransportType::Nostr,
1611			target: "nprofile1qqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq8uzqt"
1612				.to_string(),
1613			tags: vec![
1614				TagTuple::single("n", "17").unwrap(),
1615				TagTuple::single("n", "9735").unwrap(),
1616			],
1617		};
1618
1619		let payment_request = CashuPaymentRequest {
1620			payment_id: Some("f92a51b8".to_string()),
1621			amount: Some(100),
1622			unit: Some(CurrencyUnit::Sat),
1623			single_use: None,
1624			mints: Some(vec![
1625				"https://mint1.example.com".to_string(),
1626				"https://mint2.example.com".to_string(),
1627			]),
1628			description: None,
1629			transports: vec![transport],
1630			nut10: None,
1631		};
1632
1633		// Test round-trip serialization
1634		let encoded = payment_request.to_bech32_string().unwrap();
1635
1636		// Verify exact encoding matches expected
1637		assert_eq!(encoded, expected_encoded);
1638
1639		let decoded = CashuPaymentRequest::from_bech32_string(&encoded).unwrap();
1640		assert_eq!(payment_request, decoded);
1641	}
1642
1643	#[test]
1644	fn test_minimal_payment_request_vectors() {
1645		let expected_encoded =
1646			"CREQB1QYQQSDMXX3SNYC3N8YPSQQGQQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSYP0LHG";
1647
1648		let payment_request = CashuPaymentRequest {
1649			payment_id: Some("7f4a2b39".to_string()),
1650			amount: None,
1651			unit: Some(CurrencyUnit::Sat),
1652			single_use: None,
1653			mints: Some(vec!["https://mint.example.com".to_string()]),
1654			description: None,
1655			transports: vec![],
1656			nut10: None,
1657		};
1658
1659		// Test round-trip serialization
1660		let encoded = payment_request.to_bech32_string().unwrap();
1661		assert_eq!(encoded, expected_encoded);
1662		let decoded = CashuPaymentRequest::from_bech32_string(&encoded).unwrap();
1663		assert_eq!(payment_request, decoded);
1664	}
1665
1666	#[test]
1667	fn test_nut10_locking_payment_request_vectors() {
1668		let expected_encoded = "CREQB1QYQQSCEEV56R2EPJVYPQQZQQQQQQQQQQQ86QXQQPQQZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6ZQQTYQSQQGQQGQYYVPJVVEKYDTZVGERWEFNXCCNGDFHVVUNYEPEXDJRWWRYVSMNXEPNVS6NXDENXGCNZVRZXF3KVEFCVG6NQENZVVCXZCNRXCCN2EFEVVENXVGRQQXSWARFD4JK7AT5QSENVVPS2N5FAS";
1669
1670		let nut10 = Nut10SecretRequest {
1671			kind: Kind::P2PK,
1672			data: "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331".to_string(),
1673			tags: vec![TagTuple::single("timeout", "3600").unwrap()],
1674		};
1675
1676		let payment_request = CashuPaymentRequest {
1677			payment_id: Some("c9e45d2a".to_string()),
1678			amount: Some(500),
1679			unit: Some(CurrencyUnit::Sat),
1680			single_use: None,
1681			mints: Some(vec!["https://mint.example.com".to_string()]),
1682			description: None,
1683			transports: vec![],
1684			nut10: Some(nut10),
1685		};
1686
1687		// Test round-trip serialization
1688		let encoded = payment_request.to_bech32_string().unwrap();
1689		assert_eq!(encoded, expected_encoded);
1690		let decoded = CashuPaymentRequest::from_bech32_string(&encoded).unwrap();
1691		assert_eq!(payment_request, decoded);
1692	}
1693
1694	#[test]
1695	fn test_nut26_example() {
1696		let expected_encoded = "CREQB1QYQQWER9D4HNZV3NQGQQSQQQQQQQQQQRAQPSQQGQQSQQZQG9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RQQRJRDANXVET9YPCXZ7TDV4H8GXHR3TQ";
1697
1698		let payment_request = CashuPaymentRequest {
1699			payment_id: Some("demo123".to_string()),
1700			amount: Some(1000),
1701			unit: Some(CurrencyUnit::Sat),
1702			single_use: Some(true),
1703			mints: Some(vec!["https://mint.example.com".to_string()]),
1704			description: Some("Coffee payment".to_string()),
1705			transports: vec![],
1706			nut10: None,
1707		};
1708
1709		let encoded = payment_request.to_bech32_string().unwrap();
1710
1711		assert_eq!(expected_encoded, encoded);
1712	}
1713
1714	#[test]
1715	fn test_http_post_transport_kind_1() {
1716		let expected_encoded = "CREQB1QYQQJ6R5W3C97AR9WD6QYQQGQQQQQQQQQQQ05QCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MG8QPQSZQQPQYPQQGNGW368QUE69UHKZURF9EJHSCTDWPKX2TNRDAKJ7A339ACXZ7TDV4H8GQCQZ5RXXATNW3HK6PNKV9K82EF3QEMXZMR4V5EQ9X3SJM";
1717
1718		let transport = Transport {
1719			kind: TransportType::HttpPost,
1720			target: "https://api.example.com/v1/payment".to_string(),
1721			tags: vec![
1722				TagTuple::new("custom", vec!["value1".to_string(), "value2".to_string()]).unwrap()
1723			],
1724		};
1725
1726		let payment_request = CashuPaymentRequest {
1727			payment_id: Some("http_test".to_string()),
1728			amount: Some(250),
1729			unit: Some(CurrencyUnit::Sat),
1730			single_use: None,
1731			mints: Some(vec!["https://mint.example.com".to_string()]),
1732			description: None,
1733			transports: vec![transport],
1734			nut10: None,
1735		};
1736
1737		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1738
1739		// Verify exact encoding matches expected
1740		assert_eq!(encoded, expected_encoded);
1741
1742		// Decode and verify round-trip
1743		let decoded =
1744			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1745
1746		// Verify transport type is HTTP POST
1747		assert_eq!(decoded.transports.len(), 1);
1748		assert_eq!(decoded.transports[0].kind, TransportType::HttpPost);
1749		assert_eq!(decoded.transports[0].target, "https://api.example.com/v1/payment");
1750
1751		// Verify custom tags are preserved
1752		let mut tags_iter = decoded.transports[0].tags.iter();
1753		assert!(tags_iter.any(|t| t.key == "custom"
1754			&& t.values.len() >= 2
1755			&& t.values[0] == "value1"
1756			&& t.values[1] == "value2"));
1757	}
1758
1759	#[test]
1760	fn test_relay_tag_extraction_from_nprofile() {
1761		let expected_encoded = "CREQB1QYQQ5UN9D3SHJHM5V4EHGQSQPQQQQQQQQQQQQEQRQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQUQGZQGQQYQQYQPQ80CVV07TJDRRGPA0J7J7TMNYL2YR6YR7L8J4S3EVF6U64TH6GKWSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7F39EJHSCTDWPKX2TNRDAKSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7FJ9EJHSCTDWPKX2TNRDAKSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7FN9EJHSCTDWPKX2TNRDAKSKRFDAR";
1762
1763		let transport = Transport {
1764			kind: TransportType::Nostr,
1765			target: "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gprpmhxue69uhhyetvv9unztn90psk6urvv5hxxmmdqyv8wumn8ghj7un9d3shjv3wv4uxzmtsd3jjucm0d5q3samnwvaz7tmjv4kxz7fn9ejhsctdwpkx2tnrdaksxzjpjp".to_string(),
1766			tags: Vec::new(),
1767		};
1768
1769		let payment_request = CashuPaymentRequest {
1770			payment_id: Some("relay_test".to_string()),
1771			amount: Some(100),
1772			unit: Some(CurrencyUnit::Sat),
1773			single_use: None,
1774			mints: Some(vec!["https://mint.example.com".to_string()]),
1775			description: None,
1776			transports: vec![transport],
1777			nut10: None,
1778		};
1779
1780		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1781
1782		// Verify exact encoding matches expected
1783		assert_eq!(encoded, expected_encoded);
1784
1785		// Decode and verify round-trip
1786		let decoded =
1787			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1788
1789		// Verify relays were extracted and converted to a single "relay" tag
1790		let nostr_relays = decoded.transports[0].nostr_relays().unwrap();
1791		assert_eq!(nostr_relays.len(), 3);
1792
1793		// Verify the nprofile is preserved (relays are encoded back into it)
1794		assert_eq!(
1795			"nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gprpmhxue69uhhyetvv9unztn90psk6urvv5hxxmmdqyv8wumn8ghj7un9d3shjv3wv4uxzmtsd3jjucm0d5q3samnwvaz7tmjv4kxz7fn9ejhsctdwpkx2tnrdaksxzjpjp",
1796			decoded.transports[0].target
1797		);
1798	}
1799
1800	#[test]
1801	fn test_multiple_transports() {
1802		let expected_encoded = "CREQB1QYQQ7MT4D36XJHM5WFSKUUMSDAE8GQSQPQQQQQQQQQQQRAQRQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQCQZQ5RP09KK2MN5YPMKJARGYPKH2MR5D9CXCEFQW3EXZMNNWPHHYARNQUQZ7QGQQYQQYQPQ80CVV07TJDRRGPA0J7J7TMNYL2YR6YR7L8J4S3EVF6U64TH6GKWSXQQ9Q9HQYVFHQUQZWQGQQYQSYQPQDP68GURN8GHJ7CTSDYCJUETCV9KHQMR99E3K7MF0WPSHJMT9DE6QWQP6QYQQZQGZQQSXSAR5WPEN5TE0V9CXJV3WV4UXZMTSD3JJUCM0D5HHQCTED4JKUAQRQQGQSURJD9HHY6T50YRXYCTRDD6HQTSH7TP";
1803
1804		let t1 = Transport {
1805			kind: TransportType::Nostr,
1806			target: "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q"
1807				.to_string(),
1808			tags: vec![TagTuple::single("n", "17").unwrap()],
1809		};
1810		let t2 = Transport {
1811			kind: TransportType::HttpPost,
1812			target: "https://api1.example.com/payment".to_string(),
1813			tags: Vec::new(),
1814		};
1815		let t3 = Transport {
1816			kind: TransportType::HttpPost,
1817			target: "https://api2.example.com/payment".to_string(),
1818			tags: vec![TagTuple::single("priority", "backup").unwrap()],
1819		};
1820
1821		let payment_request = CashuPaymentRequest {
1822			payment_id: Some("multi_transport".to_string()),
1823			amount: Some(500),
1824			unit: Some(CurrencyUnit::Sat),
1825			mints: Some(vec!["https://mint.example.com".to_string()]),
1826			description: Some("Payment with multiple transports".to_string()),
1827			single_use: None,
1828			transports: vec![t1, t2, t3],
1829			nut10: None,
1830		};
1831
1832		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1833
1834		// Verify exact encoding matches expected
1835		assert_eq!(encoded, expected_encoded);
1836
1837		// Decode from the encoded string
1838		let decoded =
1839			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1840
1841		// Verify all three transports are preserved in order
1842		assert_eq!(decoded.transports.len(), 3);
1843
1844		// First transport: Nostr
1845		assert_eq!(decoded.transports[0].kind, TransportType::Nostr);
1846		assert!(decoded.transports[0].target.starts_with("nprofile"));
1847
1848		// Second transport: HTTP POST
1849		assert_eq!(decoded.transports[1].kind, TransportType::HttpPost);
1850		assert_eq!(decoded.transports[1].target, "https://api1.example.com/payment");
1851
1852		// Third transport: HTTP POST with tags
1853		assert_eq!(decoded.transports[2].kind, TransportType::HttpPost);
1854		assert_eq!(decoded.transports[2].target, "https://api2.example.com/payment");
1855		let mut tags_iter = decoded.transports[2].tags.iter();
1856		assert!(tags_iter
1857			.any(|t| t.key == "priority" && t.values.first().map(|s| s.as_str()) == Some("backup")));
1858	}
1859
1860	#[test]
1861	fn test_minimal_transport_nostr_only_pubkey() {
1862		let expected_encoded = "CREQB1QYQQ6MTFDE5K6CTVTAHX7UM5WGPSQQGQQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSWQP8QYQQZQQZQQSRHUXX8L9EX335Q7HE0F09AEJ04ZPAZPL0NE2CGUKYAWD24MAYT8G7QNXMQ";
1863
1864		let transport = Transport {
1865			kind: TransportType::Nostr,
1866			target: "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q"
1867				.to_string(),
1868			tags: Vec::new(),
1869		};
1870
1871		let payment_request = CashuPaymentRequest {
1872			payment_id: Some("minimal_nostr".to_string()),
1873			unit: Some(CurrencyUnit::Sat),
1874			mints: Some(vec!["https://mint.example.com".to_string()]),
1875			amount: None,
1876			description: None,
1877			single_use: None,
1878			transports: vec![transport],
1879			nut10: None,
1880		};
1881
1882		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1883
1884		// Verify exact encoding matches expected
1885		assert_eq!(encoded, expected_encoded);
1886
1887		// Decode from the encoded string
1888		let decoded =
1889			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1890
1891		assert_eq!(decoded.transports.len(), 1);
1892		assert_eq!(decoded.transports[0].kind, TransportType::Nostr);
1893		assert!(decoded.transports[0].target.starts_with("nprofile"));
1894
1895		// Tags should be empty for minimal transport
1896		assert!(decoded.transports[0].tags.is_empty());
1897	}
1898
1899	#[test]
1900	fn test_minimal_transport_http_just_url() {
1901		let expected_encoded = "CREQB1QYQQCMTFDE5K6CTVTA58GARSQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RSQ8SPQQQSZQSQZA58GARSWVAZ7TMPWP5JUETCV9KHQMR99E3K7MG0TWYGX";
1902
1903		let transport = Transport {
1904			kind: TransportType::HttpPost,
1905			target: "https://api.example.com".to_string(),
1906			tags: Vec::new(),
1907		};
1908
1909		let payment_request = CashuPaymentRequest {
1910			payment_id: Some("minimal_http".to_string()),
1911			unit: Some(CurrencyUnit::Sat),
1912			mints: Some(vec!["https://mint.example.com".to_string()]),
1913			amount: None,
1914			description: None,
1915			single_use: None,
1916			transports: vec![transport],
1917			nut10: None,
1918		};
1919
1920		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1921
1922		// Verify exact encoding matches expected
1923		assert_eq!(encoded, expected_encoded);
1924
1925		// Decode and verify round-trip
1926		let decoded =
1927			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
1928
1929		assert_eq!(decoded.transports.len(), 1);
1930		assert_eq!(decoded.transports[0].kind, TransportType::HttpPost);
1931		assert_eq!(decoded.transports[0].target, "https://api.example.com");
1932		assert!(decoded.transports[0].tags.is_empty());
1933	}
1934
1935	#[test]
1936	fn test_nut10_htlc_kind_1() {
1937		let expected_encoded = "CREQB1QYQQJ6R5D3347AR9WD6QYQQGQQQQQQQQQQP7SQCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGXQQF5S4ZVGVSXCMMRDDJKGGRSV9UK6ETWWSYQPTGPQQQSZQSQGFS46VR9XCMRSV3SVFNXYDP3XGERZVNRVCMKZC3NV3JKYVP5X5UKXEFJ8QEXZVTZXQ6XVERPXUMX2CFKXQERVCFKXAJNGVTPV5ERVE3NV33SXQQ5PPKX7CMTW35K6EG2XYMNQVPSXQCRQVPSQVQY5PNJV4N82MNYGGCRXVEJ8QCKXVEHXCMNWETPXGMNXETZXUCNSVMZXUURXVPKXANR2V35XSUNXVM9VCMNSEPCVVEKVVF4VGCKZDEHVD3RYDPKXQUNJCEJXEJS4EHJHC";
1938
1939		let nut10 = Nut10SecretRequest {
1940			kind: Kind::HTLC,
1941			data: "a]0e66820bfb412212cf7ab3deb0459ce282a1b04fda76ea6026a67e41ae26f3dc".to_string(),
1942			tags: vec![
1943				TagTuple::single("locktime", "1700000000").unwrap(),
1944				TagTuple::single(
1945					"refund",
1946					"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e",
1947				)
1948				.unwrap(),
1949			],
1950		};
1951
1952		let payment_request = CashuPaymentRequest {
1953			payment_id: Some("htlc_test".to_string()),
1954			amount: Some(1000),
1955			unit: Some(CurrencyUnit::Sat),
1956			mints: Some(vec!["https://mint.example.com".to_string()]),
1957			description: Some("HTLC locked payment".to_string()),
1958			single_use: None,
1959			transports: vec![],
1960			nut10: Some(nut10),
1961		};
1962
1963		let encoded = payment_request.to_bech32_string().expect("encoding should work");
1964
1965		// Verify exact encoding matches expected
1966		assert_eq!(encoded, expected_encoded);
1967
1968		// Decode from the encoded string and verify round-trip
1969		let decoded = CashuPaymentRequest::from_bech32_string(&expected_encoded)
1970			.expect("decoding should work");
1971
1972		// Verify all top-level fields
1973		assert_eq!(decoded.payment_id, Some("htlc_test".to_string()));
1974		assert_eq!(decoded.amount, Some(1000));
1975		assert_eq!(decoded.unit, Some(CurrencyUnit::Sat));
1976		assert_eq!(decoded.mints, Some(vec!["https://mint.example.com".to_string()]));
1977		assert_eq!(decoded.description, Some("HTLC locked payment".to_string()));
1978
1979		// Verify NUT-10 fields
1980		let nut10 = decoded.nut10.as_ref().unwrap();
1981		assert_eq!(nut10.kind, Kind::HTLC);
1982		assert_eq!(
1983			nut10.data,
1984			"a]0e66820bfb412212cf7ab3deb0459ce282a1b04fda76ea6026a67e41ae26f3dc"
1985		);
1986
1987		// Verify all tags with exact values
1988		let tags = &nut10.tags;
1989		assert_eq!(tags.len(), 2);
1990		assert_eq!(tags[0], TagTuple::single("locktime", "1700000000").unwrap());
1991		assert_eq!(
1992			tags[1],
1993			TagTuple::single(
1994				"refund",
1995				"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e"
1996			)
1997			.unwrap()
1998		);
1999	}
2000
2001	#[test]
2002	fn test_case_insensitive_decoding() {
2003		let payment_request = CashuPaymentRequest {
2004			payment_id: Some("case_test".to_string()),
2005			amount: Some(100),
2006			unit: Some(CurrencyUnit::Sat),
2007			single_use: None,
2008			mints: Some(vec!["https://mint.example.com".to_string()]),
2009			description: None,
2010			transports: vec![],
2011			nut10: None,
2012		};
2013
2014		let uppercase = payment_request.to_bech32_string().expect("encoding should work");
2015
2016		// Convert to lowercase
2017		let lowercase = uppercase.to_lowercase();
2018
2019		// Both uppercase and lowercase should decode successfully
2020		let decoded_upper =
2021			CashuPaymentRequest::from_bech32_string(&uppercase).expect("uppercase should decode");
2022		let decoded_lower =
2023			CashuPaymentRequest::from_bech32_string(&lowercase).expect("lowercase should decode");
2024
2025		// Both should produce the same result
2026		assert_eq!(decoded_upper.payment_id, Some("case_test".to_string()));
2027		assert_eq!(decoded_lower.payment_id, Some("case_test".to_string()));
2028
2029		assert_eq!(decoded_upper.amount, decoded_lower.amount);
2030		assert_eq!(decoded_upper.unit, decoded_lower.unit);
2031	}
2032
2033	#[test]
2034	fn test_custom_currency_unit() {
2035		let expected_encoded = "CREQB1QYQQKCM4WD6X7M2LW4HXJAQZQQYQQQQQQQQQQQRYQVQQXCN5VVZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6PZHCW8";
2036
2037		let payment_request = CashuPaymentRequest {
2038			payment_id: Some("custom_unit".to_string()),
2039			amount: Some(100),
2040			unit: Some(CurrencyUnit::custom("btc")),
2041			single_use: None,
2042			mints: Some(vec!["https://mint.example.com".to_string()]),
2043			description: None,
2044			transports: vec![],
2045			nut10: None,
2046		};
2047
2048		let encoded = payment_request.to_bech32_string().expect("encoding should work");
2049
2050		assert_eq!(encoded, expected_encoded);
2051
2052		// Decode from the expected encoded string
2053		let decoded =
2054			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
2055
2056		assert_eq!(decoded.unit, Some(CurrencyUnit::custom("btc")));
2057		assert_eq!(decoded.payment_id, Some("custom_unit".to_string()));
2058	}
2059
2060	#[test]
2061	fn test_custom_currency_unit_long() {
2062		// Test a unit string longer than 23 bytes to exercise heap allocation
2063		let long_unit = "this_is_a_very_long_unit_name";
2064
2065		let payment_request = CashuPaymentRequest {
2066			payment_id: None,
2067			amount: Some(100),
2068			unit: Some(CurrencyUnit::custom(long_unit)),
2069			single_use: None,
2070			mints: None,
2071			description: None,
2072			transports: vec![],
2073			nut10: None,
2074		};
2075
2076		let encoded = payment_request.to_bech32_string().expect("encoding should work");
2077		let decoded =
2078			CashuPaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
2079
2080		assert_eq!(decoded.unit, Some(CurrencyUnit::custom(long_unit)));
2081		assert_eq!(decoded.amount, Some(100));
2082	}
2083
2084	#[test]
2085	fn test_transport_tlv_ordering_target_before_kind() {
2086		// TLV fields should be order-independent. This test verifies that
2087		// target (0x02) can appear before kind (0x01) and still decode correctly.
2088
2089		// Test 1: Nostr transport with target before kind
2090		let pubkey = [0x42u8; 32]; // 32-byte x-only pubkey
2091		let mut writer = TlvWriter::with_capacity(64);
2092		writer.write_tlv(0x02, &pubkey); // Target first
2093		writer.write_tlv(0x01, &[0x00]); // Kind Nostr second
2094		let bytes = writer.into_bytes();
2095
2096		let transport = CashuPaymentRequest::decode_transport(&bytes)
2097			.expect("should decode transport with target before kind");
2098		assert_eq!(transport.kind, TransportType::Nostr);
2099		// Target should be encoded as nprofile (even with no relays)
2100		assert!(transport.target.starts_with("nprofile"));
2101
2102		// Test 2: HTTP transport with target before kind
2103		let url = b"https://example.com/callback";
2104		let mut writer = TlvWriter::with_capacity(64);
2105		writer.write_tlv(0x02, url); // Target first
2106		writer.write_tlv(0x01, &[0x01]); // Kind HTTP second
2107		let bytes = writer.into_bytes();
2108
2109		let transport = CashuPaymentRequest::decode_transport(&bytes)
2110			.expect("should decode HTTP transport with target before kind");
2111		assert_eq!(transport.kind, TransportType::HttpPost);
2112		assert_eq!(transport.target, "https://example.com/callback");
2113	}
2114
2115	#[test]
2116	fn test_duplicate_tlv_fields() {
2117		// 1. Top-level: Duplicate Amount (Tag 0x02)
2118		let mut writer = TlvWriter::with_capacity(32);
2119		writer.write_tlv(0x02, &100u64.to_be_bytes());
2120		writer.write_tlv(0x02, &200u64.to_be_bytes());
2121		let bytes = writer.into_bytes();
2122		assert_eq!(CashuPaymentRequest::from_bech32_bytes(&bytes), Err(Error::InvalidStructure));
2123
2124		// 2. Transport: Duplicate Kind (Tag 0x01)
2125		let mut writer = TlvWriter::with_capacity(16);
2126		writer.write_tlv(0x01, &[0x00]); // Nostr
2127		writer.write_tlv(0x01, &[0x01]); // HTTP
2128		let bytes = writer.into_bytes();
2129		assert_eq!(CashuPaymentRequest::decode_transport(&bytes), Err(Error::InvalidStructure));
2130
2131		// 3. Transport: Duplicate Target (Tag 0x02) for Nostr
2132		let pubkey = vec![0u8; 32];
2133		let mut writer = TlvWriter::with_capacity(128);
2134		writer.write_tlv(0x01, &[0x00]); // Kind Nostr
2135		writer.write_tlv(0x02, &pubkey);
2136		writer.write_tlv(0x02, &pubkey);
2137		let bytes = writer.into_bytes();
2138		assert_eq!(CashuPaymentRequest::decode_transport(&bytes), Err(Error::InvalidStructure));
2139
2140		// 4. Transport: Duplicate Target (Tag 0x02) for HTTP
2141		let mut writer = TlvWriter::with_capacity(64);
2142		writer.write_tlv(0x01, &[0x01]); // Kind HTTP
2143		writer.write_tlv(0x02, b"https://example.com");
2144		writer.write_tlv(0x02, b"https://example.org");
2145		let bytes = writer.into_bytes();
2146		assert_eq!(CashuPaymentRequest::decode_transport(&bytes), Err(Error::InvalidStructure));
2147
2148		// 5. NUT-10: Duplicate Kind (Tag 0x01)
2149		let mut writer = TlvWriter::with_capacity(16);
2150		writer.write_tlv(0x01, &[0x00]); // Kind P2PK
2151		writer.write_tlv(0x01, &[0x01]); // Kind HTLC
2152		let bytes = writer.into_bytes();
2153		assert_eq!(CashuPaymentRequest::decode_nut10(&bytes), Err(Error::InvalidStructure));
2154
2155		// 6. NUT-10: Duplicate Data (Tag 0x02)
2156		let mut writer = TlvWriter::with_capacity(32);
2157		writer.write_tlv(0x01, &[0x00]); // Kind P2PK
2158		writer.write_tlv(0x02, b"data1");
2159		writer.write_tlv(0x02, b"data2");
2160		let bytes = writer.into_bytes();
2161		assert_eq!(CashuPaymentRequest::decode_nut10(&bytes), Err(Error::InvalidStructure));
2162	}
2163}