Skip to content

Commit 81e0708

Browse files
committed
refactor(nut17)!: require raw websocket notification decoding
Notification payloads are not self-describing. Keep websocket notification payloads as raw JSON until callers can decode them with subscription-kind context. Expose raw websocket message aliases and make context-free NotificationPayload and WsMessageOrResponse deserialization unavailable to avoid silently selecting the wrong quote payload variant. BREAKING CHANGE: NotificationPayload and WsMessageOrResponse no longer deserialize notification payloads without subscription-kind context.
1 parent 8b002eb commit 81e0708

4 files changed

Lines changed: 107 additions & 104 deletions

File tree

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

Lines changed: 23 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -353,87 +353,6 @@ where
353353
}
354354
}
355355

356-
/// Classify a notification payload by its discriminating fields instead of
357-
/// serde `untagged` variant order. The quote responses for different payment
358-
/// methods share most field names, and the structs tolerate unknown fields
359-
/// for forward compatibility, so untagged trial-and-error would pick the
360-
/// wrong variant.
361-
fn deserialize_payload<T, E>(value: serde_json::Value) -> Result<NotificationPayload<T>, E>
362-
where
363-
T: Clone + Serialize + DeserializeOwned,
364-
E: DeError,
365-
{
366-
fn from_value<V, E>(value: serde_json::Value) -> Result<V, E>
367-
where
368-
V: DeserializeOwned,
369-
E: DeError,
370-
{
371-
serde_json::from_value(value).map_err(E::custom)
372-
}
373-
374-
match &value {
375-
serde_json::Value::Object(fields) => {
376-
if fields.contains_key("Y") {
377-
return from_value(value).map(NotificationPayload::ProofState);
378-
}
379-
380-
if fields.contains_key("fee_options") {
381-
return from_value(value).map(NotificationPayload::MeltQuoteOnchainResponse);
382-
}
383-
384-
if fields.contains_key("fee_reserve") {
385-
if fields.get("method").and_then(serde_json::Value::as_str) == Some("bolt12") {
386-
return from_value(value).map(NotificationPayload::MeltQuoteBolt12Response);
387-
}
388-
389-
return from_value(value).map(NotificationPayload::MeltQuoteBolt11Response);
390-
}
391-
392-
if fields.contains_key("state") {
393-
return from_value(value).map(NotificationPayload::MintQuoteBolt11Response);
394-
}
395-
396-
if fields.contains_key("amount") {
397-
return from_value(value).map(NotificationPayload::MintQuoteBolt12Response);
398-
}
399-
400-
from_value(value).map(NotificationPayload::MintQuoteOnchainResponse)
401-
}
402-
serde_json::Value::Array(items) if items.len() == 2 => {
403-
let method = items
404-
.first()
405-
.and_then(serde_json::Value::as_str)
406-
.ok_or_else(|| E::custom("custom notification method must be a string"))?
407-
.to_owned();
408-
let response = items
409-
.get(1)
410-
.ok_or_else(|| E::custom("custom notification payload is missing response"))?;
411-
let is_melt_quote = match response.as_object() {
412-
Some(fields) => fields.contains_key("state"),
413-
None => return Err(E::custom("custom notification response must be an object")),
414-
};
415-
416-
let mut value = value;
417-
if let serde_json::Value::Array(items) = &mut value {
418-
if let Some(response) = items.get_mut(1) {
419-
fill_response_method::<E>(response, &method)?;
420-
}
421-
}
422-
423-
if is_melt_quote {
424-
from_value(value).map(|(method, response)| {
425-
NotificationPayload::CustomMeltQuoteResponse(method, response)
426-
})
427-
} else {
428-
from_value(value).map(|(method, response)| {
429-
NotificationPayload::CustomMintQuoteResponse(method, response)
430-
})
431-
}
432-
}
433-
_ => Err(E::custom("invalid notification payload")),
434-
}
435-
}
436-
437356
impl<'de, T> Deserialize<'de> for NotificationPayload<T>
438357
where
439358
T: Clone + Serialize + DeserializeOwned,
@@ -442,8 +361,11 @@ where
442361
where
443362
D: serde::Deserializer<'de>,
444363
{
445-
let value = serde_json::Value::deserialize(deserializer)?;
446-
deserialize_payload(value)
364+
let _ = serde_json::Value::deserialize(deserializer)?;
365+
Err(D::Error::custom(
366+
"notification payloads require subscription kind context; use \
367+
nut17::deserialize_payload_for_kind",
368+
))
447369
}
448370
}
449371

