From 1678c0620d7ed7deb6f435464574685db968eb6c Mon Sep 17 00:00:00 2001 From: asmo Date: Tue, 30 Jun 2026 17:34:58 +0200 Subject: [PATCH 1/2] feat!: add method to quote responses Add a required `method` field to all mint and melt quote responses (bolt11, bolt12, onchain, and custom) so wallets can tell which payment method a quote belongs to without relying on the request route. For wire compatibility with mints that don't send the field yet, missing values deserialize to the method implied by the response type (bolt11, bolt12, or onchain), and the wallet HTTP client fills in the method for custom responses from the request route. BREAKING CHANGE: quote response structs gain a required `method` field; constructing them now requires providing it. --- crates/cashu/src/nuts/nut04.rs | 8 + crates/cashu/src/nuts/nut05.rs | 12 ++ crates/cashu/src/nuts/nut08.rs | 4 +- crates/cashu/src/nuts/nut17/mod.rs | 5 +- crates/cashu/src/nuts/nut23.rs | 97 +++++++++++- crates/cashu/src/nuts/nut25.rs | 62 +++++++- crates/cashu/src/nuts/nut30.rs | 62 +++++++- crates/cdk-common/src/melt.rs | 8 + crates/cdk-common/src/mint.rs | 15 +- crates/cdk-common/src/mint_quote.rs | 2 + crates/cdk-ffi/src/types/quote.rs | 19 +++ crates/cdk/src/mint/melt/mod.rs | 2 + crates/cdk/src/wallet/issue/mod.rs | 1 + .../src/wallet/melt/melt_lightning_address.rs | 3 +- crates/cdk/src/wallet/melt/mod.rs | 2 + crates/cdk/src/wallet/melt/saga/mod.rs | 2 + crates/cdk/src/wallet/melt/saga/resume.rs | 10 +- .../src/wallet/mint_connector/http_client.rs | 144 ++++++++++++++---- crates/cdk/src/wallet/recovery.rs | 6 +- crates/cdk/src/wallet/streams/payment.rs | 11 +- crates/cdk/src/wallet/subscription.rs | 5 + 21 files changed, 442 insertions(+), 38 deletions(-) diff --git a/crates/cashu/src/nuts/nut04.rs b/crates/cashu/src/nuts/nut04.rs index 9c35be51a..0b3691732 100644 --- a/crates/cashu/src/nuts/nut04.rs +++ b/crates/cashu/src/nuts/nut04.rs @@ -409,6 +409,7 @@ pub struct MintQuoteCustomRequest { /// ```json /// { /// "quote": "abc123", +/// "method": "paypal", /// "amount": 1000, /// "amount_paid": 0, /// "amount_issued": 0, @@ -431,6 +432,8 @@ pub struct MintQuoteCustomResponse { pub quote: Q, /// Payment request string (method-specific format) pub request: String, + /// Payment method + pub method: PaymentMethod, /// Amount pub amount: Option, /// Amount that has been paid @@ -463,6 +466,7 @@ impl MintQuoteCustomResponse { MintQuoteCustomResponse { quote: self.quote.to_string(), request: self.request.clone(), + method: self.method.clone(), amount: self.amount, amount_paid: self.amount_paid, amount_issued: self.amount_issued, @@ -480,6 +484,7 @@ impl From> for MintQuoteCustomResponse Self { quote: value.quote.to_string(), request: value.request, + method: value.method, amount: value.amount, amount_paid: value.amount_paid, amount_issued: value.amount_issued, @@ -945,6 +950,7 @@ mod tests { let response = MintQuoteCustomResponse { quote: "abc123".to_string(), request: "paypal://pay?id=123".to_string(), + method: PaymentMethod::Custom("paypal".to_string()), amount: Some(Amount::from(1000)), amount_paid: Amount::ZERO, amount_issued: Amount::ZERO, @@ -958,6 +964,7 @@ mod tests { let parsed: serde_json::Value = from_str(&serialized).unwrap(); assert!(parsed.get("state").is_none()); + assert_eq!(parsed["method"], json!("paypal")); assert_eq!(parsed["amount_paid"], json!(0)); assert_eq!(parsed["amount_issued"], json!(0)); } @@ -967,6 +974,7 @@ mod tests { let response = MintQuoteCustomResponse { quote: "q1".to_string(), request: "custom://pay".to_string(), + method: PaymentMethod::Custom("custom".to_string()), amount: Some(Amount::from(100)), amount_paid: Amount::ZERO, amount_issued: Amount::ZERO, diff --git a/crates/cashu/src/nuts/nut05.rs b/crates/cashu/src/nuts/nut05.rs index 38d9cef9f..8f504d2d5 100644 --- a/crates/cashu/src/nuts/nut05.rs +++ b/crates/cashu/src/nuts/nut05.rs @@ -510,6 +510,7 @@ pub struct MeltQuoteCustomRequest { /// ```json /// { /// "quote": "abc123", +/// "method": "custom", /// "state": "UNPAID", /// "amount": 1000, /// "fee_reserve": 10, @@ -529,6 +530,8 @@ pub struct MeltQuoteCustomRequest { pub struct MeltQuoteCustomResponse { /// Quote ID pub quote: Q, + /// Payment method + pub method: PaymentMethod, /// Amount to be melted pub amount: Amount, /// Fee reserve required, if provided @@ -563,6 +566,7 @@ impl MeltQuoteCustomResponse { pub fn to_string_id(&self) -> MeltQuoteCustomResponse { MeltQuoteCustomResponse { quote: self.quote.to_string(), + method: self.method.clone(), amount: self.amount, fee_reserve: self.fee_reserve, state: self.state, @@ -581,6 +585,7 @@ impl From> for MeltQuoteCustomResponse fn from(value: MeltQuoteCustomResponse) -> Self { Self { quote: value.quote.to_string(), + method: value.method, amount: value.amount, fee_reserve: value.fee_reserve, state: value.state, @@ -975,6 +980,7 @@ mod tests { fn test_melt_quote_custom_response_fee_reserve_optional() { let json_str = r#"{ "quote": "abc123", + "method": "cashapp", "state": "UNPAID", "amount": 1000, "expiry": 1234567890, @@ -984,6 +990,10 @@ mod tests { let response: MeltQuoteCustomResponse = from_str(json_str).unwrap(); assert_eq!(response.fee_reserve, None); + assert_eq!( + response.method, + PaymentMethod::Custom("cashapp".to_string()) + ); assert_eq!(response.extra["custom_field"], json!("value")); let serialized = to_string(&response).unwrap(); @@ -996,6 +1006,7 @@ mod tests { fn test_melt_quote_custom_response_serializes_fee_reserve_when_present() { let response = MeltQuoteCustomResponse { quote: "abc123".to_string(), + method: PaymentMethod::Custom("custom".to_string()), amount: Amount::from(1000), fee_reserve: Some(Amount::from(10)), state: QuoteState::Unpaid, @@ -1011,5 +1022,6 @@ mod tests { let parsed: serde_json::Value = from_str(&serialized).unwrap(); assert_eq!(parsed["fee_reserve"], json!(10)); + assert_eq!(parsed["method"], json!("custom")); } } diff --git a/crates/cashu/src/nuts/nut08.rs b/crates/cashu/src/nuts/nut08.rs index c2d40bfcf..35ae3e4d4 100644 --- a/crates/cashu/src/nuts/nut08.rs +++ b/crates/cashu/src/nuts/nut08.rs @@ -38,7 +38,7 @@ mod tests { use std::str::FromStr; use super::*; - use crate::nuts::{BlindSignature, Id, MeltQuoteState, PublicKey}; + use crate::nuts::{BlindSignature, Id, MeltQuoteState, PaymentMethod, PublicKey}; use crate::CurrencyUnit; fn blind_signature(amount: u64) -> BlindSignature { @@ -65,6 +65,7 @@ mod tests { change: Some(vec![blind_signature(2), blind_signature(3)]), request: Some("invoice".to_string()), unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, }; assert_eq!(response.change_amount(), Some(Amount::from(5))); @@ -82,6 +83,7 @@ mod tests { change: Some(vec![blind_signature(4), blind_signature(6)]), request: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Custom("custom".to_string()), extra: serde_json::Value::Null, }; diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index c3eb07376..280e94d55 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -394,7 +394,7 @@ pub enum Error { #[cfg(test)] mod tests { use super::*; - use crate::nuts::nut00::CurrencyUnit; + use crate::nuts::nut00::{CurrencyUnit, KnownMethod, PaymentMethod}; use crate::nuts::nut01::PublicKey; use crate::nuts::MeltQuoteState; use crate::Amount; @@ -405,6 +405,7 @@ mod tests { quote: "abc".to_string(), request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Onchain), expiry: Some(1701704757), pubkey: PublicKey::from_hex( "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", @@ -436,6 +437,7 @@ mod tests { request: "lno1...".to_string(), amount: Some(Amount::from(100_000)), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Bolt12), expiry: Some(1701704757), pubkey: PublicKey::from_hex( "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", @@ -464,6 +466,7 @@ mod tests { quote: "abc".to_string(), amount: Amount::from(100_000), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Onchain), state: MeltQuoteState::Pending, expiry: 1701704757, request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), diff --git a/crates/cashu/src/nuts/nut23.rs b/crates/cashu/src/nuts/nut23.rs index b9e0f207b..a3a9c6618 100644 --- a/crates/cashu/src/nuts/nut23.rs +++ b/crates/cashu/src/nuts/nut23.rs @@ -8,12 +8,16 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; -use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PublicKey}; +use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PaymentMethod, PublicKey}; #[cfg(feature = "mint")] use crate::quote_id::QuoteId; use crate::util::serde_helpers::deserialize_empty_string_as_none; use crate::Amount; +fn default_bolt11_method() -> PaymentMethod { + PaymentMethod::BOLT11 +} + /// NUT023 Error #[derive(Debug, Error)] pub enum Error { @@ -93,6 +97,9 @@ pub struct MintQuoteBolt11Response { /// Unit // REVIEW: This is now required in the spec, we should remove the option once all mints update pub unit: Option, + /// Payment method + #[serde(default = "default_bolt11_method")] + pub method: PaymentMethod, /// Quote State pub state: QuoteState, /// Unix timestamp until the quote is valid @@ -116,6 +123,7 @@ impl MintQuoteBolt11Response { pubkey: self.pubkey, amount: self.amount, unit: self.unit.clone(), + method: self.method.clone(), } } } @@ -131,6 +139,7 @@ impl From> for MintQuoteBolt11Response pubkey: value.pubkey, amount: value.amount, unit: value.unit.clone(), + method: value.method, } } } @@ -260,6 +269,9 @@ pub struct MeltQuoteBolt11Response { // REVIEW: This is now required in the spec, we should remove the option once all mints update #[serde(skip_serializing_if = "Option::is_none")] pub unit: Option, + /// Payment method + #[serde(default = "default_bolt11_method")] + pub method: PaymentMethod, } impl MeltQuoteBolt11Response { @@ -276,6 +288,7 @@ impl MeltQuoteBolt11Response { change: self.change, request: self.request, unit: self.unit, + method: self.method, } } } @@ -293,15 +306,18 @@ impl From> for MeltQuoteBolt11Response change: value.change, request: value.request, unit: value.unit, + method: value.method, } } } - #[cfg(test)] mod tests { use std::str::FromStr; + use serde_json::{from_value, json, to_value}; + use super::*; + use crate::nut00::KnownMethod; const INVOICE_10_SATS: &str = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9"; @@ -369,4 +385,81 @@ mod tests { assert!(matches!(result, Err(Error::InvalidAmountRequest))); } + + #[test] + fn mint_quote_bolt11_response_serializes_method() { + let response = MintQuoteBolt11Response { + quote: "quote-id".to_string(), + request: "lnbc...".to_string(), + amount: Some(Amount::from(10)), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), + state: QuoteState::Unpaid, + expiry: Some(1_701_704_757), + pubkey: None, + }; + + let value = to_value(&response).expect("serialize response"); + assert_eq!(value["method"], json!("bolt11")); + + let decoded: MintQuoteBolt11Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt11)); + } + + #[test] + fn mint_quote_bolt11_response_defaults_method() { + let value = json!({ + "quote": "quote-id", + "request": "lnbc...", + "amount": 10, + "unit": "sat", + "state": "UNPAID", + "expiry": 1_701_704_757 + }); + + let decoded: MintQuoteBolt11Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt11)); + } + + #[test] + fn melt_quote_bolt11_response_serializes_method() { + let response = MeltQuoteBolt11Response { + quote: "quote-id".to_string(), + amount: Amount::from(10), + fee_reserve: Amount::from(2), + state: MeltQuoteState::Unpaid, + expiry: 1_701_704_757, + payment_preimage: None, + change: None, + request: Some("lnbc...".to_string()), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), + }; + + let value = to_value(&response).expect("serialize response"); + assert_eq!(value["method"], json!("bolt11")); + + let decoded: MeltQuoteBolt11Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt11)); + } + + #[test] + fn melt_quote_bolt11_response_defaults_method() { + let value = json!({ + "quote": "quote-id", + "amount": 10, + "fee_reserve": 2, + "state": "UNPAID", + "expiry": 1_701_704_757, + "request": "lnbc...", + "unit": "sat" + }); + + let decoded: MeltQuoteBolt11Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt11)); + } } diff --git a/crates/cashu/src/nuts/nut25.rs b/crates/cashu/src/nuts/nut25.rs index 7fc37149c..d5a42eb88 100644 --- a/crates/cashu/src/nuts/nut25.rs +++ b/crates/cashu/src/nuts/nut25.rs @@ -2,11 +2,15 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; -use super::{CurrencyUnit, MeltOptions, PublicKey}; +use super::{CurrencyUnit, MeltOptions, PaymentMethod, PublicKey}; #[cfg(feature = "mint")] use crate::quote_id::QuoteId; use crate::Amount; +fn default_bolt12_method() -> PaymentMethod { + PaymentMethod::BOLT12 +} + /// NUT18 Error #[derive(Debug, Error)] pub enum Error { @@ -46,6 +50,9 @@ pub struct MintQuoteBolt12Response { pub amount: Option, /// Unit wallet would like to pay with pub unit: CurrencyUnit, + /// Payment method + #[serde(default = "default_bolt12_method")] + pub method: PaymentMethod, /// Unix timestamp until the quote is valid pub expiry: Option, /// Pubkey @@ -65,6 +72,7 @@ impl MintQuoteBolt12Response { request: self.request.clone(), amount: self.amount, unit: self.unit.clone(), + method: self.method.clone(), expiry: self.expiry, pubkey: self.pubkey, amount_paid: self.amount_paid, @@ -85,6 +93,7 @@ impl From> for MintQuoteBolt12Response pubkey: value.pubkey, amount: value.amount, unit: value.unit, + method: value.method, } } } @@ -102,3 +111,54 @@ pub struct MeltQuoteBolt12Request { /// Melt quote response [NUT-25] pub type MeltQuoteBolt12Response = crate::nuts::nut23::MeltQuoteBolt11Response; + +#[cfg(test)] +mod tests { + use serde_json::{from_value, json, to_value}; + + use super::*; + use crate::nut00::KnownMethod; + + #[test] + fn mint_quote_bolt12_response_serializes_method() { + let response = MintQuoteBolt12Response { + quote: "quote-id".to_string(), + request: "lno1...".to_string(), + amount: Some(Amount::from(10)), + unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Bolt12), + expiry: Some(1_701_704_757), + pubkey: PublicKey::from_hex( + "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + ) + .expect("valid public key"), + amount_paid: Amount::ZERO, + amount_issued: Amount::ZERO, + }; + + let value = to_value(&response).expect("serialize response"); + assert_eq!(value["method"], json!("bolt12")); + + let decoded: MintQuoteBolt12Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12)); + } + + #[test] + fn mint_quote_bolt12_response_defaults_method() { + let value = json!({ + "quote": "quote-id", + "request": "lno1...", + "amount": 10, + "unit": "sat", + "expiry": 1_701_704_757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "amount_paid": 0, + "amount_issued": 0 + }); + + let decoded: MintQuoteBolt12Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12)); + } +} diff --git a/crates/cashu/src/nuts/nut30.rs b/crates/cashu/src/nuts/nut30.rs index 330605929..13a1dc936 100644 --- a/crates/cashu/src/nuts/nut30.rs +++ b/crates/cashu/src/nuts/nut30.rs @@ -3,7 +3,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit}; +use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, KnownMethod, PaymentMethod}; use super::nut01::PublicKey; use super::nut05::MeltRequest; use super::MeltQuoteState; @@ -12,6 +12,10 @@ use crate::quote_id::QuoteId; use crate::util::serde_helpers::deserialize_empty_string_as_none; use crate::{Amount, Proofs}; +fn default_onchain_method() -> PaymentMethod { + PaymentMethod::Known(KnownMethod::Onchain) +} + /// Mint quote onchain request /// /// Request for an onchain mint quote. Requires a pubkey (NUT-20). @@ -38,6 +42,9 @@ pub struct MintQuoteOnchainResponse { pub request: String, /// Unit pub unit: CurrencyUnit, + /// Payment method + #[serde(default = "default_onchain_method")] + pub method: PaymentMethod, /// Unix timestamp until the quote is valid pub expiry: Option, /// NUT-20 Pubkey from the request @@ -57,6 +64,7 @@ impl MintQuoteOnchainResponse { quote: self.quote.to_string(), request: self.request.clone(), unit: self.unit.clone(), + method: self.method.clone(), expiry: self.expiry, pubkey: self.pubkey, amount_paid: self.amount_paid, @@ -72,6 +80,7 @@ impl From> for MintQuoteOnchainResponse { pub amount: Amount, /// Unit pub unit: CurrencyUnit, + /// Payment method + #[serde(default = "default_onchain_method")] + pub method: PaymentMethod, /// Quote state pub state: MeltQuoteState, /// Unix timestamp until the quote is valid @@ -189,6 +201,7 @@ impl MeltQuoteOnchainResponse { quote: self.quote.to_string(), amount: self.amount, unit: self.unit.clone(), + method: self.method.clone(), state: self.state, expiry: self.expiry, request: self.request.clone(), @@ -207,6 +220,7 @@ impl From> for MeltQuoteOnchainResponse = + serde_json::from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Onchain)); + } + #[test] fn test_melt_quote_onchain_response_serializes_null_outpoint() { let response: MeltQuoteOnchainResponse = MeltQuoteOnchainResponse { quote: "TRmjduhIsPxd...".to_string(), amount: Amount::from(100000), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain), state: MeltQuoteState::Pending, expiry: 1701704757, request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), @@ -371,6 +412,7 @@ mod tests { quote: "DSGLX9kevM...".to_string(), request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain), expiry: Some(1701704757), pubkey: PublicKey::from_hex( "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", @@ -384,6 +426,23 @@ mod tests { assert_eq!(string_id_response.quote, "DSGLX9kevM..."); } + #[test] + fn test_mint_quote_onchain_response_defaults_method() { + let value = serde_json::json!({ + "quote": "DSGLX9kevM...", + "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "unit": "sat", + "expiry": 1701704757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "amount_paid": 100000, + "amount_issued": 0 + }); + + let decoded: MintQuoteOnchainResponse = + serde_json::from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Onchain)); + } + #[test] fn test_melt_quote_onchain_response_to_string_id() { use crate::nuts::nut00::CurrencyUnit; @@ -393,6 +452,7 @@ mod tests { quote: "TRmjduhIsPxd...".to_string(), amount: Amount::from(100000), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(crate::nuts::nut00::KnownMethod::Onchain), state: MeltQuoteState::Pending, expiry: 1701704757, request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), diff --git a/crates/cdk-common/src/melt.rs b/crates/cdk-common/src/melt.rs index 8942d8825..2cba791fa 100644 --- a/crates/cdk-common/src/melt.rs +++ b/crates/cdk-common/src/melt.rs @@ -287,6 +287,7 @@ where change: None, request: Some(value.request.to_string()), unit: Some(value.unit.clone()), + method: PaymentMethod::Known(KnownMethod::Bolt11), }) } PaymentMethod::Known(KnownMethod::Bolt12) => { @@ -300,6 +301,7 @@ where change: None, request: Some(value.request.to_string()), unit: Some(value.unit.clone()), + method: PaymentMethod::Known(KnownMethod::Bolt12), }) } PaymentMethod::Known(KnownMethod::Onchain) => { @@ -307,6 +309,7 @@ where quote: value.id.clone().into(), amount: value.amount().into(), unit: value.unit.clone(), + method: PaymentMethod::Known(KnownMethod::Onchain), state: value.state, expiry: value.expiry, request: value.request.to_string(), @@ -320,6 +323,7 @@ where method.clone(), crate::nuts::nut05::MeltQuoteCustomResponse { quote: value.id.clone().into(), + method: method.clone(), amount: value.amount().into(), fee_reserve: Some(value.fee_reserve().into()), state: value.state, @@ -354,6 +358,7 @@ mod tests { change: None, request: Some("lnbc100".to_string()), unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), } } @@ -368,6 +373,7 @@ mod tests { change: None, request: Some("lno200".to_string()), unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt12), } } @@ -376,6 +382,7 @@ mod tests { quote: quote.to_string(), amount: Amount::from(400), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Onchain), state: MeltQuoteState::Paid, expiry: 4000, request: "bc1qonchainaddress".to_string(), @@ -393,6 +400,7 @@ mod tests { fn custom_response(quote: &str) -> MeltQuoteCustomResponse { MeltQuoteCustomResponse { quote: quote.to_string(), + method: PaymentMethod::Custom("custom".to_string()), amount: Amount::from(300), fee_reserve: Some(Amount::from(3)), state: MeltQuoteState::Paid, diff --git a/crates/cdk-common/src/mint.rs b/crates/cdk-common/src/mint.rs index 6a175432b..13dfcefa4 100644 --- a/crates/cdk-common/src/mint.rs +++ b/crates/cdk-common/src/mint.rs @@ -1160,6 +1160,7 @@ impl From for MeltQuoteOnchainResponse { quote: quote.id.clone(), amount: quote.amount().into(), unit: quote.unit.clone(), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain), state: quote.state, expiry: quote.expiry, request: quote.request.to_string(), @@ -1178,6 +1179,7 @@ impl TryFrom for MintQuoteOnchainResponse { quote: quote.id.clone(), request: quote.request.clone(), unit: quote.unit.clone(), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain), expiry: (quote.expiry != 0).then_some(quote.expiry), pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?, amount_paid: quote.amount_paid().into(), @@ -1247,6 +1249,7 @@ impl From for MintQuoteBolt11Response { pubkey: mint_quote.pubkey, amount: mint_quote.amount.map(Into::into), unit: Some(mint_quote.unit), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11), } } } @@ -1271,6 +1274,7 @@ impl TryFrom for MintQuoteBolt12Response { pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?, amount: mint_quote.amount.map(Into::into), unit: mint_quote.unit, + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12), }) } } @@ -1294,6 +1298,7 @@ impl TryFrom for MintQuoteCustomResponse { Ok(MintQuoteCustomResponse { quote: quote.id, request: quote.request, + method: quote.payment_method, unit: Some(quote.unit), expiry: Some(quote.expiry), pubkey: quote.pubkey, @@ -1316,6 +1321,7 @@ impl TryFrom for MintQuoteCustomResponse { impl From for crate::nuts::MeltQuoteCustomResponse { fn from(melt_quote: MeltQuote) -> Self { + let method = melt_quote.payment_method.clone(); let request = match melt_quote.request { MeltPaymentRequest::Custom { request, .. } => Some(request), _ => None, @@ -1323,6 +1329,7 @@ impl From for crate::nuts::MeltQuoteCustomResponse { Self { quote: melt_quote.id, + method, amount: melt_quote.amount.into(), fee_reserve: Some(melt_quote.fee_reserve.into()), state: melt_quote.state, @@ -1347,6 +1354,7 @@ impl TryFrom for MintQuoteResponse { expiry: Some(quote.expiry), amount: quote.amount.as_ref().map(|a| a.clone().into()), unit: Some(quote.unit.clone()), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11), pubkey: quote.pubkey, })) } else if quote.payment_method.is_bolt12() { @@ -1355,6 +1363,7 @@ impl TryFrom for MintQuoteResponse { request: quote.request.clone(), amount: quote.amount.as_ref().map(|a| a.clone().into()), unit: quote.unit.clone(), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12), expiry: Some(quote.expiry), pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?, amount_paid: quote.amount_paid().into(), @@ -1366,10 +1375,11 @@ impl TryFrom for MintQuoteResponse { } else { let method = quote.payment_method.clone(); Ok(MintQuoteResponse::Custom { - method, + method: method.clone(), response: crate::nuts::nut04::MintQuoteCustomResponse { quote: quote.id.clone(), request: quote.request.clone(), + method: method.clone(), expiry: Some(quote.expiry), amount: quote.amount.as_ref().map(|a| a.clone().into()), amount_paid: quote.amount_paid().into(), @@ -1408,6 +1418,7 @@ impl From> for MintQuoteBolt11Response { pubkey: bolt11_response.pubkey, amount: bolt11_response.amount, unit: bolt11_response.unit, + method: bolt11_response.method, }, _ => panic!("Expected Bolt11 response"), } @@ -1459,6 +1470,7 @@ impl From<&MeltQuote> for MeltQuoteBolt11Response { fee_reserve: melt_quote.fee_reserve().into(), request: None, unit: Some(melt_quote.unit.clone()), + method: melt_quote.payment_method.clone(), } } } @@ -1475,6 +1487,7 @@ impl From for MeltQuoteBolt11Response { change: None, request: Some(melt_quote.request.to_string()), unit: Some(melt_quote.unit.clone()), + method: melt_quote.payment_method.clone(), } } } diff --git a/crates/cdk-common/src/mint_quote.rs b/crates/cdk-common/src/mint_quote.rs index f48625e33..6716cb4a9 100644 --- a/crates/cdk-common/src/mint_quote.rs +++ b/crates/cdk-common/src/mint_quote.rs @@ -188,6 +188,7 @@ mod tests { response: MintQuoteCustomResponse { quote: "quote".to_string(), request: "custom-request".to_string(), + method: PaymentMethod::Custom("custom".to_string()), amount: Some(Amount::from(100)), amount_paid, amount_issued, @@ -222,6 +223,7 @@ mod tests { request: "bolt12-request".to_string(), amount: Some(Amount::from(100)), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Bolt12), expiry: None, pubkey: PublicKey::from_hex( "02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c", diff --git a/crates/cdk-ffi/src/types/quote.rs b/crates/cdk-ffi/src/types/quote.rs index 79fffcba1..908baad93 100644 --- a/crates/cdk-ffi/src/types/quote.rs +++ b/crates/cdk-ffi/src/types/quote.rs @@ -134,6 +134,8 @@ pub struct MintQuoteBolt11Response { pub quote: String, /// Request string pub request: String, + /// Payment method + pub method: PaymentMethod, /// State of the quote pub state: QuoteState, /// Expiry timestamp (optional) @@ -151,6 +153,7 @@ impl From> for MintQuoteBolt11Respons Self { quote: response.quote, request: response.request, + method: response.method.into(), state: response.state.into(), expiry: response.expiry, amount: response.amount.map(Into::into), @@ -165,6 +168,7 @@ impl From for MintQuoteBolt11Response { Self { quote: quote.id, request: quote.request, + method: quote.payment_method.into(), state: quote.state.into(), expiry: Some(quote.expiry), amount: quote.amount.map(Into::into), @@ -184,6 +188,8 @@ pub struct MintQuoteCustomResponse { pub quote: String, /// Request string pub request: String, + /// Payment method + pub method: PaymentMethod, /// Expiry timestamp (optional) pub expiry: Option, /// Amount (optional) @@ -214,6 +220,7 @@ impl From> for MintQuoteCustomRespons Self { quote: response.quote, request: response.request, + method: response.method.into(), expiry: response.expiry, amount: response.amount.map(Into::into), amount_paid: response.amount_paid.into(), @@ -230,6 +237,8 @@ impl From> for MintQuoteCustomRespons pub struct MeltQuoteBolt11Response { /// Quote ID pub quote: String, + /// Payment method + pub method: PaymentMethod, /// Amount pub amount: Amount, /// Fee reserve @@ -250,6 +259,7 @@ impl From> for MeltQuoteBolt11Respons fn from(response: cdk::nuts::MeltQuoteBolt11Response) -> Self { Self { quote: response.quote, + method: response.method.into(), amount: response.amount.into(), fee_reserve: response.fee_reserve.into(), state: response.state.into(), @@ -269,6 +279,8 @@ impl From> for MeltQuoteBolt11Respons pub struct MeltQuoteCustomResponse { /// Quote ID pub quote: String, + /// Payment method + pub method: PaymentMethod, /// Amount pub amount: Amount, /// Fee reserve @@ -300,6 +312,7 @@ impl From> for MeltQuoteCustomRespons Self { quote: response.quote, + method: response.method.into(), amount: response.amount.into(), fee_reserve: response.fee_reserve.map(Into::into), state: response.state.into(), @@ -356,6 +369,8 @@ pub struct MintQuoteOnchainResponse { pub quote: String, /// Bitcoin address to pay pub request: String, + /// Payment method + pub method: PaymentMethod, /// Unit pub unit: CurrencyUnit, /// Expiry timestamp @@ -373,6 +388,7 @@ impl From> for MintQuoteOnchainRespo Self { quote: response.quote, request: response.request, + method: response.method.into(), unit: response.unit.into(), expiry: response.expiry, pubkey: response.pubkey.to_string(), @@ -408,6 +424,8 @@ impl From for MeltQuoteOnchainFeeOp pub struct MeltQuoteOnchainResponse { /// Quote ID pub quote: String, + /// Payment method + pub method: PaymentMethod, /// Amount being paid to the onchain address pub amount: Amount, /// Unit @@ -437,6 +455,7 @@ impl From> for MeltQuoteOnchainRespo Self { quote: response.quote, + method: response.method.into(), amount: response.amount.into(), unit: response.unit.into(), state: response.state.into(), diff --git a/crates/cdk/src/mint/melt/mod.rs b/crates/cdk/src/mint/melt/mod.rs index 99c0b1668..d5f55db56 100644 --- a/crates/cdk/src/mint/melt/mod.rs +++ b/crates/cdk/src/mint/melt/mod.rs @@ -839,6 +839,7 @@ impl Mint { change: change.clone(), request: Some(quote.request.to_string()), unit: Some(quote.unit.clone()), + method: PaymentMethod::Known(KnownMethod::Bolt11), }) } PaymentMethod::Known(KnownMethod::Bolt12) => { @@ -852,6 +853,7 @@ impl Mint { change: change.clone(), request: Some(quote.request.to_string()), unit: Some(quote.unit.clone()), + method: PaymentMethod::Known(KnownMethod::Bolt12), }) } PaymentMethod::Known(KnownMethod::Onchain) => { diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs index 1f789df23..c868d1301 100644 --- a/crates/cdk/src/wallet/issue/mod.rs +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -632,6 +632,7 @@ mod tests { quote: "quote-id".to_string(), request: "bc1qexample".to_string(), unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Onchain), expiry: Some(1_700_000_000), pubkey: SecretKey::generate().public_key(), amount_paid: Amount::from(1_000), diff --git a/crates/cdk/src/wallet/melt/melt_lightning_address.rs b/crates/cdk/src/wallet/melt/melt_lightning_address.rs index 715cc2882..d160fd50e 100644 --- a/crates/cdk/src/wallet/melt/melt_lightning_address.rs +++ b/crates/cdk/src/wallet/melt/melt_lightning_address.rs @@ -97,7 +97,7 @@ mod tests { use super::*; use crate::mint_url::MintUrl; - use crate::nuts::{CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteState}; + use crate::nuts::{CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteState, PaymentMethod}; use crate::wallet::test_utils::MockMintConnector; use crate::wallet::WalletBuilder; @@ -150,6 +150,7 @@ mod tests { change: None, request: None, unit: None, + method: PaymentMethod::BOLT11, })); let wallet = test_wallet_with_connector(connector.clone()).await; diff --git a/crates/cdk/src/wallet/melt/mod.rs b/crates/cdk/src/wallet/melt/mod.rs index 5cd89622e..71a2cba19 100644 --- a/crates/cdk/src/wallet/melt/mod.rs +++ b/crates/cdk/src/wallet/melt/mod.rs @@ -471,6 +471,7 @@ impl MeltQuoteStatusResponse { change: r.change, request: Some(r.request), unit: Some(r.unit), + method: PaymentMethod::Known(KnownMethod::Onchain), }), _ => Err(Error::Custom( "Cannot convert response to standard bolt11 response".to_string(), @@ -2116,6 +2117,7 @@ mod tests { payment_preimage, change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt11), } } diff --git a/crates/cdk/src/wallet/melt/saga/mod.rs b/crates/cdk/src/wallet/melt/saga/mod.rs index 2f20d6466..6b9ecbe5e 100644 --- a/crates/cdk/src/wallet/melt/saga/mod.rs +++ b/crates/cdk/src/wallet/melt/saga/mod.rs @@ -1476,6 +1476,7 @@ mod tests { quote: quote_id.clone(), amount: quote.amount, unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Onchain), state: MeltQuoteState::Pending, expiry: quote.expiry, request: quote.request.clone(), @@ -1544,6 +1545,7 @@ mod tests { quote: quote.id.clone(), amount: quote.amount, unit: CurrencyUnit::Sat, + method: PaymentMethod::Known(KnownMethod::Onchain), state, expiry: quote.expiry, request: quote.request.clone(), diff --git a/crates/cdk/src/wallet/melt/saga/resume.rs b/crates/cdk/src/wallet/melt/saga/resume.rs index 6b3228193..94cc283d8 100644 --- a/crates/cdk/src/wallet/melt/saga/resume.rs +++ b/crates/cdk/src/wallet/melt/saga/resume.rs @@ -377,7 +377,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; - use cdk_common::nuts::{CurrencyUnit, State}; + use cdk_common::nuts::{CurrencyUnit, PaymentMethod, State}; use cdk_common::wallet::{ MeltOperationData, MeltSagaState, OperationData, Transaction, TransactionDirection, WalletSaga, WalletSagaState, @@ -562,6 +562,7 @@ mod tests { payment_preimage: Some("preimage123".to_string()), change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -653,6 +654,7 @@ mod tests { payment_preimage: None, change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -721,6 +723,7 @@ mod tests { payment_preimage: Some("preimage123".to_string()), change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -807,6 +810,7 @@ mod tests { payment_preimage: Some("preimage123".to_string()), change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -913,6 +917,7 @@ mod tests { payment_preimage: None, change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -1007,6 +1012,7 @@ mod tests { payment_preimage: Some("preimage123".to_string()), change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -1103,6 +1109,7 @@ mod tests { payment_preimage: None, change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -1181,6 +1188,7 @@ mod tests { payment_preimage: None, change: None, unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index 5e6f00298..9b77eec86 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -41,6 +41,36 @@ fn payment_method_path_segment(method: &PaymentMethod) -> Result<&str, Error> { } } +fn fill_response_method(value: &mut serde_json::Value, method: &PaymentMethod) { + if let serde_json::Value::Object(object) = value { + object + .entry("method".to_string()) + .or_insert_with(|| serde_json::Value::String(method.to_string())); + } +} + +fn fill_response_methods(value: &mut serde_json::Value, method: &PaymentMethod) { + match value { + serde_json::Value::Array(items) => { + for item in items { + fill_response_method(item, method); + } + } + _ => fill_response_method(value, method), + } +} + +fn deserialize_with_route_method( + mut value: serde_json::Value, + method: &PaymentMethod, +) -> Result +where + R: DeserializeOwned, +{ + fill_response_methods(&mut value, method); + serde_json::from_value(value).map_err(|e| Error::Custom(e.to_string())) +} + /// Http Client #[derive(Debug, Clone)] pub struct HttpClient @@ -380,8 +410,10 @@ where Ok(MintQuoteResponse::Onchain(response)) } MintQuoteRequest::Custom { request: req, .. } => { - let response: cdk_common::nut04::MintQuoteCustomResponse = + let value: serde_json::Value = self.transport_http_post(url, auth_token, req).await?; + let response: cdk_common::nut04::MintQuoteCustomResponse = + deserialize_with_route_method(value, &method)?; Ok(MintQuoteResponse::Custom { method, response }) } } @@ -458,8 +490,9 @@ where .get_auth_token(Method::Get, RoutePath::MintQuote(method_name.to_string())) .await?; + let value: serde_json::Value = self.transport_http_get(url, auth_token).await?; let response: MintQuoteCustomResponse = - self.transport_http_get(url, auth_token).await?; + deserialize_with_route_method(value, &method)?; Ok(MintQuoteResponse::Custom { method, response }) } @@ -537,8 +570,10 @@ where .collect()) } PaymentMethod::Custom(method_name) => { - let responses: Vec> = + let value: serde_json::Value = self.transport_http_post(url, auth_token, &request).await?; + let responses: Vec> = + deserialize_with_route_method(value, method)?; Ok(responses .into_iter() .map(|response| MintQuoteResponse::Custom { @@ -601,8 +636,10 @@ where Ok(MeltQuoteCreateResponse::Onchain(response)) } MeltQuoteRequest::Custom(req) => { - let response: cdk_common::nut05::MeltQuoteCustomResponse = + let value: serde_json::Value = self.transport_http_post(url, auth_token, req).await?; + let response: cdk_common::nut05::MeltQuoteCustomResponse = + deserialize_with_route_method(value, &method)?; Ok(MeltQuoteCreateResponse::Custom((method, response))) } } @@ -679,8 +716,9 @@ where .get_auth_token(Method::Get, RoutePath::MeltQuote(method_name.to_string())) .await?; + let value: serde_json::Value = self.transport_http_get(url, auth_token).await?; let response: cdk_common::nut05::MeltQuoteCustomResponse = - self.transport_http_get(url, auth_token).await?; + deserialize_with_route_method(value, &method)?; Ok(MeltQuoteResponse::Custom((method.clone(), response))) } @@ -741,9 +779,11 @@ where Ok(MeltQuoteResponse::Onchain(res)) } PaymentMethod::Custom(_) => { - let res: cdk_common::nuts::MeltQuoteCustomResponse = self + let value: serde_json::Value = self .retriable_http_request(nut19::Method::Post, path, auth_token, &request) .await?; + let res: cdk_common::nuts::MeltQuoteCustomResponse = + deserialize_with_route_method(value, method)?; Ok(MeltQuoteResponse::Custom((method.clone(), res))) } } @@ -1034,19 +1074,16 @@ mod tests { /// serializes as a JSON array. #[tokio::test] async fn test_post_mint_quote_custom_sends_request_object() { - // Build a canned MintQuoteCustomResponse for the mock - let canned_response = MintQuoteCustomResponse:: { - quote: "test-quote-id".to_string(), - request: "paypal://pay?id=123".to_string(), - amount: Some(cdk_common::Amount::from(1000)), - amount_paid: cdk_common::Amount::ZERO, - amount_issued: cdk_common::Amount::ZERO, - unit: Some(cdk_common::CurrencyUnit::Sat), - expiry: Some(9999999), - pubkey: None, - extra: serde_json::Value::Null, - }; - let canned_json = serde_json::to_string(&canned_response).expect("serialize response"); + let canned_json = serde_json::json!({ + "quote": "test-quote-id", + "request": "paypal://pay?id=123", + "amount": 1000, + "amount_paid": 0, + "amount_issued": 0, + "unit": "sat", + "expiry": 9999999 + }) + .to_string(); let transport = MockTransport { captured_payload: Arc::new(Mutex::new(None)), @@ -1071,12 +1108,18 @@ mod tests { }, }; - let result = client.post_mint_quote(request).await; - assert!( - result.is_ok(), - "post_mint_quote should succeed: {:?}", - result.err() - ); + let response = client + .post_mint_quote(request) + .await + .expect("post_mint_quote should succeed"); + + match response { + MintQuoteResponse::Custom { method, response } => { + assert_eq!(method, PaymentMethod::Custom("paypal".to_string())); + assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string())); + } + _ => panic!("expected custom response"), + } // Verify the payload sent to the transport was a JSON object (not an array) let payload = captured @@ -1225,7 +1268,9 @@ mod tests { assert_eq!(response.state(), Some(MintQuoteState::Paid)); match response { - MintQuoteResponse::Custom { response, .. } => { + MintQuoteResponse::Custom { method, response } => { + assert_eq!(method, PaymentMethod::Custom("paypal".to_string())); + assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string())); assert_eq!(response.amount_paid, cdk_common::Amount::from(1000)); assert_eq!(response.amount_issued, cdk_common::Amount::ZERO); } @@ -1266,7 +1311,52 @@ mod tests { assert_eq!(responses.len(), 1); assert_eq!(responses[0].state(), Some(MintQuoteState::Issued)); - assert!(matches!(responses[0], MintQuoteResponse::Custom { .. })); + match &responses[0] { + MintQuoteResponse::Custom { method, response } => { + assert_eq!(method, &PaymentMethod::Custom("paypal".to_string())); + assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string())); + } + _ => panic!("expected custom response"), + } + } + + #[tokio::test] + async fn test_post_melt_quote_custom_derives_missing_method_from_route() { + let canned_json = serde_json::json!({ + "quote": "test-melt-quote-id", + "amount": 1000, + "fee_reserve": 10, + "state": "UNPAID", + "expiry": 9999999, + "request": "paypal://pay?id=123", + "unit": "sat" + }) + .to_string(); + + let transport = MockTransport { + post_response: Arc::new(Mutex::new(Some(canned_json))), + ..Default::default() + }; + let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url"); + let client = HttpClient::with_transport(mint_url, transport, None); + + let response = client + .post_melt_quote(MeltQuoteRequest::Custom(MeltQuoteCustomRequest { + method: "paypal".to_string(), + request: "paypal://pay?id=123".to_string(), + unit: cdk_common::CurrencyUnit::Sat, + extra: serde_json::Value::Null, + })) + .await + .expect("custom melt quote"); + + match response { + MeltQuoteCreateResponse::Custom((method, response)) => { + assert_eq!(method, PaymentMethod::Custom("paypal".to_string())); + assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string())); + } + _ => panic!("expected custom response"), + } } #[tokio::test] diff --git a/crates/cdk/src/wallet/recovery.rs b/crates/cdk/src/wallet/recovery.rs index 5fd8daa2f..ce924ea97 100644 --- a/crates/cdk/src/wallet/recovery.rs +++ b/crates/cdk/src/wallet/recovery.rs @@ -683,7 +683,7 @@ mod tests { use std::sync::Arc; use cdk_common::mint_url::MintUrl; - use cdk_common::nuts::{MeltQuoteBolt11Response, MeltQuoteState, State}; + use cdk_common::nuts::{MeltQuoteBolt11Response, MeltQuoteState, PaymentMethod, State}; use cdk_common::wallet::{ IssueSagaState, MeltOperationData, MeltSagaState, MintOperationData, OperationData, ReceiveOperationData, ReceiveSagaState, WalletSaga, WalletSagaState, @@ -1002,6 +1002,7 @@ mod tests { change: None, request: None, unit: None, + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -1077,6 +1078,7 @@ mod tests { change: None, request: None, unit: None, + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -1146,6 +1148,7 @@ mod tests { change: None, request: None, unit: None, + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; @@ -1222,6 +1225,7 @@ mod tests { change: None, request: None, unit: None, + method: PaymentMethod::BOLT11, })); let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; diff --git a/crates/cdk/src/wallet/streams/payment.rs b/crates/cdk/src/wallet/streams/payment.rs index 08f53ba1f..aad2e3d6f 100644 --- a/crates/cdk/src/wallet/streams/payment.rs +++ b/crates/cdk/src/wallet/streams/payment.rs @@ -366,7 +366,7 @@ mod tests { Amount, CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteBolt12Response, MeltQuoteCustomResponse, MeltQuoteOnchainResponse, MeltQuoteState, MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteOnchainResponse, MintQuoteState, - NotificationPayload, + NotificationPayload, PaymentMethod, }; use super::{classify_payment_notification, handle_payment_notification, ClassifiedPayment}; @@ -409,6 +409,7 @@ mod tests { quote: "onchain_quote".to_string(), request: "test_request".to_string(), unit: CurrencyUnit::Sat, + method: PaymentMethod::from("onchain"), expiry: None, pubkey, amount_paid: Amount::from(101u64), @@ -428,6 +429,7 @@ mod tests { MintQuoteCustomResponse:: { quote: "custom_quote".to_string(), request: "test_request".to_string(), + method: PaymentMethod::Custom("custom".to_string()), amount: None, amount_paid: Amount::from(125u64), amount_issued: Amount::from(100u64), @@ -540,6 +542,7 @@ mod tests { quote: "onchain_quote".to_string(), request: "test_request".to_string(), unit: CurrencyUnit::Sat, + method: PaymentMethod::from("onchain"), expiry: None, pubkey, amount_paid: Amount::from(50u64), @@ -554,6 +557,7 @@ mod tests { MintQuoteCustomResponse:: { quote: "custom_quote".to_string(), request: "test_request".to_string(), + method: PaymentMethod::Custom("custom".to_string()), amount: None, amount_paid: Amount::from(50u64), amount_issued: Amount::from(100u64), @@ -573,6 +577,7 @@ mod tests { request: "test_request".to_string(), amount: Some(Amount::from(100u64)), unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, state, expiry: None, pubkey: None, @@ -589,6 +594,7 @@ mod tests { request: "test_request".to_string(), amount: None, unit: CurrencyUnit::Sat, + method: PaymentMethod::BOLT12, expiry: None, pubkey: SecretKey::generate().public_key(), amount_paid: Amount::from(amount_paid), @@ -607,6 +613,7 @@ mod tests { change: None, request: Some("test_request".to_string()), unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT11, } } @@ -622,6 +629,7 @@ mod tests { quote: quote.to_string(), amount: Amount::from(100u64), unit: CurrencyUnit::Sat, + method: PaymentMethod::from("onchain"), state, expiry: 1234, request: "test_request".to_string(), @@ -647,6 +655,7 @@ mod tests { change: None, request: Some("test_request".to_string()), unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Custom("custom".to_string()), extra: serde_json::Value::Null, } } diff --git a/crates/cdk/src/wallet/subscription.rs b/crates/cdk/src/wallet/subscription.rs index 91c7ad173..78792b5c6 100644 --- a/crates/cdk/src/wallet/subscription.rs +++ b/crates/cdk/src/wallet/subscription.rs @@ -718,6 +718,7 @@ mod tests { let mint_payload = json!({ "quote": "mint-quote", "request": "lnbc1...", + "method": "bolt11", "state": "PAID", "expiry": 1234, "paid": true @@ -726,6 +727,7 @@ mod tests { "quote": "melt-quote", "amount": 21, "fee_reserve": 1, + "method": "bolt11", "state": "PAID", "expiry": 1234, "payment_proof": "abc" @@ -753,6 +755,7 @@ mod tests { "request": "lni1...", "amount": null, "unit": "sat", + "method": "bolt12", "state": "UNPAID", "expiry": 1234, "pubkey": "02194603ffa062682c4f10e2dfe8f53e17d5d0329db51c8d3935cc74a4c0e0d4cb", @@ -775,6 +778,7 @@ mod tests { let mint_payload = json!({ "quote": "mint-custom", "request": "custom-request", + "method": "foo", "amount": 42, "unit": "sat", "amount_paid": 0, @@ -787,6 +791,7 @@ mod tests { "quote": "melt-custom", "amount": 42, "fee_reserve": 1, + "method": "foo", "state": "PAID", "expiry": 1234, "payment_proof": null, From 52ef5d97b04ec292dd01a3a5ece76075639f64d5 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 4 Jul 2026 15:13:32 +0100 Subject: [PATCH 2/2] fix!: distinguish bolt12 melt quote responses from bolt11 `MeltQuoteBolt12Response` was a type alias for the bolt11 response, which made melt notifications ambiguous: every bolt12 melt event had to be broadcast to both the bolt11 and bolt12 subscription topics as a workaround. Make it a distinct struct with its own `NotificationPayload` variant and a `method` default of bolt12, and drop the dual-topic broadcast. Also fill the `method` field from the notification kind when websocket payloads omit it, so custom and bolt12 quote notifications from older mints deserialize with the correct method. BREAKING CHANGE: `MeltQuoteBolt12Response` is no longer interchangeable with `MeltQuoteBolt11Response`. --- crates/cashu/src/nuts/nut08.rs | 10 ++ crates/cashu/src/nuts/nut17/mod.rs | 73 +++++++++++++-- crates/cashu/src/nuts/nut25.rs | 112 ++++++++++++++++++++++- crates/cdk-common/src/mint.rs | 48 ++++++++-- crates/cdk/src/event.rs | 24 ++--- crates/cdk/src/mint/melt/mod.rs | 2 +- crates/cdk/src/wallet/streams/payment.rs | 13 ++- crates/cdk/src/wallet/subscription.rs | 88 ++++++++++++++---- 8 files changed, 323 insertions(+), 47 deletions(-) diff --git a/crates/cashu/src/nuts/nut08.rs b/crates/cashu/src/nuts/nut08.rs index 35ae3e4d4..b0f73f71f 100644 --- a/crates/cashu/src/nuts/nut08.rs +++ b/crates/cashu/src/nuts/nut08.rs @@ -4,6 +4,7 @@ use super::nut05::{MeltQuoteCustomResponse, MeltRequest}; use super::nut23::MeltQuoteBolt11Response; +use super::nut25::MeltQuoteBolt12Response; use crate::Amount; impl MeltRequest { @@ -24,6 +25,15 @@ impl MeltQuoteBolt11Response { } } +impl MeltQuoteBolt12Response { + /// Total change [`Amount`] + pub fn change_amount(&self) -> Option { + self.change + .as_ref() + .and_then(|o| Amount::try_sum(o.iter().map(|proof| proof.amount)).ok()) + } +} + impl MeltQuoteCustomResponse { /// Total change [`Amount`] pub fn change_amount(&self) -> Option { diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index 280e94d55..e606edf19 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -217,6 +217,14 @@ where /// methods share most field names, and the structs tolerate unknown fields /// for forward compatibility, so untagged trial-and-error would pick the /// wrong variant. +fn fill_response_method(value: &mut serde_json::Value, method: &str) { + if let serde_json::Value::Object(object) = value { + object + .entry("method".to_string()) + .or_insert_with(|| serde_json::Value::String(method.to_string())); + } +} + fn deserialize_payload(value: serde_json::Value) -> Result, E> where T: Clone + Serialize + DeserializeOwned, @@ -241,6 +249,10 @@ where } if fields.contains_key("fee_reserve") { + if fields.get("method").and_then(serde_json::Value::as_str) == Some("bolt12") { + return from_value(value).map(NotificationPayload::MeltQuoteBolt12Response); + } + return from_value(value).map(NotificationPayload::MeltQuoteBolt11Response); } @@ -255,19 +267,34 @@ where from_value(value).map(NotificationPayload::MintQuoteOnchainResponse) } serde_json::Value::Array(items) if items.len() == 2 => { + let method = items + .first() + .and_then(serde_json::Value::as_str) + .ok_or_else(|| E::custom("custom notification method must be a string"))? + .to_owned(); let response = items .get(1) .ok_or_else(|| E::custom("custom notification payload is missing response"))?; - match response.as_object() { - Some(fields) if fields.contains_key("state") => { - from_value(value).map(|(method, response)| { - NotificationPayload::CustomMeltQuoteResponse(method, response) - }) + let is_melt_quote = match response.as_object() { + Some(fields) => fields.contains_key("state"), + None => return Err(E::custom("custom notification response must be an object")), + }; + + let mut value = value; + if let serde_json::Value::Array(items) = &mut value { + if let Some(response) = items.get_mut(1) { + fill_response_method(response, &method); } - Some(_) => from_value(value).map(|(method, response)| { + } + + if is_melt_quote { + from_value(value).map(|(method, response)| { + NotificationPayload::CustomMeltQuoteResponse(method, response) + }) + } else { + from_value(value).map(|(method, response)| { NotificationPayload::CustomMintQuoteResponse(method, response) - }), - None => Err(E::custom("custom notification response must be an object")), + }) } } _ => Err(E::custom("invalid notification payload")), @@ -493,6 +520,34 @@ mod tests { } } + #[test] + fn notification_payload_bolt12_melt_roundtrip() { + let resp: MeltQuoteBolt12Response = MeltQuoteBolt12Response { + quote: "abc".to_string(), + amount: Amount::from(100_000), + fee_reserve: Amount::from(10), + state: MeltQuoteState::Pending, + expiry: 1701704757, + payment_preimage: None, + change: None, + request: Some("lno1...".to_string()), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt12), + }; + let payload: NotificationPayload = + NotificationPayload::MeltQuoteBolt12Response(resp.clone()); + + let encoded = serde_json::to_string(&payload).unwrap(); + let decoded: NotificationPayload = serde_json::from_str(&encoded).unwrap(); + + match decoded { + NotificationPayload::MeltQuoteBolt12Response(r) => { + assert_eq!(r, resp); + } + other => panic!("expected MeltQuoteBolt12Response, got {:?}", other), + } + } + #[test] fn notification_payload_custom_arrays_require_method_and_object_response() { let custom_mint = r#"[ @@ -512,6 +567,7 @@ mod tests { NotificationPayload::CustomMintQuoteResponse(method, response) => { assert_eq!(method, "paypal"); assert_eq!(response.quote, "abc"); + assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string())); } other => panic!("expected CustomMintQuoteResponse, got {:?}", other), } @@ -533,6 +589,7 @@ mod tests { NotificationPayload::CustomMeltQuoteResponse(method, response) => { assert_eq!(method, "paypal"); assert_eq!(response.quote, "abc"); + assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string())); } other => panic!("expected CustomMeltQuoteResponse, got {:?}", other), } diff --git a/crates/cashu/src/nuts/nut25.rs b/crates/cashu/src/nuts/nut25.rs index d5a42eb88..3d16a0eb9 100644 --- a/crates/cashu/src/nuts/nut25.rs +++ b/crates/cashu/src/nuts/nut25.rs @@ -1,8 +1,9 @@ //! Bolt12 +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; -use super::{CurrencyUnit, MeltOptions, PaymentMethod, PublicKey}; +use super::{BlindSignature, CurrencyUnit, MeltOptions, MeltQuoteState, PaymentMethod, PublicKey}; #[cfg(feature = "mint")] use crate::quote_id::QuoteId; use crate::Amount; @@ -110,7 +111,74 @@ pub struct MeltQuoteBolt12Request { } /// Melt quote response [NUT-25] -pub type MeltQuoteBolt12Response = crate::nuts::nut23::MeltQuoteBolt11Response; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(bound = "Q: Serialize + DeserializeOwned")] +pub struct MeltQuoteBolt12Response { + /// Quote Id + pub quote: Q, + /// The amount that needs to be provided + pub amount: Amount, + /// The fee reserve that is required + pub fee_reserve: Amount, + /// Quote State + pub state: MeltQuoteState, + /// Unix timestamp until the quote is valid + pub expiry: u64, + /// Payment preimage + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_preimage: Option, + /// Change + #[serde(skip_serializing_if = "Option::is_none")] + pub change: Option>, + /// Payment request to fulfill + // REVIEW: This is now required in the spec, we should remove the option once all mints update + #[serde(skip_serializing_if = "Option::is_none")] + pub request: Option, + /// Unit + // REVIEW: This is now required in the spec, we should remove the option once all mints update + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, + /// Payment method + #[serde(default = "default_bolt12_method")] + pub method: PaymentMethod, +} + +impl MeltQuoteBolt12Response { + /// Convert a `MeltQuoteBolt12Response` with type Q (generic/unknown) to a + /// `MeltQuoteBolt12Response` with `String` + pub fn to_string_id(self) -> MeltQuoteBolt12Response { + MeltQuoteBolt12Response { + quote: self.quote.to_string(), + amount: self.amount, + fee_reserve: self.fee_reserve, + state: self.state, + expiry: self.expiry, + payment_preimage: self.payment_preimage, + change: self.change, + request: self.request, + unit: self.unit, + method: self.method, + } + } +} + +#[cfg(feature = "mint")] +impl From> for MeltQuoteBolt12Response { + fn from(value: MeltQuoteBolt12Response) -> Self { + Self { + quote: value.quote.to_string(), + amount: value.amount, + fee_reserve: value.fee_reserve, + state: value.state, + expiry: value.expiry, + payment_preimage: value.payment_preimage, + change: value.change, + request: value.request, + unit: value.unit, + method: value.method, + } + } +} #[cfg(test)] mod tests { @@ -161,4 +229,44 @@ mod tests { from_value(value).expect("deserialize response"); assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12)); } + + #[test] + fn melt_quote_bolt12_response_serializes_method() { + let response = MeltQuoteBolt12Response { + quote: "quote-id".to_string(), + amount: Amount::from(10), + fee_reserve: Amount::from(2), + state: MeltQuoteState::Unpaid, + expiry: 1_701_704_757, + payment_preimage: None, + change: None, + request: Some("lno1...".to_string()), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::Known(KnownMethod::Bolt12), + }; + + let value = to_value(&response).expect("serialize response"); + assert_eq!(value["method"], json!("bolt12")); + + let decoded: MeltQuoteBolt12Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12)); + } + + #[test] + fn melt_quote_bolt12_response_defaults_method() { + let value = json!({ + "quote": "quote-id", + "amount": 10, + "fee_reserve": 2, + "state": "UNPAID", + "expiry": 1_701_704_757, + "request": "lno1...", + "unit": "sat" + }); + + let decoded: MeltQuoteBolt12Response = + from_value(value).expect("deserialize response"); + assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12)); + } } diff --git a/crates/cdk-common/src/mint.rs b/crates/cdk-common/src/mint.rs index 13dfcefa4..ff33deedf 100644 --- a/crates/cdk-common/src/mint.rs +++ b/crates/cdk-common/src/mint.rs @@ -9,9 +9,10 @@ use cashu::nuts::nut30::MeltQuoteOnchainFeeOption; use cashu::quote_id::QuoteId; use cashu::util::unix_time; use cashu::{ - Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MeltQuoteCustomResponse, - MeltQuoteOnchainResponse, MintQuoteBolt11Response, MintQuoteBolt12Response, - MintQuoteCustomResponse, MintQuoteOnchainResponse, PaymentMethod, Proofs, State, + Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MeltQuoteBolt12Response, + MeltQuoteCustomResponse, MeltQuoteOnchainResponse, MintQuoteBolt11Response, + MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteOnchainResponse, PaymentMethod, + Proofs, State, }; use lightning::offers::offer::Offer; use serde::{Deserialize, Serialize}; @@ -1044,9 +1045,7 @@ impl MeltQuote { /// response with the provided signatures. /// /// Dispatches to the per-variant `From` conversions so that - /// field mapping stays centralized. Note that `MeltQuoteBolt12Response` - /// is a type alias for `MeltQuoteBolt11Response`, so both Bolt11 and - /// Bolt12 go through the same conversion. + /// field mapping stays centralized. pub fn into_response( self, change: Option>, @@ -1058,7 +1057,7 @@ impl MeltQuote { crate::MeltQuoteResponse::Bolt11(response) } PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => { - let mut response: MeltQuoteBolt11Response = self.into(); + let mut response: MeltQuoteBolt12Response = self.into(); response.change = change; crate::MeltQuoteResponse::Bolt12(response) } @@ -1342,6 +1341,41 @@ impl From for crate::nuts::MeltQuoteCustomResponse { } } } + +impl From<&MeltQuote> for MeltQuoteBolt12Response { + fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt12Response { + MeltQuoteBolt12Response { + quote: melt_quote.id.clone(), + payment_preimage: None, + change: None, + state: melt_quote.state, + expiry: melt_quote.expiry, + amount: melt_quote.amount().into(), + fee_reserve: melt_quote.fee_reserve().into(), + request: None, + unit: Some(melt_quote.unit.clone()), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12), + } + } +} + +impl From for MeltQuoteBolt12Response { + fn from(melt_quote: MeltQuote) -> MeltQuoteBolt12Response { + MeltQuoteBolt12Response { + quote: melt_quote.id.clone(), + amount: melt_quote.amount().into(), + fee_reserve: melt_quote.fee_reserve().into(), + state: melt_quote.state, + expiry: melt_quote.expiry, + payment_preimage: melt_quote.payment_proof, + change: None, + request: Some(melt_quote.request.to_string()), + unit: Some(melt_quote.unit.clone()), + method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12), + } + } +} + impl TryFrom for MintQuoteResponse { type Error = Error; diff --git a/crates/cdk/src/event.rs b/crates/cdk/src/event.rs index 25a33e0ea..bcaf99831 100644 --- a/crates/cdk/src/event.rs +++ b/crates/cdk/src/event.rs @@ -6,8 +6,9 @@ use std::ops::Deref; use cdk_common::nut17::NotificationId; use cdk_common::pub_sub::Event; use cdk_common::{ - MeltQuoteBolt11Response, MeltQuoteOnchainResponse, MintQuoteBolt11Response, - MintQuoteBolt12Response, MintQuoteOnchainResponse, NotificationPayload, ProofState, + MeltQuoteBolt11Response, MeltQuoteBolt12Response, MeltQuoteOnchainResponse, + MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteOnchainResponse, + NotificationPayload, ProofState, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -104,6 +105,15 @@ where } } +impl From> for MintEvent +where + T: Clone + Eq + PartialEq, +{ + fn from(value: MeltQuoteBolt12Response) -> Self { + Self(NotificationPayload::MeltQuoteBolt12Response(value)) + } +} + impl From> for MintEvent where T: Clone + Eq + PartialEq, @@ -131,15 +141,7 @@ where fn get_topics(&self) -> Vec { match &self.0 { NotificationPayload::MeltQuoteBolt11Response(r) => { - // TODO: MeltQuoteBolt12Response is a type alias for MeltQuoteBolt11Response. - // Since NotificationPayload uses untagged serde, all melt responses are - // deserialized as Bolt11. We broadcast to both topics to ensure Bolt12 - // subscribers receive the event. This workaround should be addressed by - // properly distinguishing the response types in the protocol. - vec![ - NotificationId::MeltQuoteBolt11(r.quote.to_owned()), - NotificationId::MeltQuoteBolt12(r.quote.to_owned()), - ] + vec![NotificationId::MeltQuoteBolt11(r.quote.to_owned())] } NotificationPayload::MintQuoteBolt11Response(r) => { vec![NotificationId::MintQuoteBolt11(r.quote.to_owned())] diff --git a/crates/cdk/src/mint/melt/mod.rs b/crates/cdk/src/mint/melt/mod.rs index d5f55db56..b9ed55767 100644 --- a/crates/cdk/src/mint/melt/mod.rs +++ b/crates/cdk/src/mint/melt/mod.rs @@ -423,7 +423,7 @@ impl Mint { async fn get_melt_bolt12_quote_impl( &self, melt_request: &MeltQuoteBolt12Request, - ) -> Result, Error> { + ) -> Result, Error> { #[cfg(feature = "prometheus")] let metrics = super::MintMetricGuard::new("get_melt_bolt12_quote"); diff --git a/crates/cdk/src/wallet/streams/payment.rs b/crates/cdk/src/wallet/streams/payment.rs index aad2e3d6f..ab606497c 100644 --- a/crates/cdk/src/wallet/streams/payment.rs +++ b/crates/cdk/src/wallet/streams/payment.rs @@ -618,7 +618,18 @@ mod tests { } fn melt_bolt12_response(quote: &str, state: MeltQuoteState) -> MeltQuoteBolt12Response { - melt_bolt11_response(quote, state) + MeltQuoteBolt12Response { + quote: quote.to_string(), + amount: Amount::from(100u64), + fee_reserve: Amount::from(1u64), + state, + expiry: 1234, + payment_preimage: None, + change: None, + request: Some("test_request".to_string()), + unit: Some(CurrencyUnit::Sat), + method: PaymentMethod::BOLT12, + } } fn melt_onchain_response( diff --git a/crates/cdk/src/wallet/subscription.rs b/crates/cdk/src/wallet/subscription.rs index 78792b5c6..3b8cb65c5 100644 --- a/crates/cdk/src/wallet/subscription.rs +++ b/crates/cdk/src/wallet/subscription.rs @@ -27,6 +27,7 @@ use cdk_common::{ MeltQuoteOnchainResponse, Method, MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteOnchainResponse, PaymentMethod, ProofState, RoutePath, }; +use serde::de::DeserializeOwned; use tokio::sync::mpsc; use uuid::Uuid; @@ -53,6 +54,35 @@ enum RawWsMessageOrResponse { /// Notification Payload pub type NotificationPayload = crate::nuts::NotificationPayload; +fn fill_response_method(value: &mut serde_json::Value, method: &str) { + if let serde_json::Value::Object(object) = value { + object + .entry("method".to_string()) + .or_insert_with(|| serde_json::Value::String(method.to_string())); + } +} + +fn deserialize_custom_quote_payload( + mut payload: serde_json::Value, + kind: &str, + suffix: &str, +) -> Result +where + R: DeserializeOwned, +{ + let method = kind + .strip_suffix(suffix) + .filter(|method| !method.is_empty()) + .ok_or_else(|| { + PubsubError::ParsingError(format!( + "Invalid custom websocket notification kind: {kind}" + )) + })?; + + fill_response_method(&mut payload, method); + serde_json::from_value(payload).map_err(|err| PubsubError::ParsingError(err.to_string())) +} + /// Type alias pub type ActiveSubscription = RemoteActiveConsumer; @@ -270,16 +300,22 @@ fn decode_notification_payload( .map(NotificationPayload::MeltQuoteOnchainResponse) .map_err(|err| PubsubError::ParsingError(err.to_string())) } - Kind::Custom(method) if method.ends_with("_mint_quote") => serde_json::from_value::< - MintQuoteCustomResponse, - >(payload) - .map(|response| NotificationPayload::CustomMintQuoteResponse(method.clone(), response)) - .map_err(|err| PubsubError::ParsingError(err.to_string())), - Kind::Custom(method) if method.ends_with("_melt_quote") => serde_json::from_value::< - MeltQuoteCustomResponse, - >(payload) - .map(|response| NotificationPayload::CustomMeltQuoteResponse(method.clone(), response)) - .map_err(|err| PubsubError::ParsingError(err.to_string())), + Kind::Custom(method) if method.ends_with("_mint_quote") => { + deserialize_custom_quote_payload::>( + payload, + method, + "_mint_quote", + ) + .map(|response| NotificationPayload::CustomMintQuoteResponse(method.clone(), response)) + } + Kind::Custom(method) if method.ends_with("_melt_quote") => { + deserialize_custom_quote_payload::>( + payload, + method, + "_melt_quote", + ) + .map(|response| NotificationPayload::CustomMeltQuoteResponse(method.clone(), response)) + } Kind::Custom(method) => Err(PubsubError::ParsingError(format!( "Unsupported custom websocket notification kind: {method}" ))), @@ -750,7 +786,7 @@ mod tests { #[test] fn decode_bolt12_notification() { - let payload = json!({ + let mint_payload = json!({ "quote": "quote-id", "request": "lni1...", "amount": null, @@ -762,13 +798,31 @@ mod tests { "amount_paid": 0, "amount_issued": 0 }); + let melt_payload = json!({ + "quote": "melt-quote", + "amount": 21, + "fee_reserve": 1, + "state": "PAID", + "expiry": 1234, + "request": "lni1...", + "unit": "sat" + }); - let decoded = decode_notification_payload(&Kind::Bolt12MintQuote, payload).unwrap(); + let mint_decoded = + decode_notification_payload(&Kind::Bolt12MintQuote, mint_payload).unwrap(); + let melt_decoded = + decode_notification_payload(&Kind::Bolt12MeltQuote, melt_payload).unwrap(); assert!(matches!( - decoded, + mint_decoded, NotificationPayload::MintQuoteBolt12Response(_) )); + match melt_decoded { + NotificationPayload::MeltQuoteBolt12Response(response) => { + assert_eq!(response.method, PaymentMethod::BOLT12); + } + _ => panic!("expected bolt12 melt response"), + } } #[test] @@ -778,7 +832,6 @@ mod tests { let mint_payload = json!({ "quote": "mint-custom", "request": "custom-request", - "method": "foo", "amount": 42, "unit": "sat", "amount_paid": 0, @@ -791,7 +844,6 @@ mod tests { "quote": "melt-custom", "amount": 42, "fee_reserve": 1, - "method": "foo", "state": "PAID", "expiry": 1234, "payment_proof": null, @@ -807,11 +859,13 @@ mod tests { assert!(matches!( mint_decoded, - NotificationPayload::CustomMintQuoteResponse(method, _) if method == mint_method + NotificationPayload::CustomMintQuoteResponse(method, response) + if method == mint_method && response.method == "foo" )); assert!(matches!( melt_decoded, - NotificationPayload::CustomMeltQuoteResponse(method, _) if method == melt_method + NotificationPayload::CustomMeltQuoteResponse(method, response) + if method == melt_method && response.method == "foo" )); }