diff --git a/.typos.toml b/.typos.toml index 6a6405af2..4fa0f76d0 100644 --- a/.typos.toml +++ b/.typos.toml @@ -3,6 +3,8 @@ extend-ignore-re = [ # Ignore cashu tokens "cashuA[A-Za-z0-9-_]+", "cashuB[A-Za-z0-9-_]+", + "creq[A-Za-z0-9-_]+", + "CREQ[A-Za-z0-9-_]+", "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9", "autheticator", "Gam", diff --git a/Cargo.lock b/Cargo.lock index 0a6f38f88..859576557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1088,6 +1088,7 @@ dependencies = [ "ciborium", "lightning 0.2.0", "lightning-invoice 0.34.0", + "nostr-sdk", "once_cell", "regex", "serde", diff --git a/Cargo.lock.msrv b/Cargo.lock.msrv index a3c0d4d54..36a2dd9b8 100644 --- a/Cargo.lock.msrv +++ b/Cargo.lock.msrv @@ -1088,6 +1088,7 @@ dependencies = [ "ciborium", "lightning 0.2.0", "lightning-invoice 0.34.0", + "nostr-sdk", "once_cell", "regex", "serde", @@ -1832,9 +1833,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.56" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] @@ -1915,7 +1916,7 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml 0.9.9+spec-1.0.0", + "toml 0.9.10+spec-1.1.0", "winnow 0.7.14", "yaml-rust2", ] @@ -7322,13 +7323,13 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.9+spec-1.0.0" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5238e643fc34a1d5d7e753e1532a91912d74b63b92b3ea51fde8d1b7bc79dd" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "serde_core", "serde_spanned 1.0.4", - "toml_datetime 0.7.4+spec-1.0.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", ] @@ -7344,9 +7345,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.4+spec-1.0.0" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3cea6b2aa3b910092f6abd4053ea464fab5f9c170ba5e9a6aead16ec4af2b6" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -7383,16 +7384,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap 2.12.1", - "toml_datetime 0.7.4+spec-1.0.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.5+spec-1.0.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c03bee5ce3696f31250db0bbaff18bc43301ce0e8db2ed1f07cbb2acf89984c" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow 0.7.14", ] diff --git a/crates/cashu/Cargo.toml b/crates/cashu/Cargo.toml index f9db0f548..950a04979 100644 --- a/crates/cashu/Cargo.toml +++ b/crates/cashu/Cargo.toml @@ -44,6 +44,7 @@ uuid = { workspace = true, features = ["js"], optional = true } [dev-dependencies] bip39.workspace = true +nostr-sdk.workspace = true [lints.rust] unsafe_code = "forbid" diff --git a/crates/cashu/examples/payment_request_encoding_benchmark.rs b/crates/cashu/examples/payment_request_encoding_benchmark.rs new file mode 100644 index 000000000..ba2aade74 --- /dev/null +++ b/crates/cashu/examples/payment_request_encoding_benchmark.rs @@ -0,0 +1,520 @@ +//! Payment Request Encoding Benchmark +//! +//! Compares NUT-18 (CBOR/base64) vs NUT-26 (Bech32m) encoding formats across +//! various payment request complexities to demonstrate format efficiency tradeoffs. +//! +//! # Format Overview +//! +//! ## NUT-18 (creqA prefix) +//! - **Binary Encoding**: CBOR (Concise Binary Object Representation) +//! - **Text Encoding**: URL-safe base64 +//! - **Characteristics**: Compact binary format, case-sensitive +//! +//! ## NUT-26 (CREQB prefix) +//! - **Binary Encoding**: TLV (Type-Length-Value) +//! - **Text Encoding**: Bech32m +//! - **Characteristics**: QR-optimized, case-insensitive, error detection +//! +//! # When to Use Each Format +//! +//! ## Use NUT-26 (CREQB) when: +//! - **Minimal requests** (~5 bytes / 7% smaller for simple payment IDs) +//! - **QR code display** (100% alphanumeric-compatible vs 99%+) +//! - **Error detection is critical** (Bech32m has built-in checksums) +//! - **Case-insensitive parsing** needed (URLs, voice transcription) +//! - **Visual verification** (human-readable structure) +//! +//! ## Use NUT-18 (creqA) when: +//! - **Complex requests** (~13-163 bytes / 16-19% smaller with more data) +//! - **Multiple mints** (~59 bytes / 24% smaller with 4 mints) +//! - **Transport callbacks** (~49 bytes / 19% smaller with 1 transport) +//! - **NUT-10 locking** (~91 bytes / 17% smaller with P2PK) +//! - **Nested structures** (CBOR excels at hierarchical data) +//! - **Bandwidth is constrained** (smaller encoded size) +//! +//! # Benchmark Results Summary +//! +//! | Scenario | NUT-18 Size | NUT-26 Size | Winner | Savings | +//! |----------|-------------|-------------|--------|---------| +//! | Minimal payment | 77 bytes | 72 bytes | NUT-26 | 5 bytes (7%) | +//! | With amount/unit | 81 bytes | 94 bytes | NUT-18 | 13 bytes (16%) | +//! | 4 mints | 249 bytes | 308 bytes | NUT-18 | 59 bytes (24%) | +//! | 1 transport | 253 bytes | 302 bytes | NUT-18 | 49 bytes (19%) | +//! | Complete + P2PK | 529 bytes | 620 bytes | NUT-18 | 91 bytes (17%) | +//! | Very complex | 857 bytes | 1020 bytes | NUT-18 | 163 bytes (19%) | +//! +//! **Key Insight**: NUT-26 is optimal for simple requests, NUT-18 scales better +//! for complex payment requests with multiple mints, transports, or NUT-10 locks. + +use std::str::FromStr; + +use cashu::nuts::nut10::Kind; +use cashu::nuts::{CurrencyUnit, Nut10SecretRequest, PaymentRequest, Transport, TransportType}; +use cashu::{Amount, MintUrl}; + +fn main() -> Result<(), Box> { + println!("=== NUT-18 vs NUT-26 Format Comparison ===\n"); + + // Example 1: Minimal payment request + println!("1. Minimal Payment Request:"); + minimal_comparison()?; + + // Example 2: Payment with amount and unit + println!("\n2. Payment with Amount and Unit:"); + amount_unit_comparison()?; + + // Example 3: Complex payment with multiple mints + println!("\n3. Complex Payment with Multiple Mints:"); + multiple_mints_comparison()?; + + // Example 4: Payment with transport + println!("\n4. Payment with Transport:"); + transport_comparison()?; + + // Example 5: Complete payment with NUT-10 locking + println!("\n5. Complete Payment with NUT-10 P2PK Lock:"); + complete_with_nut10_comparison()?; + + // Example 6: Very complex payment request + println!("\n6. Very Complex Payment Request:"); + very_complex_comparison()?; + + // Summary + println!("\n=== Summary ==="); + summary(); + + println!("\n=== Format Comparison Complete ==="); + Ok(()) +} + +fn minimal_comparison() -> Result<(), Box> { + let payment_request = PaymentRequest { + payment_id: Some("test123".to_string()), + amount: None, + unit: None, + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com")?]), + description: None, + transports: vec![], + nut10: None, + }; + + compare_formats(&payment_request, "Minimal")?; + Ok(()) +} + +fn amount_unit_comparison() -> Result<(), Box> { + let payment_request = PaymentRequest { + payment_id: Some("pay456".to_string()), + amount: Some(Amount::from(2100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com")?]), + description: None, + transports: vec![], + nut10: None, + }; + + compare_formats(&payment_request, "Amount + Unit")?; + Ok(()) +} + +fn multiple_mints_comparison() -> Result<(), Box> { + let payment_request = PaymentRequest { + payment_id: Some("multi789".to_string()), + amount: Some(Amount::from(10000)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![ + MintUrl::from_str("https://mint1.example.com")?, + MintUrl::from_str("https://mint2.example.com")?, + MintUrl::from_str("https://mint3.example.com")?, + MintUrl::from_str("https://backup-mint.cashu.space")?, + ]), + description: Some("Payment with multiple mint options".to_string()), + transports: vec![], + nut10: None, + }; + + compare_formats(&payment_request, "Multiple Mints")?; + Ok(()) +} + +fn transport_comparison() -> Result<(), Box> { + let transport = Transport { + _type: TransportType::HttpPost, + target: "https://api.example.com/cashu/payment/callback".to_string(), + tags: Some(vec![ + vec!["method".to_string(), "POST".to_string()], + vec!["auth".to_string(), "bearer".to_string()], + ]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("transport123".to_string()), + amount: Some(Amount::from(5000)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![MintUrl::from_str("https://mint.example.com")?]), + description: Some("Payment with callback transport".to_string()), + transports: vec![transport], + nut10: None, + }; + + compare_formats(&payment_request, "With Transport")?; + Ok(()) +} + +fn complete_with_nut10_comparison() -> Result<(), Box> { + let nut10 = Nut10SecretRequest::new( + Kind::P2PK, + "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198", + Some(vec![ + vec!["locktime".to_string(), "1609459200".to_string()], + vec![ + "refund".to_string(), + "03a34d1f4e6d1e7f8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2".to_string(), + ], + ]), + ); + + let transport = Transport { + _type: TransportType::HttpPost, + target: "https://callback.example.com/payment".to_string(), + tags: Some(vec![vec!["priority".to_string(), "high".to_string()]]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("complete789".to_string()), + amount: Some(Amount::from(5000)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![ + MintUrl::from_str("https://mint1.example.com")?, + MintUrl::from_str("https://mint2.example.com")?, + ]), + description: Some("Complete payment with P2PK locking and refund key".to_string()), + transports: vec![transport], + nut10: Some(nut10), + }; + + compare_formats(&payment_request, "Complete with NUT-10")?; + Ok(()) +} + +fn very_complex_comparison() -> Result<(), Box> { + let nut10 = Nut10SecretRequest::new( + Kind::P2PK, + "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198", + Some(vec![ + vec!["locktime".to_string(), "1609459200".to_string()], + vec![ + "refund".to_string(), + "03a34d1f4e6d1e7f8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e".to_string(), + ], + ]), + ); + + let transport1 = Transport { + _type: TransportType::HttpPost, + target: "https://primary-callback.example.com/payment/webhook".to_string(), + tags: Some(vec![ + vec!["priority".to_string(), "high".to_string()], + vec!["timeout".to_string(), "30".to_string()], + ]), + }; + + let transport2 = Transport { + _type: TransportType::HttpPost, + target: "https://backup-callback.example.com/payment/webhook".to_string(), + tags: Some(vec![ + vec!["priority".to_string(), "medium".to_string()], + vec!["timeout".to_string(), "60".to_string()], + ]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("very_complex_payment_id_12345".to_string()), + amount: Some(Amount::from(21000)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![ + MintUrl::from_str("https://primary-mint.cashu.space")?, + MintUrl::from_str("https://secondary-mint.example.com")?, + MintUrl::from_str("https://backup-mint-1.example.org")?, + MintUrl::from_str("https://backup-mint-2.example.net")?, + MintUrl::from_str("https://emergency-mint.example.io")?, + ]), + description: Some("Complex payment with multiple mints and transports".to_string()), + transports: vec![transport1, transport2], + nut10: Some(nut10), + }; + + compare_formats(&payment_request, "Very Complex")?; + Ok(()) +} + +fn compare_formats( + payment_request: &PaymentRequest, + label: &str, +) -> Result<(), Box> { + // Encode using NUT-18 (CBOR/base64, creqA) + let nut18_encoded = payment_request.to_string(); + + // Encode using NUT-26 (Bech32m, CREQB) + let nut26_encoded = payment_request.to_bech32_string()?; + + // Calculate sizes + let nut18_size = nut18_encoded.len(); + let nut26_size = nut26_encoded.len(); + let size_diff = nut26_size as i32 - nut18_size as i32; + let size_ratio = (nut26_size as f64 / nut18_size as f64) * 100.0; + + println!(" {} Payment Request:", label); + println!(" Payment ID: {:?}", payment_request.payment_id); + println!(" Amount: {:?}", payment_request.amount); + println!( + " Mints: {}", + payment_request.mints.as_ref().map_or(0, |m| m.len()) + ); + println!(" Transports: {}", payment_request.transports.len()); + println!(" NUT-10: {}", payment_request.nut10.is_some()); + + println!("\n NUT-18 (CBOR/base64, creqA):"); + println!(" Size: {} bytes", nut18_size); + println!( + " Format: {}", + &nut18_encoded[..nut18_encoded.len().min(80)] + ); + if nut18_encoded.len() > 80 { + println!(" ... ({} more chars)", nut18_encoded.len() - 80); + } + + println!("\n NUT-26 (Bech32m, CREQB):"); + println!(" Size: {} bytes", nut26_size); + println!( + " Format: {}", + &nut26_encoded[..nut26_encoded.len().min(80)] + ); + if nut26_encoded.len() > 80 { + println!(" ... ({} more chars)", nut26_encoded.len() - 80); + } + + println!("\n Comparison:"); + println!( + " Size difference: {} bytes ({:.1}%)", + size_diff, size_ratio + ); + + if size_diff < 0 { + println!(" Winner: NUT-26 is {} bytes smaller!", size_diff.abs()); + } else if size_diff > 0 { + println!(" Winner: NUT-18 is {} bytes smaller!", size_diff); + } else { + println!(" Equal size!"); + } + + // Analyze QR code efficiency + analyze_qr_efficiency(&nut18_encoded, &nut26_encoded); + + // Verify round-trip for both formats + println!("\n Round-trip verification:"); + + // NUT-18 round-trip + let nut18_decoded = PaymentRequest::from_str(&nut18_encoded)?; + assert_eq!(nut18_decoded.payment_id, payment_request.payment_id); + assert_eq!(nut18_decoded.amount, payment_request.amount); + println!(" NUT-18: ✓ Decoded successfully"); + + // NUT-26 round-trip + let nut26_decoded = PaymentRequest::from_str(&nut26_encoded)?; + assert_eq!(nut26_decoded.payment_id, payment_request.payment_id); + assert_eq!(nut26_decoded.amount, payment_request.amount); + println!(" NUT-26: ✓ Decoded successfully"); + + // Verify both decode to the same data + assert_eq!(nut18_decoded.payment_id, nut26_decoded.payment_id); + assert_eq!(nut18_decoded.amount, nut26_decoded.amount); + assert_eq!(nut18_decoded.unit, nut26_decoded.unit); + assert_eq!(nut18_decoded.single_use, nut26_decoded.single_use); + assert_eq!(nut18_decoded.description, nut26_decoded.description); + + println!(" ✓ Both formats decode to identical data"); + + Ok(()) +} + +fn analyze_qr_efficiency(nut18: &str, nut26: &str) { + // QR codes have different encoding modes: + // - Alphanumeric: 0-9, A-Z (uppercase), space, $, %, *, +, -, ., /, : (most efficient for text) + // - Byte: any data (less efficient) + + let alphanumeric_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + + let nut18_alphanumeric = nut18 + .chars() + .filter(|c| alphanumeric_chars.contains(c.to_ascii_uppercase())) + .count(); + let nut18_alphanumeric_ratio = (nut18_alphanumeric as f64 / nut18.len() as f64) * 100.0; + + let nut26_alphanumeric = nut26 + .chars() + .filter(|c| alphanumeric_chars.contains(c.to_ascii_uppercase())) + .count(); + let nut26_alphanumeric_ratio = (nut26_alphanumeric as f64 / nut26.len() as f64) * 100.0; + + println!("\n QR Code Efficiency:"); + println!( + " NUT-18: {:.1}% alphanumeric-compatible", + nut18_alphanumeric_ratio + ); + println!( + " NUT-26: {:.1}% alphanumeric-compatible", + nut26_alphanumeric_ratio + ); + + if nut26_alphanumeric_ratio > nut18_alphanumeric_ratio { + println!( + " NUT-26 is more QR-friendly (+{:.1}%)", + nut26_alphanumeric_ratio - nut18_alphanumeric_ratio + ); + } + + // Estimate QR version (simplified) + let nut18_qr_version = estimate_qr_version(nut18.len(), nut18_alphanumeric_ratio > 80.0); + let nut26_qr_version = estimate_qr_version(nut26.len(), nut26_alphanumeric_ratio > 80.0); + + println!( + " NUT-18 QR version: ~{} ({}×{} modules)", + nut18_qr_version, + 21 + (nut18_qr_version - 1) * 4, + 21 + (nut18_qr_version - 1) * 4 + ); + println!( + " NUT-26 QR version: ~{} ({}×{} modules)", + nut26_qr_version, + 21 + (nut26_qr_version - 1) * 4, + 21 + (nut26_qr_version - 1) * 4 + ); +} + +fn estimate_qr_version(data_length: usize, is_alphanumeric: bool) -> u8 { + // Simplified QR version estimation (Level L - Low error correction) + let capacity = if is_alphanumeric { + // Alphanumeric mode capacity + match data_length { + 0..=20 => 1, + 21..=38 => 2, + 39..=61 => 3, + 62..=90 => 4, + 91..=122 => 5, + 123..=154 => 6, + 155..=192 => 7, + 193..=230 => 8, + 231..=271 => 9, + 272..=321 => 10, + 322..=367 => 11, + 368..=425 => 12, + 426..=458 => 13, + 459..=520 => 14, + 521..=586 => 15, + _ => 16, + } + } else { + // Byte mode capacity + match data_length { + 0..=14 => 1, + 15..=26 => 2, + 27..=42 => 3, + 43..=62 => 4, + 63..=84 => 5, + 85..=106 => 6, + 107..=122 => 7, + 123..=152 => 8, + 153..=180 => 9, + 181..=213 => 10, + 214..=251 => 11, + 252..=287 => 12, + 288..=331 => 13, + 332..=362 => 14, + 363..=394 => 15, + _ => 16, + } + }; + capacity +} + +fn summary() { + println!(" Key Observations:"); + println!(" • NUT-18 (creqA): CBOR binary + URL-safe base64 encoding"); + println!(" • NUT-26 (CREQB): TLV binary + Bech32m encoding"); + println!(" • Bech32m is optimized for QR codes (uppercase alphanumeric)"); + println!(" • CBOR may be more compact for complex nested structures"); + println!(" • Both formats support the same feature set"); + println!(" • NUT-26 has better error detection (Bech32m checksum)"); + println!(" • NUT-26 is case-insensitive for parsing"); + println!(" • Both can be parsed from the same FromStr implementation"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimal_comparison() { + assert!(minimal_comparison().is_ok()); + } + + #[test] + fn test_amount_unit_comparison() { + assert!(amount_unit_comparison().is_ok()); + } + + #[test] + fn test_multiple_mints_comparison() { + assert!(multiple_mints_comparison().is_ok()); + } + + #[test] + fn test_transport_comparison() { + assert!(transport_comparison().is_ok()); + } + + #[test] + fn test_complete_with_nut10_comparison() { + assert!(complete_with_nut10_comparison().is_ok()); + } + + #[test] + fn test_very_complex_comparison() { + assert!(very_complex_comparison().is_ok()); + } + + #[test] + fn test_round_trip_equivalence() { + let payment_request = PaymentRequest { + payment_id: Some("test".to_string()), + amount: Some(Amount::from(1000)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Test".to_string()), + transports: vec![], + nut10: None, + }; + + // Encode both ways + let nut18 = payment_request.to_string(); + let nut26 = payment_request.to_bech32_string().unwrap(); + + // Decode both + let from_nut18 = PaymentRequest::from_str(&nut18).unwrap(); + let from_nut26 = PaymentRequest::from_str(&nut26).unwrap(); + + // Should be equal + assert_eq!(from_nut18.payment_id, from_nut26.payment_id); + assert_eq!(from_nut18.amount, from_nut26.amount); + assert_eq!(from_nut18.unit, from_nut26.unit); + assert_eq!(from_nut18.description, from_nut26.description); + } +} diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 30aa0913c..6d4d7cb54 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -25,6 +25,7 @@ pub mod nut19; pub mod nut20; pub mod nut23; pub mod nut25; +pub mod nut26; #[cfg(feature = "auth")] mod auth; @@ -65,8 +66,8 @@ pub use nut14::HTLCWitness; pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; pub use nut17::NotificationPayload; pub use nut18::{ - PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, Transport, TransportBuilder, - TransportType, + Nut10SecretRequest, PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, Transport, + TransportBuilder, TransportType, }; pub use nut23::{ MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request, diff --git a/crates/cashu/src/nuts/nut18/error.rs b/crates/cashu/src/nuts/nut18/error.rs index 8f379fb67..eb8cc023f 100644 --- a/crates/cashu/src/nuts/nut18/error.rs +++ b/crates/cashu/src/nuts/nut18/error.rs @@ -14,4 +14,7 @@ pub enum Error { /// Base64 error #[error(transparent)] Base64Error(#[from] bitcoin::base64::DecodeError), + /// NUT-26 bech32m encoding error + #[error(transparent)] + Nut26Error(#[from] crate::nuts::nut26::Error), } diff --git a/crates/cashu/src/nuts/nut18/mod.rs b/crates/cashu/src/nuts/nut18/mod.rs index 2e07a8e48..c68941722 100644 --- a/crates/cashu/src/nuts/nut18/mod.rs +++ b/crates/cashu/src/nuts/nut18/mod.rs @@ -1,4 +1,9 @@ -//! NUT-18 module imports +//! NUT-18: Payment Requests +//! +//! This module provides JSON-based payment request functionality (CREQ-A format). +//! For bech32m encoding (CREQ-B format), see NUT-26. +//! +//! pub mod error; pub mod payment_request; diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index eb3600e41..61173b57e 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use super::{Error, Nut10SecretRequest, Transport}; use crate::mint_url::MintUrl; +use crate::nut26::CREQ_B_HRP; use crate::nuts::{CurrencyUnit, Proofs}; use crate::Amount; @@ -73,6 +74,13 @@ impl FromStr for PaymentRequest { type Err = Error; fn from_str(s: &str) -> Result { + // Check if it's a bech32m format (CREQ-B) - case insensitive + if s.to_lowercase().starts_with(CREQ_B_HRP) { + // Use the bech32 decoding from NUT-26 + return Self::from_bech32_string(s).map_err(Error::Nut26Error); + } + + // Otherwise, try the legacy CBOR format (CREQ-A) let s = s .strip_prefix(PAYMENT_REQUEST_PREFIX) .ok_or(Error::InvalidPrefix)?; @@ -180,7 +188,7 @@ impl PaymentRequestBuilder { } } -/// Payment Request +/// Payment Request Payload #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentRequestPayload { /// Id @@ -674,4 +682,52 @@ mod tests { let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a"); } + + #[test] + fn test_from_str_handles_both_formats() { + // Create a payment request + let payment_request = PaymentRequest { + payment_id: Some("test456".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Test both formats".to_string()), + transports: vec![], + nut10: None, + }; + + // Test CBOR format (CREQ-A) - from Display trait + let cbor_encoded = payment_request.to_string(); + assert!(cbor_encoded.starts_with("creqA")); + let decoded_cbor = + PaymentRequest::from_str(&cbor_encoded).expect("Should decode CBOR format"); + assert_eq!(decoded_cbor.payment_id, payment_request.payment_id); + assert_eq!(decoded_cbor.amount, payment_request.amount); + assert_eq!(decoded_cbor.unit, payment_request.unit); + assert_eq!(decoded_cbor.description, payment_request.description); + + // Test bech32 format (CREQ-B) + let bech32_encoded = payment_request + .to_bech32_string() + .expect("Should encode to bech32"); + assert!(bech32_encoded.to_uppercase().starts_with("CREQB")); + let decoded_bech32 = + PaymentRequest::from_str(&bech32_encoded).expect("Should decode bech32 format"); + assert_eq!(decoded_bech32.payment_id, payment_request.payment_id); + assert_eq!(decoded_bech32.amount, payment_request.amount); + assert_eq!(decoded_bech32.unit, payment_request.unit); + assert_eq!(decoded_bech32.description, payment_request.description); + + // Test case insensitivity for bech32 + let bech32_lowercase = bech32_encoded.to_lowercase(); + let decoded_lowercase = + PaymentRequest::from_str(&bech32_lowercase).expect("Should decode lowercase bech32"); + assert_eq!(decoded_lowercase.payment_id, payment_request.payment_id); + + let bech32_uppercase = bech32_encoded.to_uppercase(); + let decoded_uppercase = + PaymentRequest::from_str(&bech32_uppercase).expect("Should decode uppercase bech32"); + assert_eq!(decoded_uppercase.payment_id, payment_request.payment_id); + } } diff --git a/crates/cashu/src/nuts/nut18/transport.rs b/crates/cashu/src/nuts/nut18/transport.rs index 6cee2d94e..0858d234f 100644 --- a/crates/cashu/src/nuts/nut18/transport.rs +++ b/crates/cashu/src/nuts/nut18/transport.rs @@ -12,6 +12,9 @@ use crate::nuts::nut18::error::Error; /// Transport Type #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] pub enum TransportType { + /// In-band transport (tokens sent directly in the payment request response) + #[serde(rename = "in_band")] + InBand, /// Nostr #[serde(rename = "nostr")] Nostr, @@ -33,6 +36,7 @@ impl FromStr for TransportType { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { + "in_band" => Ok(Self::InBand), "nostr" => Ok(Self::Nostr), "post" => Ok(Self::HttpPost), _ => Err(Error::InvalidPrefix), diff --git a/crates/cashu/src/nuts/nut26/encoding.rs b/crates/cashu/src/nuts/nut26/encoding.rs new file mode 100644 index 000000000..b9679a5e7 --- /dev/null +++ b/crates/cashu/src/nuts/nut26/encoding.rs @@ -0,0 +1,2270 @@ +//! NUT-26: Bech32m encoding for payment requests +//! +//! This module provides bech32m encoding and decoding functionality for Cashu payment requests, +//! implementing the CREQ-B format using TLV (Tag-Length-Value) encoding as specified in NUT-26. + +use std::str::FromStr; + +use bitcoin::bech32::{self, Bech32, Bech32m, Hrp}; + +use super::Error; +use crate::mint_url::MintUrl; +use crate::nuts::nut10::Kind; +use crate::nuts::nut18::{Nut10SecretRequest, PaymentRequest, Transport, TransportType}; +use crate::nuts::CurrencyUnit; +use crate::Amount; + +/// Human-readable part for CREQ-B bech32m encoding +pub const CREQ_B_HRP: &str = "creqb"; + +/// Unit representation for TLV encoding +#[derive(Debug, Clone, PartialEq, Eq)] +enum TlvUnit { + Sat, + Custom(String), +} + +impl From for TlvUnit { + fn from(unit: CurrencyUnit) -> Self { + match unit { + CurrencyUnit::Sat => TlvUnit::Sat, + CurrencyUnit::Msat => TlvUnit::Custom("msat".to_string()), + CurrencyUnit::Usd => TlvUnit::Custom("usd".to_string()), + CurrencyUnit::Eur => TlvUnit::Custom("eur".to_string()), + CurrencyUnit::Custom(c) => TlvUnit::Custom(c), + CurrencyUnit::Auth => TlvUnit::Custom("auth".to_string()), + } + } +} + +impl From for CurrencyUnit { + fn from(unit: TlvUnit) -> Self { + match unit { + TlvUnit::Sat => CurrencyUnit::Sat, + TlvUnit::Custom(s) => match s.as_str() { + "msat" => CurrencyUnit::Msat, + "usd" => CurrencyUnit::Usd, + "eur" => CurrencyUnit::Eur, + "auth" => CurrencyUnit::Auth, + _ => CurrencyUnit::Custom(s), // preserve unknown units + }, + } + } +} + +/// TLV reader helper for parsing binary TLV data +struct TlvReader<'a> { + data: &'a [u8], + position: usize, +} + +impl<'a> TlvReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, position: 0 } + } + + fn read_tlv(&mut self) -> Result)>, &'static str> { + if self.position + 3 > self.data.len() { + return Ok(None); + } + + let tag = self.data[self.position]; + let len = u16::from_be_bytes([self.data[self.position + 1], self.data[self.position + 2]]) + as usize; + self.position += 3; + + if self.position + len > self.data.len() { + return Err("TLV value extends beyond buffer"); + } + + let value = self.data[self.position..self.position + len].to_vec(); + self.position += len; + + Ok(Some((tag, value))) + } +} + +/// TLV writer helper for creating binary TLV data +struct TlvWriter { + data: Vec, +} + +impl TlvWriter { + fn new() -> Self { + Self { data: Vec::new() } + } + + fn write_tlv(&mut self, tag: u8, value: &[u8]) { + self.data.push(tag); + let len = value.len() as u16; + self.data.extend_from_slice(&len.to_be_bytes()); + self.data.extend_from_slice(value); + } + + fn into_bytes(self) -> Vec { + self.data + } +} + +/// CREQ-B encoding and decoding implementation +impl PaymentRequest { + /// Encodes a payment request to CREQB1 bech32m format. + /// + /// This function serializes a payment request according to the NUT-26 specification + /// and encodes it using the bech32m encoding scheme with the "creqb" human-readable + /// part (HRP). The output is always uppercase for optimal QR code compatibility. + /// + /// # Returns + /// + /// Returns a `Result` containing: + /// * `Ok(String)` - The bech32m-encoded payment request string in uppercase + /// * `Err(Error)` - If serialization or encoding fails + /// + /// # Errors + /// + /// This function will return an error if: + /// * The payment request cannot be serialized to TLV format + /// * The bech32m encoding process fails + /// + /// # Specification + /// + /// See [NUT-26](https://github.com/cashubtc/nuts/blob/main/26.md) for the complete + /// specification of the CREQB1 payment request format. + /// + /// # Examples + /// + /// ``` + /// use cashu::nuts::nut18::PaymentRequest; + /// use cashu::{Amount, MintUrl}; + /// use std::str::FromStr; + /// + /// let payment_request = PaymentRequest { + /// payment_id: Some("test123".to_string()), + /// amount: Some(Amount::from(1000)), + /// unit: Some(cashu::nuts::CurrencyUnit::Sat), + /// single_use: None, + /// mints: Some(vec![MintUrl::from_str("https://mint.example.com")?]), + /// description: None, + /// transports: vec![], + /// nut10: None, + /// }; + /// + /// let encoded = payment_request.to_bech32_string()?; + /// assert!(encoded.starts_with("CREQB1")); + /// # Ok::<(), Box>(()) + /// ``` + pub fn to_bech32_string(&self) -> Result { + let tlv_bytes = self.encode_tlv()?; + let hrp = Hrp::parse(CREQ_B_HRP).map_err(|_| Error::InvalidPrefix)?; + + // Always emit uppercase for QR compatibility + let encoded = + bech32::encode_upper::(hrp, &tlv_bytes).map_err(|_| Error::InvalidPrefix)?; + Ok(encoded) + } + + /// Decodes a payment request from CREQB1 bech32m format. + /// + /// This function takes a bech32m-encoded payment request string (case-insensitive) + /// with the "creqb" human-readable part and deserializes it back into a + /// payment request according to the NUT-26 specification. + /// + /// # Arguments + /// + /// * `s` - The bech32m-encoded payment request string (case-insensitive) + /// + /// # Returns + /// + /// Returns a `Result` containing: + /// * `Ok(PaymentRequest)` - The decoded payment request + /// * `Err(Error)` - If decoding or deserialization fails + /// + /// # Errors + /// + /// This function will return an error if: + /// * The input string is not valid bech32m encoding + /// * The human-readable part is not "creqb" (case-insensitive) + /// * The decoded data cannot be deserialized into a valid payment request + /// * The TLV structure is malformed + /// + /// # Specification + /// + /// See [NUT-26](https://github.com/cashubtc/nuts/blob/main/26.md) for the complete + /// specification of the CREQB1 payment request format. + /// + /// # Examples + /// + /// ``` + /// use cashu::nuts::nut18::PaymentRequest; + /// + /// let encoded = "CREQB1QYQQWAR9WD6RZV3NQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKS4U8XXF"; + /// let payment_request = PaymentRequest::from_bech32_string(encoded)?; + /// assert_eq!(payment_request.payment_id, Some("test123".to_string())); + /// # Ok::<(), cashu::nuts::nut26::Error>(()) + /// ``` + pub fn from_bech32_string(s: &str) -> Result { + let (hrp, data) = bech32::decode(s).map_err(|_| Error::InvalidPrefix)?; + if !hrp.as_str().eq_ignore_ascii_case(CREQ_B_HRP) { + return Err(Error::InvalidPrefix); + } + + Self::from_bech32_bytes(&data) + } + + /// Decode from TLV bytes + fn from_bech32_bytes(bytes: &[u8]) -> Result { + let mut reader = TlvReader::new(bytes); + + let mut id: Option = None; + let mut amount: Option = None; + let mut unit: Option = None; + let mut single_use: Option = None; + let mut mints: Vec = Vec::new(); + let mut description: Option = None; + let mut transports: Vec = Vec::new(); + let mut nut10: Option = None; + + while let Some((tag, value)) = reader.read_tlv().map_err(|_| Error::InvalidPrefix)? { + match tag { + 0x01 => { + // id: string + id = Some(String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?); + } + 0x02 => { + // amount: u64 + if value.len() != 8 { + return Err(Error::InvalidPrefix); + } + let amount_val = u64::from_be_bytes([ + value[0], value[1], value[2], value[3], value[4], value[5], value[6], + value[7], + ]); + amount = Some(Amount::from(amount_val)); + } + 0x03 => { + // unit: u8 or string + if value.len() == 1 && value[0] == 0 { + unit = Some(CurrencyUnit::Sat); + } else { + let unit_str = + String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?; + unit = Some(TlvUnit::Custom(unit_str).into()); + } + } + 0x04 => { + // single_use: u8 (0 or 1) + if !value.is_empty() { + single_use = Some(value[0] != 0); + } + } + 0x05 => { + // mint: string (repeatable) + let mint_str = String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?; + let mint_url = + MintUrl::from_str(&mint_str).map_err(|_| Error::InvalidPrefix)?; + mints.push(mint_url); + } + 0x06 => { + // description: string + description = Some(String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?); + } + 0x07 => { + // transport: sub-TLV (repeatable) + let transport = Self::decode_transport(&value)?; + transports.push(transport); + } + 0x08 => { + // nut10: sub-TLV + nut10 = Some(Self::decode_nut10(&value)?); + } + _ => { + // Unknown tags are ignored + } + } + } + + Ok(PaymentRequest { + payment_id: id, + amount, + unit, + single_use, + mints: if mints.is_empty() { None } else { Some(mints) }, + description, + transports, + nut10, + }) + } + + /// Encode to TLV bytes + fn encode_tlv(&self) -> Result, Error> { + let mut writer = TlvWriter::new(); + + // 0x01 id: string + if let Some(ref id) = self.payment_id { + writer.write_tlv(0x01, id.as_bytes()); + } + + // 0x02 amount: u64 + if let Some(amount) = self.amount { + let amount_bytes = (amount.to_u64()).to_be_bytes(); + writer.write_tlv(0x02, &amount_bytes); + } + + // 0x03 unit: u8 or string + if let Some(ref unit) = self.unit { + let tlv_unit = TlvUnit::from(unit.clone()); + match tlv_unit { + TlvUnit::Sat => writer.write_tlv(0x03, &[0]), + TlvUnit::Custom(s) => writer.write_tlv(0x03, s.as_bytes()), + } + } + + // 0x04 single_use: u8 (0 or 1) + if let Some(single_use) = self.single_use { + writer.write_tlv(0x04, &[if single_use { 1 } else { 0 }]); + } + + // 0x05 mint: string (repeatable) + if let Some(ref mints) = self.mints { + for mint in mints { + writer.write_tlv(0x05, mint.to_string().as_bytes()); + } + } + + // 0x06 description: string + if let Some(ref description) = self.description { + writer.write_tlv(0x06, description.as_bytes()); + } + + // 0x07 transport: sub-TLV (repeatable, order = priority) + // In-band transports are represented by the absence of a transport tag (NUT-18 semantics) + for transport in &self.transports { + if transport._type == TransportType::InBand { + // Skip in-band transports - absence of transport tag means in-band + continue; + } + let transport_bytes = Self::encode_transport(transport)?; + writer.write_tlv(0x07, &transport_bytes); + } + + // 0x08 nut10: sub-TLV + if let Some(ref nut10) = self.nut10 { + let nut10_bytes = Self::encode_nut10(nut10)?; + writer.write_tlv(0x08, &nut10_bytes); + } + + Ok(writer.into_bytes()) + } + + /// Decode transport sub-TLV + fn decode_transport(bytes: &[u8]) -> Result { + let mut reader = TlvReader::new(bytes); + + let mut kind: Option = None; + let mut pubkey: Option> = None; + let mut tags: Vec<(String, Vec)> = Vec::new(); + let mut http_target: Option = None; + + while let Some((tag, value)) = reader.read_tlv().map_err(|_| Error::InvalidPrefix)? { + match tag { + 0x01 => { + // kind: u8 + if value.len() != 1 { + return Err(Error::InvalidPrefix); + } + kind = Some(value[0]); + } + 0x02 => { + // target: bytes (interpretation depends on kind) + match kind { + Some(0x00) => { + // nostr: 32-byte x-only pubkey + if value.len() != 32 { + return Err(Error::InvalidPrefix); + } + pubkey = Some(value); + } + Some(0x01) => { + // http_post: UTF-8 URL string + http_target = + Some(String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?); + } + None => { + // kind should always be present if there's a target + } + _ => return Err(Error::InvalidPrefix), + } + } + 0x03 => { + // tag_tuple: generic tuple (repeatable) + let tag_tuple = Self::decode_tag_tuple(&value)?; + tags.push(tag_tuple); + } + _ => { + // Unknown sub-TLV tags are ignored + } + } + } + + // In-band transport is represented by absence of transport tag (0x07) + // If we're here, we have a transport tag, so it must be nostr or http_post + let transport_type = match kind.ok_or(Error::InvalidPrefix)? { + 0x00 => TransportType::Nostr, + 0x01 => TransportType::HttpPost, + _ => return Err(Error::InvalidPrefix), + }; + + // Extract relays from "r" tag tuples for Nostr transport + let relays: Vec = tags + .iter() + .filter(|(k, _)| k == "r") + .flat_map(|(_, v)| v.clone()) + .collect(); + + // Build the target string based on transport type + let target = match transport_type { + TransportType::Nostr => { + // Always use nprofile (with empty relay list if no relays) + if let Some(pk) = pubkey { + Self::encode_nprofile(&pk, &relays)? + } else { + return Err(Error::InvalidPrefix); + } + } + TransportType::HttpPost => http_target.ok_or(Error::InvalidPrefix)?, + TransportType::InBand => { + // This case should not be reachable since InBand is not decoded from transport tag + unreachable!("InBand transport should not be decoded from transport tag") + } + }; + + // Convert tags to the Transport format + // For Nostr: keep "n" tags as-is, convert "r" tags to "relay" for compatibility + let mut final_tags: Vec<(String, Vec)> = Vec::new(); + for (key, values) in tags { + if key == "r" { + // Convert "r" tag tuples to "relay" tags for compatibility + for relay in values { + final_tags.push(("relay".to_string(), vec![relay])); + } + } else { + final_tags.push((key, values)); + } + } + + Ok(Transport { + _type: transport_type, + target, + tags: if final_tags.is_empty() { + None + } else { + Some( + final_tags + .into_iter() + .map(|(k, v)| { + let mut result = vec![k]; + result.extend(v); + result + }) + .collect(), + ) + }, + }) + } + + /// Encode transport to sub-TLV + fn encode_transport(transport: &Transport) -> Result, Error> { + let mut writer = TlvWriter::new(); + + // 0x01 kind: u8 + // Note: InBand transports should not reach here (filtered out in encode_tlv) + // but we handle it defensively + let kind = match transport._type { + TransportType::InBand => { + // In-band is represented by absence of transport tag, not by encoding + return Err(Error::InvalidPrefix); + } + TransportType::Nostr => 0x00u8, + TransportType::HttpPost => 0x01u8, + }; + writer.write_tlv(0x01, &[kind]); + + // 0x02 target: bytes + // Note: InBand already returned error above, so only Nostr and HttpPost reach here + match transport._type { + TransportType::Nostr => { + // For nostr, decode nprofile to extract pubkey and relays + let (pubkey, relays) = Self::decode_nprofile(&transport.target)?; + + // Write the 32-byte pubkey + writer.write_tlv(0x02, &pubkey); + + // Collect all relays (from nprofile and from "relay" tags) + let mut all_relays = relays; + + // Extract NIPs and other tags from the tags field + if let Some(ref tags) = transport.tags { + for tag in tags { + if tag.is_empty() { + continue; + } + if tag[0] == "n" && tag.len() >= 2 { + // Encode NIPs as tag tuples with key "n" + let tag_bytes = Self::encode_tag_tuple(tag)?; + writer.write_tlv(0x03, &tag_bytes); + } else if tag[0] == "relay" && tag.len() >= 2 { + // Collect relays from tags to encode as "r" tag tuples + all_relays.push(tag[1].clone()); + } else { + // Other tags as generic tag tuples + let tag_bytes = Self::encode_tag_tuple(tag)?; + writer.write_tlv(0x03, &tag_bytes); + } + } + } + + // 0x03 tag_tuple: encode relays as tag tuples with key "r" + for relay in all_relays { + let relay_tag = vec!["r".to_string(), relay]; + let tag_bytes = Self::encode_tag_tuple(&relay_tag)?; + writer.write_tlv(0x03, &tag_bytes); + } + } + TransportType::HttpPost => { + writer.write_tlv(0x02, transport.target.as_bytes()); + + // 0x03 tag_tuple: generic tuple (repeatable) + if let Some(ref tags) = transport.tags { + for tag in tags { + if !tag.is_empty() { + let tag_bytes = Self::encode_tag_tuple(tag)?; + writer.write_tlv(0x03, &tag_bytes); + } + } + } + } + TransportType::InBand => { + // This case is unreachable since we return early with error for InBand + unreachable!("InBand transport should not reach target encoding") + } + } + + Ok(writer.into_bytes()) + } + + /// Decode NUT-10 sub-TLV + fn decode_nut10(bytes: &[u8]) -> Result { + let mut reader = TlvReader::new(bytes); + + let mut kind: Option = None; + let mut data: Option> = None; + let mut tags: Vec<(String, Vec)> = Vec::new(); + + while let Some((tag, value)) = reader.read_tlv().map_err(|_| Error::InvalidPrefix)? { + match tag { + 0x01 => { + // kind: u8 + if value.len() != 1 { + return Err(Error::InvalidPrefix); + } + kind = Some(value[0]); + } + 0x02 => { + // data: bytes + data = Some(value); + } + 0x03 | 0x05 => { + // tag_tuple: generic tuple (repeatable) + let tag_tuple = Self::decode_tag_tuple(&value)?; + tags.push(tag_tuple); + } + _ => { + // Unknown tags are ignored + } + } + } + + let kind_val = kind.ok_or(Error::InvalidPrefix)?; + let data_val = data.unwrap_or_default(); + + // Convert kind u8 to Kind enum + let data_str = String::from_utf8(data_val).map_err(|_| Error::InvalidUtf8)?; + + // Map kind value to Kind enum, error on unknown kinds + let kind_enum = match kind_val { + 0 => Kind::P2PK, + 1 => Kind::HTLC, + _ => return Err(Error::UnknownKind(kind_val)), + }; + + Ok(Nut10SecretRequest::new( + kind_enum, + &data_str, + if tags.is_empty() { + None + } else { + Some( + tags.into_iter() + .map(|(k, v)| { + let mut result = vec![k]; + result.extend(v); + result + }) + .collect::>(), + ) + }, + )) + } + + /// Encode NUT-10 to sub-TLV + fn encode_nut10(nut10: &Nut10SecretRequest) -> Result, Error> { + let mut writer = TlvWriter::new(); + + // 0x01 kind: u8 + let kind_val = match nut10.kind { + Kind::P2PK => 0u8, + Kind::HTLC => 1u8, + }; + writer.write_tlv(0x01, &[kind_val]); + + // 0x02 data: bytes + writer.write_tlv(0x02, nut10.data.as_bytes()); + + // 0x03 tag_tuple: generic tuple (repeatable) + if let Some(ref tags) = nut10.tags { + for tag in tags { + let tag_bytes = Self::encode_tag_tuple(tag)?; + writer.write_tlv(0x03, &tag_bytes); + } + } + + Ok(writer.into_bytes()) + } + + /// Decode tag tuple + fn decode_tag_tuple(bytes: &[u8]) -> Result<(String, Vec), Error> { + if bytes.is_empty() { + return Err(Error::InvalidPrefix); + } + + let key_len = bytes[0] as usize; + if bytes.len() < 1 + key_len { + return Err(Error::InvalidPrefix); + } + + let key = + String::from_utf8(bytes[1..1 + key_len].to_vec()).map_err(|_| Error::InvalidPrefix)?; + + let mut values = Vec::new(); + let mut pos = 1 + key_len; + + while pos < bytes.len() { + let val_len = bytes[pos] as usize; + pos += 1; + + if pos + val_len > bytes.len() { + return Err(Error::InvalidPrefix); + } + + let value = String::from_utf8(bytes[pos..pos + val_len].to_vec()) + .map_err(|_| Error::InvalidPrefix)?; + values.push(value); + pos += val_len; + } + + Ok((key, values)) + } + + /// Encode tag tuple + fn encode_tag_tuple(tag: &[String]) -> Result, Error> { + if tag.is_empty() { + return Err(Error::InvalidPrefix); + } + + let mut bytes = Vec::new(); + + // Key length + key + let key = &tag[0]; + bytes.push(key.len() as u8); + bytes.extend_from_slice(key.as_bytes()); + + // Values + for value in &tag[1..] { + bytes.push(value.len() as u8); + bytes.extend_from_slice(value.as_bytes()); + } + + Ok(bytes) + } + + /// Decode nprofile bech32 string to (pubkey, relays) + /// NIP-19 nprofile TLV format: + /// - Type 0: 32-byte pubkey (required, only one) + /// - Type 1: relay URL string (optional, repeatable) + fn decode_nprofile(nprofile: &str) -> Result<(Vec, Vec), Error> { + let (hrp, data) = bech32::decode(nprofile).map_err(|_| Error::InvalidPrefix)?; + if hrp.as_str() != "nprofile" { + return Err(Error::InvalidPrefix); + } + + // Parse NIP-19 TLV format (Type: 1 byte, Length: 1 byte, Value: variable) + let mut pos = 0; + let mut pubkey: Option> = None; + let mut relays: Vec = Vec::new(); + + while pos < data.len() { + if pos + 2 > data.len() { + break; // Not enough data for type + length + } + + let tag = data[pos]; + let len = data[pos + 1] as usize; + pos += 2; + + if pos + len > data.len() { + return Err(Error::InvalidPrefix); + } + + let value = &data[pos..pos + len]; + pos += len; + + match tag { + 0 => { + // pubkey: 32 bytes + if value.len() != 32 { + return Err(Error::InvalidPrefix); + } + pubkey = Some(value.to_vec()); + } + 1 => { + // relay: UTF-8 string + let relay = + String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidPrefix)?; + relays.push(relay); + } + _ => { + // Unknown TLV types are ignored per NIP-19 + } + } + } + + let pubkey = pubkey.ok_or(Error::InvalidPrefix)?; + Ok((pubkey, relays)) + } + + /// Encode pubkey and relays to nprofile bech32 string + /// NIP-19 nprofile TLV format (Type: 1 byte, Length: 1 byte, Value: variable) + fn encode_nprofile(pubkey: &[u8], relays: &[String]) -> Result { + if pubkey.len() != 32 { + return Err(Error::InvalidPrefix); + } + + let mut tlv_bytes = Vec::new(); + + // Type 0: pubkey (32 bytes) - Length must fit in 1 byte + tlv_bytes.push(0); // type + tlv_bytes.push(32); // length + tlv_bytes.extend_from_slice(pubkey); + + // Type 1: relays (repeatable) - Length must fit in 1 byte + for relay in relays { + if relay.len() > 255 { + return Err(Error::InvalidPrefix); // Relay URL too long for NIP-19 + } + tlv_bytes.push(1); // type + tlv_bytes.push(relay.len() as u8); // length + tlv_bytes.extend_from_slice(relay.as_bytes()); + } + + let hrp = Hrp::parse("nprofile").map_err(|_| Error::InvalidPrefix)?; + bech32::encode::(hrp, &tlv_bytes).map_err(|_| Error::InvalidPrefix) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + use crate::nuts::nut10::Kind; + use crate::util::hex; + use crate::TransportType; + + #[test] + fn test_bech32_basic_round_trip() { + let transport = Transport { + _type: TransportType::HttpPost, + target: "https://api.example.com/payment".to_string(), + tags: None, + }; + + let payment_request = PaymentRequest { + payment_id: Some("test123".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Test payment".to_string()), + transports: vec![transport], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify it starts with CREQB1 + assert!(encoded.starts_with("CREQB1")); + + // Round-trip test + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + assert_eq!(decoded.payment_id, payment_request.payment_id); + assert_eq!(decoded.amount, payment_request.amount); + assert_eq!(decoded.unit, payment_request.unit); + assert_eq!(decoded.single_use, payment_request.single_use); + assert_eq!(decoded.description, payment_request.description); + } + + #[test] + fn test_bech32_minimal() { + let payment_request = PaymentRequest { + payment_id: Some("minimal".to_string()), + amount: None, + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + assert_eq!(decoded.payment_id, payment_request.payment_id); + assert_eq!(decoded.mints, payment_request.mints); + } + + #[test] + fn test_bech32_with_nut10() { + let nut10 = Nut10SecretRequest::new( + Kind::P2PK, + "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198", + Some(vec![vec!["timeout".to_string(), "3600".to_string()]]), + ); + + let payment_request = PaymentRequest { + payment_id: Some("nut10test".to_string()), + amount: Some(Amount::from(500)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("P2PK locked payment".to_string()), + transports: vec![], + nut10: Some(nut10.clone()), + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + assert_eq!(decoded.nut10.as_ref().unwrap().kind, nut10.kind); + assert_eq!(decoded.nut10.as_ref().unwrap().data, nut10.data); + } + + #[test] + fn test_parse_creq_param_bech32() { + let payment_request = PaymentRequest { + payment_id: Some("test123".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + let decoded_payment_request = + PaymentRequest::from_bech32_string(&encoded).expect("should parse bech32"); + assert_eq!( + decoded_payment_request.payment_id, + payment_request.payment_id + ); + } + + #[test] + fn test_from_bech32_string_errors_on_wrong_encoding() { + // Test that from_bech32_string errors if given a non-CREQ-B string + let legacy_creq = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U="; + + // Should error because it's not bech32m encoded + assert!(PaymentRequest::from_bech32_string(legacy_creq).is_err()); + + // Test with a string that's not CREQ-B + assert!(PaymentRequest::from_bech32_string("not_a_creq").is_err()); + + // Test with wrong HRP (nprofile instead of creqb) + let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let pubkey_bytes = hex::decode(pubkey_hex).unwrap(); + let nprofile = + PaymentRequest::encode_nprofile(&pubkey_bytes, &[]).expect("should encode nprofile"); + assert!(PaymentRequest::from_bech32_string(&nprofile).is_err()); + } + + #[test] + fn test_unit_encoding_bech32() { + // Test default sat unit + let payment_request = PaymentRequest { + payment_id: Some("unit_test".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + assert_eq!(decoded.unit, Some(CurrencyUnit::Sat)); + + // Test custom unit + let payment_request_usd = PaymentRequest { + payment_id: Some("unit_test_usd".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Usd), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded_usd = payment_request_usd + .to_bech32_string() + .expect("encoding should work"); + + let decoded_usd = + PaymentRequest::from_bech32_string(&encoded_usd).expect("decoding should work"); + assert_eq!(decoded_usd.unit, Some(CurrencyUnit::Usd)); + } + + #[test] + fn test_nprofile_no_relays() { + // Test vector: a known 32-byte pubkey + let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let pubkey_bytes = hex::decode(pubkey_hex).unwrap(); + + // Encode to nprofile with empty relay list + let nprofile = + PaymentRequest::encode_nprofile(&pubkey_bytes, &[]).expect("should encode nprofile"); + assert!(nprofile.starts_with("nprofile")); + + // Decode back + let decoded = PaymentRequest::decode_nprofile(&nprofile).expect("should decode nprofile"); + assert_eq!(decoded.0, pubkey_bytes); + assert!(decoded.1.is_empty()); + } + + #[test] + fn test_nprofile_encoding_decoding() { + use nostr_sdk::prelude::*; + + let keys = Keys::generate(); + let pubkey_bytes = keys.public_key().to_bytes().to_vec(); + let relays = vec![ + "wss://relay.example.com".to_string(), + "wss://another-relay.example.com".to_string(), + ]; + + // Encode to nprofile + let nprofile = PaymentRequest::encode_nprofile(&pubkey_bytes, &relays) + .expect("should encode nprofile"); + assert!(nprofile.starts_with("nprofile")); + + // Decode back + let (decoded_pubkey, decoded_relays) = + PaymentRequest::decode_nprofile(&nprofile).expect("should decode nprofile"); + assert_eq!(decoded_pubkey, pubkey_bytes); + assert_eq!(decoded_relays, relays); + } + + #[test] + fn test_nprofile_matches_nostr_crate() { + use nostr_sdk::prelude::*; + + let keys = Keys::generate(); + let nostr_pubkey = keys.public_key(); + let pubkey_bytes = nostr_pubkey.to_bytes().to_vec(); + let relays = vec![ + "wss://relay.example.com".to_string(), + "wss://relay.damus.io".to_string(), + ]; + + // Create nostr-sdk relay URLs + let nostr_relays: Vec = relays + .iter() + .map(|r| RelayUrl::parse(r).expect("valid relay url")) + .collect(); + + // Test 1: Encode with our implementation, decode with nostr-sdk + let our_nprofile = PaymentRequest::encode_nprofile(&pubkey_bytes, &relays) + .expect("should encode nprofile"); + + let nostr_decoded = + Nip19Profile::from_bech32(&our_nprofile).expect("nostr-sdk should decode our nprofile"); + assert_eq!(nostr_decoded.public_key, nostr_pubkey); + assert_eq!(nostr_decoded.relays.len(), relays.len()); + for (decoded_relay, expected_relay) in nostr_decoded.relays.iter().zip(nostr_relays.iter()) + { + assert_eq!(decoded_relay, expected_relay); + } + + // Test 2: Encode with nostr-sdk, decode with our implementation + let nostr_profile = Nip19Profile::new(nostr_pubkey, nostr_relays.clone()); + let nostr_nprofile = nostr_profile.to_bech32().expect("should encode nprofile"); + + let (our_decoded_pubkey, our_decoded_relays) = + PaymentRequest::decode_nprofile(&nostr_nprofile) + .expect("should decode nostr-sdk nprofile"); + assert_eq!(our_decoded_pubkey, pubkey_bytes); + assert_eq!(our_decoded_relays.len(), relays.len()); + for (decoded_relay, expected_relay) in our_decoded_relays.iter().zip(relays.iter()) { + assert_eq!(decoded_relay, expected_relay); + } + + // Test 3: Both implementations produce identical bech32 strings + assert_eq!(our_nprofile, nostr_nprofile); + } + + #[test] + fn test_nprofile_empty_relays_matches_nostr_crate() { + use nostr_sdk::prelude::*; + + let keys = Keys::generate(); + let nostr_pubkey = keys.public_key(); + let pubkey_bytes = nostr_pubkey.to_bytes().to_vec(); + + // Create nostr-sdk types with empty relays + let nostr_relays: Vec = vec![]; + + // Test with empty relays + let our_nprofile = + PaymentRequest::encode_nprofile(&pubkey_bytes, &[]).expect("should encode nprofile"); + + let nostr_profile = Nip19Profile::new(nostr_pubkey, nostr_relays); + let nostr_nprofile = nostr_profile.to_bech32().expect("should encode nprofile"); + + // Verify both can decode each other's output + let nostr_decoded = + Nip19Profile::from_bech32(&our_nprofile).expect("nostr-sdk should decode our nprofile"); + assert_eq!(nostr_decoded.public_key, nostr_pubkey); + assert!(nostr_decoded.relays.is_empty()); + + let (our_decoded_pubkey, our_decoded_relays) = + PaymentRequest::decode_nprofile(&nostr_nprofile) + .expect("should decode nostr-sdk nprofile"); + assert_eq!(our_decoded_pubkey, pubkey_bytes); + assert!(our_decoded_relays.is_empty()); + + // Both should produce identical strings + assert_eq!(our_nprofile, nostr_nprofile); + } + + #[test] + fn nut_18_payment_request() { + use nostr_sdk::prelude::*; + let nprofile = "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5"; + + let nostr_decoded = + Nip19Profile::from_bech32(&nprofile).expect("nostr-sdk should decode our nprofile"); + + // Verify the decoded data can be re-encoded (round-trip works) + let encoded = nostr_decoded.to_bech32().unwrap(); + + // Re-decode to verify content is preserved (encoding may differ due to normalization) + let re_decoded = Nip19Profile::from_bech32(&encoded) + .expect("nostr-sdk should decode re-encoded nprofile"); + + // Verify the semantic content is preserved + assert_eq!(nostr_decoded.public_key, re_decoded.public_key); + assert_eq!(nostr_decoded.relays.len(), re_decoded.relays.len()); + } + + #[test] + fn test_nostr_transport_with_nprofile_no_relays() { + // Create a payment request with nostr transport using nprofile with empty relay list + let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let pubkey_bytes = hex::decode(pubkey_hex).unwrap(); + let nprofile = + PaymentRequest::encode_nprofile(&pubkey_bytes, &[]).expect("encode nprofile"); + + let transport = Transport { + _type: TransportType::Nostr, + target: nprofile.clone(), + tags: Some(vec![vec!["n".to_string(), "17".to_string()]]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("nostr_test".to_string()), + amount: Some(Amount::from(1000)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Nostr payment".to_string()), + transports: vec![transport], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + assert_eq!(decoded.payment_id, payment_request.payment_id); + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::Nostr); + assert!(decoded.transports[0].target.starts_with("nprofile")); + + // Check that NIP-17 tag was preserved + let tags = decoded.transports[0].tags.as_ref().unwrap(); + assert!(tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "17")); + } + + #[test] + fn test_nostr_transport_with_nprofile() { + // Create a payment request with nostr transport using nprofile + let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let pubkey_bytes = hex::decode(pubkey_hex).unwrap(); + let relays = vec!["wss://relay.example.com".to_string()]; + let nprofile = + PaymentRequest::encode_nprofile(&pubkey_bytes, &relays).expect("encode nprofile"); + + let transport = Transport { + _type: TransportType::Nostr, + target: nprofile.clone(), + tags: Some(vec![vec!["n".to_string(), "17".to_string()]]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("nprofile_test".to_string()), + amount: Some(Amount::from(2100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Nostr payment with relays".to_string()), + transports: vec![transport], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + assert_eq!(decoded.payment_id, payment_request.payment_id); + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::Nostr); + + // Should be encoded back as nprofile since it has relays + assert!(decoded.transports[0].target.starts_with("nprofile")); + + // Check that relay was preserved in tags + let tags = decoded.transports[0].tags.as_ref().unwrap(); + assert!(tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://relay.example.com")); + } + + #[test] + fn test_spec_example_nostr_transport() { + // Test a complete example as specified in the spec: + // Payment request with nostr transport, NIP-17, pubkey, and one relay + let pubkey_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let pubkey_bytes = hex::decode(pubkey_hex).unwrap(); + let relays = vec!["wss://relay.damus.io".to_string()]; + let nprofile = + PaymentRequest::encode_nprofile(&pubkey_bytes, &relays).expect("encode nprofile"); + + let transport = Transport { + _type: TransportType::Nostr, + target: nprofile, + tags: Some(vec![vec!["n".to_string(), "17".to_string()]]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("spec_example".to_string()), + amount: Some(Amount::from(10)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Coffee".to_string()), + transports: vec![transport], + nut10: None, + }; + + // Encode and decode + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + println!("Spec example encoded: {}", encoded); + + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + // Verify round-trip + assert_eq!(decoded.payment_id, Some("spec_example".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(10))); + assert_eq!(decoded.unit, Some(CurrencyUnit::Sat)); + assert_eq!(decoded.single_use, Some(true)); + assert_eq!(decoded.description, Some("Coffee".to_string())); + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::Nostr); + + // Verify relay and NIP are preserved + let tags = decoded.transports[0].tags.as_ref().unwrap(); + assert!(tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "17")); + assert!(tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://relay.damus.io")); + } + + #[test] + fn test_decode_valid_bech32_with_nostr_pubkeys_and_mints() { + // First, create a payment request with multiple mints and nostr transports with different pubkeys + let pubkey1_hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let pubkey1_bytes = hex::decode(pubkey1_hex).unwrap(); + // Use nprofile with empty relay list instead of npub + let nprofile1 = + PaymentRequest::encode_nprofile(&pubkey1_bytes, &[]).expect("encode nprofile1"); + + let pubkey2_hex = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let pubkey2_bytes = hex::decode(pubkey2_hex).unwrap(); + let relays2 = vec![ + "wss://relay.damus.io".to_string(), + "wss://nos.lol".to_string(), + ]; + let nprofile2 = + PaymentRequest::encode_nprofile(&pubkey2_bytes, &relays2).expect("encode nprofile2"); + + let transport1 = Transport { + _type: TransportType::Nostr, + target: nprofile1.clone(), + tags: Some(vec![vec!["n".to_string(), "17".to_string()]]), + }; + + let transport2 = Transport { + _type: TransportType::Nostr, + target: nprofile2.clone(), + tags: Some(vec![ + vec!["n".to_string(), "17".to_string()], + vec!["n".to_string(), "44".to_string()], + ]), + }; + + let payment_request = PaymentRequest { + payment_id: Some("multi_test".to_string()), + amount: Some(Amount::from(5000)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(false), + mints: Some(vec![ + MintUrl::from_str("https://mint1.example.com").unwrap(), + MintUrl::from_str("https://mint2.example.com").unwrap(), + MintUrl::from_str("https://testnut.cashu.space").unwrap(), + ]), + description: Some("Payment with multiple transports and mints".to_string()), + transports: vec![transport1, transport2], + nut10: None, + }; + + // Encode to bech32 string + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + println!("Encoded payment request: {}", encoded); + + // Now decode the bech32 string and verify contents + let decoded = PaymentRequest::from_bech32_string(&encoded) + .expect("should decode valid bech32 string"); + + // Verify basic fields + assert_eq!(decoded.payment_id, Some("multi_test".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(5000))); + assert_eq!(decoded.unit, Some(CurrencyUnit::Sat)); + assert_eq!(decoded.single_use, Some(false)); + assert_eq!( + decoded.description, + Some("Payment with multiple transports and mints".to_string()) + ); + + // Verify mints + let mints = decoded.mints.as_ref().expect("should have mints"); + assert_eq!(mints.len(), 3); + + // MintUrl normalizes URLs and may add trailing slashes + let mint_strings: Vec = mints.iter().map(|m| m.to_string()).collect(); + assert!( + mint_strings[0] == "https://mint1.example.com/" + || mint_strings[0] == "https://mint1.example.com" + ); + assert!( + mint_strings[1] == "https://mint2.example.com/" + || mint_strings[1] == "https://mint2.example.com" + ); + assert!( + mint_strings[2] == "https://testnut.cashu.space/" + || mint_strings[2] == "https://testnut.cashu.space" + ); + + // Verify transports + assert_eq!(decoded.transports.len(), 2); + + // Verify first transport (nprofile with no relays) + let transport1_decoded = &decoded.transports[0]; + assert_eq!(transport1_decoded._type, TransportType::Nostr); + assert!(transport1_decoded.target.starts_with("nprofile")); + + // Decode the nprofile to verify the pubkey + let (decoded_pubkey1, decoded_relays1) = + PaymentRequest::decode_nprofile(&transport1_decoded.target) + .expect("should decode nprofile"); + assert_eq!(decoded_pubkey1, pubkey1_bytes); + assert!(decoded_relays1.is_empty()); + + // Verify NIP-17 tag + let tags1 = transport1_decoded.tags.as_ref().unwrap(); + assert!(tags1 + .iter() + .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "17")); + + // Verify second transport (nprofile) + let transport2_decoded = &decoded.transports[1]; + assert_eq!(transport2_decoded._type, TransportType::Nostr); + assert!(transport2_decoded.target.starts_with("nprofile")); + + // Decode the nprofile to verify the pubkey and relays + let (decoded_pubkey2, decoded_relays2) = + PaymentRequest::decode_nprofile(&transport2_decoded.target) + .expect("should decode nprofile"); + assert_eq!(decoded_pubkey2, pubkey2_bytes); + assert_eq!(decoded_relays2, relays2); + + // Verify tags include both NIPs and relays + let tags2 = transport2_decoded.tags.as_ref().unwrap(); + assert!(tags2 + .iter() + .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "17")); + assert!(tags2 + .iter() + .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "44")); + assert!(tags2 + .iter() + .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://relay.damus.io")); + assert!(tags2 + .iter() + .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://nos.lol")); + } + + // Test vectors from NUT-26 specification + // https://github.com/cashubtc/nuts/blob/main/tests/26-tests.md + #[test] + fn test_basic_payment_request() { + // Basic payment request with required fields + let json = r#"{ + "i": "b7a90176", + "a": 10, + "u": "sat", + "m": ["https://8333.space:3338"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n", + "g": [["n", "17"]] + } + ] + }"#; + + let expected_encoded = "CREQB1QYQQSC3HVYUNQVFHXCPQQZQQQQQQQQQQQQ9QXQQPQQZSQ9MGW368QUE69UHNSVENXVH8XURPVDJN5VENXVUQWQREQYQQZQQZQQSGM6QFA3C8DTZ2FVZHVFQEACMWM0E50PE3K5TFMVPJJMN0VJ7M2TGRQQZSZMSZXYMSXQQHQ9EPGAMNWVAZ7TMJV4KXZ7FWV3SK6ATN9E5K7QCQRGQHY9MHWDEN5TE0WFJKCCTE9CURXVEN9EEHQCTRV5HSXQQSQ9EQ6AMNWVAZ7TMWDAEJUMR0DSRYDPGF"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "b7a90176" + ); + assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(10)); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![MintUrl::from_str("https://8333.space:3338").unwrap()] + ); + + let transport = payment_request.transports.first().unwrap(); + assert_eq!(transport._type, TransportType::Nostr); + assert_eq!(transport.target, "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n"); + assert_eq!( + transport.tags, + Some(vec![vec!["n".to_string(), "17".to_string()]]) + ); + + // Test bech32m encoding (CREQ-B format) - this is what NUT-26 is about + let encoded = payment_request + .to_bech32_string() + .expect("Failed to encode to bech32"); + + // Verify it starts with CREQB1 (uppercase because we use encode_upper) + assert!(encoded.starts_with("CREQB1")); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Test round-trip via bech32 format + let decoded = PaymentRequest::from_bech32_string(&encoded).unwrap(); + + // Verify decoded fields match original + assert_eq!(decoded.payment_id.as_ref().unwrap(), "b7a90176"); + assert_eq!(decoded.amount.unwrap(), Amount::from(10)); + assert_eq!(decoded.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + decoded.mints.unwrap(), + vec![MintUrl::from_str("https://8333.space:3338").unwrap()] + ); + + // Verify transport type and that it has the NIP-17 tag + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::Nostr); + let tags = decoded.transports[0].tags.as_ref().unwrap(); + assert!(tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "17")); + + // Verify the pubkey is preserved (decode both nprofiles and compare pubkeys) + let (original_pubkey, _) = PaymentRequest::decode_nprofile(&transport.target).unwrap(); + let (decoded_pubkey, _) = + PaymentRequest::decode_nprofile(&decoded.transports[0].target).unwrap(); + assert_eq!(original_pubkey, decoded_pubkey); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "b7a90176"); + } + + #[test] + fn test_nostr_transport_payment_request() { + // Nostr transport payment request with multiple mints + let json = r#"{ + "i": "f92a51b8", + "a": 100, + "u": "sat", + "m": ["https://mint1.example.com", "https://mint2.example.com"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq8uzqt", + "g": [["n", "17"], ["n", "9735"]] + } + ] + }"#; + + let expected_encoded = "CREQB1QYQQSE3EXFSN2VTZ8QPQQZQQQQQQQQQQQPJQXQQPQQZSQXTGW368QUE69UHK66TWWSCJUETCV9KHQMR99E3K7MG9QQVKSAR5WPEN5TE0D45KUAPJ9EJHSCTDWPKX2TNRDAKSWQPEQYQQZQQZQQSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQRQQZSZMSZXYMSXQQ8Q9HQGWFHXV6SCAGZ48"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "f92a51b8" + ); + assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(100)); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![ + MintUrl::from_str("https://mint1.example.com").unwrap(), + MintUrl::from_str("https://mint2.example.com").unwrap() + ] + ); + + let transport = payment_request_cloned.transports.first().unwrap(); + assert_eq!(transport._type, TransportType::Nostr); + assert_eq!( + transport.target, + "nprofile1qqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq8uzqt" + ); + assert_eq!( + transport.tags, + Some(vec![ + vec!["n".to_string(), "17".to_string()], + vec!["n".to_string(), "9735".to_string()] + ]) + ); + + // Test round-trip serialization + let encoded = payment_request.to_bech32_string().unwrap(); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "f92a51b8"); + } + + #[test] + fn test_minimal_payment_request() { + // Minimal payment request with only required fields + let json = r#"{ + "i": "7f4a2b39", + "u": "sat", + "m": ["https://mint.example.com"] + }"#; + + let expected_encoded = + "CREQB1QYQQSDMXX3SNYC3N8YPSQQGQQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSYP0LHG"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "7f4a2b39" + ); + assert_eq!(payment_request_cloned.amount, None); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + assert_eq!(payment_request_cloned.transports, vec![]); + + // Test round-trip serialization + let encoded = payment_request.to_bech32_string().unwrap(); + assert_eq!(encoded, expected_encoded); + let decoded = PaymentRequest::from_bech32_string(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "7f4a2b39"); + } + + #[test] + fn test_nut10_locking_payment_request() { + // Payment request with NUT-10 P2PK locking + let json = r#"{ + "i": "c9e45d2a", + "a": 500, + "u": "sat", + "m": ["https://mint.example.com"], + "nut10": { + "k": "P2PK", + "d": "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331", + "t": [["timeout", "3600"]] + } + }"#; + + let expected_encoded = "CREQB1QYQQSCEEV56R2EPJVYPQQZQQQQQQQQQQQ86QXQQPQQZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6ZQQTYQSQQGQQGQYYVPJVVEKYDTZVGERWEFNXCCNGDFHVVUNYEPEXDJRWWRYVSMNXEPNVS6NXDENXGCNZVRZXF3KVEFCVG6NQENZVVCXZCNRXCCN2EFEVVENXVGRQQXSWARFD4JK7AT5QSENVVPS2N5FAS"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "c9e45d2a" + ); + assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(500)); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + + // Test NUT-10 locking + let nut10 = payment_request_cloned.nut10.unwrap(); + assert_eq!(nut10.kind, Kind::P2PK); + assert_eq!( + nut10.data, + "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331" + ); + assert_eq!( + nut10.tags, + Some(vec![vec!["timeout".to_string(), "3600".to_string()]]) + ); + + // Test round-trip serialization + let encoded = payment_request.to_bech32_string().unwrap(); + assert_eq!(encoded, expected_encoded); + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a"); + } + + #[test] + fn test_nut26_example() { + // Payment request with NUT-10 P2PK locking + let json = r#"{ + "i": "demo123", + "a": 1000, + "u": "sat", + "s": true, + "m": ["https://mint.example.com"], + "d": "Coffee payment" +}"#; + + let expected_encoded = "CREQB1QYQQWER9D4HNZV3NQGQQSQQQQQQQQQQRAQPSQQGQQSQQZQG9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RQQRJRDANXVET9YPCXZ7TDV4H8GXHR3TQ"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request.to_bech32_string().unwrap(); + + assert_eq!(expected_encoded, encoded); + } + + #[test] + fn test_http_post_transport_kind_1() { + // Test HTTP POST transport (kind=0x01) encoding and decoding + let json = r#"{ + "i": "http_test", + "a": 250, + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "post", + "a": "https://api.example.com/v1/payment", + "g": [["custom", "value1", "value2"]] + } + ] + }"#; + + // Note: The encoded string is generated by our implementation and verified via round-trip + let expected_encoded = "CREQB1QYQQJ6R5W3C97AR9WD6QYQQGQQQQQQQQQQQ05QCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MG8QPQSZQQPQYPQQGNGW368QUE69UHKZURF9EJHSCTDWPKX2TNRDAKJ7A339ACXZ7TDV4H8GQCQZ5RXXATNW3HK6PNKV9K82EF3QEMXZMR4V5EQ9X3SJM"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Decode and verify round-trip + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + // Verify transport type is HTTP POST + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::HttpPost); + assert_eq!( + decoded.transports[0].target, + "https://api.example.com/v1/payment" + ); + + // Verify custom tags are preserved + let tags = decoded.transports[0].tags.as_ref().unwrap(); + assert!(tags + .iter() + .any(|t| t.len() >= 3 && t[0] == "custom" && t[1] == "value1" && t[2] == "value2")); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "http_test"); + } + + #[test] + fn test_relay_tag_extraction_from_nprofile() { + // Test that relays are properly extracted from nprofile and converted to "relay" tags + let json = r#"{ + "i": "relay_test", + "a": 100, + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gprpmhxue69uhhyetvv9unztn90psk6urvv5hxxmmdqyv8wumn8ghj7un9d3shjv3wv4uxzmtsd3jjucm0d5q3samnwvaz7tmjv4kxz7fn9ejhsctdwpkx2tnrdaksxzjpjp" + } + ] + }"#; + + let expected_encoded = "CREQB1QYQQ5UN9D3SHJHM5V4EHGQSQPQQQQQQQQQQQQEQRQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQUQGZQGQQYQQYQPQ80CVV07TJDRRGPA0J7J7TMNYL2YR6YR7L8J4S3EVF6U64TH6GKWSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7F39EJHSCTDWPKX2TNRDAKSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7FJ9EJHSCTDWPKX2TNRDAKSXQQMQ9EPSAMNWVAZ7TMJV4KXZ7FN9EJHSCTDWPKX2TNRDAKSKRFDAR"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Decode and verify round-trip + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + // Verify relays were extracted and converted to "relay" tags + let tags = decoded.transports[0] + .tags + .as_ref() + .expect("should have tags"); + + // Check all three relays are present as "relay" tags + let relay_tags: Vec<&Vec> = tags + .iter() + .filter(|t| !t.is_empty() && t[0] == "relay") + .collect(); + assert_eq!(relay_tags.len(), 3); + + let relay_values: Vec<&str> = relay_tags + .iter() + .filter(|t| t.len() >= 2) + .map(|t| t[1].as_str()) + .collect(); + // The nprofile has 3 relays embedded - verified by decode + assert_eq!(relay_values.len(), 3); + + // Verify the nprofile is preserved (relays are encoded back into it) + assert_eq!( + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gprpmhxue69uhhyetvv9unztn90psk6urvv5hxxmmdqyv8wumn8ghj7un9d3shjv3wv4uxzmtsd3jjucm0d5q3samnwvaz7tmjv4kxz7fn9ejhsctdwpkx2tnrdaksxzjpjp", + decoded.transports[0].target + ); + + // Also verify the nprofile contains the relays + let (_, decoded_relays) = + PaymentRequest::decode_nprofile(&decoded.transports[0].target).unwrap(); + assert_eq!(decoded_relays.len(), 3); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "relay_test"); + } + + #[test] + fn test_description_field() { + // Test description field (tag 0x06) encoding and decoding + let expected_encoded = "CREQB1QYQQJER9WD347AR9WD6QYQQGQQQQQQQQQQQXGQCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGXQQV9GETNWSS8QCTED4JKUAPQV3JHXCMJD9C8G6T0DCFLJJRX"; + + let payment_request = PaymentRequest { + payment_id: Some("desc_test".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: Some("Test payment description".to_string()), + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + assert_eq!(encoded, expected_encoded); + + // Decode from the expected encoded string + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + + assert_eq!( + decoded.description, + Some("Test payment description".to_string()) + ); + assert_eq!(decoded.payment_id, Some("desc_test".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(100))); + assert_eq!(decoded.unit, Some(CurrencyUnit::Sat)); + } + + #[test] + fn test_single_use_field_true() { + // Test single_use field (tag 0x04) with value true + let expected_encoded = "CREQB1QYQQ7UMFDENKCE2LW4EK2HM5WF6K2QSQPQQQQQQQQQQQQEQRQQQSQPQQQYQS2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGX0AYM7"; + + let payment_request = PaymentRequest { + payment_id: Some("single_use_true".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(true), + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + assert_eq!(encoded, expected_encoded); + + // Decode from the expected encoded string + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + + assert_eq!(decoded.single_use, Some(true)); + assert_eq!(decoded.payment_id, Some("single_use_true".to_string())); + } + + #[test] + fn test_single_use_field_false() { + // Test single_use field (tag 0x04) with value false + let expected_encoded = "CREQB1QYQPQUMFDENKCE2LW4EK2HMXV9K8XEGZQQYQQQQQQQQQQQRYQVQQZQQYQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQ40L90"; + + let payment_request = PaymentRequest { + payment_id: Some("single_use_false".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: Some(false), + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + assert_eq!(encoded, expected_encoded); + + // Decode from the expected encoded string + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + + assert_eq!(decoded.single_use, Some(false)); + assert_eq!(decoded.payment_id, Some("single_use_false".to_string())); + } + + #[test] + fn test_unit_msat() { + // Test msat unit encoding (should be string, not 0x00) + let expected_encoded = "CREQB1QYQQJATWD9697MTNV96QYQQGQQQQQQQQQQP7SQCQQ3KHXCT5Q5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSYYMU95"; + + let payment_request = PaymentRequest { + payment_id: Some("unit_msat".to_string()), + amount: Some(Amount::from(1000)), + unit: Some(CurrencyUnit::Msat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + assert_eq!(encoded, expected_encoded); + + // Decode from the expected encoded string + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + + assert_eq!(decoded.unit, Some(CurrencyUnit::Msat)); + assert_eq!(decoded.payment_id, Some("unit_msat".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(1000))); + } + + #[test] + fn test_unit_usd() { + // Test usd unit encoding (should be string, not 0x00) + let expected_encoded = "CREQB1QYQQSATWD9697ATNVSPQQZQQQQQQQQQQQ86QXQQRW4EKGPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDEPCJYC"; + + let payment_request = PaymentRequest { + payment_id: Some("unit_usd".to_string()), + amount: Some(Amount::from(500)), + unit: Some(CurrencyUnit::Usd), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + assert_eq!(encoded, expected_encoded); + + // Decode from the expected encoded string + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + + assert_eq!(decoded.unit, Some(CurrencyUnit::Usd)); + assert_eq!(decoded.payment_id, Some("unit_usd".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(500))); + } + + #[test] + fn test_multiple_transports() { + // Test payment request with multiple transport options (priority order) + let json = r#"{ + "i": "multi_transport", + "a": 500, + "u": "sat", + "m": ["https://mint.example.com"], + "d": "Payment with multiple transports", + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q", + "g": [["n", "17"]] + }, + { + "t": "post", + "a": "https://api1.example.com/payment" + }, + { + "t": "post", + "a": "https://api2.example.com/payment", + "g": [["priority", "backup"]] + } + ] + }"#; + + let expected_encoded = "CREQB1QYQQ7MT4D36XJHM5WFSKUUMSDAE8GQSQPQQQQQQQQQQQRAQRQQQSQPGQRP58GARSWVAZ7TMDD9H8GTN90PSK6URVV5HXXMMDQCQZQ5RP09KK2MN5YPMKJARGYPKH2MR5D9CXCEFQW3EXZMNNWPHHYARNQUQZ7QGQQYQQYQPQ80CVV07TJDRRGPA0J7J7TMNYL2YR6YR7L8J4S3EVF6U64TH6GKWSXQQ9Q9HQYVFHQUQZWQGQQYQSYQPQDP68GURN8GHJ7CTSDYCJUETCV9KHQMR99E3K7MF0WPSHJMT9DE6QWQP6QYQQZQGZQQSXSAR5WPEN5TE0V9CXJV3WV4UXZMTSD3JJUCM0D5HHQCTED4JKUAQRQQGQSURJD9HHY6T50YRXYCTRDD6HQTSH7TP"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Decode from the encoded string + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + // Verify all three transports are preserved in order + assert_eq!(decoded.transports.len(), 3); + + // First transport: Nostr + assert_eq!(decoded.transports[0]._type, TransportType::Nostr); + assert!(decoded.transports[0].target.starts_with("nprofile")); + + // Second transport: HTTP POST + assert_eq!(decoded.transports[1]._type, TransportType::HttpPost); + assert_eq!( + decoded.transports[1].target, + "https://api1.example.com/payment" + ); + + // Third transport: HTTP POST with tags + assert_eq!(decoded.transports[2]._type, TransportType::HttpPost); + assert_eq!( + decoded.transports[2].target, + "https://api2.example.com/payment" + ); + let tags = decoded.transports[2].tags.as_ref().unwrap(); + assert!(tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "priority" && t[1] == "backup")); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!( + decoded_from_spec.payment_id.as_ref().unwrap(), + "multi_transport" + ); + } + + #[test] + fn test_minimal_transport_nostr_only_pubkey() { + // Test minimal Nostr transport with just pubkey (no relays, no tags) + let json = r#"{ + "i": "minimal_nostr", + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8g2lcy6q" + } + ] + }"#; + + let expected_encoded = "CREQB1QYQQ6MTFDE5K6CTVTAHX7UM5WGPSQQGQQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSWQP8QYQQZQQZQQSRHUXX8L9EX335Q7HE0F09AEJ04ZPAZPL0NE2CGUKYAWD24MAYT8G7QNXMQ"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Decode from the encoded string + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::Nostr); + assert!(decoded.transports[0].target.starts_with("nprofile")); + + // Tags should be None for minimal transport + assert!(decoded.transports[0].tags.is_none()); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!( + decoded_from_spec.payment_id.as_ref().unwrap(), + "minimal_nostr" + ); + } + + #[test] + fn test_minimal_transport_http_just_url() { + // Test minimal HTTP POST transport with just URL (no tags) + let json = r#"{ + "i": "minimal_http", + "u": "sat", + "m": ["https://mint.example.com"], + "t": [ + { + "t": "post", + "a": "https://api.example.com" + } + ] + }"#; + + let expected_encoded = "CREQB1QYQQCMTFDE5K6CTVTA58GARSQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RSQ8SPQQQSZQSQZA58GARSWVAZ7TMPWP5JUETCV9KHQMR99E3K7MG0TWYGX"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Decode and verify round-trip + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + assert_eq!(decoded.transports.len(), 1); + assert_eq!(decoded.transports[0]._type, TransportType::HttpPost); + assert_eq!(decoded.transports[0].target, "https://api.example.com"); + assert!(decoded.transports[0].tags.is_none()); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!( + decoded_from_spec.payment_id.as_ref().unwrap(), + "minimal_http" + ); + } + + #[test] + fn test_in_band_transport_implicit() { + // Test in-band transport: absence of transport tag means in-band (NUT-18 semantics) + // In-band transports are NOT encoded - they're represented by the absence of a transport tag + + let transport = Transport { + _type: TransportType::InBand, + target: String::new(), // In-band has no target + tags: None, + }; + + let payment_request = PaymentRequest { + payment_id: Some("in_band_test".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![transport], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Decode the encoded string + let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work"); + + // In-band transports are not encoded, so when decoded, transports should be empty + // (absence of transport tag = in-band is implicit) + assert_eq!(decoded.transports.len(), 0); + assert_eq!(decoded.payment_id, Some("in_band_test".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(100))); + } + + #[test] + fn test_nut10_htlc_kind_1() { + // Test NUT-10 HTLC (kind=1) encoding and decoding + let json = r#"{ + "i": "htlc_test", + "a": 1000, + "u": "sat", + "m": ["https://mint.example.com"], + "d": "HTLC locked payment", + "nut10": { + "k": "HTLC", + "d": "a]0e66820bfb412212cf7ab3deb0459ce282a1b04fda76ea6026a67e41ae26f3dc", + "t": [ + ["locktime", "1700000000"], + ["refund", "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e"] + ] + } + }"#; + + // Note: The encoded string is generated by our implementation and verified via round-trip + let expected_encoded = "CREQB1QYQQJ6R5D3347AR9WD6QYQQGQQQQQQQQQQP7SQCQQYQQ2QQCDP68GURN8GHJ7MTFDE6ZUETCV9KHQMR99E3K7MGXQQF5S4ZVGVSXCMMRDDJKGGRSV9UK6ETWWSYQPTGPQQQSZQSQGFS46VR9XCMRSV3SVFNXYDP3XGERZVNRVCMKZC3NV3JKYVP5X5UKXEFJ8QEXZVTZXQ6XVERPXUMX2CFKXQERVCFKXAJNGVTPV5ERVE3NV33SXQQ5PPKX7CMTW35K6EG2XYMNQVPSXQCRQVPSQVQY5PNJV4N82MNYGGCRXVEJ8QCKXVEHXCMNWETPXGMNXETZXUCNSVMZXUURXVPKXANR2V35XSUNXVM9VCMNSEPCVVEKVVF4VGCKZDEHVD3RYDPKXQUNJCEJXEJS4EHJHC"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Verify exact encoding matches expected + assert_eq!(encoded, expected_encoded); + + // Decode from the encoded string and verify round-trip + let decoded = + PaymentRequest::from_bech32_string(&expected_encoded).expect("decoding should work"); + + // Verify all top-level fields + assert_eq!(decoded.payment_id, Some("htlc_test".to_string())); + assert_eq!(decoded.amount, Some(Amount::from(1000))); + assert_eq!(decoded.unit, Some(CurrencyUnit::Sat)); + assert_eq!( + decoded.mints, + Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]) + ); + assert_eq!(decoded.description, Some("HTLC locked payment".to_string())); + + // Verify NUT-10 fields + let nut10 = decoded.nut10.as_ref().unwrap(); + assert_eq!(nut10.kind, Kind::HTLC); + assert_eq!( + nut10.data, + "a]0e66820bfb412212cf7ab3deb0459ce282a1b04fda76ea6026a67e41ae26f3dc" + ); + + // Verify all tags with exact values + let tags = nut10.tags.as_ref().unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!( + tags[0], + vec!["locktime".to_string(), "1700000000".to_string()] + ); + assert_eq!( + tags[1], + vec![ + "refund".to_string(), + "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e".to_string() + ] + ); + } + + #[test] + fn test_case_insensitive_decoding() { + // Test that decoder accepts both lowercase and uppercase input + // Note: Per BIP-173/BIP-350, mixed-case is NOT valid for bech32/bech32m + // "Decoders MUST NOT accept strings where some characters are uppercase and some are lowercase" + let payment_request = PaymentRequest { + payment_id: Some("case_test".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let uppercase = payment_request + .to_bech32_string() + .expect("encoding should work"); + + // Convert to lowercase + let lowercase = uppercase.to_lowercase(); + + // Both uppercase and lowercase should decode successfully + let decoded_upper = + PaymentRequest::from_bech32_string(&uppercase).expect("uppercase should decode"); + let decoded_lower = + PaymentRequest::from_bech32_string(&lowercase).expect("lowercase should decode"); + + // Both should produce the same result + assert_eq!(decoded_upper.payment_id, Some("case_test".to_string())); + assert_eq!(decoded_lower.payment_id, Some("case_test".to_string())); + + assert_eq!(decoded_upper.amount, decoded_lower.amount); + assert_eq!(decoded_upper.unit, decoded_lower.unit); + } + + #[test] + fn test_custom_currency_unit() { + // Test that custom/unknown currency units are preserved + let expected_encoded = "CREQB1QYQQKCM4WD6X7M2LW4HXJAQZQQYQQQQQQQQQQQRYQVQQXCN5VVZSQXRGW368QUE69UHK66TWWSHX27RPD4CXCEFWVDHK6PZHCW8"; + + let payment_request = PaymentRequest { + payment_id: Some("custom_unit".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Custom("btc".to_string())), + single_use: None, + mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]), + description: None, + transports: vec![], + nut10: None, + }; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + + assert_eq!(encoded, expected_encoded); + + // Decode from the expected encoded string + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + + assert_eq!(decoded.unit, Some(CurrencyUnit::Custom("btc".to_string()))); + assert_eq!(decoded.payment_id, Some("custom_unit".to_string())); + } +} diff --git a/crates/cashu/src/nuts/nut26/error.rs b/crates/cashu/src/nuts/nut26/error.rs new file mode 100644 index 000000000..2163248b5 --- /dev/null +++ b/crates/cashu/src/nuts/nut26/error.rs @@ -0,0 +1,62 @@ +//! NUT-26 Error types + +use std::fmt; + +/// NUT-26 specific errors +#[derive(Debug)] +pub enum Error { + /// Invalid bech32m prefix (expected "creqb") + InvalidPrefix, + /// Invalid TLV structure + InvalidTlvStructure, + /// Invalid UTF-8 in string field + InvalidUtf8, + /// Invalid public key + InvalidPubkey, + /// Unknown NUT-10 kind + UnknownKind(u8), + /// Tag too long (>255 bytes) + TagTooLong, + /// Bech32 encoding error + Bech32Error(bitcoin::bech32::DecodeError), + /// Base64 decoding error + Base64DecodeError(bitcoin::base64::DecodeError), + /// CBOR serialization error + CborError(ciborium::de::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::InvalidPrefix => write!(f, "Invalid bech32m prefix, expected 'creqb'"), + Error::InvalidTlvStructure => write!(f, "Invalid TLV structure"), + Error::InvalidUtf8 => write!(f, "Invalid UTF-8 encoding in string field"), + Error::InvalidPubkey => write!(f, "Invalid public key"), + Error::UnknownKind(kind) => write!(f, "Unknown NUT-10 kind: {}", kind), + Error::TagTooLong => write!(f, "Tag exceeds 255 byte limit"), + Error::Bech32Error(e) => write!(f, "Bech32 error: {}", e), + Error::Base64DecodeError(e) => write!(f, "Base64 decode error: {}", e), + Error::CborError(e) => write!(f, "CBOR error: {}", e), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: bitcoin::bech32::DecodeError) -> Self { + Error::Bech32Error(e) + } +} + +impl From for Error { + fn from(e: bitcoin::base64::DecodeError) -> Self { + Error::Base64DecodeError(e) + } +} + +impl From> for Error { + fn from(e: ciborium::de::Error) -> Self { + Error::CborError(e) + } +} diff --git a/crates/cashu/src/nuts/nut26/mod.rs b/crates/cashu/src/nuts/nut26/mod.rs new file mode 100644 index 000000000..3b209a98f --- /dev/null +++ b/crates/cashu/src/nuts/nut26/mod.rs @@ -0,0 +1,15 @@ +//! NUT-26: Payment Request Bech32m Encoding +//! +//! This module implements NUT-26, which provides bech32m encoding for Cashu payment requests. +//! NUT-26 is an alternative encoding to the JSON-based CREQ-A format (NUT-18), offering +//! improved QR code compatibility and more efficient encoding. +//! +//! The encoding methods are implemented as extensions to `PaymentRequest` from NUT-18. +//! +//! + +mod encoding; +mod error; + +pub use encoding::CREQ_B_HRP; +pub use error::Error; diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index 97959700e..332555aa1 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -10,6 +10,8 @@ use crate::error::FfiError; /// Transport type for payment request delivery #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum TransportType { + /// In-band transport (tokens returned directly in response) + InBand, /// Nostr transport (privacy-preserving) Nostr, /// HTTP POST transport @@ -19,6 +21,7 @@ pub enum TransportType { impl From for TransportType { fn from(t: cdk::nuts::TransportType) -> Self { match t { + cdk::nuts::TransportType::InBand => TransportType::InBand, cdk::nuts::TransportType::Nostr => TransportType::Nostr, cdk::nuts::TransportType::HttpPost => TransportType::HttpPost, } @@ -28,6 +31,7 @@ impl From for TransportType { impl From for cdk::nuts::TransportType { fn from(t: TransportType) -> Self { match t { + TransportType::InBand => cdk::nuts::TransportType::InBand, TransportType::Nostr => cdk::nuts::TransportType::Nostr, TransportType::HttpPost => cdk::nuts::TransportType::HttpPost, } diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index 69f871f78..7ff1661b2 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -182,6 +182,14 @@ impl Wallet { Err(Error::HttpError(Some(status.as_u16()), body)) } } + TransportType::InBand => { + // In-band transport means tokens should be returned directly + // in the payment request response, not sent via this method. + // The caller should handle the proofs directly. + Err(Error::Custom( + "In-band transport: tokens should be returned directly, not sent via pay_payment_request".to_string(), + )) + } } } else { // If no transport is available, return an error instead of printing the token diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f2f24f5a7..afbb1c443 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ name = "cdk-fuzz" version = "0.0.0" dependencies = [ + "bitcoin", "cashu", "libfuzzer-sys", "serde_json", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 14e142501..1ee7b01c6 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -11,6 +11,7 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" serde_json = "1" +bitcoin = { version = "0.32.2", default-features = false } [dependencies.cashu] path = "../crates/cashu" @@ -133,3 +134,17 @@ path = "fuzz_targets/fuzz_dleq.rs" test = false doc = false bench = false + +[[bin]] +name = "fuzz_payment_request_bech32" +path = "fuzz_targets/fuzz_payment_request_bech32.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_payment_request_bech32_bytes" +path = "fuzz_targets/fuzz_payment_request_bech32_bytes.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_payment_request_bech32.rs b/fuzz/fuzz_targets/fuzz_payment_request_bech32.rs new file mode 100644 index 000000000..bb2b21f7b --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_payment_request_bech32.rs @@ -0,0 +1,10 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use cashu::PaymentRequest; + +// Fuzz the NUT-26 bech32m decoding with arbitrary strings +fuzz_target!(|data: &str| { + let _ = PaymentRequest::from_bech32_string(data); +}); diff --git a/fuzz/fuzz_targets/fuzz_payment_request_bech32_bytes.rs b/fuzz/fuzz_targets/fuzz_payment_request_bech32_bytes.rs new file mode 100644 index 000000000..db7eb7b5d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_payment_request_bech32_bytes.rs @@ -0,0 +1,17 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use bitcoin::bech32::{Bech32m, Hrp}; +use cashu::PaymentRequest; + +// Fuzz the NUT-26 TLV parser by constructing valid bech32m encoding +// around arbitrary bytes. This bypasses bech32 charset validation to +// directly stress-test the TLV parsing logic. +fuzz_target!(|data: &[u8]| { + // Construct a valid bech32m string with "creqb" HRP and fuzzed payload + let hrp = Hrp::parse("creqb").unwrap(); + if let Ok(encoded) = bitcoin::bech32::encode::(hrp, data) { + let _ = PaymentRequest::from_bech32_string(&encoded); + } +}); diff --git a/fuzz/seeds/fuzz_payment_request_bech32/empty b/fuzz/seeds/fuzz_payment_request_bech32/empty new file mode 100644 index 000000000..e69de29bb diff --git a/fuzz/seeds/fuzz_payment_request_bech32/invalid_chars b/fuzz/seeds/fuzz_payment_request_bech32/invalid_chars new file mode 100644 index 000000000..1b3f3745b --- /dev/null +++ b/fuzz/seeds/fuzz_payment_request_bech32/invalid_chars @@ -0,0 +1 @@ +CREQB1INVALID!@#$%^&*() \ No newline at end of file diff --git a/fuzz/seeds/fuzz_payment_request_bech32/just_prefix b/fuzz/seeds/fuzz_payment_request_bech32/just_prefix new file mode 100644 index 000000000..ebc354b5d --- /dev/null +++ b/fuzz/seeds/fuzz_payment_request_bech32/just_prefix @@ -0,0 +1 @@ +creqb1 \ No newline at end of file diff --git a/fuzz/seeds/fuzz_payment_request_bech32/legacy_creq_a b/fuzz/seeds/fuzz_payment_request_bech32/legacy_creq_a new file mode 100644 index 000000000..1cba439cc --- /dev/null +++ b/fuzz/seeds/fuzz_payment_request_bech32/legacy_creq_a @@ -0,0 +1 @@ +creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U= \ No newline at end of file diff --git a/fuzz/seeds/fuzz_payment_request_bech32/valid_basic b/fuzz/seeds/fuzz_payment_request_bech32/valid_basic new file mode 100644 index 000000000..3a8fa5bd0 --- /dev/null +++ b/fuzz/seeds/fuzz_payment_request_bech32/valid_basic @@ -0,0 +1 @@ +CREQB1QYQQWAR9WD6RZV3NQ5QPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKS4U8XXF \ No newline at end of file diff --git a/fuzz/seeds/fuzz_payment_request_bech32/valid_lowercase b/fuzz/seeds/fuzz_payment_request_bech32/valid_lowercase new file mode 100644 index 000000000..e3b79c579 --- /dev/null +++ b/fuzz/seeds/fuzz_payment_request_bech32/valid_lowercase @@ -0,0 +1 @@ +creqb1qyqqwar9wd6rzv3nq5qps6r5w3c8xw309akkjmn59ejhsctdwpkx2tnrdaks4u8xxf \ No newline at end of file diff --git a/fuzz/seeds/fuzz_payment_request_bech32/wrong_hrp b/fuzz/seeds/fuzz_payment_request_bech32/wrong_hrp new file mode 100644 index 000000000..6000f74bc --- /dev/null +++ b/fuzz/seeds/fuzz_payment_request_bech32/wrong_hrp @@ -0,0 +1 @@ +nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p \ No newline at end of file diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/empty b/fuzz/seeds/fuzz_payment_request_bech32_bytes/empty new file mode 100644 index 000000000..e69de29bb diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/invalid_amount_len b/fuzz/seeds/fuzz_payment_request_bech32_bytes/invalid_amount_len new file mode 100644 index 000000000..8cf511bfe Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/invalid_amount_len differ diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/truncated_value b/fuzz/seeds/fuzz_payment_request_bech32_bytes/truncated_value new file mode 100644 index 000000000..d9073ee29 Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/truncated_value differ diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/unknown_tag b/fuzz/seeds/fuzz_payment_request_bech32_bytes/unknown_tag new file mode 100644 index 000000000..463882683 Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/unknown_tag differ diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id new file mode 100644 index 000000000..740a0d574 Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id differ diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_amount b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_amount new file mode 100644 index 000000000..9d6102a30 Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_amount differ diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_mint b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_mint new file mode 100644 index 000000000..58afcc9a6 Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_mint differ diff --git a/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_unit_sat b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_unit_sat new file mode 100644 index 000000000..48f1c7871 Binary files /dev/null and b/fuzz/seeds/fuzz_payment_request_bech32_bytes/valid_id_unit_sat differ