diff --git a/crates/cashu/examples/payment_request_encoding_benchmark.rs b/crates/cashu/examples/payment_request_encoding_benchmark.rs index 38100e1b31..a8f8fb4d32 100644 --- a/crates/cashu/examples/payment_request_encoding_benchmark.rs +++ b/crates/cashu/examples/payment_request_encoding_benchmark.rs @@ -94,6 +94,7 @@ fn minimal_comparison() -> Result<(), Box> { unit: None, single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -110,6 +111,7 @@ fn amount_unit_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com")?], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -131,6 +133,7 @@ fn multiple_mints_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint3.example.com")?, MintUrl::from_str("https://backup-mint.cashu.space")?, ], + preferred_mints: vec![], description: Some("Payment with multiple mint options".to_string()), transports: vec![], nut10: None, @@ -156,6 +159,7 @@ fn transport_comparison() -> Result<(), Box> { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com")?], + preferred_mints: vec![], description: Some("Payment with callback transport".to_string()), transports: vec![transport], nut10: None, @@ -193,6 +197,7 @@ fn complete_with_nut10_comparison() -> Result<(), Box> { MintUrl::from_str("https://mint1.example.com")?, MintUrl::from_str("https://mint2.example.com")?, ], + preferred_mints: vec![], description: Some("Complete payment with P2PK locking and refund key".to_string()), transports: vec![transport], nut10: Some(nut10), @@ -245,6 +250,7 @@ fn very_complex_comparison() -> Result<(), Box> { MintUrl::from_str("https://backup-mint-2.example.net")?, MintUrl::from_str("https://emergency-mint.example.io")?, ], + preferred_mints: vec![], description: Some("Complex payment with multiple mints and transports".to_string()), transports: vec![transport1, transport2], nut10: Some(nut10), @@ -503,6 +509,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Test".to_string()), transports: vec![], nut10: None, diff --git a/crates/cashu/src/nuts/nut18/error.rs b/crates/cashu/src/nuts/nut18/error.rs index eb8cc023f9..48bbf39209 100644 --- a/crates/cashu/src/nuts/nut18/error.rs +++ b/crates/cashu/src/nuts/nut18/error.rs @@ -17,4 +17,7 @@ pub enum Error { /// NUT-26 bech32m encoding error #[error(transparent)] Nut26Error(#[from] crate::nuts::nut26::Error), + /// Mutually exclusive fields + #[error("mints and preferred_mints are mutually exclusive")] + MutuallyExclusiveMints, } diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index 8964b3fa08..76d0e90d16 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -36,6 +36,10 @@ pub struct PaymentRequest { #[serde(rename = "m")] #[serde(skip_serializing_if = "Vec::is_empty", default)] pub mints: Vec, + /// Preferred Mints + #[serde(rename = "pm")] + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub preferred_mints: Vec, /// Description #[serde(rename = "d")] pub description: Option, @@ -90,7 +94,12 @@ impl FromStr for PaymentRequest { .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent); let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; - Ok(ciborium::from_reader(&decoded[..])?) + let request: PaymentRequest = ciborium::from_reader(&decoded[..])?; + if !request.mints.is_empty() && !request.preferred_mints.is_empty() { + return Err(Error::MutuallyExclusiveMints); + } + + Ok(request) } } @@ -102,6 +111,7 @@ pub struct PaymentRequestBuilder { unit: Option, single_use: Option, mints: Vec, + preferred_mints: Vec, description: Option, transports: Vec, nut10: Option, @@ -150,6 +160,18 @@ impl PaymentRequestBuilder { self } + /// Set preferred mints + pub fn preferred_mints(mut self, preferred_mints: Vec) -> Self { + self.preferred_mints = preferred_mints; + self + } + + /// Add a preferred mint + pub fn add_preferred_mint(mut self, mint: MintUrl) -> Self { + self.preferred_mints.push(mint); + self + } + /// Set description pub fn description>(mut self, description: S) -> Self { self.description = Some(description.into()); @@ -175,17 +197,22 @@ impl PaymentRequestBuilder { } /// Build the PaymentRequest - pub fn build(self) -> PaymentRequest { - PaymentRequest { + pub fn build(self) -> Result { + if !self.mints.is_empty() && !self.preferred_mints.is_empty() { + return Err(Error::MutuallyExclusiveMints); + } + + Ok(PaymentRequest { payment_id: self.payment_id, amount: self.amount, unit: self.unit, single_use: self.single_use, mints: self.mints, + preferred_mints: self.preferred_mints, description: self.description, transports: self.transports, nut10: self.nut10, - } + }) } } @@ -249,6 +276,7 @@ mod tests { mints: vec!["https://nofees.testnut.cashu.space" .parse() .expect("valid mint url")], + preferred_mints: vec![], description: None, transports: vec![transport.clone()], nut10: None, @@ -291,7 +319,8 @@ mod tests { .unit(CurrencyUnit::Sat) .add_mint(mint_url.clone()) .add_transport(transport.clone()) - .build(); + .build() + .unwrap(); // Verify the built request assert_eq!(&request.payment_id.clone().unwrap(), "b7a90176"); @@ -372,7 +401,8 @@ mod tests { .payment_id("test123") .amount(Amount::from(100)) .nut10(secret_request.clone()) - .build(); + .build() + .unwrap(); assert_eq!(payment_request.nut10, Some(secret_request)); } @@ -393,7 +423,8 @@ mod tests { .unit(CurrencyUnit::Sat) .amount(10) .mints(mint_urls) - .build(); + .build() + .unwrap(); let payment_request_str = payment_request.to_string(); @@ -417,7 +448,8 @@ mod tests { .unit(CurrencyUnit::Sat) .amount(10) .nut10(nut10.into()) - .build(); + .build() + .unwrap(); let payment_request_str = payment_request.to_string(); @@ -444,7 +476,8 @@ mod tests { .payment_id("test-p2pk-id") .description("P2PK locked payment") .nut10(nut10.into()) - .build(); + .build() + .unwrap(); // Convert to string representation let payment_request_str = payment_request.to_string(); @@ -693,6 +726,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Test both formats".to_string()), transports: vec![], nut10: None, @@ -731,4 +765,53 @@ mod tests { PaymentRequest::from_str(&bech32_uppercase).expect("Should decode uppercase bech32"); assert_eq!(decoded_uppercase.payment_id, payment_request.payment_id); } + + #[test] + fn test_preferred_mints_payment_request() { + let json = r#"{ + "i": "pm_test", + "a": 100, + "u": "sat", + "pm": ["https://mint.example.com"] + }"#; + + let expected_encoded = + "creqApGFpZ3BtX3Rlc3RhYRhkYXVjc2F0YnBtgXgYaHR0cHM6Ly9taW50LmV4YW1wbGUuY29t"; + + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + assert_eq!(payment_request.payment_id.as_ref().unwrap(), "pm_test"); + assert_eq!(payment_request.amount.unwrap(), Amount::from(100)); + assert_eq!(payment_request.unit.clone().unwrap(), CurrencyUnit::Sat); + assert!(payment_request.mints.is_empty()); + assert_eq!( + payment_request.preferred_mints, + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + + let encoded = payment_request.to_string(); + // Ciborium encodes None fields as null because we don't have skip_serializing_if = "Option::is_none" + // so we just test round trip instead of exact match with expected_encoded + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "pm_test"); + + // Test mutually exclusive mints error + let invalid_json = r#"{ + "i": "pm_test", + "a": 100, + "u": "sat", + "m": ["https://mint.example.com"], + "pm": ["https://mint.example.com"] + }"#; + + let invalid_req: PaymentRequest = serde_json::from_str(invalid_json).unwrap(); + let mut invalid_encoded = Vec::new(); + ciborium::into_writer(&invalid_req, &mut invalid_encoded).unwrap(); + let invalid_encoded_str = + format!("creqA{}", general_purpose::URL_SAFE.encode(invalid_encoded)); + assert!(PaymentRequest::from_str(&invalid_encoded_str).is_err()); + } } diff --git a/crates/cashu/src/nuts/nut26/encoding.rs b/crates/cashu/src/nuts/nut26/encoding.rs index e18c704f92..d5394f403d 100644 --- a/crates/cashu/src/nuts/nut26/encoding.rs +++ b/crates/cashu/src/nuts/nut26/encoding.rs @@ -145,6 +145,7 @@ impl PaymentRequest { /// unit: Some(cashu::nuts::CurrencyUnit::Sat), /// single_use: None, /// mints: vec![MintUrl::from_str("https://mint.example.com")?], + /// preferred_mints: vec![], /// description: None, /// transports: vec![], /// nut10: None, @@ -221,6 +222,7 @@ impl PaymentRequest { let mut unit: Option = None; let mut single_use: Option = None; let mut mints: Vec = Vec::new(); + let mut preferred_mints: Vec = Vec::new(); let mut description: Option = None; let mut transports: Vec = Vec::new(); let mut nut10: Option = None; @@ -292,18 +294,30 @@ impl PaymentRequest { // nut10: sub-TLV nut10 = Some(Self::decode_nut10(&value)?); } + 0x09 => { + // preferred_mints: string (repeatable) + let mint_str = String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?; + let mint_url = + MintUrl::from_str(&mint_str).map_err(|_| Error::InvalidStructure)?; + preferred_mints.push(mint_url); + } _ => { // Unknown tags are ignored } } } + if !mints.is_empty() && !preferred_mints.is_empty() { + return Err(Error::MutuallyExclusiveMints); + } + Ok(PaymentRequest { payment_id: id, amount, unit, single_use, mints, + preferred_mints, description, transports, nut10, @@ -358,8 +372,12 @@ impl PaymentRequest { // 0x08 nut10: sub-TLV if let Some(ref nut10) = self.nut10 { - let nut10_bytes = Self::encode_nut10(nut10)?; - writer.write_tlv(0x08, &nut10_bytes); + writer.write_tlv(0x08, &Self::encode_nut10(nut10)?); + } + + // 0x09 preferred_mints: string (repeatable) + for mint in &self.preferred_mints { + writer.write_tlv(0x09, mint.to_string().as_bytes()); } Ok(writer.into_bytes()) @@ -787,6 +805,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Test payment".to_string()), transports: vec![transport], nut10: None, @@ -816,6 +835,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -844,6 +864,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("P2PK locked payment".to_string()), transports: vec![], nut10: Some(nut10.clone()), @@ -866,6 +887,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -911,6 +933,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -930,6 +953,7 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -1106,6 +1130,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Nostr payment".to_string()), transports: vec![transport], nut10: None, @@ -1150,6 +1175,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Nostr payment with relays".to_string()), transports: vec![transport], nut10: None, @@ -1197,6 +1223,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Coffee".to_string()), transports: vec![transport], nut10: None, @@ -1273,6 +1300,7 @@ mod tests { MintUrl::from_str("https://mint2.example.com").unwrap(), MintUrl::from_str("https://testnut.cashu.space").unwrap(), ], + preferred_mints: vec![], description: Some("Payment with multiple transports and mints".to_string()), transports: vec![transport1, transport2], nut10: None, @@ -1767,6 +1795,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: Some("Test payment description".to_string()), transports: vec![], nut10: None, @@ -1802,6 +1831,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(true), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -1832,6 +1862,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: Some(false), mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -1862,6 +1893,7 @@ mod tests { unit: Some(CurrencyUnit::Msat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -1893,6 +1925,7 @@ mod tests { unit: Some(CurrencyUnit::Usd), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -2087,6 +2120,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], // Empty transports = in-band per NUT-26 nut10: None, @@ -2186,6 +2220,7 @@ mod tests { unit: Some(CurrencyUnit::Sat), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -2223,6 +2258,7 @@ mod tests { unit: Some(CurrencyUnit::Custom("btc".to_string())), single_use: None, mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()], + preferred_mints: vec![], description: None, transports: vec![], nut10: None, @@ -2241,4 +2277,40 @@ mod tests { assert_eq!(decoded.unit, Some(CurrencyUnit::Custom("btc".to_string()))); assert_eq!(decoded.payment_id, Some("custom_unit".to_string())); } + + #[test] + fn test_preferred_mints_payment_request_nut26() { + let json = r#"{ + "i": "pm_test", + "a": 100, + "u": "sat", + "pm": ["https://mint.example.com"] + }"#; + + let expected_encoded = "CREQB1QYQQWURDTA6X2UM5QGQQSQQQQQQQQQQQVSPSQQGQPYQPS6R5W3C8XW309AKKJMN59EJHSCTDWPKX2TNRDAKSYWN0VM"; + + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + + let encoded = payment_request.to_bech32_string().unwrap(); + assert_eq!(encoded.to_uppercase(), expected_encoded); + + let decoded = PaymentRequest::from_bech32_string(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + let decoded_from_spec = PaymentRequest::from_bech32_string(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "pm_test"); + + // Test mutually exclusive mints error + let invalid_json = r#"{ + "i": "pm_test", + "a": 100, + "u": "sat", + "m": ["https://mint.example.com"], + "pm": ["https://mint.example.com"] + }"#; + + let invalid_req: PaymentRequest = serde_json::from_str(invalid_json).unwrap(); + let invalid_encoded = invalid_req.to_bech32_string().unwrap(); + assert!(PaymentRequest::from_bech32_string(&invalid_encoded).is_err()); + } } diff --git a/crates/cashu/src/nuts/nut26/error.rs b/crates/cashu/src/nuts/nut26/error.rs index 923dcc1200..2dca960032 100644 --- a/crates/cashu/src/nuts/nut26/error.rs +++ b/crates/cashu/src/nuts/nut26/error.rs @@ -19,6 +19,8 @@ pub enum Error { UnknownKind(u8), /// Tag too long (>255 bytes) TagTooLong, + /// Mutually exclusive mints + MutuallyExclusiveMints, /// Bech32 encoding error Bech32Error(bitcoin::bech32::DecodeError), /// Base64 decoding error @@ -37,6 +39,9 @@ impl fmt::Display for Error { Error::InvalidPubkey => write!(f, "Invalid public key"), Error::UnknownKind(kind) => write!(f, "Unknown NUT-10 kind: {}", kind), Error::TagTooLong => write!(f, "Tag exceeds 255 byte limit"), + Error::MutuallyExclusiveMints => { + write!(f, "mints and preferred_mints are mutually exclusive") + } Error::Bech32Error(e) => write!(f, "Bech32 error: {}", e), Error::Base64DecodeError(e) => write!(f, "Base64 decode error: {}", e), Error::CborError(e) => write!(f, "CBOR error: {}", e), diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs index 439256098c..2374e458fc 100644 --- a/crates/cdk-cli/src/sub_commands/create_request.rs +++ b/crates/cdk-cli/src/sub_commands/create_request.rs @@ -56,8 +56,11 @@ pub struct CreateRequestSubCommand { #[arg(long, action = clap::ArgAction::Append)] nostr_relay: Option>, /// Mint URLs the receiver trusts. Can be specified multiple times. - #[arg(long, action = clap::ArgAction::Append)] + #[arg(long, action = clap::ArgAction::Append, conflicts_with = "preferred_mints")] mints: Option>, + /// Preferred Mint URLs. Can be specified multiple times. + #[arg(long, action = clap::ArgAction::Append, conflicts_with = "mints")] + preferred_mints: Option>, /// 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(), + preferred_mints: sub_command_args.preferred_mints.clone(), }; 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 4623dead94..0f85e7b99b 100644 --- a/crates/cdk-ffi/src/types/payment_request.rs +++ b/crates/cdk-ffi/src/types/payment_request.rs @@ -159,6 +159,15 @@ impl PaymentRequest { self.inner.mints.iter().map(|m| m.to_string()).collect() } + /// Get the list of preferred mint URLs + pub fn preferred_mints(&self) -> Vec { + self.inner + .preferred_mints + .iter() + .map(|m| m.to_string()) + .collect() + } + /// Get the description pub fn description(&self) -> Option { self.inner.description.clone() @@ -200,6 +209,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>, + /// Optional list of preferred mint URLs. Mints in this list are preferred over the ones in `mints`. Mutually exclusive with `mints`. + pub preferred_mints: Option>, } impl Default for CreateRequestParams { @@ -216,6 +227,7 @@ impl Default for CreateRequestParams { http_url: None, nostr_relays: None, mints: None, + preferred_mints: None, } } } @@ -234,6 +246,7 @@ impl From for cdk::wallet::payment_request::CreateRequestPa http_url: params.http_url, nostr_relays: params.nostr_relays, mints: params.mints, + preferred_mints: params.preferred_mints, } } } @@ -252,6 +265,7 @@ impl From for CreateRequestPa http_url: params.http_url, nostr_relays: params.nostr_relays, mints: params.mints, + preferred_mints: params.preferred_mints, } } } @@ -503,6 +517,32 @@ mod tests { assert_eq!(params.num_sigs, 1); assert_eq!(params.transport, "none"); assert!(params.amount.is_none()); + assert!(params.preferred_mints.is_none()); + assert!(params.mints.is_none()); + } + + #[test] + fn test_create_request_params_conversion() { + let params = CreateRequestParams { + amount: Some(100), + unit: "sat".to_string(), + description: Some("Test".to_string()), + pubkeys: Some(vec!["key".to_string()]), + num_sigs: 2, + hash: Some("hash".to_string()), + preimage: Some("preimage".to_string()), + transport: "nostr".to_string(), + http_url: Some("http".to_string()), + nostr_relays: Some(vec!["relay".to_string()]), + mints: Some(vec!["mint".to_string()]), + preferred_mints: Some(vec!["pref".to_string()]), + }; + + let cdk_params: cdk::wallet::payment_request::CreateRequestParams = params.clone().into(); + assert_eq!(cdk_params.preferred_mints, params.preferred_mints); + + let back: CreateRequestParams = cdk_params.into(); + assert_eq!(back.preferred_mints, params.preferred_mints); } #[test] diff --git a/crates/cdk/examples/payment_request.rs b/crates/cdk/examples/payment_request.rs index 8595750ba5..97befe0722 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, + preferred_mints: 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, + preferred_mints: 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, + preferred_mints: 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 4cccf10bcb..7b15397714 100644 --- a/crates/cdk/src/wallet/payment_request.rs +++ b/crates/cdk/src/wallet/payment_request.rs @@ -222,6 +222,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>, + /// Optional list of preferred mint URLs. + pub preferred_mints: 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, + preferred_mints: None, + } + } } /// Extra information needed to wait for an incoming Nostr payment @@ -279,6 +300,7 @@ 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 preferred_mints = &payment_request.preferred_mints; // Get the unit from the payment request, defaulting to Sat let unit = payment_request.unit.clone().unwrap_or(CurrencyUnit::Sat); @@ -298,8 +320,12 @@ 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 @@ -315,16 +341,26 @@ impl WalletRepository { continue; } - // Check balance meets requirements and is best so far - if *balance >= amount && *balance > best_balance { - if let Ok(wallet) = self.get_wallet(&wallet_key.mint_url, &unit).await { - best_balance = *balance; - best_wallet = Some(Arc::new(wallet)); + // Check balance meets requirements + if *balance >= amount { + let is_preferred = preferred_mints.contains(&wallet_key.mint_url); + + if is_preferred && *balance > best_preferred_balance { + if let Ok(wallet) = self.get_wallet(&wallet_key.mint_url, &unit).await { + best_preferred_balance = *balance; + best_preferred_wallet = Some(Arc::new(wallet)); + } + } else if !is_preferred && *balance > best_fallback_balance { + if let Ok(wallet) = self.get_wallet(&wallet_key.mint_url, &unit).await { + 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)? }; @@ -477,6 +513,14 @@ impl WalletRepository { .filter_map(|url| MintUrl::from_str(&url).ok()) .collect(); + let preferred_mints: Vec = params + .preferred_mints + .clone() + .unwrap_or_default() + .into_iter() + .filter_map(|url| MintUrl::from_str(&url).ok()) + .collect(); + // Transports let transport_type = params.transport.to_lowercase(); let (transports, nostr_info): (Vec, Option) = @@ -546,6 +590,7 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, + preferred_mints, description: params.description, transports, nut10, @@ -581,6 +626,14 @@ impl WalletRepository { .filter_map(|url| MintUrl::from_str(&url).ok()) .collect(); + let preferred_mints: Vec = params + .preferred_mints + .clone() + .unwrap_or_default() + .into_iter() + .filter_map(|url| MintUrl::from_str(&url).ok()) + .collect(); + // Transports let transport_type = params.transport.to_lowercase(); let transports: Vec = match transport_type.as_str() { @@ -615,6 +668,7 @@ impl WalletRepository { unit: Some(CurrencyUnit::from_str(¶ms.unit)?), single_use: Some(true), mints, + preferred_mints, description: params.description, transports, nut10, @@ -761,3 +815,25 @@ impl WalletRepository { Ok(Amount::ZERO) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_request_params_default() { + let params = CreateRequestParams::default(); + assert_eq!(params.amount, None); + assert_eq!(params.unit, "sat"); + assert_eq!(params.description, None); + assert_eq!(params.pubkeys, None); + assert_eq!(params.num_sigs, 1); + assert_eq!(params.hash, None); + assert_eq!(params.preimage, None); + assert_eq!(params.transport, "none"); + assert_eq!(params.http_url, None); + assert_eq!(params.nostr_relays, None); + assert_eq!(params.mints, None); + assert_eq!(params.preferred_mints, None); + } +}