From 229616f380287aba47f8ae5c1d22e8e088537e05 Mon Sep 17 00:00:00 2001 From: d4rp4t Date: Tue, 26 May 2026 19:43:44 +0200 Subject: [PATCH 1/9] feat(nut18): support mint constraints in payment requests Add the strict mint flag, fee reserve, and supported payment methods to the NUT-18 payment request model and builder. Encode the new fields in NUT-26 TLV records and expose them through the wallet defaults, FFI wrapper, fuzz generator, and encoding benchmark. --- .../payment_request_encoding_benchmark.rs | 21 ++++ .../cashu/src/nuts/nut18/payment_request.rs | 45 ++++++++ crates/cashu/src/nuts/nut26/encoding.rs | 106 ++++++++++++++++++ crates/cdk-ffi/src/types/payment_request.rs | 15 +++ crates/cdk/src/wallet/payment_request.rs | 6 + fuzz/src/arbitrary_ext.rs | 3 + 6 files changed, 196 insertions(+) diff --git a/crates/cashu/examples/payment_request_encoding_benchmark.rs b/crates/cashu/examples/payment_request_encoding_benchmark.rs index 38100e1b3..c14a0c39f 100644 --- a/crates/cashu/examples/payment_request_encoding_benchmark.rs +++ b/crates/cashu/examples/payment_request_encoding_benchmark.rs @@ -94,6 +94,9 @@ fn minimal_comparison() -> Result<(), Box> { unit: None, single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -110,6 +113,9 @@ fn amount_unit_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -131,6 +137,9 @@ fn multiple_mints_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint3.example.com")?, MintUrl::from_str("https://backup-mint.cashu.space")?, ], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Payment with multiple mint options".to_string()), transports: vec![], nut10: None, @@ -156,6 +165,9 @@ fn transport_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com")?], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Payment with callback transport".to_string()), transports: vec![transport], nut10: None, @@ -193,6 +205,9 @@ fn complete_with_nut10_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint1.example.com")?, MintUrl::from_str("https://mint2.example.com")?, ], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Complete payment with P2PK locking and refund key".to_string()), transports: vec![transport], nut10: Some(nut10), @@ -245,6 +260,9 @@ fn very_complex_comparison() -> Result<(), Box> { MintUrl::from_str("https://backup-mint-2.example.net")?, MintUrl::from_str("https://emergency-mint.example.io")?, ], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Complex payment with multiple mints and transports".to_string()), transports: vec![transport1, transport2], nut10: Some(nut10), @@ -503,6 +521,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Test".to_string()), transports: vec![], nut10: None, diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index e8829b291..beaf62f59 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -36,6 +36,18 @@ pub struct PaymentRequest { #[serde(rename = "m")] #[serde(skip_serializing_if = "Vec::is_empty", default)] pub mints: Vec, + /// Mints strict flag + #[serde(rename = "ms")] + #[serde(skip_serializing_if = "Option::is_none")] + pub mints_strict: Option, + /// Additional fee reserve for payments from non-preferred mints + #[serde(rename = "fr")] + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_reserve: Option, + /// Supported payment methods the mint must support + #[serde(rename = "sm")] + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub supported_methods: Vec, /// Description #[serde(rename = "d")] pub description: Option, @@ -102,6 +114,9 @@ pub struct PaymentRequestBuilder { unit: Option, single_use: Option, mints: Vec, + mints_strict: Option, + fee_reserve: Option, + supported_methods: Vec, description: Option, transports: Vec, nut10: Option, @@ -150,6 +165,27 @@ impl PaymentRequestBuilder { self } + /// Set mints strict flag + pub fn mints_strict(mut self, mints_strict: bool) -> Self { + self.mints_strict = Some(mints_strict); + self + } + + /// Set fee reserve for payments from non-preferred mints + pub fn fee_reserve(mut self, fee_reserve: A) -> Self + where + A: Into, + { + self.fee_reserve = Some(fee_reserve.into()); + self + } + + /// Set supported payment methods + pub fn supported_methods(mut self, methods: Vec) -> Self { + self.supported_methods = methods; + self + } + /// Set description pub fn description>(mut self, description: S) -> Self { self.description = Some(description.into()); @@ -182,6 +218,9 @@ impl PaymentRequestBuilder { unit: self.unit, single_use: self.single_use, mints: self.mints, + mints_strict: self.mints_strict, + fee_reserve: self.fee_reserve, + supported_methods: self.supported_methods, description: self.description, transports: self.transports, nut10: self.nut10, @@ -249,6 +288,9 @@ mod tests { mints: vec!["https://nofees.testnut.cashu.space" .parse() .expect("valid mint url")], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![transport.clone()], nut10: None, @@ -693,6 +735,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Test both formats".to_string()), transports: vec![], nut10: None, diff --git a/crates/cashu/src/nuts/nut26/encoding.rs b/crates/cashu/src/nuts/nut26/encoding.rs index aa3a9d440..c45eb4670 100644 --- a/crates/cashu/src/nuts/nut26/encoding.rs +++ b/crates/cashu/src/nuts/nut26/encoding.rs @@ -146,6 +146,9 @@ impl PaymentRequest { /// unit: Some(cashu::nuts::CurrencyUnit::Sat), /// single_use: None, /// mints: vec![MintUrl::from_str("https://mint.example.com")?], + /// mints_strict: None, + /// fee_reserve: None, + /// supported_methods: vec![], /// description: None, /// transports: vec![], /// nut10: None, @@ -222,6 +225,9 @@ impl PaymentRequest { let mut unit: Option = None; let mut single_use: Option = None; let mut mints: Vec = Vec::new(); + let mut mints_strict: Option = None; + let mut fee_reserve: 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,34 @@ impl PaymentRequest { } nut10 = Some(Self::decode_nut10(&value)?); } + 0x09 => { + // mint_strict: u8 (0 or 1) + if mints_strict.is_some() { + return Err(Error::InvalidStructure); + } + if !value.is_empty() { + mints_strict = Some(value[0] != 0); + } + } + 0x0a => { + // fee_reserve: u64 + if fee_reserve.is_some() { + return Err(Error::InvalidStructure); + } + if value.len() != 8 { + return Err(Error::InvalidLength); + } + let fr_val = u64::from_be_bytes([ + value[0], value[1], value[2], value[3], value[4], value[5], value[6], + value[7], + ]); + fee_reserve = Some(Amount::from(fr_val)); + } + 0x0b => { + // supported_methods: string (repeatable) + let method = String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?; + supported_methods.push(method); + } _ => { // Unknown tags are ignored } @@ -308,6 +342,9 @@ impl PaymentRequest { unit, single_use, mints, + mints_strict, + fee_reserve, + supported_methods, description, transports, nut10, @@ -366,6 +403,21 @@ impl PaymentRequest { writer.write_tlv(0x08, &nut10_bytes)?; } + // 0x09 mint_strict: u8 (0 or 1) + if let Some(mints_strict) = self.mints_strict { + writer.write_tlv(0x09, &[if mints_strict { 1 } else { 0 }]); + } + + // 0x0a fee_reserve: u64 + if let Some(fee_reserve) = self.fee_reserve { + writer.write_tlv(0x0a, &fee_reserve.to_u64().to_be_bytes()); + } + + // 0x0b supported_methods: string (repeatable) + for method in &self.supported_methods { + writer.write_tlv(0x0b, method.as_bytes()); + } + Ok(writer.into_bytes()) } @@ -919,6 +971,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Test payment".to_string()), transports: vec![transport], nut10: None, @@ -948,6 +1003,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -976,6 +1034,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("P2PK locked payment".to_string()), transports: vec![], nut10: Some(nut10.clone()), @@ -998,6 +1059,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1043,6 +1107,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1062,6 +1129,9 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1238,6 +1308,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Nostr payment".to_string()), transports: vec![transport], nut10: None, @@ -1282,6 +1355,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Nostr payment with relays".to_string()), transports: vec![transport], nut10: None, @@ -1329,6 +1405,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Coffee".to_string()), transports: vec![transport], nut10: None, @@ -1405,6 +1484,9 @@ mod tests { MintUrl::from_str("https://mint2.example.com").unwrap(), MintUrl::from_str("https://testnut.cashu.space").unwrap(), ], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Payment with multiple transports and mints".to_string()), transports: vec![transport1, transport2], nut10: None, @@ -1899,6 +1981,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: Some("Test payment description".to_string()), transports: vec![], nut10: None, @@ -1934,6 +2019,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1964,6 +2052,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(false), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -1994,6 +2085,9 @@ mod tests { unit: Some(CurrencyUnit::Msat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2025,6 +2119,9 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2219,6 +2316,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], // Empty transports = in-band per NUT-26 nut10: None, @@ -2318,6 +2418,9 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -2355,6 +2458,9 @@ mod tests { unit: Some(CurrencyUnit::Custom("btc".to_string())), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index 4623dead9..16a9dfbe7 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -159,6 +159,21 @@ impl PaymentRequest { self.inner.mints.iter().map(|m| m.to_string()).collect() } + /// Get whether the mint list is strict + pub fn mints_strict(&self) -> Option { + self.inner.mints_strict + } + + /// Get the fee reserve for payments from non-preferred mints + pub fn fee_reserve(&self) -> Option { + self.inner.fee_reserve.map(|a| a.into()) + } + + /// Get the list of supported payment methods the mint must support + pub fn supported_methods(&self) -> Vec { + self.inner.supported_methods.clone() + } + /// Get the description pub fn description(&self) -> Option { self.inner.description.clone() diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index 8b003cf3d..b7b577db2 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -542,6 +542,9 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: params.description, transports, nut10, @@ -611,6 +614,9 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, + mints_strict: None, + fee_reserve: None, + supported_methods: vec![], description: params.description, transports, nut10, diff --git a/fuzz/src/arbitrary_ext.rs b/fuzz/src/arbitrary_ext.rs index e96879969..1f16f6d44 100644 --- a/fuzz/src/arbitrary_ext.rs +++ b/fuzz/src/arbitrary_ext.rs @@ -623,6 +623,9 @@ impl<'a> Arbitrary<'a> for PaymentRequestArb { unit, single_use, mints, + mints_strict: None, + fee_reserve: None, + supported_methods: Vec::new(), description, transports: Vec::new(), nut10: None, From 6a89d73f2ec304a5aad21866fae84d91975ee22c Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Jun 2026 21:43:42 +0100 Subject: [PATCH 2/9] fix(nut18): align mint preference semantics with spec Use the updated mp field and preferred-polarity naming for NUT-18 payment requests. Enforce strict mint lists, add fee reserve when using unlisted preferred mints, and document the field semantics. --- .../payment_request_encoding_benchmark.rs | 14 +- .../cashu/src/nuts/nut18/payment_request.rs | 106 +++++++++---- crates/cashu/src/nuts/nut26/encoding.rs | 61 ++++---- crates/cdk-ffi/src/types/payment_request.rs | 6 +- crates/cdk/src/wallet/payment_request.rs | 140 ++++++++++++++++-- fuzz/src/arbitrary_ext.rs | 2 +- 6 files changed, 248 insertions(+), 81 deletions(-) diff --git a/crates/cashu/examples/payment_request_encoding_benchmark.rs b/crates/cashu/examples/payment_request_encoding_benchmark.rs index c14a0c39f..d3d28e6a1 100644 --- a/crates/cashu/examples/payment_request_encoding_benchmark.rs +++ b/crates/cashu/examples/payment_request_encoding_benchmark.rs @@ -94,7 +94,7 @@ fn minimal_comparison() -> Result<(), Box> { unit: None, single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -113,7 +113,7 @@ fn amount_unit_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -137,7 +137,7 @@ fn multiple_mints_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint3.example.com")?, MintUrl::from_str("https://backup-mint.cashu.space")?, ], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Payment with multiple mint options".to_string()), @@ -165,7 +165,7 @@ fn transport_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com")?], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Payment with callback transport".to_string()), @@ -205,7 +205,7 @@ fn complete_with_nut10_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint1.example.com")?, MintUrl::from_str("https://mint2.example.com")?, ], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Complete payment with P2PK locking and refund key".to_string()), @@ -260,7 +260,7 @@ fn very_complex_comparison() -> Result<(), Box> { MintUrl::from_str("https://backup-mint-2.example.net")?, MintUrl::from_str("https://emergency-mint.example.io")?, ], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Complex payment with multiple mints and transports".to_string()), @@ -521,7 +521,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Test".to_string()), diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index beaf62f59..55dfdbab3 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -17,45 +17,71 @@ use crate::Amount; const PAYMENT_REQUEST_PREFIX: &str = "creqA"; -/// Payment Request +/// 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. + /// + /// 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, - /// Mints strict flag - #[serde(rename = "ms")] + /// 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 mints_strict: Option, - /// Additional fee reserve for payments from non-preferred mints + pub mint_preferred: Option, + /// Additional fee reserve for payments from outside a preferred mint list. + /// + /// When [`Self::mints`] is non-empty and [`Self::mint_preferred`] is + /// `true`, a payer using a mint outside [`Self::mints`] must add this + /// amount to the requested amount. Ignored when the mint list is strict or + /// empty. #[serde(rename = "fr")] #[serde(skip_serializing_if = "Option::is_none")] pub fee_reserve: Option, - /// Supported payment methods the mint must support + /// 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, such as `bolt11`, `bolt12`, or `onchain`. #[serde(rename = "sm")] #[serde(skip_serializing_if = "Vec::is_empty", default)] pub supported_methods: Vec, - /// Description + /// 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, } @@ -114,7 +140,7 @@ pub struct PaymentRequestBuilder { unit: Option, single_use: Option, mints: Vec, - mints_strict: Option, + mint_preferred: Option, fee_reserve: Option, supported_methods: Vec, description: Option, @@ -132,7 +158,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, @@ -159,19 +188,26 @@ 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 mints strict flag - pub fn mints_strict(mut self, mints_strict: bool) -> Self { - self.mints_strict = Some(mints_strict); + /// 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 fee reserve for payments from non-preferred mints + /// Set fee reserve for payments from outside a preferred mint list. pub fn fee_reserve(mut self, fee_reserve: A) -> Self where A: Into, @@ -180,7 +216,7 @@ impl PaymentRequestBuilder { self } - /// Set supported payment methods + /// Set payment methods the payer's mint must support. pub fn supported_methods(mut self, methods: Vec) -> Self { self.supported_methods = methods; self @@ -218,7 +254,7 @@ impl PaymentRequestBuilder { unit: self.unit, single_use: self.single_use, mints: self.mints, - mints_strict: self.mints_strict, + mint_preferred: self.mint_preferred, fee_reserve: self.fee_reserve, supported_methods: self.supported_methods, description: self.description, @@ -288,7 +324,7 @@ mod tests { mints: vec!["https://nofees.testnut.cashu.space" .parse() .expect("valid mint url")], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -444,6 +480,26 @@ 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_nut10_secret_request_htlc() { let bolt11 = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9"; @@ -735,7 +791,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Test both formats".to_string()), diff --git a/crates/cashu/src/nuts/nut26/encoding.rs b/crates/cashu/src/nuts/nut26/encoding.rs index c45eb4670..4699a12c1 100644 --- a/crates/cashu/src/nuts/nut26/encoding.rs +++ b/crates/cashu/src/nuts/nut26/encoding.rs @@ -146,7 +146,7 @@ impl PaymentRequest { /// unit: Some(cashu::nuts::CurrencyUnit::Sat), /// single_use: None, /// mints: vec![MintUrl::from_str("https://mint.example.com")?], - /// mints_strict: None, + /// mint_preferred: None, /// fee_reserve: None, /// supported_methods: vec![], /// description: None, @@ -225,7 +225,7 @@ impl PaymentRequest { let mut unit: Option = None; let mut single_use: Option = None; let mut mints: Vec = Vec::new(); - let mut mints_strict: Option = None; + let mut mint_preferred: Option = None; let mut fee_reserve: Option = None; let mut supported_methods: Vec = Vec::new(); let mut description: Option = None; @@ -303,12 +303,12 @@ impl PaymentRequest { nut10 = Some(Self::decode_nut10(&value)?); } 0x09 => { - // mint_strict: u8 (0 or 1) - if mints_strict.is_some() { + // mint_preferred: u8 (0 or 1) + if mint_preferred.is_some() { return Err(Error::InvalidStructure); } if !value.is_empty() { - mints_strict = Some(value[0] != 0); + mint_preferred = Some(value[0] != 0); } } 0x0a => { @@ -342,7 +342,7 @@ impl PaymentRequest { unit, single_use, mints, - mints_strict, + mint_preferred, fee_reserve, supported_methods, description, @@ -403,19 +403,19 @@ impl PaymentRequest { writer.write_tlv(0x08, &nut10_bytes)?; } - // 0x09 mint_strict: u8 (0 or 1) - if let Some(mints_strict) = self.mints_strict { - writer.write_tlv(0x09, &[if mints_strict { 1 } else { 0 }]); + // 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 fee_reserve: u64 if let Some(fee_reserve) = self.fee_reserve { - writer.write_tlv(0x0a, &fee_reserve.to_u64().to_be_bytes()); + writer.write_tlv(0x0a, &fee_reserve.to_u64().to_be_bytes())?; } // 0x0b supported_methods: string (repeatable) for method in &self.supported_methods { - writer.write_tlv(0x0b, method.as_bytes()); + writer.write_tlv(0x0b, method.as_bytes())?; } Ok(writer.into_bytes()) @@ -971,7 +971,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Test payment".to_string()), @@ -1003,7 +1003,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -1034,7 +1034,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("P2PK locked payment".to_string()), @@ -1059,7 +1059,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -1107,7 +1107,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -1129,7 +1129,7 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -1308,7 +1308,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Nostr payment".to_string()), @@ -1355,7 +1355,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Nostr payment with relays".to_string()), @@ -1405,7 +1405,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Coffee".to_string()), @@ -1484,7 +1484,7 @@ mod tests { MintUrl::from_str("https://mint2.example.com").unwrap(), MintUrl::from_str("https://testnut.cashu.space").unwrap(), ], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Payment with multiple transports and mints".to_string()), @@ -1981,7 +1981,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: Some("Test payment description".to_string()), @@ -2019,7 +2019,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2052,7 +2052,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(false), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2085,7 +2085,7 @@ mod tests { unit: Some(CurrencyUnit::Msat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2119,7 +2119,7 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2316,7 +2316,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2418,7 +2418,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2458,7 +2458,7 @@ mod tests { unit: Some(CurrencyUnit::Custom("btc".to_string())), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: None, @@ -2488,6 +2488,9 @@ mod tests { unit: None, single_use: None, mints: vec![], + mint_preferred: None, + fee_reserve: None, + supported_methods: vec![], description: Some("x".repeat(usize::from(u16::MAX) + 1)), transports: vec![], nut10: None, diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index 16a9dfbe7..0a925c150 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -159,9 +159,9 @@ impl PaymentRequest { self.inner.mints.iter().map(|m| m.to_string()).collect() } - /// Get whether the mint list is strict - pub fn mints_strict(&self) -> Option { - self.inner.mints_strict + /// Get whether the mint list is preferred instead of strict. + pub fn mint_preferred(&self) -> Option { + self.inner.mint_preferred } /// Get the fee reserve for payments from non-preferred mints diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index b7b577db2..9208b70b4 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -22,7 +22,7 @@ use crate::mint_url::MintUrl; 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,13 +39,25 @@ impl Wallet { payment_request: PaymentRequest, custom_amount: Option, ) -> Result<(), Error> { - let amount = match payment_request.amount { + let base_amount = match payment_request.amount { Some(amount) => amount, None => match custom_amount { Some(a) => a, None => return Err(Error::AmountUndefined), }, }; + let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); + + if unit != self.unit { + return Err(Error::UnsupportedUnit); + } + + let amount = + payment_request_amount_for_mint(base_amount, &payment_request, &self.mint_url)?; + + if !wallet_supports_payment_request_methods(self, &payment_request, &unit).await? { + return Err(Error::UnsupportedPaymentMethod); + } // Extract optional NUT-10 spending conditions from the payment request. // @@ -189,6 +201,74 @@ impl Wallet { } } +fn payment_request_mint_list_is_strict(payment_request: &PaymentRequest) -> bool { + !payment_request.mints.is_empty() && payment_request.mint_preferred != Some(true) +} + +fn payment_request_uses_unlisted_mint( + payment_request: &PaymentRequest, + mint_url: &MintUrl, +) -> bool { + !payment_request.mints.is_empty() && !payment_request.mints.contains(mint_url) +} + +fn payment_request_amount_for_mint( + amount: Amount, + payment_request: &PaymentRequest, + mint_url: &MintUrl, +) -> Result { + if payment_request_mint_list_is_strict(payment_request) + && payment_request_uses_unlisted_mint(payment_request, mint_url) + { + return Err(Error::Custom(format!( + "Mint {} is not accepted by this payment request. Accepted mints: {:?}", + mint_url, payment_request.mints + ))); + } + + if payment_request.mint_preferred == Some(true) + && payment_request_uses_unlisted_mint(payment_request, mint_url) + { + if let Some(fee_reserve) = payment_request.fee_reserve { + return amount.checked_add(fee_reserve).ok_or(Error::AmountOverflow); + } + } + + Ok(amount) +} + +async fn wallet_supports_payment_request_methods( + wallet: &Wallet, + payment_request: &PaymentRequest, + unit: &CurrencyUnit, +) -> Result { + if payment_request.supported_methods.is_empty() { + return Ok(true); + } + + let requested_methods = payment_request + .supported_methods + .iter() + .map(|method| PaymentMethod::from_str(method)) + .collect::, _>>()?; + let mint_info = wallet.load_mint_info().await?; + + let mint_supports_method = mint_info + .nuts + .nut04 + .methods + .iter() + .any(|settings| settings.unit == *unit && requested_methods.contains(&settings.method)); + let melt_supports_method = mint_info + .nuts + .nut05 + .methods + .iter() + .any(|settings| settings.unit == *unit && requested_methods.contains(&settings.method)); + + Ok(mint_supports_method || melt_supports_method) +} + /// Parameters for creating a PaymentRequest /// /// This mirrors the CLI inputs and is used by `create_request` to build a @@ -275,14 +355,17 @@ impl WalletRepository { // Get the list of mints accepted by the payment request (empty means any mint is accepted) let accepted_mints = &payment_request.mints; + let mint_list_is_preferred = payment_request.mint_preferred == Some(true); // Get the unit from the payment request, defaulting to Sat let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); // 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 +377,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 +388,47 @@ 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 Ok(wallet) = self.get_wallet(&wallet_key.mint_url, &unit).await else { + continue; + }; + + if !wallet_supports_payment_request_methods(&wallet, &payment_request, &unit) + .await? + { + continue; + } + + let required_amount = payment_request_amount_for_mint( + amount, + &payment_request, + &wallet_key.mint_url, + )?; + // 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)? }; @@ -542,7 +650,7 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: params.description, @@ -614,7 +722,7 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: vec![], description: params.description, diff --git a/fuzz/src/arbitrary_ext.rs b/fuzz/src/arbitrary_ext.rs index 1f16f6d44..3b7790849 100644 --- a/fuzz/src/arbitrary_ext.rs +++ b/fuzz/src/arbitrary_ext.rs @@ -623,7 +623,7 @@ impl<'a> Arbitrary<'a> for PaymentRequestArb { unit, single_use, mints, - mints_strict: None, + mint_preferred: None, fee_reserve: None, supported_methods: Vec::new(), description, From ab377054a0c6cb098101cdf1c771a07cecd719e3 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Jun 2026 21:59:46 +0100 Subject: [PATCH 3/9] fix(nut26): reject malformed mint_preferred TLVs --- crates/cashu/src/nuts/nut26/encoding.rs | 34 +++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/cashu/src/nuts/nut26/encoding.rs b/crates/cashu/src/nuts/nut26/encoding.rs index 4699a12c1..3a4fbf55f 100644 --- a/crates/cashu/src/nuts/nut26/encoding.rs +++ b/crates/cashu/src/nuts/nut26/encoding.rs @@ -307,9 +307,14 @@ impl PaymentRequest { if mint_preferred.is_some() { return Err(Error::InvalidStructure); } - if !value.is_empty() { - mint_preferred = Some(value[0] != 0); + if value.len() != 1 { + return Err(Error::InvalidLength); } + mint_preferred = Some(match value[0] { + 0 => false, + 1 => true, + _ => return Err(Error::InvalidStructure), + }); } 0x0a => { // fee_reserve: u64 @@ -2525,6 +2530,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)]; From 40ca8318757498813bd45aa3f2d50cb898f5657d Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Jun 2026 22:08:38 +0100 Subject: [PATCH 4/9] fix(wallet): enforce mint policy for nostr payments --- crates/cdk-ffi/src/types/payment_request.rs | 10 +++ crates/cdk/src/wallet/payment_request.rs | 88 ++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index 0a925c150..a8ba3876c 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -318,6 +318,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 diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index 9208b70b4..6eadac85c 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -201,8 +201,68 @@ impl Wallet { } } +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[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 + )); + } +} + fn payment_request_mint_list_is_strict(payment_request: &PaymentRequest) -> bool { - !payment_request.mints.is_empty() && payment_request.mint_preferred != Some(true) + 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) +} + +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( @@ -314,6 +374,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 { @@ -620,6 +684,8 @@ impl WalletRepository { keys, relays, pubkey: nprofile.public_key, + mints: mints.clone(), + mint_preferred: None, }), ) } @@ -744,6 +810,8 @@ impl WalletRepository { keys, relays, pubkey, + mints, + mint_preferred, } = info; let mut stream = NostrPaymentEventStream::new(keys, relays, pubkey); @@ -755,6 +823,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, @@ -801,6 +877,8 @@ impl WalletRepository { keys, relays, pubkey, + mints, + mint_preferred, } = info; let client = nostr_sdk::Client::new(keys); @@ -830,6 +908,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, From 9066c48bf308f8df7fbaaa917fafe1586887c5c2 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Jun 2026 22:15:02 +0100 Subject: [PATCH 5/9] fuzz: add fuzz target --- fuzz/src/arbitrary_ext.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fuzz/src/arbitrary_ext.rs b/fuzz/src/arbitrary_ext.rs index 3b7790849..d53d77405 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,7 +624,7 @@ impl<'a> Arbitrary<'a> for PaymentRequestArb { unit, single_use, mints, - mint_preferred: None, + mint_preferred, fee_reserve: None, supported_methods: Vec::new(), description, From 217068d5658e68eb89d7624421e6aeea11d83b22 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 27 Jun 2026 22:20:37 +0100 Subject: [PATCH 6/9] fix(cli): delegate payment request wallet selection --- .../cdk-cli/src/sub_commands/pay_request.rs | 41 ++++--------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index 303410b10..6b0453ab3 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,35 +31,8 @@ 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())) } @@ -73,6 +44,7 @@ mod tests { use std::time::Duration; use cdk::mint_url::MintUrl; + use cdk::nuts::CurrencyUnit; use cdk::wallet::WalletRepositoryBuilder; use cdk_sqlite::wallet::memory; @@ -102,6 +74,9 @@ mod tests { unit: None, single_use: None, mints: vec![], + mint_preferred: None, + fee_reserve: None, + supported_methods: vec![], description: None, transports: vec![], nut10: None, @@ -120,7 +95,7 @@ mod tests { .expect_err("usd wallet must not match unitless fixed-amount request"); assert!( - result.to_string().contains("No wallet found"), + result.to_string().contains("Insufficient funds"), "unexpected error: {result}" ); } From 67307908342b92ba946cdf79ae5f17f2c7bc2bb9 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 8 Jul 2026 15:19:26 +0100 Subject: [PATCH 7/9] feat(nut18): support payment request method fees Replace the flat fee reserve with `sm` method objects carrying optional `mf` values, and encode them as NUT-26 supported-method sub-TLVs on tag 0x0a. Apply method fees when paying from an unlisted mint, or from any mint when the request has no mint list. Select the lowest fee among matching NUT-05 melt methods and keep requested amounts net of input fees. Cover the current NUT-18 and NUT-26 spec vectors, including the preferred-method-fee cases. Accept the standard-base64 NUT-18 vectors published by the spec while continuing to emit urlsafe CREQ-A strings. --- .../payment_request_encoding_benchmark.rs | 7 - crates/cashu/src/nuts/mod.rs | 4 +- crates/cashu/src/nuts/nut18/mod.rs | 4 +- .../cashu/src/nuts/nut18/payment_request.rs | 213 +++++++++++++++--- crates/cashu/src/nuts/nut18/transport.rs | 15 +- crates/cashu/src/nuts/nut26/encoding.rs | 142 ++++++++---- .../cdk-cli/src/sub_commands/pay_request.rs | 1 - crates/cdk-ffi/src/types/payment_request.rs | 41 +++- crates/cdk/src/wallet/payment_request.rs | 203 +++++++++++++---- fuzz/src/arbitrary_ext.rs | 1 - 10 files changed, 488 insertions(+), 143 deletions(-) diff --git a/crates/cashu/examples/payment_request_encoding_benchmark.rs b/crates/cashu/examples/payment_request_encoding_benchmark.rs index d3d28e6a1..495d94b97 100644 --- a/crates/cashu/examples/payment_request_encoding_benchmark.rs +++ b/crates/cashu/examples/payment_request_encoding_benchmark.rs @@ -95,7 +95,6 @@ fn minimal_comparison() -> Result<(), Box> { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -114,7 +113,6 @@ fn amount_unit_comparison() -> Result<(), Box> { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -138,7 +136,6 @@ fn multiple_mints_comparison() -> Result<(), Box> { MintUrl::from_str("https://backup-mint.cashu.space")?, ], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Payment with multiple mint options".to_string()), transports: vec![], @@ -166,7 +163,6 @@ fn transport_comparison() -> Result<(), Box> { single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com")?], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Payment with callback transport".to_string()), transports: vec![transport], @@ -206,7 +202,6 @@ fn complete_with_nut10_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint2.example.com")?, ], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Complete payment with P2PK locking and refund key".to_string()), transports: vec![transport], @@ -261,7 +256,6 @@ fn very_complex_comparison() -> Result<(), Box> { MintUrl::from_str("https://emergency-mint.example.io")?, ], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Complex payment with multiple mints and transports".to_string()), transports: vec![transport1, transport2], @@ -522,7 +516,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Test".to_string()), transports: vec![], 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 55dfdbab3..18c978c16 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -17,6 +17,43 @@ use crate::Amount; const PAYMENT_REQUEST_PREFIX: &str = "creqA"; +/// 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 @@ -27,7 +64,7 @@ pub struct PaymentRequest { /// Payment id to include in the payment payload. #[serde(rename = "i")] pub payment_id: Option, - /// Requested amount. + /// Requested amount net of input fees. /// /// If this is set, [`Self::unit`] must also be set. #[serde(rename = "a")] @@ -55,22 +92,15 @@ pub struct PaymentRequest { #[serde(rename = "mp")] #[serde(skip_serializing_if = "Option::is_none")] pub mint_preferred: Option, - /// Additional fee reserve for payments from outside a preferred mint list. - /// - /// When [`Self::mints`] is non-empty and [`Self::mint_preferred`] is - /// `true`, a payer using a mint outside [`Self::mints`] must add this - /// amount to the requested amount. Ignored when the mint list is strict or - /// empty. - #[serde(rename = "fr")] - #[serde(skip_serializing_if = "Option::is_none")] - pub fee_reserve: 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, such as `bolt11`, `bolt12`, or `onchain`. + /// 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, + pub supported_methods: Vec, /// Human-readable description for the payer to display. #[serde(rename = "d")] pub description: Option, @@ -126,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[..])?) } @@ -141,8 +182,7 @@ pub struct PaymentRequestBuilder { single_use: Option, mints: Vec, mint_preferred: Option, - fee_reserve: Option, - supported_methods: Vec, + supported_methods: Vec, description: Option, transports: Vec, nut10: Option, @@ -207,18 +247,15 @@ impl PaymentRequestBuilder { self } - /// Set fee reserve for payments from outside a preferred mint list. - pub fn fee_reserve(mut self, fee_reserve: A) -> Self - where - A: Into, - { - self.fee_reserve = Some(fee_reserve.into()); + /// Set payment methods the payer's mint must support. + pub fn supported_methods(mut self, methods: Vec) -> Self { + self.supported_methods = methods; self } - /// Set payment methods the payer's mint must support. - pub fn supported_methods(mut self, methods: Vec) -> Self { - self.supported_methods = methods; + /// 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 } @@ -255,7 +292,6 @@ impl PaymentRequestBuilder { single_use: self.single_use, mints: self.mints, mint_preferred: self.mint_preferred, - fee_reserve: self.fee_reserve, supported_methods: self.supported_methods, description: self.description, transports: self.transports, @@ -325,7 +361,6 @@ mod tests { .parse() .expect("valid mint url")], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![transport.clone()], @@ -500,6 +535,38 @@ mod tests { 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"; @@ -626,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 @@ -782,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 @@ -792,7 +952,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Test both formats".to_string()), transports: vec![], 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 3a4fbf55f..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; @@ -147,7 +149,6 @@ impl PaymentRequest { /// single_use: None, /// mints: vec![MintUrl::from_str("https://mint.example.com")?], /// mint_preferred: None, - /// fee_reserve: None, /// supported_methods: vec![], /// description: None, /// transports: vec![], @@ -226,8 +227,7 @@ impl PaymentRequest { let mut single_use: Option = None; let mut mints: Vec = Vec::new(); let mut mint_preferred: Option = None; - let mut fee_reserve: Option = None; - let mut supported_methods: Vec = Vec::new(); + let mut supported_methods: Vec = Vec::new(); let mut description: Option = None; let mut transports: Vec = Vec::new(); let mut nut10: Option = None; @@ -317,22 +317,8 @@ impl PaymentRequest { }); } 0x0a => { - // fee_reserve: u64 - if fee_reserve.is_some() { - return Err(Error::InvalidStructure); - } - if value.len() != 8 { - return Err(Error::InvalidLength); - } - let fr_val = u64::from_be_bytes([ - value[0], value[1], value[2], value[3], value[4], value[5], value[6], - value[7], - ]); - fee_reserve = Some(Amount::from(fr_val)); - } - 0x0b => { - // supported_methods: string (repeatable) - let method = String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?; + // supported_method: sub-TLV (repeatable) + let method = Self::decode_supported_method(&value)?; supported_methods.push(method); } _ => { @@ -348,7 +334,6 @@ impl PaymentRequest { single_use, mints, mint_preferred, - fee_reserve, supported_methods, description, transports, @@ -413,14 +398,10 @@ impl PaymentRequest { writer.write_tlv(0x09, &[if mint_preferred { 1 } else { 0 }])?; } - // 0x0a fee_reserve: u64 - if let Some(fee_reserve) = self.fee_reserve { - writer.write_tlv(0x0a, &fee_reserve.to_u64().to_be_bytes())?; - } - - // 0x0b supported_methods: string (repeatable) + // 0x0a supported_method: sub-TLV (repeatable) for method in &self.supported_methods { - writer.write_tlv(0x0b, method.as_bytes())?; + let method_bytes = Self::encode_supported_method(method)?; + writer.write_tlv(0x0a, &method_bytes)?; } Ok(writer.into_bytes()) @@ -590,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); @@ -917,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, @@ -977,7 +1014,6 @@ mod tests { single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Test payment".to_string()), transports: vec![transport], @@ -1000,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 { @@ -1009,7 +1075,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -1040,7 +1105,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("P2PK locked payment".to_string()), transports: vec![], @@ -1065,7 +1129,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -1113,7 +1176,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -1135,7 +1197,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -1314,7 +1375,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Nostr payment".to_string()), transports: vec![transport], @@ -1361,7 +1421,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Nostr payment with relays".to_string()), transports: vec![transport], @@ -1411,7 +1470,6 @@ mod tests { single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Coffee".to_string()), transports: vec![transport], @@ -1490,7 +1548,6 @@ mod tests { MintUrl::from_str("https://testnut.cashu.space").unwrap(), ], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Payment with multiple transports and mints".to_string()), transports: vec![transport1, transport2], @@ -1987,7 +2044,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("Test payment description".to_string()), transports: vec![], @@ -2025,7 +2081,6 @@ mod tests { single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -2058,7 +2113,6 @@ mod tests { single_use: Some(false), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -2091,7 +2145,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -2125,7 +2178,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -2322,7 +2374,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], // Empty transports = in-band per NUT-26 @@ -2424,7 +2475,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -2464,7 +2514,6 @@ mod tests { single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], @@ -2494,7 +2543,6 @@ mod tests { single_use: None, mints: vec![], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: Some("x".repeat(usize::from(u16::MAX) + 1)), transports: vec![], diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index 6b0453ab3..6a0fc0e08 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -75,7 +75,6 @@ mod tests { single_use: None, mints: vec![], mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: None, transports: vec![], diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index a8ba3876c..b698d3d8e 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 { @@ -164,14 +191,14 @@ impl PaymentRequest { self.inner.mint_preferred } - /// Get the fee reserve for payments from non-preferred mints - pub fn fee_reserve(&self) -> Option { - self.inner.fee_reserve.map(|a| a.into()) - } - /// Get the list of supported payment methods the mint must support - pub fn supported_methods(&self) -> Vec { - self.inner.supported_methods.clone() + pub fn supported_methods(&self) -> Vec { + self.inner + .supported_methods + .iter() + .cloned() + .map(Into::into) + .collect() } /// Get the description diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index 6eadac85c..4f64dd2b3 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")] @@ -22,7 +24,7 @@ use crate::mint_url::MintUrl; use crate::nuts::nut10::{Conditions, SpendingConditions}; use crate::nuts::nut11::SigFlag; use crate::nuts::nut18::Nut10SecretRequest; -use crate::nuts::{CurrencyUnit, Nut10Secret, PaymentMethod, Transport}; +use crate::nuts::{nut05::MeltMethodSettings, CurrencyUnit, Nut10Secret, PaymentMethod, Transport}; #[cfg(feature = "nostr")] use crate::wallet::ReceiveOptions; use crate::wallet::{SendOptions, WalletRepository}; @@ -53,11 +55,7 @@ impl Wallet { } let amount = - payment_request_amount_for_mint(base_amount, &payment_request, &self.mint_url)?; - - if !wallet_supports_payment_request_methods(self, &payment_request, &unit).await? { - return Err(Error::UnsupportedPaymentMethod); - } + payment_request_amount_for_wallet(base_amount, &payment_request, self, &unit).await?; // Extract optional NUT-10 spending conditions from the payment request. // @@ -247,6 +245,88 @@ mod tests { &unlisted_mint )); } + + #[test] + fn method_fee_defaults_to_zero_when_request_has_no_method_restriction() { + let fee = payment_request_method_fee_from_melt_methods(&[], &[], &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, + &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, + &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, + &CurrencyUnit::Sat, + ) + .expect("fee"); + + assert_eq!(fee, 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_mint_list_is_strict(payment_request: &PaymentRequest) -> bool { @@ -272,61 +352,89 @@ fn payment_request_uses_unlisted_mint( !payment_request.mints.is_empty() && !payment_request.mints.contains(mint_url) } -fn payment_request_amount_for_mint( +async fn payment_request_amount_for_wallet( amount: Amount, payment_request: &PaymentRequest, - mint_url: &MintUrl, + wallet: &Wallet, + unit: &CurrencyUnit, ) -> Result { if payment_request_mint_list_is_strict(payment_request) - && payment_request_uses_unlisted_mint(payment_request, mint_url) + && 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: {:?}", - mint_url, payment_request.mints + wallet.mint_url, payment_request.mints ))); } - if payment_request.mint_preferred == Some(true) - && payment_request_uses_unlisted_mint(payment_request, mint_url) - { - if let Some(fee_reserve) = payment_request.fee_reserve { - return amount.checked_add(fee_reserve).ok_or(Error::AmountOverflow); - } + 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) } -async fn wallet_supports_payment_request_methods( +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 { +) -> Result, Error> { if payment_request.supported_methods.is_empty() { - return Ok(true); + return Ok(Some(Amount::ZERO)); } - let requested_methods = payment_request - .supported_methods - .iter() - .map(|method| PaymentMethod::from_str(method)) - .collect::, _>>()?; let mint_info = wallet.load_mint_info().await?; - let mint_supports_method = mint_info - .nuts - .nut04 - .methods - .iter() - .any(|settings| settings.unit == *unit && requested_methods.contains(&settings.method)); - let melt_supports_method = mint_info - .nuts - .nut05 - .methods + payment_request_method_fee_from_melt_methods( + &payment_request.supported_methods, + &mint_info.nuts.nut05.methods, + unit, + ) +} + +fn payment_request_method_fee_from_melt_methods( + supported_methods: &[SupportedMethod], + melt_methods: &[MeltMethodSettings], + unit: &CurrencyUnit, +) -> Result, Error> { + if supported_methods.is_empty() { + return Ok(Some(Amount::ZERO)); + } + + let requested_methods = supported_methods .iter() - .any(|settings| settings.unit == *unit && requested_methods.contains(&settings.method)); + .map(|method| { + PaymentMethod::from_str(&method.method) + .map(|payment_method| (payment_method, method.fee.unwrap_or(Amount::ZERO))) + }) + .collect::, _>>()?; - Ok(mint_supports_method || melt_supports_method) + 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 @@ -463,17 +571,18 @@ impl WalletRepository { continue; }; - if !wallet_supports_payment_request_methods(&wallet, &payment_request, &unit) - .await? - { - continue; - } - - let required_amount = payment_request_amount_for_mint( + let required_amount = match payment_request_amount_for_wallet( amount, &payment_request, - &wallet_key.mint_url, - )?; + &wallet, + &unit, + ) + .await + { + Ok(required_amount) => required_amount, + Err(Error::UnsupportedPaymentMethod) => continue, + Err(err) => return Err(err), + }; // Check balance meets requirements and is best so far if *balance < required_amount { @@ -717,7 +826,6 @@ impl WalletRepository { single_use: Some(true), mints, mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: params.description, transports, @@ -789,7 +897,6 @@ impl WalletRepository { single_use: Some(true), mints, mint_preferred: None, - fee_reserve: None, supported_methods: vec![], description: params.description, transports, diff --git a/fuzz/src/arbitrary_ext.rs b/fuzz/src/arbitrary_ext.rs index d53d77405..6d7dcf712 100644 --- a/fuzz/src/arbitrary_ext.rs +++ b/fuzz/src/arbitrary_ext.rs @@ -625,7 +625,6 @@ impl<'a> Arbitrary<'a> for PaymentRequestArb { single_use, mints, mint_preferred, - fee_reserve: None, supported_methods: Vec::new(), description, transports: Vec::new(), From 0a4f13feffa19434d92646a3eb31216b74138810 Mon Sep 17 00:00:00 2001 From: tsk Date: Fri, 17 Jul 2026 14:25:01 +0000 Subject: [PATCH 8/9] feat(nut18): expose preferred mint request creation Ports the request-creation plumbing from cashubtc/cdk#1925. Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> --- .../src/sub_commands/create_request.rs | 4 ++ crates/cdk-ffi/src/types/payment_request.rs | 12 ++++++ crates/cdk/examples/payment_request.rs | 3 ++ crates/cdk/src/wallet/payment_request.rs | 38 +++++++++++++++++-- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs index 439256098..1290dbb73 100644 --- a/crates/cdk-cli/src/sub_commands/create_request.rs +++ b/crates/cdk-cli/src/sub_commands/create_request.rs @@ -58,6 +58,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 +84,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?; diff --git a/crates/cdk-ffi/src/types/payment_request.rs b/crates/cdk-ffi/src/types/payment_request.rs index b698d3d8e..ebc70934a 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -242,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 { @@ -258,6 +260,7 @@ impl Default for CreateRequestParams { http_url: None, nostr_relays: None, mints: None, + mint_preferred: None, } } } @@ -276,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, } } } @@ -294,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, } } } @@ -555,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] @@ -565,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() }; @@ -574,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 4f64dd2b3..f2f0e0631 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -205,6 +205,17 @@ mod tests { 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 strict_mint_policy_only_accepts_listed_mints() { let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid URL"); @@ -466,6 +477,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 @@ -794,7 +826,7 @@ impl WalletRepository { relays, pubkey: nprofile.public_key, mints: mints.clone(), - mint_preferred: None, + mint_preferred: params.mint_preferred, }), ) } @@ -825,7 +857,7 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, - mint_preferred: None, + mint_preferred: params.mint_preferred, supported_methods: vec![], description: params.description, transports, @@ -896,7 +928,7 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, - mint_preferred: None, + mint_preferred: params.mint_preferred, supported_methods: vec![], description: params.description, transports, From ca341b9f5464edb76fd0ace3f568600c44ca5534 Mon Sep 17 00:00:00 2001 From: tsk Date: Fri, 17 Jul 2026 14:27:31 +0000 Subject: [PATCH 9/9] fix(nut18): harden payment request policy handling --- .../src/sub_commands/check_requests.rs | 20 +-- .../src/sub_commands/create_request.rs | 75 ++++++++++- .../cdk-cli/src/sub_commands/pay_request.rs | 25 +--- crates/cdk/src/wallet/payment_request.rs | 127 ++++++++++++++++-- 4 files changed, 202 insertions(+), 45 deletions(-) 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 1290dbb73..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, } } } @@ -101,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 @@ -116,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 6a0fc0e08..becaceed1 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -39,19 +39,15 @@ pub async fn pay_request( #[cfg(test)] mod tests { - use std::str::FromStr; use std::sync::Arc; - use std::time::Duration; - use cdk::mint_url::MintUrl; - use cdk::nuts::CurrencyUnit; 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() @@ -61,13 +57,6 @@ 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)), @@ -85,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("Insufficient funds"), + result.to_string().contains("Invalid payment request"), "unexpected error: {result}" ); } diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs index f2f0e0631..66ba94dc5 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -21,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::{nut05::MeltMethodSettings, CurrencyUnit, Nut10Secret, PaymentMethod, Transport}; +use crate::nuts::{CurrencyUnit, Nut10Secret, PaymentMethod, Transport}; #[cfg(feature = "nostr")] use crate::wallet::ReceiveOptions; use crate::wallet::{SendOptions, WalletRepository}; @@ -41,6 +42,7 @@ impl Wallet { payment_request: PaymentRequest, custom_amount: Option, ) -> Result<(), Error> { + let unit = payment_request_unit(&payment_request)?; let base_amount = match payment_request.amount { Some(amount) => amount, None => match custom_amount { @@ -48,7 +50,6 @@ impl Wallet { None => return Err(Error::AmountUndefined), }, }; - let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); if unit != self.unit { return Err(Error::UnsupportedUnit); @@ -216,6 +217,40 @@ mod tests { 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"); @@ -259,7 +294,7 @@ mod tests { #[test] fn method_fee_defaults_to_zero_when_request_has_no_method_restriction() { - let fee = payment_request_method_fee_from_melt_methods(&[], &[], &CurrencyUnit::Sat) + let fee = payment_request_method_fee_from_melt_methods(&[], &[], false, &CurrencyUnit::Sat) .expect("fee"); assert_eq!(fee, Some(Amount::ZERO)); @@ -285,6 +320,7 @@ mod tests { let fee = payment_request_method_fee_from_melt_methods( &supported_methods, &melt_methods, + false, &CurrencyUnit::Sat, ) .expect("fee"); @@ -303,6 +339,7 @@ mod tests { let fee = payment_request_method_fee_from_melt_methods( &supported_methods, &melt_methods, + false, &CurrencyUnit::Sat, ) .expect("fee"); @@ -321,6 +358,7 @@ mod tests { let fee = payment_request_method_fee_from_melt_methods( &supported_methods, &melt_methods, + false, &CurrencyUnit::Sat, ) .expect("fee"); @@ -328,6 +366,44 @@ mod tests { 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, @@ -340,6 +416,18 @@ mod tests { } } +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) } @@ -348,6 +436,7 @@ fn payment_request_mint_policy_is_strict(mints: &[MintUrl], mint_preferred: Opti !mints.is_empty() && mint_preferred != Some(true) } +#[cfg(any(feature = "nostr", test))] fn payment_request_mint_policy_accepts_mint( mints: &[MintUrl], mint_preferred: Option, @@ -410,6 +499,7 @@ async fn wallet_payment_request_method_fee( payment_request_method_fee_from_melt_methods( &payment_request.supported_methods, &mint_info.nuts.nut05.methods, + mint_info.nuts.nut05.disabled, unit, ) } @@ -417,12 +507,17 @@ async fn wallet_payment_request_method_fee( 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| { @@ -549,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 { @@ -561,9 +657,6 @@ impl WalletRepository { let accepted_mints = &payment_request.mints; let mint_list_is_preferred = payment_request.mint_preferred == Some(true); - // Get the unit from the payment request, defaulting to Sat - let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); - // 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 strict payment requests. @@ -599,8 +692,16 @@ impl WalletRepository { continue; } - let Ok(wallet) = self.get_wallet(&wallet_key.mint_url, &unit).await else { - 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( @@ -612,8 +713,14 @@ impl WalletRepository { .await { Ok(required_amount) => required_amount, - Err(Error::UnsupportedPaymentMethod) => continue, - Err(err) => return Err(err), + 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