diff --git a/crates/cashu/examples/payment_request_encoding_benchmark.rs b/crates/cashu/examples/payment_request_encoding_benchmark.rs index 38100e1b3..495d94b97 100644 --- a/crates/cashu/examples/payment_request_encoding_benchmark.rs +++ b/crates/cashu/examples/payment_request_encoding_benchmark.rs @@ -94,6 +94,8 @@ fn minimal_comparison() -> Result<(), Box> { unit: None, single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -110,6 +112,8 @@ fn amount_unit_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -131,6 +135,8 @@ fn multiple_mints_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint3.example.com")?, MintUrl::from_str("https://backup-mint.cashu.space")?, ], + mint_preferred: None, + supported_methods: vec![], description: Some("Payment with multiple mint options".to_string()), transports: vec![], nut10: None, @@ -156,6 +162,8 @@ fn transport_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com")?], + mint_preferred: None, + supported_methods: vec![], description: Some("Payment with callback transport".to_string()), transports: vec![transport], nut10: None, @@ -193,6 +201,8 @@ fn complete_with_nut10_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint1.example.com")?, MintUrl::from_str("https://mint2.example.com")?, ], + mint_preferred: None, + supported_methods: vec![], description: Some("Complete payment with P2PK locking and refund key".to_string()), transports: vec![transport], nut10: Some(nut10), @@ -245,6 +255,8 @@ fn very_complex_comparison() -> Result<(), Box> { MintUrl::from_str("https://backup-mint-2.example.net")?, MintUrl::from_str("https://emergency-mint.example.io")?, ], + mint_preferred: None, + supported_methods: vec![], description: Some("Complex payment with multiple mints and transports".to_string()), transports: vec![transport1, transport2], nut10: Some(nut10), @@ -503,6 +515,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Test".to_string()), transports: vec![], nut10: None, diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 54a185ac0..c73030921 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -73,8 +73,8 @@ pub use nut14::HTLCWitness; pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; pub use nut17::NotificationPayload; pub use nut18::{ - Nut10SecretRequest, PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, Transport, - TransportBuilder, TransportType, + Nut10SecretRequest, PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, + SupportedMethod, Transport, TransportBuilder, TransportType, }; pub use nut23::{ MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request, diff --git a/crates/cashu/src/nuts/nut18/mod.rs b/crates/cashu/src/nuts/nut18/mod.rs index c68941722..ae4edab83 100644 --- a/crates/cashu/src/nuts/nut18/mod.rs +++ b/crates/cashu/src/nuts/nut18/mod.rs @@ -11,6 +11,8 @@ pub mod secret; pub mod transport; pub use error::Error; -pub use payment_request::{PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload}; +pub use payment_request::{ + PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, SupportedMethod, +}; pub use secret::Nut10SecretRequest; pub use transport::{Transport, TransportBuilder, TransportType}; diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index e8829b291..18c978c16 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -17,33 +17,101 @@ use crate::Amount; const PAYMENT_REQUEST_PREFIX: &str = "creqA"; -/// Payment Request +/// Payment method accepted by the receiver for a payment request. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SupportedMethod { + /// Payment method name, such as `bolt11`, `bolt12`, or `onchain`. + #[serde(rename = "mn")] + pub method: String, + /// Additional fee in the request unit for payments from non-preferred mints. + #[serde(rename = "mf")] + #[serde(skip_serializing_if = "Option::is_none")] + pub fee: Option, +} + +impl SupportedMethod { + /// Create a supported method without an additional method fee. + pub fn new(method: S) -> Self + where + S: Into, + { + Self { + method: method.into(), + fee: None, + } + } + + /// Create a supported method with an additional method fee. + pub fn with_fee(method: S, fee: A) -> Self + where + S: Into, + A: Into, + { + Self { + method: method.into(), + fee: Some(fee.into()), + } + } +} + +/// NUT-18 payment request. +/// +/// A receiver creates this request to tell a payer how much to send, which +/// mints and payment methods are acceptable, and which transports can deliver +/// the resulting [`PaymentRequestPayload`]. #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentRequest { - /// `Payment id` + /// Payment id to include in the payment payload. #[serde(rename = "i")] pub payment_id: Option, - /// Amount + /// Requested amount net of input fees. + /// + /// If this is set, [`Self::unit`] must also be set. #[serde(rename = "a")] pub amount: Option, - /// Unit + /// Unit of the requested amount. #[serde(rename = "u")] pub unit: Option, - /// Single use + /// Whether this request is intended for a single payment. #[serde(rename = "s")] pub single_use: Option, - /// Mints + /// Mint URLs the receiver accepts or prefers. + /// + /// If non-empty and [`Self::mint_preferred`] is omitted or `false`, this + /// list is strict and the payer must only send proofs from these mints. If + /// [`Self::mint_preferred`] is `true`, this list is advisory and other + /// mints may be used. #[serde(rename = "m")] #[serde(skip_serializing_if = "Vec::is_empty", default)] pub mints: Vec, - /// Description + /// Whether [`Self::mints`] is preferred instead of strict. + /// + /// `true` means the payer should prefer the listed mints but may send from + /// others. `false` or omitted means the mint list is strict. Ignored when + /// [`Self::mints`] is empty. + #[serde(rename = "mp")] + #[serde(skip_serializing_if = "Option::is_none")] + pub mint_preferred: Option, + /// Payment methods the payer's mint must support. + /// + /// If non-empty, the payer must send ecash from a mint that supports at + /// least one listed method. Each method can carry a fee that only applies + /// to payments from non-preferred mints, or from any mint if no mint list is + /// set. + #[serde(rename = "sm")] + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub supported_methods: Vec, + /// Human-readable description for the payer to display. #[serde(rename = "d")] pub description: Option, - /// Transport + /// Transports for delivering the payment payload, sorted by preference. + /// + /// An empty list means the payment is expected to be delivered in-band by + /// the surrounding protocol. #[serde(rename = "t")] #[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::default")] pub transports: Vec, - /// Nut10 + /// Optional NUT-10 locking condition requested for the payment proofs. #[serde(skip_serializing_if = "Option::is_none")] pub nut10: Option, } @@ -88,7 +156,18 @@ impl FromStr for PaymentRequest { let decode_config = general_purpose::GeneralPurposeConfig::new() .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent); - let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; + let decoded = match GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s) { + Ok(decoded) => decoded, + Err(url_safe_err) => { + let decode_config = general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode( + bitcoin::base64::engine::DecodePaddingMode::Indifferent, + ); + GeneralPurpose::new(&alphabet::STANDARD, decode_config) + .decode(s) + .map_err(|_| url_safe_err)? + } + }; Ok(ciborium::from_reader(&decoded[..])?) } @@ -102,6 +181,8 @@ pub struct PaymentRequestBuilder { unit: Option, single_use: Option, mints: Vec, + mint_preferred: Option, + supported_methods: Vec, description: Option, transports: Vec, nut10: Option, @@ -117,7 +198,10 @@ impl PaymentRequestBuilder { self } - /// Set amount + /// Set requested amount. + /// + /// Call [`Self::unit`] as well to produce a spec-valid fixed-amount + /// request. pub fn amount(mut self, amount: A) -> Self where A: Into, @@ -144,12 +228,37 @@ impl PaymentRequestBuilder { self } - /// Set mints + /// Set mint URLs the receiver accepts or prefers. + /// + /// Unless [`Self::mint_preferred`] is set to `true`, a non-empty list is + /// strict. pub fn mints(mut self, mints: Vec) -> Self { self.mints = mints; self } + /// Set whether the mint list is preferred instead of strict. + /// + /// `true` means the payer should prefer listed mints but may use other + /// mints. `false` means the payer must only use listed mints. Omit this + /// field to get the same strict behavior as `false`. + pub fn mint_preferred(mut self, mint_preferred: bool) -> Self { + self.mint_preferred = Some(mint_preferred); + self + } + + /// Set payment methods the payer's mint must support. + pub fn supported_methods(mut self, methods: Vec) -> Self { + self.supported_methods = methods; + self + } + + /// Add a payment method the payer's mint must support. + pub fn add_supported_method(mut self, method: SupportedMethod) -> Self { + self.supported_methods.push(method); + self + } + /// Set description pub fn description>(mut self, description: S) -> Self { self.description = Some(description.into()); @@ -182,6 +291,8 @@ impl PaymentRequestBuilder { unit: self.unit, single_use: self.single_use, mints: self.mints, + mint_preferred: self.mint_preferred, + supported_methods: self.supported_methods, description: self.description, transports: self.transports, nut10: self.nut10, @@ -249,6 +360,8 @@ mod tests { mints: vec!["https://nofees.testnut.cashu.space" .parse() .expect("valid mint url")], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![transport.clone()], nut10: None, @@ -402,6 +515,58 @@ mod tests { assert_eq!(payment_request, r); } + #[test] + fn test_mint_preferred_serializes_as_mp() { + let payment_request = PaymentRequestBuilder::default() + .mints(vec![MintUrl::from_str("https://mint.example.com").unwrap()]) + .mint_preferred(true) + .build(); + + let value = serde_json::to_value(&payment_request).unwrap(); + + assert_eq!(value.get("mp"), Some(&serde_json::Value::Bool(true))); + assert!(value.get("ms").is_none()); + + let decoded: PaymentRequest = serde_json::from_value(serde_json::json!({ + "m": ["https://mint.example.com"], + "mp": false + })) + .unwrap(); + assert_eq!(decoded.mint_preferred, Some(false)); + } + + #[test] + fn test_supported_methods_serialize_as_method_objects() { + let payment_request = PaymentRequestBuilder::default() + .add_supported_method(SupportedMethod::new("bolt11")) + .add_supported_method(SupportedMethod::with_fee("bolt12", 5)) + .build(); + + let value = serde_json::to_value(&payment_request).unwrap(); + assert_eq!( + value.get("sm"), + Some(&serde_json::json!([ + { "mn": "bolt11" }, + { "mn": "bolt12", "mf": 5 } + ])) + ); + + let decoded: PaymentRequest = serde_json::from_value(serde_json::json!({ + "sm": [ + { "mn": "bolt11" }, + { "mn": "bolt12", "mf": 5 } + ] + })) + .unwrap(); + assert_eq!( + decoded.supported_methods, + vec![ + SupportedMethod::new("bolt11"), + SupportedMethod::with_fee("bolt12", 5), + ] + ); + } + #[test] fn test_nut10_secret_request_htlc() { let bolt11 = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9"; @@ -528,6 +693,72 @@ mod tests { ); } + #[test] + fn test_complete_payment_request() { + // Complete payment request with all optional fields included + let expected_encoded = "creqAqGF0gaNhdGRwb3N0YWF4G2h0dHBzOi8vYXBpLmV4YW1wbGUuY29tL3BheWFn92FpaDQ4NDBmNTFlYWEZA+hhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWFkcFByb2R1Y3QgcHVyY2hhc2Vhc/VlbnV0MTCjYWtkUDJQS2FkeEIwM2JhZjBjM2FjMjIwMzY2YzJjMzk3YmY5MzA1NzljNDE2MzQzNTU4NGY1NzNiMTA5MTA5ODdjNTQ0YzU5ZTYxZjFhdIGCZ3B1cnBvc2Vnb2ZmbGluZQ=="; + + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + + assert_eq!(decoded_from_spec.payment_id, Some("4840f51e".to_string())); + assert_eq!(decoded_from_spec.amount, Some(Amount::from(1000))); + assert_eq!(decoded_from_spec.unit, Some(CurrencyUnit::Sat)); + assert_eq!(decoded_from_spec.single_use, Some(true)); + assert_eq!( + decoded_from_spec.mints, + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + assert_eq!( + decoded_from_spec.description, + Some("Product purchase".to_string()) + ); + assert_eq!(decoded_from_spec.transports.len(), 1); + assert_eq!( + decoded_from_spec.transports[0]._type, + TransportType::HttpPost + ); + assert_eq!( + decoded_from_spec.transports[0].target, + "https://api.example.com/pay" + ); + + let nut10 = decoded_from_spec.nut10.expect("nut10"); + assert_eq!(nut10.kind, Kind::P2PK); + assert_eq!( + nut10.data, + "03baf0c3ac220366c2c397bf930579c4163435584f573b10910987c544c59e61f1" + ); + assert_eq!( + nut10.tags, + Some(vec![vec!["purpose".to_string(), "offline".to_string()]]) + ); + } + + #[test] + fn test_http_transport_payment_request() { + // HTTP POST transport payment request + let expected_encoded = "creqApWF0gaNhdGRwb3N0YWF4H2h0dHBzOi8vYXBpLmV4YW1wbGUuY29tL3JlY2VpdmVhZ/dhaWhhMmMxMmY0NWFhGDJhdWNzYXRhbYF4GWh0dHBzOi8vY2FzaHUuZXhhbXBsZS5jb20="; + + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + + assert_eq!(decoded_from_spec.payment_id, Some("a2c12f45".to_string())); + assert_eq!(decoded_from_spec.amount, Some(Amount::from(50))); + assert_eq!(decoded_from_spec.unit, Some(CurrencyUnit::Sat)); + assert_eq!( + decoded_from_spec.mints, + vec![MintUrl::from_str("https://cashu.example.com").unwrap()] + ); + assert_eq!(decoded_from_spec.transports.len(), 1); + assert_eq!( + decoded_from_spec.transports[0]._type, + TransportType::HttpPost + ); + assert_eq!( + decoded_from_spec.transports[0].target, + "https://api.example.com/receive" + ); + } + #[test] fn test_nostr_transport_payment_request() { // Nostr transport payment request with multiple mints @@ -684,6 +915,33 @@ mod tests { assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a"); } + #[test] + fn test_preferred_mint_list_with_supported_methods() { + // Preferred mint list with supported methods and per-method fee + let expected_encoded = "creqApmFpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPVic22CoWJtbmZib2x0MTGiYm1uZmJvbHQxMmJtZgU="; + + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + + assert_eq!( + decoded_from_spec.payment_id, + Some("preferred_fee_methods".to_string()) + ); + assert_eq!(decoded_from_spec.amount, Some(Amount::from(100))); + assert_eq!(decoded_from_spec.unit, Some(CurrencyUnit::Sat)); + assert_eq!( + decoded_from_spec.mints, + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + assert_eq!(decoded_from_spec.mint_preferred, Some(true)); + assert_eq!( + decoded_from_spec.supported_methods, + vec![ + SupportedMethod::new("bolt11"), + SupportedMethod::with_fee("bolt12", 5), + ] + ); + } + #[test] fn test_from_str_handles_both_formats() { // Create a payment request @@ -693,6 +951,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Test both formats".to_string()), transports: vec![], nut10: None, diff --git a/crates/cashu/src/nuts/nut18/transport.rs b/crates/cashu/src/nuts/nut18/transport.rs index abcecbc7d..d5cb0659f 100644 --- a/crates/cashu/src/nuts/nut18/transport.rs +++ b/crates/cashu/src/nuts/nut18/transport.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; use bitcoin::base64::{alphabet, Engine}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::nuts::nut18::error::Error; @@ -51,10 +51,21 @@ pub struct Transport { pub target: String, /// Tags #[serde(rename = "g")] - #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde( + skip_serializing_if = "Vec::is_empty", + default, + deserialize_with = "deserialize_tags" + )] pub tags: Vec>, } +fn deserialize_tags<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Option::>>::deserialize(deserializer)?.unwrap_or_default()) +} + impl Transport { /// Create a new TransportBuilder pub fn builder() -> TransportBuilder { diff --git a/crates/cashu/src/nuts/nut26/encoding.rs b/crates/cashu/src/nuts/nut26/encoding.rs index aa3a9d440..fffb47377 100644 --- a/crates/cashu/src/nuts/nut26/encoding.rs +++ b/crates/cashu/src/nuts/nut26/encoding.rs @@ -10,7 +10,9 @@ 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::nut18::{ + Nut10SecretRequest, PaymentRequest, SupportedMethod, Transport, TransportType, +}; use crate::nuts::CurrencyUnit; use crate::Amount; @@ -146,6 +148,8 @@ impl PaymentRequest { /// unit: Some(cashu::nuts::CurrencyUnit::Sat), /// single_use: None, /// mints: vec![MintUrl::from_str("https://mint.example.com")?], + /// mint_preferred: None, + /// supported_methods: vec![], /// description: None, /// transports: vec![], /// nut10: None, @@ -222,6 +226,8 @@ impl PaymentRequest { let mut unit: Option = None; let mut single_use: Option = None; let mut mints: Vec = Vec::new(); + let mut mint_preferred: Option = None; + let mut supported_methods: Vec = Vec::new(); let mut description: Option = None; let mut transports: Vec = Vec::new(); let mut nut10: Option = None; @@ -296,6 +302,25 @@ impl PaymentRequest { } nut10 = Some(Self::decode_nut10(&value)?); } + 0x09 => { + // mint_preferred: u8 (0 or 1) + if mint_preferred.is_some() { + return Err(Error::InvalidStructure); + } + if value.len() != 1 { + return Err(Error::InvalidLength); + } + mint_preferred = Some(match value[0] { + 0 => false, + 1 => true, + _ => return Err(Error::InvalidStructure), + }); + } + 0x0a => { + // supported_method: sub-TLV (repeatable) + let method = Self::decode_supported_method(&value)?; + supported_methods.push(method); + } _ => { // Unknown tags are ignored } @@ -308,6 +333,8 @@ impl PaymentRequest { unit, single_use, mints, + mint_preferred, + supported_methods, description, transports, nut10, @@ -366,6 +393,17 @@ impl PaymentRequest { writer.write_tlv(0x08, &nut10_bytes)?; } + // 0x09 mint_preferred: u8 (0 or 1) + if let Some(mint_preferred) = self.mint_preferred { + writer.write_tlv(0x09, &[if mint_preferred { 1 } else { 0 }])?; + } + + // 0x0a supported_method: sub-TLV (repeatable) + for method in &self.supported_methods { + let method_bytes = Self::encode_supported_method(method)?; + writer.write_tlv(0x0a, &method_bytes)?; + } + Ok(writer.into_bytes()) } @@ -533,6 +571,60 @@ impl PaymentRequest { Ok(writer.into_bytes()) } + /// Decode supported payment method sub-TLV. + fn decode_supported_method(bytes: &[u8]) -> Result { + let mut reader = TlvReader::new(bytes); + + let mut method: Option = None; + let mut fee: Option = None; + + while let Some((tag, value)) = reader.read_tlv()? { + match tag { + 0x01 => { + // method: string + if method.is_some() { + return Err(Error::InvalidStructure); + } + method = Some(String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?); + } + 0x02 => { + // fee: u64 + if fee.is_some() { + return Err(Error::InvalidStructure); + } + if value.len() != 8 { + return Err(Error::InvalidLength); + } + let fee_val = u64::from_be_bytes([ + value[0], value[1], value[2], value[3], value[4], value[5], value[6], + value[7], + ]); + fee = Some(Amount::from(fee_val)); + } + _ => { + // Unknown tags are ignored + } + } + } + + Ok(SupportedMethod { + method: method.ok_or(Error::InvalidStructure)?, + fee, + }) + } + + /// Encode supported payment method to sub-TLV. + fn encode_supported_method(method: &SupportedMethod) -> Result, Error> { + let mut writer = TlvWriter::new(); + + writer.write_tlv(0x01, method.method.as_bytes())?; + if let Some(fee) = method.fee { + writer.write_tlv(0x02, &fee.to_u64().to_be_bytes())?; + } + + Ok(writer.into_bytes()) + } + /// Decode NUT-10 sub-TLV fn decode_nut10(bytes: &[u8]) -> Result { let mut reader = TlvReader::new(bytes); @@ -860,6 +952,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![transport], nut10: None, @@ -919,6 +1013,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Test payment".to_string()), transports: vec![transport], nut10: None, @@ -940,6 +1036,36 @@ mod tests { assert_eq!(decoded.description, payment_request.description); } + #[test] + fn test_bech32_supported_methods_with_method_fee() { + let payment_request = PaymentRequest { + payment_id: Some("preferred_fee_methods".to_string()), + amount: Some(Amount::from(100)), + unit: Some(CurrencyUnit::Sat), + single_use: None, + mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: Some(true), + supported_methods: vec![ + SupportedMethod::new("bolt11"), + SupportedMethod::with_fee("bolt12", 5), + ], + description: None, + transports: vec![], + nut10: None, + }; + + let expected_encoded = "CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQQZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568"; + + let encoded = payment_request + .to_bech32_string() + .expect("encoding should work"); + assert_eq!(encoded, expected_encoded); + + let decoded = + PaymentRequest::from_bech32_string(expected_encoded).expect("decoding should work"); + assert_eq!(decoded, payment_request); + } + #[test] fn test_bech32_minimal() { let payment_request = PaymentRequest { @@ -948,6 +1074,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -976,6 +1104,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("P2PK locked payment".to_string()), transports: vec![], nut10: Some(nut10.clone()), @@ -998,6 +1128,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1043,6 +1175,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1062,6 +1196,8 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1238,6 +1374,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Nostr payment".to_string()), transports: vec![transport], nut10: None, @@ -1282,6 +1420,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Nostr payment with relays".to_string()), transports: vec![transport], nut10: None, @@ -1329,6 +1469,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Coffee".to_string()), transports: vec![transport], nut10: None, @@ -1405,6 +1547,8 @@ mod tests { MintUrl::from_str("https://mint2.example.com").unwrap(), MintUrl::from_str("https://testnut.cashu.space").unwrap(), ], + mint_preferred: None, + supported_methods: vec![], description: Some("Payment with multiple transports and mints".to_string()), transports: vec![transport1, transport2], nut10: None, @@ -1899,6 +2043,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: Some("Test payment description".to_string()), transports: vec![], nut10: None, @@ -1934,6 +2080,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1964,6 +2112,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(false), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1994,6 +2144,8 @@ mod tests { unit: Some(CurrencyUnit::Msat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2025,6 +2177,8 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2219,6 +2373,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], // Empty transports = in-band per NUT-26 nut10: None, @@ -2318,6 +2474,8 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2355,6 +2513,8 @@ mod tests { unit: Some(CurrencyUnit::Custom("btc".to_string())), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2382,6 +2542,8 @@ mod tests { unit: None, single_use: None, mints: vec![], + mint_preferred: None, + supported_methods: vec![], description: Some("x".repeat(usize::from(u16::MAX) + 1)), transports: vec![], nut10: None, @@ -2416,6 +2578,31 @@ mod tests { )); } + #[test] + fn test_decode_rejects_malformed_mint_preferred_tlv() { + for value in [&[][..], &[0, 1][..]] { + let mut writer = TlvWriter::new(); + writer + .write_tlv(0x09, value) + .expect("mint_preferred should fit in TLV length"); + + assert!(matches!( + PaymentRequest::from_bech32_bytes(&writer.into_bytes()), + Err(Error::InvalidLength) + )); + } + + let mut writer = TlvWriter::new(); + writer + .write_tlv(0x09, &[2]) + .expect("mint_preferred should fit in TLV length"); + + assert!(matches!( + PaymentRequest::from_bech32_bytes(&writer.into_bytes()), + Err(Error::InvalidStructure) + )); + } + #[test] fn test_rejects_tag_tuple_key_exceeding_u8_length() { let tag = vec!["x".repeat(usize::from(u8::MAX) + 1)]; diff --git a/crates/cdk-cli/src/sub_commands/check_requests.rs b/crates/cdk-cli/src/sub_commands/check_requests.rs index 346d5baa9..89c18e99f 100644 --- a/crates/cdk-cli/src/sub_commands/check_requests.rs +++ b/crates/cdk-cli/src/sub_commands/check_requests.rs @@ -6,17 +6,10 @@ use cdk::nuts::Token; use cdk::wallet::{ReceiveOptions, WalletRepository}; use cdk_common::PaymentRequestPayload; use nostr_sdk::{Filter, Keys, Kind, PublicKey, SecretKey}; -use serde::{Deserialize, Serialize}; +use super::create_request::StoredNostrWaitInfo; use crate::utils::get_or_create_wallet; -#[derive(Serialize, Deserialize)] -struct NostrWaitInfoSerializable { - secret_key_hex: String, - relays: Vec, - pubkey_hex: String, -} - pub async fn check_requests(wallet_repository: &WalletRepository) -> Result<()> { let wallets = wallet_repository.get_wallets().await; @@ -39,7 +32,7 @@ pub async fn check_requests(wallet_repository: &WalletRepository) -> Result<()> .kv_read("cdk_cli", "pending_nostr_requests", &key) .await? { - let info: NostrWaitInfoSerializable = serde_json::from_slice(&val)?; + let info: StoredNostrWaitInfo = serde_json::from_slice(&val)?; let secret_key = SecretKey::from_str(&info.secret_key_hex)?; let keys = Keys::new(secret_key); @@ -59,6 +52,15 @@ pub async fn check_requests(wallet_repository: &WalletRepository) -> Result<()> if let Ok(payload) = serde_json::from_str::(&unwrapped.rumor.content) { + if !info.accepts_mint(&payload.mint) { + tracing::warn!( + "Ignoring payment for request {} from unaccepted mint {}", + key, + payload.mint + ); + continue; + } + let token = Token::new( payload.mint.clone(), payload.proofs, diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs index 439256098..ee2060b00 100644 --- a/crates/cdk-cli/src/sub_commands/create_request.rs +++ b/crates/cdk-cli/src/sub_commands/create_request.rs @@ -1,22 +1,35 @@ use anyhow::Result; +use cdk::mint_url::MintUrl; use cdk::nuts::CurrencyUnit; use cdk::wallet::{payment_request as pr, NostrWaitInfo, WalletRepository}; use clap::Args; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] -struct NostrWaitInfoSerializable { - secret_key_hex: String, - relays: Vec, - pubkey_hex: String, +pub(super) struct StoredNostrWaitInfo { + pub(super) secret_key_hex: String, + pub(super) relays: Vec, + pub(super) pubkey_hex: String, + #[serde(default)] + pub(super) mints: Vec, + #[serde(default)] + pub(super) mint_preferred: Option, } -impl From for NostrWaitInfoSerializable { +impl StoredNostrWaitInfo { + pub(super) fn accepts_mint(&self, mint_url: &MintUrl) -> bool { + self.mints.is_empty() || self.mint_preferred == Some(true) || self.mints.contains(mint_url) + } +} + +impl From for StoredNostrWaitInfo { fn from(info: NostrWaitInfo) -> Self { Self { secret_key_hex: info.keys.secret_key().to_secret_hex(), relays: info.relays, pubkey_hex: info.pubkey.to_hex(), + mints: info.mints, + mint_preferred: info.mint_preferred, } } } @@ -58,6 +71,9 @@ pub struct CreateRequestSubCommand { /// Mint URLs the receiver trusts. Can be specified multiple times. #[arg(long, action = clap::ArgAction::Append)] mints: Option>, + /// Prefer the listed mints while allowing payment from other mints + #[arg(long)] + mint_preferred: bool, /// Use bech32 encoding (CREQ-B) #[arg(short, long)] bech32: bool, @@ -81,6 +97,7 @@ pub async fn create_request( http_url: sub_command_args.http_url.clone(), nostr_relays: sub_command_args.nostr_relay.clone(), mints: sub_command_args.mints.clone(), + mint_preferred: sub_command_args.mint_preferred.then_some(true), }; let (req, nostr_wait) = wallet_repository.create_request(params).await?; @@ -97,7 +114,7 @@ pub async fn create_request( let key = info.pubkey.to_string(); if let Some(wallet) = wallet_repository.get_wallets().await.first() { - let serializable_info = NostrWaitInfoSerializable::from(info.clone()); + let serializable_info = StoredNostrWaitInfo::from(info.clone()); let val = serde_json::to_vec(&serializable_info)?; wallet .localstore @@ -112,3 +129,53 @@ pub async fn create_request( Ok(()) } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn stored_nostr_wait_info_enforces_strict_mints() { + let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid mint"); + let unlisted_mint = MintUrl::from_str("https://unlisted.example.com").expect("valid mint"); + let info = stored_info(vec![listed_mint.clone()], None); + + assert!(info.accepts_mint(&listed_mint)); + assert!(!info.accepts_mint(&unlisted_mint)); + } + + #[test] + fn stored_nostr_wait_info_allows_preferred_or_empty_mints() { + let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid mint"); + let unlisted_mint = MintUrl::from_str("https://unlisted.example.com").expect("valid mint"); + + assert!(stored_info(vec![listed_mint], Some(true)).accepts_mint(&unlisted_mint)); + assert!(stored_info(vec![], None).accepts_mint(&unlisted_mint)); + } + + #[test] + fn old_stored_nostr_wait_info_deserializes_with_empty_policy() { + let json = r#"{ + "secret_key_hex":"secret", + "relays":["wss://relay.example.com"], + "pubkey_hex":"pubkey" + }"#; + + let info: StoredNostrWaitInfo = serde_json::from_str(json).expect("old record"); + + assert!(info.mints.is_empty()); + assert!(info.mint_preferred.is_none()); + } + + fn stored_info(mints: Vec, mint_preferred: Option) -> StoredNostrWaitInfo { + StoredNostrWaitInfo { + secret_key_hex: "secret".to_string(), + relays: vec![], + pubkey_hex: "pubkey".to_string(), + mints, + mint_preferred, + } + } +} diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index 303410b10..becaceed1 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -1,5 +1,5 @@ use anyhow::{anyhow, Result}; -use cdk::nuts::{CurrencyUnit, PaymentRequest}; +use cdk::nuts::PaymentRequest; use cdk::wallet::WalletRepository; use cdk::Amount; use clap::Args; @@ -20,8 +20,6 @@ pub async fn pay_request( ) -> Result<()> { let payment_request = &sub_command_args.payment_request; - let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); - let amount: Amount = match payment_request.amount { Some(amount) => amount, None => match sub_command_args.amount { @@ -33,53 +31,23 @@ pub async fn pay_request( }, }; - let request_mints = &payment_request.mints; - - let wallet_mints = wallet_repository.get_wallets().await; - - // Wallets where unit, balance and mint match request - let mut matching_wallets = vec![]; - - for wallet in wallet_mints.iter() { - let balance = wallet.total_balance().await?; - - if !request_mints.is_empty() && !request_mints.contains(&wallet.mint_url) { - continue; - } - - if wallet.unit != unit { - continue; - } - - if balance >= amount { - matching_wallets.push(wallet); - } - } - - let matching_wallet = matching_wallets - .first() - .ok_or_else(|| anyhow!("No wallet found that can pay this request"))?; - - matching_wallet - .pay_request(payment_request.clone(), Some(amount)) + wallet_repository + .pay_request(payment_request.clone(), None, Some(amount)) .await .map_err(|e| anyhow!(e.to_string())) } #[cfg(test)] mod tests { - use std::str::FromStr; use std::sync::Arc; - use std::time::Duration; - use cdk::mint_url::MintUrl; use cdk::wallet::WalletRepositoryBuilder; use cdk_sqlite::wallet::memory; use super::*; #[tokio::test] - async fn unitless_fixed_amount_request_defaults_to_sat_wallet_selection() { + async fn unitless_fixed_amount_request_is_invalid() { let seed = [0u8; 64]; let localstore = Arc::new(memory::empty().await.expect("memory store")); let wallet_repository = WalletRepositoryBuilder::new() @@ -89,19 +57,14 @@ mod tests { .await .expect("wallet repository"); - let mint_url = - MintUrl::from_str("https://nonexistent.example.invalid").expect("valid mint url"); - wallet_repository - .create_wallet(mint_url, CurrencyUnit::Usd, None) - .await - .expect("wallet"); - let payment_request = PaymentRequest { payment_id: None, amount: Some(Amount::from(0_u64)), unit: None, single_use: None, mints: vec![], + mint_preferred: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -111,16 +74,12 @@ mod tests { amount: None, }; - let result = tokio::time::timeout( - Duration::from_secs(10), - pay_request(&wallet_repository, &sub_command_args), - ) - .await - .expect("pay_request should not hang") - .expect_err("usd wallet must not match unitless fixed-amount request"); + let result = pay_request(&wallet_repository, &sub_command_args) + .await + .expect_err("unitless fixed-amount request must be rejected"); assert!( - result.to_string().contains("No wallet found"), + result.to_string().contains("Invalid payment request"), "unexpected error: {result}" ); } diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index 4623dead9..ebc70934a 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -47,6 +47,33 @@ pub struct Transport { pub tags: Vec>, } +/// Supported payment method for a NUT-18 payment request +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct SupportedMethod { + /// Payment method name, such as "bolt11", "bolt12", or "onchain" + pub method: String, + /// Additional fee for payments from non-preferred mints + pub fee: Option, +} + +impl From for SupportedMethod { + fn from(method: cdk::nuts::SupportedMethod) -> Self { + Self { + method: method.method, + fee: method.fee.map(Into::into), + } + } +} + +impl From for cdk::nuts::SupportedMethod { + fn from(method: SupportedMethod) -> Self { + Self { + method: method.method, + fee: method.fee.map(Into::into), + } + } +} + impl From for Transport { fn from(t: cdk::nuts::Transport) -> Self { Self { @@ -159,6 +186,21 @@ impl PaymentRequest { self.inner.mints.iter().map(|m| m.to_string()).collect() } + /// Get whether the mint list is preferred instead of strict. + pub fn mint_preferred(&self) -> Option { + self.inner.mint_preferred + } + + /// Get the list of supported payment methods the mint must support + pub fn supported_methods(&self) -> Vec { + self.inner + .supported_methods + .iter() + .cloned() + .map(Into::into) + .collect() + } + /// Get the description pub fn description(&self) -> Option { self.inner.description.clone() @@ -200,6 +242,8 @@ pub struct CreateRequestParams { pub nostr_relays: Option>, /// Optional list of mint URLs the receiver trusts. If not provided, the wallet's current mints for the requested unit will be used. pub mints: Option>, + /// Whether the mint list is preferred rather than required + pub mint_preferred: Option, } impl Default for CreateRequestParams { @@ -216,6 +260,7 @@ impl Default for CreateRequestParams { http_url: None, nostr_relays: None, mints: None, + mint_preferred: None, } } } @@ -234,6 +279,7 @@ impl From for cdk::wallet::payment_request::CreateRequestPa http_url: params.http_url, nostr_relays: params.nostr_relays, mints: params.mints, + mint_preferred: params.mint_preferred, } } } @@ -252,6 +298,7 @@ impl From for CreateRequestPa http_url: params.http_url, nostr_relays: params.nostr_relays, mints: params.mints, + mint_preferred: params.mint_preferred, } } } @@ -303,6 +350,16 @@ impl NostrWaitInfo { pub fn pubkey(&self) -> String { self.inner.pubkey.to_hex() } + + /// Get the mint URLs accepted or preferred by the original payment request + pub fn mints(&self) -> Vec { + self.inner.mints.iter().map(|m| m.to_string()).collect() + } + + /// Get whether the original request's mint list is preferred instead of strict + pub fn mint_preferred(&self) -> Option { + self.inner.mint_preferred + } } /// Result of creating a payment request @@ -503,6 +560,7 @@ mod tests { assert_eq!(params.num_sigs, 1); assert_eq!(params.transport, "none"); assert!(params.amount.is_none()); + assert!(params.mint_preferred.is_none()); } #[test] @@ -513,6 +571,7 @@ mod tests { description: Some("Test payment".to_string()), transport: "http".to_string(), http_url: Some("https://example.com/callback".to_string()), + mint_preferred: Some(true), ..Default::default() }; @@ -522,5 +581,10 @@ mod tests { assert_eq!(params.amount, decoded.amount); assert_eq!(params.unit, decoded.unit); assert_eq!(params.description, decoded.description); + assert_eq!(params.mint_preferred, decoded.mint_preferred); + + let cdk_params: cdk::wallet::payment_request::CreateRequestParams = decoded.into(); + let ffi_params: CreateRequestParams = cdk_params.into(); + assert_eq!(ffi_params.mint_preferred, Some(true)); } } diff --git a/crates/cdk/examples/payment_request.rs b/crates/cdk/examples/payment_request.rs index f4cce0c3b..89f252f4d 100644 --- a/crates/cdk/examples/payment_request.rs +++ b/crates/cdk/examples/payment_request.rs @@ -131,6 +131,7 @@ async fn main() -> anyhow::Result<()> { "wss://nos.lol".to_string(), ]), mints: None, + mint_preferred: None, }; let (payment_request, nostr_wait_info) = wallet.create_request(nostr_params).await?; @@ -187,6 +188,7 @@ async fn main() -> anyhow::Result<()> { http_url: Some("https://example.com/cashu/callback".to_string()), nostr_relays: None, mints: None, + mint_preferred: None, }; let (http_request, _) = wallet.create_request(http_params).await?; @@ -231,6 +233,7 @@ async fn main() -> anyhow::Result<()> { http_url: None, nostr_relays: Some(vec!["wss://relay.damus.io".to_string()]), mints: None, + mint_preferred: None, }; let (p2pk_request, _) = wallet.create_request(p2pk_params).await?; diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index 8b003cf3d..66ba94dc5 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use anyhow::Result; use bitcoin::hashes::sha256::Hash as Sha256Hash; -use cdk_common::{Amount, HttpClient, PaymentRequest, PaymentRequestPayload, TransportType}; +use cdk_common::{ + Amount, HttpClient, PaymentRequest, PaymentRequestPayload, SupportedMethod, TransportType, +}; #[cfg(feature = "nostr")] use nostr_sdk::nips::nip19::Nip19Profile; #[cfg(feature = "nostr")] @@ -19,10 +21,11 @@ use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys, ToBech32} use crate::error::Error; use crate::mint_url::MintUrl; +use crate::nuts::nut05::MeltMethodSettings; use crate::nuts::nut10::{Conditions, SpendingConditions}; use crate::nuts::nut11::SigFlag; use crate::nuts::nut18::Nut10SecretRequest; -use crate::nuts::{CurrencyUnit, Nut10Secret, Transport}; +use crate::nuts::{CurrencyUnit, Nut10Secret, PaymentMethod, Transport}; #[cfg(feature = "nostr")] use crate::wallet::ReceiveOptions; use crate::wallet::{SendOptions, WalletRepository}; @@ -39,7 +42,8 @@ impl Wallet { payment_request: PaymentRequest, custom_amount: Option, ) -> Result<(), Error> { - let amount = match payment_request.amount { + let unit = payment_request_unit(&payment_request)?; + let base_amount = match payment_request.amount { Some(amount) => amount, None => match custom_amount { Some(a) => a, @@ -47,6 +51,13 @@ impl Wallet { }, }; + if unit != self.unit { + return Err(Error::UnsupportedUnit); + } + + let amount = + payment_request_amount_for_wallet(base_amount, &payment_request, self, &unit).await?; + // Extract optional NUT-10 spending conditions from the payment request. // // NUT-18 encodes spending conditions in the optional `nut10` field using @@ -189,6 +200,349 @@ impl Wallet { } } +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn create_request_params_default_is_strict_by_default() { + let params = CreateRequestParams::default(); + + assert_eq!(params.unit, "sat"); + assert_eq!(params.num_sigs, 1); + assert_eq!(params.transport, "none"); + assert!(params.amount.is_none()); + assert!(params.mint_preferred.is_none()); + } + + #[test] + fn payment_request_rejects_missing_unit_with_amount() { + let payment_request = payment_request(None, Some(Amount::from(1)), vec![]); + + assert!(matches!( + payment_request_unit(&payment_request), + Err(Error::InvalidPaymentRequest) + )); + } + + #[test] + fn payment_request_rejects_missing_unit_with_supported_methods() { + let payment_request = payment_request( + None, + None, + vec![SupportedMethod::new(PaymentMethod::BOLT11.to_string())], + ); + + assert!(matches!( + payment_request_unit(&payment_request), + Err(Error::InvalidPaymentRequest) + )); + } + + #[test] + fn legacy_amountless_request_without_unit_defaults_to_sats() { + let payment_request = payment_request(None, None, vec![]); + + assert_eq!( + payment_request_unit(&payment_request).expect("legacy unit"), + CurrencyUnit::Sat + ); + } + + #[test] + fn strict_mint_policy_only_accepts_listed_mints() { + let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid URL"); + let unlisted_mint = MintUrl::from_str("https://unlisted.example.com").expect("valid URL"); + let mints = vec![listed_mint.clone()]; + + assert!(payment_request_mint_policy_accepts_mint( + &mints, + None, + &listed_mint + )); + assert!(!payment_request_mint_policy_accepts_mint( + &mints, + None, + &unlisted_mint + )); + assert!(!payment_request_mint_policy_accepts_mint( + &mints, + Some(false), + &unlisted_mint + )); + } + + #[test] + fn preferred_or_empty_mint_policy_accepts_unlisted_mints() { + let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid URL"); + let unlisted_mint = MintUrl::from_str("https://unlisted.example.com").expect("valid URL"); + let mints = vec![listed_mint]; + + assert!(payment_request_mint_policy_accepts_mint( + &mints, + Some(true), + &unlisted_mint + )); + assert!(payment_request_mint_policy_accepts_mint( + &[], + None, + &unlisted_mint + )); + } + + #[test] + fn method_fee_defaults_to_zero_when_request_has_no_method_restriction() { + let fee = payment_request_method_fee_from_melt_methods(&[], &[], false, &CurrencyUnit::Sat) + .expect("fee"); + + assert_eq!(fee, Some(Amount::ZERO)); + } + + #[test] + fn method_fee_uses_lowest_supported_melt_method_fee() { + let supported_methods = vec![ + SupportedMethod { + method: PaymentMethod::BOLT11.to_string(), + fee: Some(Amount::from(4)), + }, + SupportedMethod { + method: PaymentMethod::BOLT12.to_string(), + fee: Some(Amount::from(2)), + }, + ]; + let melt_methods = vec![ + melt_method(PaymentMethod::BOLT11, CurrencyUnit::Sat), + melt_method(PaymentMethod::BOLT12, CurrencyUnit::Sat), + ]; + + let fee = payment_request_method_fee_from_melt_methods( + &supported_methods, + &melt_methods, + false, + &CurrencyUnit::Sat, + ) + .expect("fee"); + + assert_eq!(fee, Some(Amount::from(2))); + } + + #[test] + fn method_fee_ignores_melt_methods_for_other_units() { + let supported_methods = vec![SupportedMethod { + method: PaymentMethod::BOLT11.to_string(), + fee: Some(Amount::from(4)), + }]; + let melt_methods = vec![melt_method(PaymentMethod::BOLT11, CurrencyUnit::Usd)]; + + let fee = payment_request_method_fee_from_melt_methods( + &supported_methods, + &melt_methods, + false, + &CurrencyUnit::Sat, + ) + .expect("fee"); + + assert_eq!(fee, None); + } + + #[test] + fn method_fee_requires_matching_melt_method() { + let supported_methods = vec![SupportedMethod { + method: PaymentMethod::BOLT11.to_string(), + fee: Some(Amount::from(4)), + }]; + let melt_methods = vec![melt_method(PaymentMethod::BOLT12, CurrencyUnit::Sat)]; + + let fee = payment_request_method_fee_from_melt_methods( + &supported_methods, + &melt_methods, + false, + &CurrencyUnit::Sat, + ) + .expect("fee"); + + assert_eq!(fee, None); + } + + #[test] + fn method_fee_rejects_methods_when_melting_is_disabled() { + let supported_methods = vec![SupportedMethod { + method: PaymentMethod::BOLT11.to_string(), + fee: Some(Amount::from(4)), + }]; + let melt_methods = vec![melt_method(PaymentMethod::BOLT11, CurrencyUnit::Sat)]; + + let fee = payment_request_method_fee_from_melt_methods( + &supported_methods, + &melt_methods, + true, + &CurrencyUnit::Sat, + ) + .expect("fee"); + + assert_eq!(fee, None); + } + + fn payment_request( + unit: Option, + amount: Option, + supported_methods: Vec, + ) -> PaymentRequest { + PaymentRequest { + payment_id: None, + amount, + unit, + single_use: None, + mints: vec![], + mint_preferred: None, + supported_methods, + description: None, + transports: vec![], + nut10: None, + } + } + + fn melt_method(method: PaymentMethod, unit: CurrencyUnit) -> MeltMethodSettings { + MeltMethodSettings { + method, + unit, + method_name: None, + min_amount: None, + max_amount: None, + options: None, + } + } +} + +fn payment_request_unit(payment_request: &PaymentRequest) -> Result { + match &payment_request.unit { + Some(unit) => Ok(unit.clone()), + None if payment_request.amount.is_none() + && payment_request.supported_methods.is_empty() => + { + Ok(CurrencyUnit::Sat) + } + None => Err(Error::InvalidPaymentRequest), + } +} + +fn payment_request_mint_list_is_strict(payment_request: &PaymentRequest) -> bool { + payment_request_mint_policy_is_strict(&payment_request.mints, payment_request.mint_preferred) +} + +fn payment_request_mint_policy_is_strict(mints: &[MintUrl], mint_preferred: Option) -> bool { + !mints.is_empty() && mint_preferred != Some(true) +} + +#[cfg(any(feature = "nostr", test))] +fn payment_request_mint_policy_accepts_mint( + mints: &[MintUrl], + mint_preferred: Option, + mint_url: &MintUrl, +) -> bool { + !payment_request_mint_policy_is_strict(mints, mint_preferred) || mints.contains(mint_url) +} + +fn payment_request_uses_unlisted_mint( + payment_request: &PaymentRequest, + mint_url: &MintUrl, +) -> bool { + !payment_request.mints.is_empty() && !payment_request.mints.contains(mint_url) +} + +async fn payment_request_amount_for_wallet( + amount: Amount, + payment_request: &PaymentRequest, + wallet: &Wallet, + unit: &CurrencyUnit, +) -> Result { + if payment_request_mint_list_is_strict(payment_request) + && payment_request_uses_unlisted_mint(payment_request, &wallet.mint_url) + { + return Err(Error::Custom(format!( + "Mint {} is not accepted by this payment request. Accepted mints: {:?}", + wallet.mint_url, payment_request.mints + ))); + } + + let method_fee = wallet_payment_request_method_fee(wallet, payment_request, unit) + .await? + .ok_or(Error::UnsupportedPaymentMethod)?; + + if payment_request_method_fee_applies(payment_request, &wallet.mint_url) { + return amount.checked_add(method_fee).ok_or(Error::AmountOverflow); + } + + Ok(amount) +} + +fn payment_request_method_fee_applies( + payment_request: &PaymentRequest, + mint_url: &MintUrl, +) -> bool { + payment_request.mints.is_empty() || !payment_request.mints.contains(mint_url) +} + +async fn wallet_payment_request_method_fee( + wallet: &Wallet, + payment_request: &PaymentRequest, + unit: &CurrencyUnit, +) -> Result, Error> { + if payment_request.supported_methods.is_empty() { + return Ok(Some(Amount::ZERO)); + } + + let mint_info = wallet.load_mint_info().await?; + + payment_request_method_fee_from_melt_methods( + &payment_request.supported_methods, + &mint_info.nuts.nut05.methods, + mint_info.nuts.nut05.disabled, + unit, + ) +} + +fn payment_request_method_fee_from_melt_methods( + supported_methods: &[SupportedMethod], + melt_methods: &[MeltMethodSettings], + melting_disabled: bool, + unit: &CurrencyUnit, +) -> Result, Error> { + if supported_methods.is_empty() { + return Ok(Some(Amount::ZERO)); + } + + if melting_disabled { + return Ok(None); + } + + let requested_methods = supported_methods + .iter() + .map(|method| { + PaymentMethod::from_str(&method.method) + .map(|payment_method| (payment_method, method.fee.unwrap_or(Amount::ZERO))) + }) + .collect::, _>>()?; + + let mut lowest_fee: Option = None; + for (method, fee) in requested_methods { + let melt_supports_method = melt_methods + .iter() + .any(|settings| settings.unit == *unit && settings.method == method); + + if melt_supports_method { + lowest_fee = match lowest_fee { + Some(current) if current <= fee => Some(current), + _ => Some(fee), + }; + } + } + + Ok(lowest_fee) +} + /// Parameters for creating a PaymentRequest /// /// This mirrors the CLI inputs and is used by `create_request` to build a @@ -218,6 +572,27 @@ pub struct CreateRequestParams { pub nostr_relays: Option>, // when transport == nostr /// Optional list of mint URLs the receiver trusts. If not provided, the wallet's current mints for the requested unit will be used. pub mints: Option>, + /// Whether the mint list is preferred rather than required + pub mint_preferred: Option, +} + +impl Default for CreateRequestParams { + fn default() -> Self { + Self { + amount: None, + unit: "sat".to_string(), + description: None, + pubkeys: None, + num_sigs: 1, + hash: None, + preimage: None, + transport: "none".to_string(), + http_url: None, + nostr_relays: None, + mints: None, + mint_preferred: None, + } + } } /// Extra information needed to wait for an incoming Nostr payment @@ -234,6 +609,10 @@ pub struct NostrWaitInfo { pub relays: Vec, /// The recipient public key to subscribe to for incoming events pub pubkey: nostr_sdk::PublicKey, + /// Mint URLs accepted or preferred by the original payment request + pub mints: Vec, + /// Whether the original request's mint list is preferred instead of strict + pub mint_preferred: Option, } impl WalletRepository { @@ -265,6 +644,7 @@ impl WalletRepository { mint_url: Option, custom_amount: Option, ) -> Result<(), Error> { + let unit = payment_request_unit(&payment_request)?; let amount = match payment_request.amount { Some(amount) => amount, None => match custom_amount { @@ -275,14 +655,14 @@ impl WalletRepository { // Get the list of mints accepted by the payment request (empty means any mint is accepted) let accepted_mints = &payment_request.mints; - - // Get the unit from the payment request, defaulting to Sat - let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); + let mint_list_is_preferred = payment_request.mint_preferred == Some(true); // Select the wallet to use for payment let selected_wallet = if let Some(specified_mint) = &mint_url { - // User specified a mint - verify it's accepted by the payment request - if !accepted_mints.is_empty() && !accepted_mints.contains(specified_mint) { + // User specified a mint - verify it's accepted by strict payment requests. + if payment_request_mint_list_is_strict(&payment_request) + && !accepted_mints.contains(specified_mint) + { return Err(Error::Custom(format!( "Mint {} is not accepted by this payment request. Accepted mints: {:?}", specified_mint, accepted_mints @@ -294,8 +674,10 @@ impl WalletRepository { } else { // No mint specified - find the best matching mint with highest balance let balances = self.get_balances().await?; - let mut best_wallet: Option> = None; - let mut best_balance = Amount::ZERO; + let mut best_preferred_wallet: Option> = None; + let mut best_preferred_balance = Amount::ZERO; + let mut best_fallback_wallet: Option> = None; + let mut best_fallback_balance = Amount::ZERO; for (wallet_key, balance) in balances.iter() { // Only consider wallets with matching unit @@ -303,24 +685,62 @@ impl WalletRepository { continue; } - // Check if this mint is accepted by the payment request - let is_accepted = + let mint_is_listed = accepted_mints.is_empty() || accepted_mints.contains(&wallet_key.mint_url); - if !is_accepted { + if !mint_is_listed && !mint_list_is_preferred { continue; } + let wallet = match self.get_wallet(&wallet_key.mint_url, &unit).await { + Ok(wallet) => wallet, + Err(err) => { + tracing::warn!( + "Skipping mint {} while selecting a payment-request wallet: {}", + wallet_key.mint_url, + err + ); + continue; + } + }; + + let required_amount = match payment_request_amount_for_wallet( + amount, + &payment_request, + &wallet, + &unit, + ) + .await + { + Ok(required_amount) => required_amount, + Err(err) => { + tracing::warn!( + "Skipping mint {} after its payment-method probe failed: {}", + wallet_key.mint_url, + err + ); + continue; + } + }; + // Check balance meets requirements and is best so far - if *balance >= amount && *balance > best_balance { - if let Ok(wallet) = self.get_wallet(&wallet_key.mint_url, &unit).await { - best_balance = *balance; - best_wallet = Some(Arc::new(wallet)); + if *balance < required_amount { + continue; + } + + if mint_is_listed { + if *balance > best_preferred_balance { + best_preferred_balance = *balance; + best_preferred_wallet = Some(Arc::new(wallet)); } + } else if *balance > best_fallback_balance { + best_fallback_balance = *balance; + best_fallback_wallet = Some(Arc::new(wallet)); } } - best_wallet + best_preferred_wallet + .or(best_fallback_wallet) .map(|w| (*w).clone()) .ok_or(Error::InsufficientFunds)? }; @@ -512,6 +932,8 @@ impl WalletRepository { keys, relays, pubkey: nprofile.public_key, + mints: mints.clone(), + mint_preferred: params.mint_preferred, }), ) } @@ -542,6 +964,8 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, + mint_preferred: params.mint_preferred, + supported_methods: vec![], description: params.description, transports, nut10, @@ -611,6 +1035,8 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, + mint_preferred: params.mint_preferred, + supported_methods: vec![], description: params.description, transports, nut10, @@ -630,6 +1056,8 @@ impl WalletRepository { keys, relays, pubkey, + mints, + mint_preferred, } = info; let mut stream = NostrPaymentEventStream::new(keys, relays, pubkey); @@ -641,6 +1069,14 @@ impl WalletRepository { while let Some(item) = stream.next().await { match item { Ok(payload) => { + if !payment_request_mint_policy_accepts_mint( + &mints, + mint_preferred, + &payload.mint, + ) { + continue; + } + let token = crate::nuts::Token::new( payload.mint.clone(), payload.proofs, @@ -687,6 +1123,8 @@ impl WalletRepository { keys, relays, pubkey, + mints, + mint_preferred, } = info; let client = nostr_sdk::Client::new(keys); @@ -716,6 +1154,14 @@ impl WalletRepository { let rumor = unwrapped.rumor; match serde_json::from_str::(&rumor.content) { Ok(payload) => { + if !payment_request_mint_policy_accepts_mint( + &mints, + mint_preferred, + &payload.mint, + ) { + continue; + } + let token = crate::nuts::Token::new( payload.mint.clone(), payload.proofs, diff --git a/fuzz/src/arbitrary_ext.rs b/fuzz/src/arbitrary_ext.rs index e96879969..6d7dcf712 100644 --- a/fuzz/src/arbitrary_ext.rs +++ b/fuzz/src/arbitrary_ext.rs @@ -611,6 +611,7 @@ impl<'a> Arbitrary<'a> for PaymentRequestArb { None }; let single_use: Option = u.arbitrary()?; + let mint_preferred: Option = u.arbitrary()?; let num_mints = u.int_in_range(0..=2)?; let mints: Vec = (0..num_mints) .map(|_| MintUrlArb::arbitrary(u).map(|m| m.into_inner())) @@ -623,6 +624,8 @@ impl<'a> Arbitrary<'a> for PaymentRequestArb { unit, single_use, mints, + mint_preferred, + supported_methods: Vec::new(), description, transports: Vec::new(), nut10: None,