Skip to main content

nula_core/nips/
nip65.rs

1//! [NIP-65] Relay List Metadata.
2//!
3//! NIP-65 lets a user advertise the relays they use by publishing a `kind:
4//! 10002` event whose only meaningful payload is a list of `r` tags:
5//!
6//! ```json
7//! ["r", "wss://relay.example"]
8//! ["r", "wss://read.example", "read"]
9//! ["r", "wss://write.example", "write"]
10//! ```
11//!
12//! - No marker (third element absent) means the relay is used for both
13//!   reading and writing.
14//! - `read` means the user *consumes* events from this relay.
15//! - `write` means the user *publishes* events to this relay.
16//!
17//! [NIP-65]: https://github.com/nostr-protocol/nips/blob/master/65.md
18//!
19//! # Example
20//!
21//! ```
22//! use nula_core::nips::nip65::{RelayList, RelayMarker};
23//! use nula_core::{Keys, RelayUrl};
24//!
25//! let mut list = RelayList::new();
26//! list.insert(RelayUrl::parse("wss://read.example").unwrap(), RelayMarker::Read);
27//! list.insert(
28//!     RelayUrl::parse("wss://write.example").unwrap(),
29//!     RelayMarker::Write,
30//! );
31//!
32//! let keys = Keys::generate().unwrap();
33//! let event = list.to_event_builder().sign_with_keys(&keys).unwrap();
34//! event.verify().unwrap();
35//! ```
36
37use std::collections::BTreeMap;
38use std::fmt;
39use std::str::FromStr;
40
41use thiserror::Error;
42
43use crate::event::{Event, EventBuilder, Kind, Tag, TagKind, Tags};
44use crate::types::{RelayUrl, RelayUrlError};
45
46/// Whether a NIP-65 relay entry is intended for reading, writing, or both.
47#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
48#[non_exhaustive]
49pub enum RelayMarker {
50    /// The relay is used for both reading and writing (default; encoded as
51    /// the absence of a third tag element).
52    #[default]
53    ReadWrite,
54    /// The user reads events from this relay.
55    Read,
56    /// The user publishes events to this relay.
57    Write,
58}
59
60impl RelayMarker {
61    /// Return the wire string the marker uses on the third tag element, or
62    /// `None` when the marker is [`RelayMarker::ReadWrite`] (which omits the
63    /// element entirely).
64    #[must_use]
65    pub const fn as_wire(self) -> Option<&'static str> {
66        match self {
67            Self::ReadWrite => None,
68            Self::Read => Some("read"),
69            Self::Write => Some("write"),
70        }
71    }
72
73    /// True when the relay should be queried for events.
74    #[must_use]
75    pub const fn is_read(self) -> bool {
76        matches!(self, Self::Read | Self::ReadWrite)
77    }
78
79    /// True when the user should publish to this relay.
80    #[must_use]
81    pub const fn is_write(self) -> bool {
82        matches!(self, Self::Write | Self::ReadWrite)
83    }
84}
85
86impl fmt::Display for RelayMarker {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str(self.as_wire().unwrap_or("read+write"))
89    }
90}
91
92impl FromStr for RelayMarker {
93    type Err = RelayMarkerError;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        match s {
97            "read" => Ok(Self::Read),
98            "write" => Ok(Self::Write),
99            other => Err(RelayMarkerError::Unknown(other.to_owned())),
100        }
101    }
102}
103
104/// Errors raised when parsing a NIP-65 marker string.
105#[derive(Debug, Clone, Error)]
106#[non_exhaustive]
107pub enum RelayMarkerError {
108    /// The marker string was neither `read` nor `write`.
109    #[error("unknown NIP-65 relay marker `{0}`")]
110    Unknown(String),
111}
112
113/// Errors raised when building a [`RelayList`] from an [`Event`].
114#[derive(Debug, Clone, Error)]
115#[non_exhaustive]
116pub enum RelayListError {
117    /// The event's `kind` was not `10002`.
118    #[error("expected kind {expected}, got {got}")]
119    UnexpectedKind {
120        /// `Kind::RELAY_LIST.as_u16()`.
121        expected: u16,
122        /// What the event actually advertised.
123        got: u16,
124    },
125    /// An `r` tag had no URL.
126    #[error("`r` tag is missing the relay URL")]
127    MissingRelayUrl,
128    /// An `r` tag's URL did not parse.
129    #[error(transparent)]
130    InvalidRelayUrl(#[from] RelayUrlError),
131    /// An `r` tag's marker did not parse.
132    #[error(transparent)]
133    InvalidMarker(#[from] RelayMarkerError),
134}
135
136/// A user's NIP-65 relay list.
137///
138/// The internal representation is a [`BTreeMap`] keyed by [`RelayUrl`]; each
139/// relay appears at most once and the relays iterate in deterministic order.
140#[derive(Debug, Default, Clone, PartialEq, Eq)]
141pub struct RelayList {
142    relays: BTreeMap<RelayUrl, RelayMarker>,
143}
144
145impl RelayList {
146    /// Construct an empty list.
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Insert or replace the relay's marker. Returns the previous marker, if
153    /// any.
154    ///
155    /// # Spec recommendation
156    ///
157    /// NIP-65 §Size says: "Clients SHOULD guide users to keep `kind:10002`
158    /// lists small (2-4 relays of each category)." The crate does not
159    /// enforce that bound — it would be a breaking surprise — but a
160    /// caller building user-facing UX should warn well before the list
161    /// crosses, say, 8 read or 8 write relays.
162    pub fn insert(&mut self, url: RelayUrl, marker: RelayMarker) -> Option<RelayMarker> {
163        self.relays.insert(url, marker)
164    }
165
166    /// Remove a relay from the list. Returns the previous marker, if any.
167    pub fn remove(&mut self, url: &RelayUrl) -> Option<RelayMarker> {
168        self.relays.remove(url)
169    }
170
171    /// Lookup a relay's marker.
172    #[must_use]
173    pub fn get(&self, url: &RelayUrl) -> Option<RelayMarker> {
174        self.relays.get(url).copied()
175    }
176
177    /// Whether the list contains the given relay.
178    #[must_use]
179    pub fn contains(&self, url: &RelayUrl) -> bool {
180        self.relays.contains_key(url)
181    }
182
183    /// Number of relays in the list.
184    #[must_use]
185    pub fn len(&self) -> usize {
186        self.relays.len()
187    }
188
189    /// True when the list is empty.
190    #[must_use]
191    pub fn is_empty(&self) -> bool {
192        self.relays.is_empty()
193    }
194
195    /// Iterate over `(url, marker)` pairs in deterministic order.
196    pub fn iter(&self) -> impl Iterator<Item = (&RelayUrl, RelayMarker)> {
197        self.relays.iter().map(|(url, marker)| (url, *marker))
198    }
199
200    /// Iterate over relays the user reads from.
201    pub fn read_relays(&self) -> impl Iterator<Item = &RelayUrl> {
202        self.iter().filter(|(_, m)| m.is_read()).map(|(url, _)| url)
203    }
204
205    /// Iterate over relays the user writes to.
206    pub fn write_relays(&self) -> impl Iterator<Item = &RelayUrl> {
207        self.iter()
208            .filter(|(_, m)| m.is_write())
209            .map(|(url, _)| url)
210    }
211
212    /// Render the list as the [`Tags`] vector the kind-10002 event must
213    /// carry.
214    #[must_use]
215    pub fn to_tags(&self) -> Tags {
216        let tags = self
217            .relays
218            .iter()
219            .map(|(url, marker)| build_r_tag(url, *marker))
220            .collect::<Vec<_>>();
221        Tags::from_vec(tags)
222    }
223
224    /// Build an [`EventBuilder`] for the kind-10002 event that publishes the
225    /// list.
226    ///
227    /// The event's `content` is empty per NIP-65; consumers populate the
228    /// builder further (e.g. with [`EventBuilder::created_at`]) before
229    /// signing.
230    #[must_use]
231    pub fn to_event_builder(&self) -> EventBuilder {
232        EventBuilder::new(Kind::RELAY_LIST, "").tags(self.to_tags())
233    }
234
235    /// Reconstruct a [`RelayList`] from a kind-10002 [`Event`].
236    ///
237    /// Tags whose first element is not `r` are silently ignored
238    /// (forward-compat).
239    ///
240    /// # Errors
241    ///
242    /// Returns [`RelayListError::UnexpectedKind`] if the event's kind is not
243    /// `10002`, or any of the parsing errors when an `r` tag is malformed.
244    pub fn from_event(event: &Event) -> Result<Self, RelayListError> {
245        if event.kind != Kind::RELAY_LIST {
246            return Err(RelayListError::UnexpectedKind {
247                expected: Kind::RELAY_LIST.as_u16(),
248                got: event.kind.as_u16(),
249            });
250        }
251        let mut list = Self::new();
252        for tag in &event.tags {
253            if !is_relay_tag(&tag.kind()) {
254                continue;
255            }
256            let mut args = tag.values().iter().skip(1);
257            let url_str = args.next().ok_or(RelayListError::MissingRelayUrl)?;
258            let url = RelayUrl::parse(url_str)?;
259            let marker = match args.next() {
260                Some(s) if !s.is_empty() => s.parse::<RelayMarker>()?,
261                _ => RelayMarker::ReadWrite,
262            };
263            list.insert(url, marker);
264        }
265        Ok(list)
266    }
267}
268
269fn build_r_tag(url: &RelayUrl, marker: RelayMarker) -> Tag {
270    let kind = TagKind::from_wire("r");
271    let mut values = vec![url.as_str().to_owned()];
272    if let Some(extra) = marker.as_wire() {
273        values.push(extra.to_owned());
274    }
275    Tag::with(&kind, values)
276}
277
278fn is_relay_tag(kind: &TagKind) -> bool {
279    kind.as_str() == "r"
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::Keys;
286
287    fn relay(url: &str) -> RelayUrl {
288        RelayUrl::parse(url).unwrap()
289    }
290
291    fn keys() -> Keys {
292        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
293    }
294
295    #[test]
296    fn marker_wire_strings() {
297        assert_eq!(RelayMarker::Read.as_wire(), Some("read"));
298        assert_eq!(RelayMarker::Write.as_wire(), Some("write"));
299        assert_eq!(RelayMarker::ReadWrite.as_wire(), None);
300    }
301
302    #[test]
303    fn marker_parsing() {
304        assert_eq!("read".parse::<RelayMarker>().unwrap(), RelayMarker::Read);
305        assert_eq!("write".parse::<RelayMarker>().unwrap(), RelayMarker::Write);
306        let err = "both".parse::<RelayMarker>().unwrap_err();
307        assert!(err.to_string().contains("unknown"));
308    }
309
310    #[test]
311    fn marker_predicates() {
312        assert!(RelayMarker::ReadWrite.is_read());
313        assert!(RelayMarker::ReadWrite.is_write());
314        assert!(RelayMarker::Read.is_read());
315        assert!(!RelayMarker::Read.is_write());
316        assert!(!RelayMarker::Write.is_read());
317        assert!(RelayMarker::Write.is_write());
318    }
319
320    #[test]
321    fn round_trip_through_event() {
322        let mut list = RelayList::new();
323        list.insert(relay("wss://both.example"), RelayMarker::ReadWrite);
324        list.insert(relay("wss://read.example"), RelayMarker::Read);
325        list.insert(relay("wss://write.example"), RelayMarker::Write);
326
327        let event = list.to_event_builder().sign_with_keys(&keys()).unwrap();
328        event.verify().unwrap();
329        assert_eq!(event.kind, Kind::RELAY_LIST);
330
331        let parsed = RelayList::from_event(&event).unwrap();
332        assert_eq!(parsed, list);
333    }
334
335    #[test]
336    fn unknown_tags_are_ignored() {
337        let event = EventBuilder::new(Kind::RELAY_LIST, "")
338            .tags([
339                Tag::new(["r", "wss://relay.example"]).unwrap(),
340                Tag::new(["alt", "ignored"]).unwrap(),
341            ])
342            .sign_with_keys(&keys())
343            .unwrap();
344        let list = RelayList::from_event(&event).unwrap();
345        assert_eq!(list.len(), 1);
346        assert!(list.contains(&relay("wss://relay.example")));
347    }
348
349    #[test]
350    fn missing_url_is_rejected() {
351        let event = EventBuilder::new(Kind::RELAY_LIST, "")
352            .tag(Tag::new(["r"]).unwrap())
353            .sign_with_keys(&keys())
354            .unwrap();
355        let err = RelayList::from_event(&event).unwrap_err();
356        assert!(matches!(err, RelayListError::MissingRelayUrl));
357    }
358
359    #[test]
360    fn unknown_marker_is_rejected() {
361        let event = EventBuilder::new(Kind::RELAY_LIST, "")
362            .tag(Tag::new(["r", "wss://relay.example", "duplex"]).unwrap())
363            .sign_with_keys(&keys())
364            .unwrap();
365        let err = RelayList::from_event(&event).unwrap_err();
366        assert!(matches!(
367            err,
368            RelayListError::InvalidMarker(RelayMarkerError::Unknown(_))
369        ));
370    }
371
372    #[test]
373    fn wrong_kind_is_rejected() {
374        let event = EventBuilder::text_note("not a relay list")
375            .sign_with_keys(&keys())
376            .unwrap();
377        let err = RelayList::from_event(&event).unwrap_err();
378        assert!(matches!(
379            err,
380            RelayListError::UnexpectedKind {
381                expected: 10_002,
382                got: 1
383            }
384        ));
385    }
386
387    #[test]
388    fn read_and_write_iterators() {
389        let mut list = RelayList::new();
390        list.insert(relay("wss://both.example"), RelayMarker::ReadWrite);
391        list.insert(relay("wss://read.example"), RelayMarker::Read);
392        list.insert(relay("wss://write.example"), RelayMarker::Write);
393
394        let read: Vec<_> = list.read_relays().collect();
395        let write: Vec<_> = list.write_relays().collect();
396        assert_eq!(read.len(), 2);
397        assert_eq!(write.len(), 2);
398        assert!(read.contains(&&relay("wss://both.example")));
399        assert!(read.contains(&&relay("wss://read.example")));
400        assert!(write.contains(&&relay("wss://both.example")));
401        assert!(write.contains(&&relay("wss://write.example")));
402    }
403}