Skip to main content

nula_core/message/
client.rs

1//! Messages a client sends to a relay.
2//!
3//! Per [NIP-01], every message is a heterogeneous JSON array tagged by its
4//! command (`"EVENT"`, `"REQ"`, `"CLOSE"`, `"AUTH"`, `"COUNT"`). Custom
5//! `Serialize`/`Deserialize` impls preserve the wire shape; the public enum
6//! layout matches the protocol surface so callers don't have to know about
7//! the magic command strings.
8//!
9//! [NIP-01]: https://github.com/nostr-protocol/nips/blob/master/01.md
10
11use std::fmt;
12
13use serde::de::{self, SeqAccess, Visitor};
14use serde::ser::{SerializeSeq, Serializer};
15use serde::{Deserialize, Deserializer, Serialize};
16use thiserror::Error;
17
18use super::subscription_id::SubscriptionId;
19use crate::event::Event;
20use crate::filter::Filter;
21
22const TAG_EVENT: &str = "EVENT";
23const TAG_REQ: &str = "REQ";
24const TAG_CLOSE: &str = "CLOSE";
25const TAG_AUTH: &str = "AUTH";
26const TAG_COUNT: &str = "COUNT";
27const TAG_NEG_OPEN: &str = "NEG-OPEN";
28const TAG_NEG_MSG: &str = "NEG-MSG";
29const TAG_NEG_CLOSE: &str = "NEG-CLOSE";
30
31/// Errors raised when parsing a [`ClientMessage`].
32#[derive(Debug, Clone, Error)]
33#[non_exhaustive]
34pub enum ClientMessageError {
35    /// The wire array was empty.
36    #[error("client message must not be empty")]
37    Empty,
38    /// The message tag was not recognised.
39    #[error("unknown client message tag `{0}`")]
40    UnknownTag(String),
41    /// The message tag was recognised but the payload was malformed.
42    #[error("malformed `{tag}` message: {reason}")]
43    Malformed {
44        /// The wire tag string.
45        tag: &'static str,
46        /// Human-readable explanation.
47        reason: String,
48    },
49}
50
51/// Messages sent from a client to a relay.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ClientMessage {
55    /// Publish an event to the relay.
56    ///
57    /// Wire form: `["EVENT", <event>]`.
58    Event(Event),
59    /// Open or replace a subscription with one or more filters.
60    ///
61    /// Wire form: `["REQ", <subscription_id>, <filter1>, <filter2>, …]`.
62    Req {
63        /// Subscription identifier.
64        subscription_id: SubscriptionId,
65        /// Filters that the relay must AND together when resolving.
66        filters: Vec<Filter>,
67    },
68    /// Close a subscription.
69    ///
70    /// Wire form: `["CLOSE", <subscription_id>]`.
71    Close(SubscriptionId),
72    /// Reply to a NIP-42 challenge with a signed kind-22242 event.
73    ///
74    /// Wire form: `["AUTH", <event>]`.
75    Auth(Event),
76    /// Count events matching a filter (NIP-45).
77    ///
78    /// Wire form: `["COUNT", <subscription_id>, <filter>]`.
79    Count {
80        /// Subscription identifier.
81        subscription_id: SubscriptionId,
82        /// Counting filter.
83        filter: Filter,
84    },
85    /// Open a NIP-77 Negentropy reconciliation session.
86    ///
87    /// Wire form (current protocol):
88    /// `["NEG-OPEN", <subscription_id>, <filter>, <initial_message_hex>]`.
89    NegOpen {
90        /// Subscription identifier the relay will use for follow-up
91        /// `NEG-MSG` / `NEG-ERR` frames.
92        subscription_id: SubscriptionId,
93        /// Filter that scopes the reconciliation.
94        filter: Filter,
95        /// Initial negentropy message, lowercase hex-encoded.
96        initial_message: String,
97    },
98    /// One step of an in-flight NIP-77 reconciliation.
99    ///
100    /// Wire form: `["NEG-MSG", <subscription_id>, <message_hex>]`.
101    NegMsg {
102        /// Subscription identifier the client picked in the matching
103        /// [`Self::NegOpen`].
104        subscription_id: SubscriptionId,
105        /// Reconciliation payload, lowercase hex-encoded.
106        message: String,
107    },
108    /// Close a NIP-77 reconciliation session.
109    ///
110    /// Wire form: `["NEG-CLOSE", <subscription_id>]`.
111    NegClose {
112        /// Subscription identifier to release on the relay side.
113        subscription_id: SubscriptionId,
114    },
115}
116
117impl ClientMessage {
118    /// Convenience constructor for [`ClientMessage::Event`].
119    #[must_use]
120    pub const fn event(event: Event) -> Self {
121        Self::Event(event)
122    }
123
124    /// Convenience constructor for [`ClientMessage::Req`].
125    #[must_use]
126    pub const fn req(subscription_id: SubscriptionId, filters: Vec<Filter>) -> Self {
127        Self::Req {
128            subscription_id,
129            filters,
130        }
131    }
132
133    /// Convenience constructor for [`ClientMessage::Close`].
134    #[must_use]
135    pub const fn close(subscription_id: SubscriptionId) -> Self {
136        Self::Close(subscription_id)
137    }
138
139    /// Convenience constructor for [`ClientMessage::Auth`].
140    #[must_use]
141    pub const fn auth(event: Event) -> Self {
142        Self::Auth(event)
143    }
144
145    /// Convenience constructor for [`ClientMessage::Count`].
146    #[must_use]
147    pub const fn count(subscription_id: SubscriptionId, filter: Filter) -> Self {
148        Self::Count {
149            subscription_id,
150            filter,
151        }
152    }
153
154    /// Convenience constructor for [`ClientMessage::NegOpen`].
155    #[must_use]
156    pub const fn neg_open(
157        subscription_id: SubscriptionId,
158        filter: Filter,
159        initial_message: String,
160    ) -> Self {
161        Self::NegOpen {
162            subscription_id,
163            filter,
164            initial_message,
165        }
166    }
167
168    /// Convenience constructor for [`ClientMessage::NegMsg`].
169    #[must_use]
170    pub const fn neg_msg(subscription_id: SubscriptionId, message: String) -> Self {
171        Self::NegMsg {
172            subscription_id,
173            message,
174        }
175    }
176
177    /// Convenience constructor for [`ClientMessage::NegClose`].
178    #[must_use]
179    pub const fn neg_close(subscription_id: SubscriptionId) -> Self {
180        Self::NegClose { subscription_id }
181    }
182}
183
184impl Serialize for ClientMessage {
185    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
186    where
187        S: Serializer,
188    {
189        match self {
190            Self::Event(event) => {
191                let mut seq = serializer.serialize_seq(Some(2))?;
192                seq.serialize_element(TAG_EVENT)?;
193                seq.serialize_element(event)?;
194                seq.end()
195            }
196            Self::Req {
197                subscription_id,
198                filters,
199            } => {
200                let mut seq = serializer.serialize_seq(Some(2 + filters.len()))?;
201                seq.serialize_element(TAG_REQ)?;
202                seq.serialize_element(subscription_id)?;
203                for f in filters {
204                    seq.serialize_element(f)?;
205                }
206                seq.end()
207            }
208            Self::Close(id) => {
209                let mut seq = serializer.serialize_seq(Some(2))?;
210                seq.serialize_element(TAG_CLOSE)?;
211                seq.serialize_element(id)?;
212                seq.end()
213            }
214            Self::Auth(event) => {
215                let mut seq = serializer.serialize_seq(Some(2))?;
216                seq.serialize_element(TAG_AUTH)?;
217                seq.serialize_element(event)?;
218                seq.end()
219            }
220            Self::Count {
221                subscription_id,
222                filter,
223            } => {
224                let mut seq = serializer.serialize_seq(Some(3))?;
225                seq.serialize_element(TAG_COUNT)?;
226                seq.serialize_element(subscription_id)?;
227                seq.serialize_element(filter)?;
228                seq.end()
229            }
230            Self::NegOpen {
231                subscription_id,
232                filter,
233                initial_message,
234            } => {
235                let mut seq = serializer.serialize_seq(Some(4))?;
236                seq.serialize_element(TAG_NEG_OPEN)?;
237                seq.serialize_element(subscription_id)?;
238                seq.serialize_element(filter)?;
239                seq.serialize_element(initial_message)?;
240                seq.end()
241            }
242            Self::NegMsg {
243                subscription_id,
244                message,
245            } => {
246                let mut seq = serializer.serialize_seq(Some(3))?;
247                seq.serialize_element(TAG_NEG_MSG)?;
248                seq.serialize_element(subscription_id)?;
249                seq.serialize_element(message)?;
250                seq.end()
251            }
252            Self::NegClose { subscription_id } => {
253                let mut seq = serializer.serialize_seq(Some(2))?;
254                seq.serialize_element(TAG_NEG_CLOSE)?;
255                seq.serialize_element(subscription_id)?;
256                seq.end()
257            }
258        }
259    }
260}
261
262impl<'de> Deserialize<'de> for ClientMessage {
263    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
264    where
265        D: Deserializer<'de>,
266    {
267        struct ClientVisitor;
268
269        impl<'de> Visitor<'de> for ClientVisitor {
270            type Value = ClientMessage;
271
272            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273                f.write_str("a Nostr client message array")
274            }
275
276            fn visit_seq<A>(self, mut seq: A) -> Result<ClientMessage, A::Error>
277            where
278                A: SeqAccess<'de>,
279            {
280                let tag: String = seq
281                    .next_element()?
282                    .ok_or_else(|| de::Error::custom(ClientMessageError::Empty))?;
283                match tag.as_str() {
284                    TAG_EVENT => decode_event(&mut seq),
285                    TAG_REQ => decode_req(&mut seq),
286                    TAG_CLOSE => decode_close(&mut seq),
287                    TAG_AUTH => decode_auth(&mut seq),
288                    TAG_COUNT => decode_count(&mut seq),
289                    TAG_NEG_OPEN => decode_neg_open(&mut seq),
290                    TAG_NEG_MSG => decode_neg_msg(&mut seq),
291                    TAG_NEG_CLOSE => decode_neg_close(&mut seq),
292                    other => Err(de::Error::custom(ClientMessageError::UnknownTag(
293                        other.to_owned(),
294                    ))),
295                }
296            }
297        }
298
299        deserializer.deserialize_seq(ClientVisitor)
300    }
301}
302
303fn decode_event<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
304where
305    A: SeqAccess<'de>,
306{
307    let event: Event = seq.next_element()?.ok_or_else(|| {
308        de::Error::custom(ClientMessageError::Malformed {
309            tag: TAG_EVENT,
310            reason: "missing event".to_owned(),
311        })
312    })?;
313    Ok(ClientMessage::Event(event))
314}
315
316fn decode_req<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
317where
318    A: SeqAccess<'de>,
319{
320    let subscription_id: SubscriptionId = seq.next_element()?.ok_or_else(|| {
321        de::Error::custom(ClientMessageError::Malformed {
322            tag: TAG_REQ,
323            reason: "missing subscription id".to_owned(),
324        })
325    })?;
326    let mut filters = Vec::new();
327    while let Some(filter) = seq.next_element::<Filter>()? {
328        filters.push(filter);
329    }
330    if filters.is_empty() {
331        return Err(de::Error::custom(ClientMessageError::Malformed {
332            tag: TAG_REQ,
333            reason: "REQ requires at least one filter".to_owned(),
334        }));
335    }
336    Ok(ClientMessage::Req {
337        subscription_id,
338        filters,
339    })
340}
341
342fn decode_close<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
343where
344    A: SeqAccess<'de>,
345{
346    let id: SubscriptionId = seq.next_element()?.ok_or_else(|| {
347        de::Error::custom(ClientMessageError::Malformed {
348            tag: TAG_CLOSE,
349            reason: "missing subscription id".to_owned(),
350        })
351    })?;
352    Ok(ClientMessage::Close(id))
353}
354
355fn decode_auth<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
356where
357    A: SeqAccess<'de>,
358{
359    let event: Event = seq.next_element()?.ok_or_else(|| {
360        de::Error::custom(ClientMessageError::Malformed {
361            tag: TAG_AUTH,
362            reason: "missing event".to_owned(),
363        })
364    })?;
365    Ok(ClientMessage::Auth(event))
366}
367
368fn decode_count<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
369where
370    A: SeqAccess<'de>,
371{
372    let subscription_id: SubscriptionId = seq.next_element()?.ok_or_else(|| {
373        de::Error::custom(ClientMessageError::Malformed {
374            tag: TAG_COUNT,
375            reason: "missing subscription id".to_owned(),
376        })
377    })?;
378    let filter: Filter = seq.next_element()?.ok_or_else(|| {
379        de::Error::custom(ClientMessageError::Malformed {
380            tag: TAG_COUNT,
381            reason: "missing filter".to_owned(),
382        })
383    })?;
384    Ok(ClientMessage::Count {
385        subscription_id,
386        filter,
387    })
388}
389
390fn decode_neg_open<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
391where
392    A: SeqAccess<'de>,
393{
394    let subscription_id: SubscriptionId = seq.next_element()?.ok_or_else(|| {
395        de::Error::custom(ClientMessageError::Malformed {
396            tag: TAG_NEG_OPEN,
397            reason: "missing subscription id".to_owned(),
398        })
399    })?;
400    let filter: Filter = seq.next_element()?.ok_or_else(|| {
401        de::Error::custom(ClientMessageError::Malformed {
402            tag: TAG_NEG_OPEN,
403            reason: "missing filter".to_owned(),
404        })
405    })?;
406    // Newer NIP-77 protocol: `["NEG-OPEN", id, filter, msg_hex]`.
407    // Older callers sent a 5-element form with an extra `id_size`
408    // integer between the filter and the message; the relay-side
409    // tolerance is documented in the spec but we accept only the
410    // current 4-element shape on parse — round-tripping our own
411    // serialised form is enough for interop with modern relays.
412    let initial_message: String = seq.next_element()?.ok_or_else(|| {
413        de::Error::custom(ClientMessageError::Malformed {
414            tag: TAG_NEG_OPEN,
415            reason: "missing initial message".to_owned(),
416        })
417    })?;
418    Ok(ClientMessage::NegOpen {
419        subscription_id,
420        filter,
421        initial_message,
422    })
423}
424
425fn decode_neg_msg<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
426where
427    A: SeqAccess<'de>,
428{
429    let subscription_id: SubscriptionId = seq.next_element()?.ok_or_else(|| {
430        de::Error::custom(ClientMessageError::Malformed {
431            tag: TAG_NEG_MSG,
432            reason: "missing subscription id".to_owned(),
433        })
434    })?;
435    let message: String = seq.next_element()?.ok_or_else(|| {
436        de::Error::custom(ClientMessageError::Malformed {
437            tag: TAG_NEG_MSG,
438            reason: "missing message".to_owned(),
439        })
440    })?;
441    Ok(ClientMessage::NegMsg {
442        subscription_id,
443        message,
444    })
445}
446
447fn decode_neg_close<'de, A>(seq: &mut A) -> Result<ClientMessage, A::Error>
448where
449    A: SeqAccess<'de>,
450{
451    let subscription_id: SubscriptionId = seq.next_element()?.ok_or_else(|| {
452        de::Error::custom(ClientMessageError::Malformed {
453            tag: TAG_NEG_CLOSE,
454            reason: "missing subscription id".to_owned(),
455        })
456    })?;
457    Ok(ClientMessage::NegClose { subscription_id })
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::Keys;
464    use crate::event::EventBuilder;
465    use crate::types::Timestamp;
466    use crate::{Kind, Tag};
467
468    fn keys() -> Keys {
469        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
470    }
471
472    fn signed_event() -> Event {
473        EventBuilder::text_note("hello")
474            .tag(Tag::new(["alt", "test"]).unwrap())
475            .created_at(Timestamp::from_secs(1_700_000_000))
476            .sign_with_keys(&keys())
477            .unwrap()
478    }
479
480    #[test]
481    fn event_round_trip() {
482        let msg = ClientMessage::event(signed_event());
483        let json = serde_json::to_string(&msg).unwrap();
484        assert!(json.starts_with("[\"EVENT\","));
485        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
486        assert_eq!(parsed, msg);
487    }
488
489    #[test]
490    fn req_round_trip() {
491        let msg = ClientMessage::req(
492            SubscriptionId::new("sub-1").unwrap(),
493            vec![Filter::new().kind(Kind::TEXT_NOTE)],
494        );
495        let json = serde_json::to_string(&msg).unwrap();
496        assert!(json.starts_with("[\"REQ\",\"sub-1\","));
497        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
498        assert_eq!(parsed, msg);
499    }
500
501    #[test]
502    fn req_rejects_zero_filters() {
503        let json = "[\"REQ\",\"sub-1\"]";
504        let err = serde_json::from_str::<ClientMessage>(json).unwrap_err();
505        assert!(err.to_string().contains("at least one filter"));
506    }
507
508    #[test]
509    fn close_round_trip() {
510        let msg = ClientMessage::close(SubscriptionId::new("sub-1").unwrap());
511        let json = serde_json::to_string(&msg).unwrap();
512        assert_eq!(json, "[\"CLOSE\",\"sub-1\"]");
513        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
514        assert_eq!(parsed, msg);
515    }
516
517    #[test]
518    fn auth_round_trip() {
519        let msg = ClientMessage::auth(signed_event());
520        let json = serde_json::to_string(&msg).unwrap();
521        assert!(json.starts_with("[\"AUTH\","));
522        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
523        assert_eq!(parsed, msg);
524    }
525
526    #[test]
527    fn count_round_trip() {
528        let msg = ClientMessage::count(
529            SubscriptionId::new("sub-1").unwrap(),
530            Filter::new().kind(Kind::TEXT_NOTE),
531        );
532        let json = serde_json::to_string(&msg).unwrap();
533        assert!(json.starts_with("[\"COUNT\",\"sub-1\","));
534        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
535        assert_eq!(parsed, msg);
536    }
537
538    #[test]
539    fn unknown_tag_rejected() {
540        let json = "[\"FOO\",\"sub-1\"]";
541        let err = serde_json::from_str::<ClientMessage>(json).unwrap_err();
542        assert!(err.to_string().contains("unknown client message tag"));
543    }
544
545    #[test]
546    fn empty_array_rejected() {
547        let json = "[]";
548        let err = serde_json::from_str::<ClientMessage>(json).unwrap_err();
549        assert!(err.to_string().contains("must not be empty"));
550    }
551
552    #[test]
553    fn neg_open_round_trip() {
554        let msg = ClientMessage::neg_open(
555            SubscriptionId::new("sync-1").unwrap(),
556            Filter::new().kind(Kind::TEXT_NOTE),
557            "0123456789abcdef".to_owned(),
558        );
559        let json = serde_json::to_string(&msg).unwrap();
560        assert!(json.starts_with("[\"NEG-OPEN\",\"sync-1\","));
561        assert!(json.ends_with(",\"0123456789abcdef\"]"));
562        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
563        assert_eq!(parsed, msg);
564    }
565
566    #[test]
567    fn neg_msg_round_trip() {
568        let msg = ClientMessage::neg_msg(
569            SubscriptionId::new("sync-1").unwrap(),
570            "deadbeef".to_owned(),
571        );
572        let json = serde_json::to_string(&msg).unwrap();
573        assert_eq!(json, "[\"NEG-MSG\",\"sync-1\",\"deadbeef\"]");
574        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
575        assert_eq!(parsed, msg);
576    }
577
578    #[test]
579    fn neg_close_round_trip() {
580        let msg = ClientMessage::neg_close(SubscriptionId::new("sync-1").unwrap());
581        let json = serde_json::to_string(&msg).unwrap();
582        assert_eq!(json, "[\"NEG-CLOSE\",\"sync-1\"]");
583        let parsed: ClientMessage = serde_json::from_str(&json).unwrap();
584        assert_eq!(parsed, msg);
585    }
586
587    #[test]
588    fn neg_open_missing_initial_message_rejected() {
589        // Filter without the trailing initial-message string should fail
590        // to parse rather than be silently accepted as the legacy
591        // 5-element form.
592        let json = "[\"NEG-OPEN\",\"sync-1\",{}]";
593        let err = serde_json::from_str::<ClientMessage>(json).unwrap_err();
594        assert!(err.to_string().contains("missing initial message"));
595    }
596}