diff --git a/crates/cashu/src/nuts/nut04.rs b/crates/cashu/src/nuts/nut04.rs index b5293f8419..f4c040ffea 100644 --- a/crates/cashu/src/nuts/nut04.rs +++ b/crates/cashu/src/nuts/nut04.rs @@ -348,7 +348,11 @@ impl Settings { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MintQuoteCustomRequest { /// Amount to mint - pub amount: Amount, + /// + /// Optional common field. Method-specific NUTs make it required or ignore + /// it as needed (e.g. NUT-23 requires `amount`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option, /// Currency unit pub unit: CurrencyUnit, /// Optional description diff --git a/crates/cashu/src/nuts/nut05.rs b/crates/cashu/src/nuts/nut05.rs index b71eed9f4b..dd943eefde 100644 --- a/crates/cashu/src/nuts/nut05.rs +++ b/crates/cashu/src/nuts/nut05.rs @@ -460,6 +460,12 @@ pub struct MeltQuoteCustomRequest { pub request: String, /// Currency unit pub unit: CurrencyUnit, + /// Amount the wallet would like to pay + /// + /// Optional common field. Method-specific NUTs make it required or ignore + /// it as needed (e.g. NUT-30 requires `amount` for onchain melts). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option, /// Extra payment-method-specific fields /// /// These fields are flattened into the JSON representation, allowing diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 910b7e73c7..0ae1e60729 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -869,7 +869,7 @@ mod tests { .get_mint_quote(cdk::mint::MintQuoteRequest::Custom { method: PaymentMethod::Custom(method.to_string()), request: MintQuoteCustomRequest { - amount: Amount::from(amount), + amount: Some(Amount::from(amount)), unit: CurrencyUnit::Sat, description: None, pubkey: None, diff --git a/crates/cdk-common/src/melt.rs b/crates/cdk-common/src/melt.rs index 8942d88259..63df26cc7c 100644 --- a/crates/cdk-common/src/melt.rs +++ b/crates/cdk-common/src/melt.rs @@ -489,6 +489,7 @@ mod tests { method: "cashapp".to_string(), unit: CurrencyUnit::Sat, request: "$tag".to_string(), + amount: None, extra: serde_json::Value::Null, }; let req: MeltQuoteRequest = custom_req.into(); diff --git a/crates/cdk-common/src/mint_quote.rs b/crates/cdk-common/src/mint_quote.rs index f48625e338..5279158032 100644 --- a/crates/cdk-common/src/mint_quote.rs +++ b/crates/cdk-common/src/mint_quote.rs @@ -63,7 +63,7 @@ impl MintQuoteRequest { Self::Bolt11(request) => Some(request.amount), Self::Bolt12(request) => request.amount, Self::Onchain(_) => None, - Self::Custom { request, .. } => Some(request.amount), + Self::Custom { request, .. } => request.amount, } } diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index c16d1d9f7a..af8dfaa1e7 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -222,8 +222,8 @@ pub struct CustomIncomingPaymentOptions { pub method: String, /// Optional description for the payment request pub description: Option, - /// Amount for the payment request - pub amount: Amount, + /// Optional amount for the payment request + pub amount: Option>, /// Optional expiry time as Unix timestamp in seconds pub unix_expiry: Option, /// Extra payment-method-specific fields as JSON string @@ -293,6 +293,8 @@ pub struct CustomOutgoingPaymentOptions { pub method: String, /// Payment request string (method-specific format) pub request: String, + /// Optional amount the wallet would like to pay (from the melt quote request) + pub amount: Option>, /// Maximum fee amount allowed for the payment pub max_fee_amount: Option>, /// Optional timeout in seconds @@ -391,6 +393,8 @@ impl OutgoingPaymentOptions { Box::new(CustomOutgoingPaymentOptions { method: method.to_string(), request: request.to_string(), + // Payment is already quoted; correlation is via quote_id. + amount: None, max_fee_amount: Some(fee_reserve), timeout_secs: None, melt_options: melt_quote.options, diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index c14e142f01..6b8940ac46 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -878,7 +878,9 @@ impl MintPayment for FakeWallet { ( PaymentIdentifier::CustomId(custom_id), request, - custom_options.amount, + custom_options + .amount + .unwrap_or_else(|| Amount::new(0, self.unit.clone())), custom_options.unix_expiry, ) } @@ -1110,7 +1112,7 @@ mod tests { CustomIncomingPaymentOptions { method: "venmo".to_string(), description: None, - amount: Amount::new(10, CurrencyUnit::Sat), + amount: Some(Amount::new(10, CurrencyUnit::Sat)), unix_expiry: None, extra_json: None, }, diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 1aa89dccbc..94faee2df1 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -1753,6 +1753,7 @@ async fn test_direct_connector_custom_melt_enum_roundtrip() { method: "paypal".to_string(), request: "invoice-123".to_string(), unit: CurrencyUnit::Sat, + amount: None, extra: serde_json::Value::Null, }); @@ -2482,6 +2483,7 @@ async fn test_custom_melt_quote_status_preserves_extra_json() { method: "test-custom".to_string(), request: "custom-request".to_string(), unit: CurrencyUnit::Sat, + amount: None, extra: serde_json::json!({ "request_metadata": true }), }, )) @@ -2543,6 +2545,7 @@ async fn test_custom_melt_quote_id_propagates_to_payment_processor() { method: "test-custom".to_string(), request: "custom-request".to_string(), unit: CurrencyUnit::Sat, + amount: None, extra: serde_json::json!({ "request_metadata": true }), }, )) diff --git a/crates/cdk-payment-processor/src/proto/client.rs b/crates/cdk-payment-processor/src/proto/client.rs index b6d7eed50e..9dfc250033 100644 --- a/crates/cdk-payment-processor/src/proto/client.rs +++ b/crates/cdk-payment-processor/src/proto/client.rs @@ -181,7 +181,7 @@ impl MintPayment for PaymentProcessorClient { options: Some(super::incoming_payment_options::Options::Custom( super::CustomIncomingPaymentOptions { description: opts.description, - amount: Some(opts.amount.into()), + amount: opts.amount.map(Into::into), unix_expiry: opts.unix_expiry, extra_json: opts.extra_json.clone(), }, @@ -330,6 +330,7 @@ impl MintPayment for PaymentProcessorClient { options: Some(super::outgoing_payment_variant::Options::Custom( super::CustomOutgoingPaymentOptions { offer: opts.request.to_string(), + amount: opts.amount.map(Into::into), max_fee_amount: opts.max_fee_amount.into_proto(), timeout_secs: opts.timeout_secs, melt_options: opts.melt_options.map(Into::into), diff --git a/crates/cdk-payment-processor/src/proto/payment_processor.proto b/crates/cdk-payment-processor/src/proto/payment_processor.proto index bba74f3e89..a9cc9f69b6 100644 --- a/crates/cdk-payment-processor/src/proto/payment_processor.proto +++ b/crates/cdk-payment-processor/src/proto/payment_processor.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package cdk_payment_processor; -service CdkPaymentProcessor { +service CdkPaymentProcessor { rpc GetSettings(EmptyRequest) returns (SettingsResponse) {} rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse) {} rpc GetPaymentQuote(PaymentQuoteRequest) returns (PaymentQuoteResponse) {} @@ -232,6 +232,8 @@ message CustomOutgoingPaymentOptions { string offer = 1; optional AmountMessage max_fee_amount = 2; optional uint64 timeout_secs = 3; + // Optional amount the wallet would like to pay (from the melt quote request) + optional AmountMessage amount = 4; optional MeltOptions melt_options = 5; // Extra payment-method-specific fields as JSON string // These fields are flattened into the JSON representation on the client side diff --git a/crates/cdk-payment-processor/src/proto/server.rs b/crates/cdk-payment-processor/src/proto/server.rs index d45825c08a..372b7c4485 100644 --- a/crates/cdk-payment-processor/src/proto/server.rs +++ b/crates/cdk-payment-processor/src/proto/server.rs @@ -224,11 +224,13 @@ impl CdkPaymentProcessor for PaymentProcessorServer { .ok_or_else(|| Status::invalid_argument("Missing options"))? { incoming_payment_options::Options::Custom(opts) => { - let amount = opts - .amount - .ok_or_else(|| Status::invalid_argument("Missing amount"))? - .try_into() - .map_err(|_| Status::invalid_argument("Invalid amount"))?; + let amount: Option> = match opts.amount { + Some(a) => Some( + a.try_into() + .map_err(|_| Status::invalid_argument("Invalid amount"))?, + ), + None => None, + }; IncomingPaymentOptions::Custom(Box::new( cdk_common::payment::CustomIncomingPaymentOptions { method: "".to_string(), @@ -332,6 +334,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer { cdk_common::payment::CustomOutgoingPaymentOptions { method: String::new(), // Will be set from variant request: request.request.clone(), + amount: None, max_fee_amount: None, timeout_secs: None, melt_options: request.options.map(TryInto::try_into).transpose()?, @@ -450,11 +453,19 @@ impl CdkPaymentProcessor for PaymentProcessorServer { .try_from_proto() .map_err(|_| Status::invalid_argument("Invalid max_fee_amount"))?; let quote_id = parse_quote_id(&opts.quote_id)?; + let amount: Option> = match opts.amount { + Some(a) => Some( + a.try_into() + .map_err(|_| Status::invalid_argument("Invalid amount"))?, + ), + None => None, + }; cdk_common::payment::OutgoingPaymentOptions::Custom(Box::new( cdk_common::payment::CustomOutgoingPaymentOptions { method: String::new(), // Method will be determined from context request: opts.offer, // Reusing offer field for custom request string + amount, max_fee_amount, timeout_secs: opts.timeout_secs, melt_options: opts.melt_options.map(TryInto::try_into).transpose()?, diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 1707c28008..edb2dae93b 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -312,7 +312,7 @@ impl Mint { let custom_options = CustomIncomingPaymentOptions { method: payment_method.to_string(), description: request.description, - amount: request.amount.with_unit(unit.clone()), + amount: request.amount.map(|a| a.with_unit(unit.clone())), unix_expiry: Some(quote_expiry), extra_json, }; diff --git a/crates/cdk/src/mint/melt/mod.rs b/crates/cdk/src/mint/melt/mod.rs index 99c0b1668b..0fda87f77c 100644 --- a/crates/cdk/src/mint/melt/mod.rs +++ b/crates/cdk/src/mint/melt/mod.rs @@ -682,6 +682,7 @@ impl Mint { request, unit, method, + amount, extra, } = melt_request; @@ -720,6 +721,7 @@ impl Mint { OutgoingPaymentOptions::Custom(Box::new(CustomOutgoingPaymentOptions { method: method.to_string(), request: request.clone(), + amount: (*amount).map(|a| a.with_unit(unit.clone())), max_fee_amount: None, timeout_secs: None, melt_options: None, diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs index dd09693f16..f66dd745cf 100644 --- a/crates/cdk/src/wallet/issue/mod.rs +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -155,7 +155,7 @@ impl Wallet { MintQuoteRequest::Custom { method: method.clone(), request: cdk_common::nuts::MintQuoteCustomRequest { - amount, + amount: Some(amount), unit: unit.clone(), description, pubkey: Some(secret_key.public_key()), diff --git a/crates/cdk/src/wallet/melt/custom.rs b/crates/cdk/src/wallet/melt/custom.rs index b166e8b636..397c1e0c45 100644 --- a/crates/cdk/src/wallet/melt/custom.rs +++ b/crates/cdk/src/wallet/melt/custom.rs @@ -27,6 +27,7 @@ impl Wallet { method: method.to_string(), request: request.clone(), unit: self.unit.clone(), + amount: None, extra: extra.unwrap_or(serde_json::Value::Null), }; let quote_res = self diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index e3438f69d5..abc52f83a4 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -980,7 +980,7 @@ mod tests { let request = MintQuoteRequest::Custom { method: PaymentMethod::Custom("paypal".to_string()), request: MintQuoteCustomRequest { - amount: cdk_common::Amount::from(1000), + amount: Some(cdk_common::Amount::from(1000)), unit: cdk_common::CurrencyUnit::Sat, description: None, pubkey: None, @@ -1016,7 +1016,7 @@ mod tests { // Verify the actual field values round-tripped correctly let parsed = parsed.expect("already checked"); - assert_eq!(parsed.amount, cdk_common::Amount::from(1000)); + assert_eq!(parsed.amount, Some(cdk_common::Amount::from(1000))); assert_eq!(parsed.unit, cdk_common::CurrencyUnit::Sat); } @@ -1033,7 +1033,7 @@ mod tests { .post_mint_quote(MintQuoteRequest::Custom { method: invalid_method.clone(), request: MintQuoteCustomRequest { - amount: cdk_common::Amount::from(1000), + amount: Some(cdk_common::Amount::from(1000)), unit: cdk_common::CurrencyUnit::Sat, description: None, pubkey: None, @@ -1088,6 +1088,7 @@ mod tests { method: "../../v1/swap".to_string(), request: "custom-payment-request".to_string(), unit: cdk_common::CurrencyUnit::Sat, + amount: None, extra: serde_json::Value::Null, })) .await;