@@ -890,4 +812,22 @@ mod tests {
890812
)
891813
.is_err());
892814
}
815+
#[test]
816+
fn notification_payload_without_kind_context_errors() {
817+
let encoded = r#"{
818+
"quote": "abc",
819+
"request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
820+
"unit": "sat",
821+
"expiry": 1701704757,
822+
"pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
823+
"amount_paid": 0,
824+
"amount_issued": 0
825+
}"#;
826+
827+
let err = serde_json::from_str::<NotificationPayload<String>>(encoded).unwrap_err();
828+
829+
assert!(err
830+
.to_string()
831+
.contains("require subscription kind context"));
832+
}
893833
}

crates/cashu/src/nuts/nut17/ws.rs

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@ pub struct WsUnsubscribeResponse<I> {
3434
///
3535
/// This is the notification that is sent to the client when an event matches a
3636
/// subscription
37-
#[derive(Debug, Clone, Serialize, Deserialize)]
38-
#[serde(bound = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned")]
37+
///
38+
/// This type is serialize-only in practice: notification payloads are not
39+
/// self-describing. Clients should deserialize notifications as
40+
/// [`RawNotificationInner`] and decode the payload with
41+
/// [`deserialize_payload_for_kind`](super::deserialize_payload_for_kind), using
42+
/// the kind of the subscription the notification belongs to.
43+
#[derive(Debug, Clone, Serialize)]
44+
#[serde(bound(serialize = "T: Serialize + DeserializeOwned, I: Serialize"))]
3945
pub struct NotificationInner<T, I>
4046
where
4147
T: Clone,
@@ -48,6 +54,21 @@ where
4854
pub payload: NotificationPayload<T>,
4955
}
5056

57+
/// The raw notification received from the websocket server.
58+
///
59+
/// This keeps the payload as JSON so clients can decode it with the kind of the
60+
/// subscription that produced the notification.
61+
#[derive(Debug, Clone, Serialize, Deserialize)]
62+
#[serde(bound = "I: Serialize + DeserializeOwned")]
63+
pub struct RawNotificationInner<I> {
64+
/// The subscription ID
65+
#[serde(rename = "subId")]
66+
pub sub_id: I,
67+
68+
/// The raw notification payload
69+
pub payload: serde_json::Value,
70+
}
71+
5172
/// Responses from the web socket server
5273
#[derive(Debug, Clone, Serialize, Deserialize)]
5374
#[serde(bound = "I: Serialize + DeserializeOwned")]
@@ -158,8 +179,12 @@ pub struct WsErrorResponse {
158179
}
159180

