diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index 32044ad24..023b508af 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -355,87 +355,6 @@ where } } -/// Classify a notification payload by its discriminating fields instead of -/// serde `untagged` variant order. The quote responses for different payment -/// 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 deserialize_payload(value: serde_json::Value) -> Result, E> -where - T: Clone + Serialize + DeserializeOwned, - E: DeError, -{ - fn from_value(value: serde_json::Value) -> Result - where - V: DeserializeOwned, - E: DeError, - { - serde_json::from_value(value).map_err(E::custom) - } - - match &value { - serde_json::Value::Object(fields) => { - if fields.contains_key("Y") { - return from_value(value).map(NotificationPayload::ProofState); - } - - if fields.contains_key("fee_options") { - return from_value(value).map(NotificationPayload::MeltQuoteOnchainResponse); - } - - 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); - } - - if fields.contains_key("state") { - return from_value(value).map(NotificationPayload::MintQuoteBolt11Response); - } - - if fields.contains_key("amount") { - return from_value(value).map(NotificationPayload::MintQuoteBolt12Response); - } - - 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"))?; - 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)?; - } - } - - 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) - }) - } - } - _ => Err(E::custom("invalid notification payload")), - } -} - impl<'de, T> Deserialize<'de> for NotificationPayload where T: Clone + Serialize + DeserializeOwned, @@ -444,8 +363,11 @@ where where D: serde::Deserializer<'de>, { - let value = serde_json::Value::deserialize(deserializer)?; - deserialize_payload(value) + let _ = serde_json::Value::deserialize(deserializer)?; + Err(D::Error::custom( + "notification payloads require subscription kind context; use \ + nut17::deserialize_payload_for_kind", + )) } } @@ -918,4 +840,22 @@ mod tests { ) .is_err()); } + #[test] + fn notification_payload_without_kind_context_errors() { + let encoded = r#"{ + "quote": "abc", + "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "unit": "sat", + "expiry": 1701704757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "amount_paid": 0, + "amount_issued": 0 + }"#; + + let err = serde_json::from_str::>(encoded).unwrap_err(); + + assert!(err + .to_string() + .contains("require subscription kind context")); + } } diff --git a/crates/cashu/src/nuts/nut17/ws.rs b/crates/cashu/src/nuts/nut17/ws.rs index 3cbcf8319..30b89ebb6 100644 --- a/crates/cashu/src/nuts/nut17/ws.rs +++ b/crates/cashu/src/nuts/nut17/ws.rs @@ -34,8 +34,14 @@ pub struct WsUnsubscribeResponse { /// /// This is the notification that is sent to the client when an event matches a /// subscription -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned")] +/// +/// This type is serialize-only in practice: notification payloads are not +/// self-describing. Clients should deserialize notifications as +/// [`RawNotificationInner`] and decode the payload with +/// [`deserialize_payload_for_kind`](super::deserialize_payload_for_kind), using +/// the kind of the subscription the notification belongs to. +#[derive(Debug, Clone, Serialize)] +#[serde(bound(serialize = "T: Serialize + DeserializeOwned, I: Serialize"))] pub struct NotificationInner where T: Clone, @@ -48,6 +54,21 @@ where pub payload: NotificationPayload, } +/// The raw notification received from the websocket server. +/// +/// This keeps the payload as JSON so clients can decode it with the kind of the +/// subscription that produced the notification. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound = "I: Serialize + DeserializeOwned")] +pub struct RawNotificationInner { + /// The subscription ID + #[serde(rename = "subId")] + pub sub_id: I, + + /// The raw notification payload + pub payload: serde_json::Value, +} + /// Responses from the web socket server #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(bound = "I: Serialize + DeserializeOwned")] @@ -158,8 +179,12 @@ pub struct WsErrorResponse { } /// Message from the server to the client -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound = "I: Serialize + DeserializeOwned")] +/// +/// This type is serialize-only in practice. Clients parsing incoming messages +/// should use [`RawWsMessageOrResponse`] so notification payloads are kept as +/// raw JSON until the subscription kind is known. +#[derive(Debug, Clone, Serialize)] +#[serde(bound(serialize = "I: Serialize + DeserializeOwned"))] #[serde(untagged)] pub enum WsMessageOrResponse { /// A response to a request @@ -170,6 +195,23 @@ pub enum WsMessageOrResponse { Notification(Box>>), } +/// Raw message from the server to the client. +/// +/// Use this type when deserializing websocket messages from a mint. Notification +/// payloads must then be decoded with +/// [`deserialize_payload_for_kind`](super::deserialize_payload_for_kind). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound = "I: Serialize + DeserializeOwned")] +#[serde(untagged)] +pub enum RawWsMessageOrResponse { + /// A response to a request + Response(WsResponse), + /// An error response + ErrorResponse(WsErrorResponse), + /// A notification with raw JSON payload + Notification(Box>>), +} + impl From<(usize, Result, WsErrorBody>)> for WsMessageOrResponse { fn from((id, result): (usize, Result, WsErrorBody>)) -> Self { match result { @@ -186,3 +228,34 @@ impl From<(usize, Result, WsErrorBody>)> for WsMessageOrR } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_ws_message_deserializes_notification_payload_as_json() { + let encoded = r#"{ + "jsonrpc": "2.0", + "method": "subscribe", + "params": { + "subId": "sub-id", + "payload": { + "quote": "quote-id", + "method": "bolt12" + } + } + }"#; + + let decoded: RawWsMessageOrResponse = + serde_json::from_str(encoded).expect("raw websocket notification"); + + match decoded { + RawWsMessageOrResponse::Notification(notification) => { + assert_eq!(notification.params.sub_id, "sub-id"); + assert_eq!(notification.params.payload["quote"], "quote-id"); + } + other => panic!("expected notification, got {:?}", other), + } + } +} diff --git a/crates/cdk-common/src/ws.rs b/crates/cdk-common/src/ws.rs index bce068fcf..1b9eab57d 100644 --- a/crates/cdk-common/src/ws.rs +++ b/crates/cdk-common/src/ws.rs @@ -44,6 +44,12 @@ pub type WsErrorBody = nut17::ws::WsErrorBody; /// Either a websocket message or a response pub type WsMessageOrResponse = nut17::ws::WsMessageOrResponse; +/// Raw notification content with an undecoded JSON payload +pub type RawNotificationInner = nut17::ws::RawNotificationInner; + +/// Either a websocket message or a response with raw notification payloads +pub type RawWsMessageOrResponse = nut17::ws::RawWsMessageOrResponse; + /// Inner content of a notification with generic payload type pub type NotificationInner = nut17::ws::NotificationInner; diff --git a/crates/cdk/src/wallet/subscription.rs b/crates/cdk/src/wallet/subscription.rs index de4a21807..6773c6f1b 100644 --- a/crates/cdk/src/wallet/subscription.rs +++ b/crates/cdk/src/wallet/subscription.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use cdk_common::nut00::KnownMethod; use cdk_common::nut17::ws::{ - WsErrorResponse, WsMethodRequest, WsNotification, WsRequest, WsResponse, WsUnsubscribeRequest, + RawWsMessageOrResponse, WsMethodRequest, WsRequest, WsUnsubscribeRequest, }; use cdk_common::nut17::{deserialize_payload_for_kind, Kind, NotificationId}; use cdk_common::parking_lot::RwLock; @@ -30,22 +30,6 @@ use crate::event::MintEvent; use crate::mint_url::MintUrl; use crate::wallet::MintConnector; -#[derive(Debug, Clone, serde::Deserialize)] -struct RawNotificationInner { - #[serde(rename = "subId")] - sub_id: I, - payload: serde_json::Value, -} - -#[derive(Debug, Clone, serde::Deserialize)] -#[serde(bound = "I: serde::Serialize + serde::de::DeserializeOwned")] -#[serde(untagged)] -enum RawWsMessageOrResponse { - Response(WsResponse), - ErrorResponse(WsErrorResponse), - Notification(Box>>), -} - /// Notification Payload pub type NotificationPayload = crate::nuts::NotificationPayload;