Skip to content

Commit ee3e33d

Browse files
committed
feat(nut18): support payment request method fees
Replace the flat fee reserve with `sm` method objects carrying optional `mf` values, and encode them as NUT-26 supported-method sub-TLVs on tag 0x0a. Apply method fees when paying from an unlisted mint, or from any mint when the request has no mint list. Select the lowest fee among matching NUT-05 melt methods and keep requested amounts net of input fees. Cover the current NUT-18 and NUT-26 spec vectors, including the preferred-method-fee cases. Accept the standard-base64 NUT-18 vectors published by the spec while continuing to emit urlsafe CREQ-A strings.
1 parent d53a6d7 commit ee3e33d

10 files changed

Lines changed: 488 additions & 143 deletions

File tree

crates/cashu/examples/payment_request_encoding_benchmark.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ fn minimal_comparison() -> Result<(), Box<dyn std::error::Error>> {
9595
single_use: None,
9696
mints: vec![MintUrl::from_str("https://mint.example.com")?],
9797
mint_preferred: None,
98-
fee_reserve: None,
9998
supported_methods: vec![],
10099
description: None,
101100
transports: vec![],
@@ -114,7 +113,6 @@ fn amount_unit_comparison() -> Result<(), Box<dyn std::error::Error>> {
114113
single_use: None,
115114
mints: vec![MintUrl::from_str("https://mint.example.com")?],
116115
mint_preferred: None,
117-
fee_reserve: None,
118116
supported_methods: vec![],
119117
description: None,
120118
transports: vec![],
@@ -138,7 +136,6 @@ fn multiple_mints_comparison() -> Result<(), Box<dyn std::error::Error>> {
138136
MintUrl::from_str("https://backup-mint.cashu.space")?,
139137
],
140138
mint_preferred: None,
141-
fee_reserve: None,
142139
supported_methods: vec![],
143140
description: Some("Payment with multiple mint options".to_string()),
144141
transports: vec![],
@@ -166,7 +163,6 @@ fn transport_comparison() -> Result<(), Box<dyn std::error::Error>> {
166163
single_use: Some(true),
167164
mints: vec![MintUrl::from_str("https://mint.example.com")?],
168165
mint_preferred: None,
169-
fee_reserve: None,
170166
supported_methods: vec![],
171167
description: Some("Payment with callback transport".to_string()),
172168
transports: vec![transport],
@@ -206,7 +202,6 @@ fn complete_with_nut10_comparison() -> Result<(), Box<dyn std::error::Error>> {
206202
MintUrl::from_str("https://mint2.example.com")?,
207203
],
208204
mint_preferred: None,
209-
fee_reserve: None,
210205
supported_methods: vec![],
211206
description: Some("Complete payment with P2PK locking and refund key".to_string()),
212207
transports: vec![transport],
@@ -261,7 +256,6 @@ fn very_complex_comparison() -> Result<(), Box<dyn std::error::Error>> {
261256
MintUrl::from_str("https://emergency-mint.example.io")?,
262257
],
263258
mint_preferred: None,
264-
fee_reserve: None,
265259
supported_methods: vec![],
266260
description: Some("Complex payment with multiple mints and transports".to_string()),
267261
transports: vec![transport1, transport2],
@@ -522,7 +516,6 @@ mod tests {
522516
single_use: None,
523517
mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()],
524518
mint_preferred: None,
525-
fee_reserve: None,
526519
supported_methods: vec![],
527520
description: Some("Test".to_string()),
528521
transports: vec![],

crates/cashu/src/nuts/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ pub use nut14::HTLCWitness;
7373
pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings};
7474
pub use nut17::NotificationPayload;
7575
pub use nut18::{
76-
Nut10SecretRequest, PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, Transport,
77-
TransportBuilder, TransportType,
76+
Nut10SecretRequest, PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload,
77+
SupportedMethod, Transport, TransportBuilder, TransportType,
7878
};
7979
pub use nut23::{
8080
MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request,

crates/cashu/src/nuts/nut18/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pub mod secret;
1111
pub mod transport;
1212

1313
pub use error::Error;
14-
pub use payment_request::{PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload};
14+
pub use payment_request::{
15+
PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, SupportedMethod,
16+
};
1517
pub use secret::Nut10SecretRequest;
1618
pub use transport::{Transport, TransportBuilder, TransportType};

crates/cashu/src/nuts/nut18/payment_request.rs

Lines changed: 186 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,43 @@ use crate::Amount;
1717

1818
const PAYMENT_REQUEST_PREFIX: &str = "creqA";
1919

20+
/// Payment method accepted by the receiver for a payment request.
21+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22+
pub struct SupportedMethod {
23+
/// Payment method name, such as `bolt11`, `bolt12`, or `onchain`.
24+
#[serde(rename = "mn")]
25+
pub method: String,
26+
/// Additional fee in the request unit for payments from non-preferred mints.
27+
#[serde(rename = "mf")]
28+
#[serde(skip_serializing_if = "Option::is_none")]
29+
pub fee: Option<Amount>,
30+
}
31+
32+
impl SupportedMethod {
33+
/// Create a supported method without an additional method fee.
34+
pub fn new<S>(method: S) -> Self
35+
where
36+
S: Into<String>,
37+
{
38+
Self {
39+
method: method.into(),
40+
fee: None,
41+
}
42+
}
43+
44+
/// Create a supported method with an additional method fee.
45+
pub fn with_fee<S, A>(method: S, fee: A) -> Self
46+
where
47+
S: Into<String>,
48+
A: Into<Amount>,
49+
{
50+
Self {
51+
method: method.into(),
52+
fee: Some(fee.into()),
53+
}
54+
}
55+
}
56+
2057
/// NUT-18 payment request.
2158
///
2259
/// A receiver creates this request to tell a payer how much to send, which
@@ -27,7 +64,7 @@ pub struct PaymentRequest {
2764
/// Payment id to include in the payment payload.
2865
#[serde(rename = "i")]
2966
pub payment_id: Option<String>,
30-
/// Requested amount.
67+
/// Requested amount net of input fees.
3168
///
3269
/// If this is set, [`Self::unit`] must also be set.
3370
#[serde(rename = "a")]
@@ -55,22 +92,15 @@ pub struct PaymentRequest {
5592
#[serde(rename = "mp")]
5693
#[serde(skip_serializing_if = "Option::is_none")]
5794
pub mint_preferred: Option<bool>,
58-
/// Additional fee reserve for payments from outside a preferred mint list.
59-
///
60-
/// When [`Self::mints`] is non-empty and [`Self::mint_preferred`] is
61-
/// `true`, a payer using a mint outside [`Self::mints`] must add this
62-
/// amount to the requested amount. Ignored when the mint list is strict or
63-
/// empty.
64-
#[serde(rename = "fr")]
65-
#[serde(skip_serializing_if = "Option::is_none")]
66-
pub fee_reserve: Option<Amount>,
6795
/// Payment methods the payer's mint must support.
6896
///
6997
/// If non-empty, the payer must send ecash from a mint that supports at
70-
/// least one listed method, such as `bolt11`, `bolt12`, or `onchain`.
98+
/// least one listed method. Each method can carry a fee that only applies
99+
/// to payments from non-preferred mints, or from any mint if no mint list is
100+
/// set.
71101
#[serde(rename = "sm")]
72102
#[serde(skip_serializing_if = "Vec::is_empty", default)]
73-
pub supported_methods: Vec<String>,
103+
pub supported_methods: Vec<SupportedMethod>,
74104
/// Human-readable description for the payer to display.
75105
#[serde(rename = "d")]
76106
pub description: Option<String>,
@@ -126,7 +156,18 @@ impl FromStr for PaymentRequest {
126156

127157
let decode_config = general_purpose::GeneralPurposeConfig::new()
128158
.with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
129-
let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
159+
let decoded = match GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s) {
160+
Ok(decoded) => decoded,
161+
Err(url_safe_err) => {
162+
let decode_config = general_purpose::GeneralPurposeConfig::new()
163+
.with_decode_padding_mode(
164+
bitcoin::base64::engine::DecodePaddingMode::Indifferent,
165+
);
166+
GeneralPurpose::new(&alphabet::STANDARD, decode_config)
167+
.decode(s)
168+
.map_err(|_| url_safe_err)?
169+
}
170+
};
130171

131172
Ok(ciborium::from_reader(&decoded[..])?)
132173
}
@@ -141,8 +182,7 @@ pub struct PaymentRequestBuilder {
141182
single_use: Option<bool>,
142183
mints: Vec<MintUrl>,
143184
mint_preferred: Option<bool>,
144-
fee_reserve: Option<Amount>,
145-
supported_methods: Vec<String>,
185+
supported_methods: Vec<SupportedMethod>,
146186
description: Option<String>,
147187
transports: Vec<Transport>,
148188
nut10: Option<Nut10SecretRequest>,
@@ -206,18 +246,15 @@ impl PaymentRequestBuilder {
206246
self
207247
}
208248

209-
/// Set fee reserve for payments from outside a preferred mint list.
210-
pub fn fee_reserve<A>(mut self, fee_reserve: A) -> Self
211-
where
212-
A: Into<Amount>,
213-
{
214-
self.fee_reserve = Some(fee_reserve.into());
249+
/// Set payment methods the payer's mint must support.
250+
pub fn supported_methods(mut self, methods: Vec<SupportedMethod>) -> Self {
251+
self.supported_methods = methods;
215252
self
216253
}
217254

218-
/// Set payment methods the payer's mint must support.
219-
pub fn supported_methods(mut self, methods: Vec<String>) -> Self {
220-
self.supported_methods = methods;
255+
/// Add a payment method the payer's mint must support.
256+
pub fn add_supported_method(mut self, method: SupportedMethod) -> Self {
257+
self.supported_methods.push(method);
221258
self
222259
}
223260

@@ -254,7 +291,6 @@ impl PaymentRequestBuilder {
254291
single_use: self.single_use,
255292
mints: self.mints,
256293
mint_preferred: self.mint_preferred,
257-
fee_reserve: self.fee_reserve,
258294
supported_methods: self.supported_methods,
259295
description: self.description,
260296
transports: self.transports,
@@ -324,7 +360,6 @@ mod tests {
324360
.parse()
325361
.expect("valid mint url")],
326362
mint_preferred: None,
327-
fee_reserve: None,
328363
supported_methods: vec![],
329364
description: None,
330365
transports: vec![transport.clone()],
@@ -499,6 +534,38 @@ mod tests {
499534
assert_eq!(decoded.mint_preferred, Some(false));
500535
}
501536

537+
#[test]
538+
fn test_supported_methods_serialize_as_method_objects() {
539+
let payment_request = PaymentRequestBuilder::default()
540+
.add_supported_method(SupportedMethod::new("bolt11"))
541+
.add_supported_method(SupportedMethod::with_fee("bolt12", 5))
542+
.build();
543+
544+
let value = serde_json::to_value(&payment_request).unwrap();
545+
assert_eq!(
546+
value.get("sm"),
547+
Some(&serde_json::json!([
548+
{ "mn": "bolt11" },
549+
{ "mn": "bolt12", "mf": 5 }
550+
]))
551+
);
552+
553+
let decoded: PaymentRequest = serde_json::from_value(serde_json::json!({
554+
"sm": [
555+
{ "mn": "bolt11" },
556+
{ "mn": "bolt12", "mf": 5 }
557+
]
558+
}))
559+
.unwrap();
560+
assert_eq!(
561+
decoded.supported_methods,
562+
vec![
563+
SupportedMethod::new("bolt11"),
564+
SupportedMethod::with_fee("bolt12", 5),
565+
]
566+
);
567+
}
568+
502569
#[test]
503570
fn test_nut10_secret_request_htlc() {
504571
let bolt11 = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9";
@@ -625,6 +692,72 @@ mod tests {
625692
);
626693
}
627694

695+
#[test]
696+
fn test_complete_payment_request() {
697+
// Complete payment request with all optional fields included
698+
let expected_encoded = "creqAqGF0gaNhdGRwb3N0YWF4G2h0dHBzOi8vYXBpLmV4YW1wbGUuY29tL3BheWFn92FpaDQ4NDBmNTFlYWEZA+hhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWFkcFByb2R1Y3QgcHVyY2hhc2Vhc/VlbnV0MTCjYWtkUDJQS2FkeEIwM2JhZjBjM2FjMjIwMzY2YzJjMzk3YmY5MzA1NzljNDE2MzQzNTU4NGY1NzNiMTA5MTA5ODdjNTQ0YzU5ZTYxZjFhdIGCZ3B1cnBvc2Vnb2ZmbGluZQ==";
699+
700+
let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
701+
702+
assert_eq!(decoded_from_spec.payment_id, Some("4840f51e".to_string()));
703+
assert_eq!(decoded_from_spec.amount, Some(Amount::from(1000)));
704+
assert_eq!(decoded_from_spec.unit, Some(CurrencyUnit::Sat));
705+
assert_eq!(decoded_from_spec.single_use, Some(true));
706+
assert_eq!(
707+
decoded_from_spec.mints,
708+
vec![MintUrl::from_str("https://mint.example.com").unwrap()]
709+
);
710+
assert_eq!(
711+
decoded_from_spec.description,
712+
Some("Product purchase".to_string())
713+
);
714+
assert_eq!(decoded_from_spec.transports.len(), 1);
715+
assert_eq!(
716+
decoded_from_spec.transports[0]._type,
717+
TransportType::HttpPost
718+
);
719+
assert_eq!(
720+
decoded_from_spec.transports[0].target,
721+
"https://api.example.com/pay"
722+
);
723+
724+
let nut10 = decoded_from_spec.nut10.expect("nut10");
725+
assert_eq!(nut10.kind, Kind::P2PK);
726+
assert_eq!(
727+
nut10.data,
728+
"03baf0c3ac220366c2c397bf930579c4163435584f573b10910987c544c59e61f1"
729+
);
730+
assert_eq!(
731+
nut10.tags,
732+
Some(vec![vec!["purpose".to_string(), "offline".to_string()]])
733+
);
734+
}
735+
736+
#[test]
737+
fn test_http_transport_payment_request() {
738+
// HTTP POST transport payment request
739+
let expected_encoded = "creqApWF0gaNhdGRwb3N0YWF4H2h0dHBzOi8vYXBpLmV4YW1wbGUuY29tL3JlY2VpdmVhZ/dhaWhhMmMxMmY0NWFhGDJhdWNzYXRhbYF4GWh0dHBzOi8vY2FzaHUuZXhhbXBsZS5jb20=";
740+
741+
let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
742+
743+
assert_eq!(decoded_from_spec.payment_id, Some("a2c12f45".to_string()));
744+
assert_eq!(decoded_from_spec.amount, Some(Amount::from(50)));
745+
assert_eq!(decoded_from_spec.unit, Some(CurrencyUnit::Sat));
746+
assert_eq!(
747+
decoded_from_spec.mints,
748+
vec![MintUrl::from_str("https://cashu.example.com").unwrap()]
749+
);
750+
assert_eq!(decoded_from_spec.transports.len(), 1);
751+
assert_eq!(
752+
decoded_from_spec.transports[0]._type,
753+
TransportType::HttpPost
754+
);
755+
assert_eq!(
756+
decoded_from_spec.transports[0].target,
757+
"https://api.example.com/receive"
758+
);
759+
}
760+
628761
#[test]
629762
fn test_nostr_transport_payment_request() {
630763
// Nostr transport payment request with multiple mints
@@ -781,6 +914,33 @@ mod tests {
781914
assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a");
782915
}
783916

917+
#[test]
918+
fn test_preferred_mint_list_with_supported_methods() {
919+
// Preferred mint list with supported methods and per-method fee
920+
let expected_encoded = "creqApmFpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWJtcPVic22CoWJtbmZib2x0MTGiYm1uZmJvbHQxMmJtZgU=";
921+
922+
let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
923+
924+
assert_eq!(
925+
decoded_from_spec.payment_id,
926+
Some("preferred_fee_methods".to_string())
927+
);
928+
assert_eq!(decoded_from_spec.amount, Some(Amount::from(100)));
929+
assert_eq!(decoded_from_spec.unit, Some(CurrencyUnit::Sat));
930+
assert_eq!(
931+
decoded_from_spec.mints,
932+
vec![MintUrl::from_str("https://mint.example.com").unwrap()]
933+
);
934+
assert_eq!(decoded_from_spec.mint_preferred, Some(true));
935+
assert_eq!(
936+
decoded_from_spec.supported_methods,
937+
vec![
938+
SupportedMethod::new("bolt11"),
939+
SupportedMethod::with_fee("bolt12", 5),
940+
]
941+
);
942+
}
943+
784944
#[test]
785945
fn test_from_str_handles_both_formats() {
786946
// Create a payment request
@@ -791,7 +951,6 @@ mod tests {
791951
single_use: None,
792952
mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()],
793953
mint_preferred: None,
794-
fee_reserve: None,
795954
supported_methods: vec![],
796955
description: Some("Test both formats".to_string()),
797956
transports: vec![],

0 commit comments

Comments
 (0)