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
106 changes: 23 additions & 83 deletions crates/cashu/src/nuts/nut17/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,13 @@

from_value(value)
}
serde_json::Value::Array(_) => {
Err(E::custom("custom notification payload must have two items"))
}

Check warning on line 311 in crates/cashu/src/nuts/nut17/mod.rs

View workflow job for this annotation

GitHub Actions / cargo-mutants

Missed mutant

delete match arm serde_json::Value::Array(_) in deserialize_payload_for_kind::custom_response
serde_json::Value::Object(_) => {
fill_response_method::<E>(&mut value, expected_method)?;
from_value(value).map(|response| (expected_method.to_string(), response))
}

Check warning on line 315 in crates/cashu/src/nuts/nut17/mod.rs

View workflow job for this annotation

GitHub Actions / cargo-mutants

Missed mutant

delete match arm serde_json::Value::Object(_) in deserialize_payload_for_kind::custom_response
_ => Err(E::custom("custom notification response must be an object")),
}
}
Expand Down Expand Up @@ -355,87 +355,6 @@
}
}

/// 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<T, E>(value: serde_json::Value) -> Result<NotificationPayload<T>, E>
where
T: Clone + Serialize + DeserializeOwned,
E: DeError,
{
fn from_value<V, E>(value: serde_json::Value) -> Result<V, E>
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::<E>(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<T>
where
T: Clone + Serialize + DeserializeOwned,
Expand All @@ -444,8 +363,11 @@
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",
))
}
}

Expand Down Expand Up @@ -918,4 +840,22 @@
)
.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::<NotificationPayload<String>>(encoded).unwrap_err();

assert!(err
.to_string()
.contains("require subscription kind context"));
}
}
81 changes: 77 additions & 4 deletions crates/cashu/src/nuts/nut17/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ pub struct WsUnsubscribeResponse<I> {
///
/// 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<T, I>
where
T: Clone,
Expand All @@ -48,6 +54,21 @@ where
pub payload: NotificationPayload<T>,
}

/// 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<I> {
/// 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")]
Expand Down Expand Up @@ -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<I> {
/// A response to a request
Expand All @@ -170,6 +195,23 @@ pub enum WsMessageOrResponse<I> {
Notification(Box<WsNotification<NotificationInner<String, I>>>),
}

/// 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<I> {
/// A response to a request
Response(WsResponse<I>),
/// An error response
ErrorResponse(WsErrorResponse),
/// A notification with raw JSON payload
Notification(Box<WsNotification<RawNotificationInner<I>>>),
}

impl<I> From<(usize, Result<WsResponseResult<I>, WsErrorBody>)> for WsMessageOrResponse<I> {
fn from((id, result): (usize, Result<WsResponseResult<I>, WsErrorBody>)) -> Self {
match result {
Expand All @@ -186,3 +228,34 @@ impl<I> From<(usize, Result<WsResponseResult<I>, 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<String> =
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),
}
}
}
6 changes: 6 additions & 0 deletions crates/cdk-common/src/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ pub type WsErrorBody = nut17::ws::WsErrorBody;
/// Either a websocket message or a response
pub type WsMessageOrResponse = nut17::ws::WsMessageOrResponse<SubId>;

/// Raw notification content with an undecoded JSON payload
pub type RawNotificationInner = nut17::ws::RawNotificationInner<SubId>;

/// Either a websocket message or a response with raw notification payloads
pub type RawWsMessageOrResponse = nut17::ws::RawWsMessageOrResponse<SubId>;

/// Inner content of a notification with generic payload type
pub type NotificationInner<T> = nut17::ws::NotificationInner<T, SubId>;

Expand Down
18 changes: 1 addition & 17 deletions crates/cdk/src/wallet/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,22 +30,6 @@ use crate::event::MintEvent;
use crate::mint_url::MintUrl;
use crate::wallet::MintConnector;

#[derive(Debug, Clone, serde::Deserialize)]
struct RawNotificationInner<I> {
#[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<I> {
Response(WsResponse<I>),
ErrorResponse(WsErrorResponse),
Notification(Box<WsNotification<RawNotificationInner<I>>>),
}

/// Notification Payload
pub type NotificationPayload = crate::nuts::NotificationPayload<String>;

Expand Down
Loading