Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/cashu/src/nuts/nut04.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ pub struct MintQuoteCustomRequest {
/// ```json
/// {
/// "quote": "abc123",
/// "method": "paypal",
/// "amount": 1000,
/// "amount_paid": 0,
/// "amount_issued": 0,
Expand All @@ -431,6 +432,8 @@ pub struct MintQuoteCustomResponse<Q> {
pub quote: Q,
/// Payment request string (method-specific format)
pub request: String,
/// Payment method
pub method: PaymentMethod,
/// Amount
pub amount: Option<Amount>,
/// Amount that has been paid
Expand Down Expand Up @@ -463,6 +466,7 @@ impl<Q: ToString> MintQuoteCustomResponse<Q> {
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,
Expand All @@ -480,6 +484,7 @@ impl From<MintQuoteCustomResponse<QuoteId>> for MintQuoteCustomResponse<String>
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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));
}
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions crates/cashu/src/nuts/nut05.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ pub struct MeltQuoteCustomRequest {
/// ```json
/// {
/// "quote": "abc123",
/// "method": "custom",
/// "state": "UNPAID",
/// "amount": 1000,
/// "fee_reserve": 10,
Expand All @@ -529,6 +530,8 @@ pub struct MeltQuoteCustomRequest {
pub struct MeltQuoteCustomResponse<Q> {
/// Quote ID
pub quote: Q,
/// Payment method
pub method: PaymentMethod,
/// Amount to be melted
pub amount: Amount,
/// Fee reserve required, if provided
Expand Down Expand Up @@ -563,6 +566,7 @@ impl<Q: ToString> MeltQuoteCustomResponse<Q> {
pub fn to_string_id(&self) -> MeltQuoteCustomResponse<String> {
MeltQuoteCustomResponse {
quote: self.quote.to_string(),
method: self.method.clone(),
amount: self.amount,
fee_reserve: self.fee_reserve,
state: self.state,
Expand All @@ -581,6 +585,7 @@ impl From<MeltQuoteCustomResponse<QuoteId>> for MeltQuoteCustomResponse<String>
fn from(value: MeltQuoteCustomResponse<QuoteId>) -> Self {
Self {
quote: value.quote.to_string(),
method: value.method,
amount: value.amount,
fee_reserve: value.fee_reserve,
state: value.state,
Expand Down Expand Up @@ -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,
Expand All @@ -984,6 +990,10 @@ mod tests {
let response: MeltQuoteCustomResponse<String> = 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();
Expand All @@ -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,
Expand All @@ -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"));
}
}
14 changes: 13 additions & 1 deletion crates/cashu/src/nuts/nut08.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use super::nut05::{MeltQuoteCustomResponse, MeltRequest};
use super::nut23::MeltQuoteBolt11Response;
use super::nut25::MeltQuoteBolt12Response;
use crate::Amount;

impl<Q> MeltRequest<Q> {
Expand All @@ -24,6 +25,15 @@ impl<Q> MeltQuoteBolt11Response<Q> {
}
}

impl<Q> MeltQuoteBolt12Response<Q> {
/// Total change [`Amount`]
pub fn change_amount(&self) -> Option<Amount> {
self.change
.as_ref()
.and_then(|o| Amount::try_sum(o.iter().map(|proof| proof.amount)).ok())
}
}

impl<Q> MeltQuoteCustomResponse<Q> {
/// Total change [`Amount`]
pub fn change_amount(&self) -> Option<Amount> {
Expand All @@ -38,7 +48,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 {
Expand All @@ -65,6 +75,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)));
Expand All @@ -82,6 +93,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,
};

Expand Down
78 changes: 69 additions & 9 deletions crates/cashu/src/nuts/nut17/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, E>(value: serde_json::Value) -> Result<NotificationPayload<T>, E>
where
T: Clone + Serialize + DeserializeOwned,
Expand All @@ -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);
}

Expand All @@ -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")),
Expand Down Expand Up @@ -394,7 +421,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;
Expand All @@ -405,6 +432,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",
Expand Down Expand Up @@ -436,6 +464,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",
Expand Down Expand Up @@ -464,6 +493,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(),
Expand All @@ -490,6 +520,34 @@ mod tests {
}
}

#[test]
fn notification_payload_bolt12_melt_roundtrip() {
let resp: MeltQuoteBolt12Response<String> = 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<String> =
NotificationPayload::MeltQuoteBolt12Response(resp.clone());

let encoded = serde_json::to_string(&payload).unwrap();
let decoded: NotificationPayload<String> = 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#"[
Expand All @@ -509,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),
}
Expand All @@ -530,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),
}
Expand Down
Loading
Loading