160181
/// Message from the server to the client
161-
#[derive(Debug, Clone, Serialize, Deserialize)]
162-
#[serde(bound = "I: Serialize + DeserializeOwned")]
182+
///
183+
/// This type is serialize-only in practice. Clients parsing incoming messages
184+
/// should use [`RawWsMessageOrResponse`] so notification payloads are kept as
185+
/// raw JSON until the subscription kind is known.
186+
#[derive(Debug, Clone, Serialize)]
187+
#[serde(bound(serialize = "I: Serialize + DeserializeOwned"))]
163188
#[serde(untagged)]
164189
pub enum WsMessageOrResponse<I> {
165190
/// A response to a request
@@ -170,6 +195,23 @@ pub enum WsMessageOrResponse<I> {
170195
Notification(Box<WsNotification<NotificationInner<String, I>>>),
171196
}
172197

198+
/// Raw message from the server to the client.
199+
///
200+
/// Use this type when deserializing websocket messages from a mint. Notification
201+
/// payloads must then be decoded with
202+
/// [`deserialize_payload_for_kind`](super::deserialize_payload_for_kind).
203+
#[derive(Debug, Clone, Serialize, Deserialize)]
204+
#[serde(bound = "I: Serialize + DeserializeOwned")]
205+
#[serde(untagged)]
206+
pub enum RawWsMessageOrResponse<I> {
207+
/// A response to a request
208+
Response(WsResponse<I>),
209+
/// An error response
210+
ErrorResponse(WsErrorResponse),
211+
/// A notification with raw JSON payload
212+
Notification(Box<WsNotification<RawNotificationInner<I>>>),
213+
}
214+
173215
impl<I> From<(usize, Result<WsResponseResult<I>, WsErrorBody>)> for WsMessageOrResponse<I> {
174216
fn from((id, result): (usize, Result<WsResponseResult<I>, WsErrorBody>)) -> Self {
175217
match result {
@@ -186,3 +228,34 @@ impl<I> From<(usize, Result<WsResponseResult<I>, WsErrorBody>)> for WsMessageOrR
186228
}
187229
}
188230
}
231+
232+
#[cfg(test)]
233+
mod tests {
234+
use super::*;
235+
236+
#[test]
237+
fn raw_ws_message_deserializes_notification_payload_as_json() {
238+
let encoded = r#"{
239+
"jsonrpc": "2.0",
240+
"method": "subscribe",
241+
"params": {
242+
"subId": "sub-id",
243+
"payload": {
244+
"quote": "quote-id",
245+
"method": "bolt12"
246+
}
247+
}
248+
}"#;
249+
250+
let decoded: RawWsMessageOrResponse<String> =
251+
serde_json::from_str(encoded).expect("raw websocket notification");
252+
253+
match decoded {
254+
RawWsMessageOrResponse::Notification(notification) => {
255+
assert_eq!(notification.params.sub_id, "sub-id");
256+
assert_eq!(notification.params.payload["quote"], "quote-id");
257+
}
258+
other => panic!("expected notification, got {:?}", other),
259+
}
260+
}
261+
}

crates/cdk-common/src/ws.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ pub type WsErrorBody = nut17::ws::WsErrorBody;
4444
/// Either a websocket message or a response
4545
pub type WsMessageOrResponse = nut17::ws::WsMessageOrResponse<SubId>;
4646

47+
/// Raw notification content with an undecoded JSON payload
48+
pub type RawNotificationInner = nut17::ws::RawNotificationInner<SubId>;
49+
50+
/// Either a websocket message or a response with raw notification payloads
51+
pub type RawWsMessageOrResponse = nut17::ws::RawWsMessageOrResponse<SubId>;
52+
4753
/// Inner content of a notification with generic payload type
4854
pub type NotificationInner<T> = nut17::ws::NotificationInner<T, SubId>;
4955

crates/cdk/src/wallet/subscription.rs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::sync::Arc;
1212

1313
use cdk_common::nut00::KnownMethod;
1414
use cdk_common::nut17::ws::{
15-
WsErrorResponse, WsMethodRequest, WsNotification, WsRequest, WsResponse, WsUnsubscribeRequest,
15+
RawWsMessageOrResponse, WsMethodRequest, WsRequest, WsUnsubscribeRequest,
1616
};
1717
use cdk_common::nut17::{deserialize_payload_for_kind, Kind, NotificationId};
1818
use cdk_common::parking_lot::RwLock;
@@ -30,22 +30,6 @@ use crate::event::MintEvent;
3030
use crate::mint_url::MintUrl;
3131
use crate::wallet::MintConnector;
3232

33-
#[derive(Debug, Clone, serde::Deserialize)]
34-
struct RawNotificationInner<I> {
35-
#[serde(rename = "subId")]
36-
sub_id: I,
37-
payload: serde_json::Value,
38-
}
39-
40-
#[derive(Debug, Clone, serde::Deserialize)]
41-
#[serde(bound = "I: serde::Serialize + serde::de::DeserializeOwned")]
42-
#[serde(untagged)]
43-
enum RawWsMessageOrResponse<I> {
44-
Response(WsResponse<I>),
45-
ErrorResponse(WsErrorResponse),
46-
Notification(Box<WsNotification<RawNotificationInner<I>>>),
47-
}
48-
4933
/// Notification Payload
5034
pub type NotificationPayload = crate::nuts::NotificationPayload<String>;
5135

0 commit comments

Comments
 (0)