Skip to main content

nula_core/nips/
nip02.rs

1//! [NIP-02] Follow List.
2//!
3//! NIP-02 publishes the author's follow set as a `kind: 3` event whose `p`
4//! tags name the followed pubkeys. The full tag form is:
5//!
6//! ```text
7//! ["p", "<pubkey hex>", "<relay-hint>?", "<petname>?"]
8//! ```
9//!
10//! Empty optional fields are encoded as `""` to preserve column position.
11//! The event's `content` historically stored a JSON-encoded relay list
12//! (now superseded by NIP-65); the modern crate ignores that field.
13//!
14//! [NIP-02]: https://github.com/nostr-protocol/nips/blob/master/02.md
15
16use std::collections::BTreeMap;
17
18use serde::Deserialize;
19use thiserror::Error;
20
21use super::nip65::RelayMarker;
22use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind};
23use crate::key::{PublicKey, PublicKeyError};
24use crate::types::{RelayUrl, RelayUrlError};
25
26/// A single follow entry inside a NIP-02 contact list.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct Contact {
29    /// Followed pubkey.
30    pub pubkey: PublicKey,
31    /// Optional relay hint where the followee posts.
32    pub relay_hint: Option<RelayUrl>,
33    /// Optional human-readable petname.
34    pub petname: Option<String>,
35}
36
37impl Contact {
38    /// Construct a bare follow entry (no hint, no petname).
39    #[must_use]
40    pub const fn new(pubkey: PublicKey) -> Self {
41        Self {
42            pubkey,
43            relay_hint: None,
44            petname: None,
45        }
46    }
47
48    /// Set the relay hint.
49    #[must_use]
50    pub fn with_relay_hint(mut self, hint: RelayUrl) -> Self {
51        self.relay_hint = Some(hint);
52        self
53    }
54
55    /// Set the petname.
56    #[must_use]
57    pub fn with_petname(mut self, petname: impl Into<String>) -> Self {
58        self.petname = Some(petname.into());
59        self
60    }
61}
62
63/// Ordered NIP-02 contact list.
64///
65/// The order is preserved: it is meaningful for clients that render
66/// follow lists, and for "I just followed X" diffs against the previous
67/// contact list event.
68#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct ContactList {
70    /// Contacts in insertion order.
71    pub contacts: Vec<Contact>,
72}
73
74impl ContactList {
75    /// Construct an empty contact list.
76    #[must_use]
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Append a contact and return `self` for chaining.
82    #[must_use]
83    pub fn follow(mut self, contact: Contact) -> Self {
84        self.contacts.push(contact);
85        self
86    }
87
88    /// Number of follows.
89    #[must_use]
90    pub const fn len(&self) -> usize {
91        self.contacts.len()
92    }
93
94    /// True if the list has no follows.
95    #[must_use]
96    pub const fn is_empty(&self) -> bool {
97        self.contacts.is_empty()
98    }
99
100    /// Render the list as the [`Tag`]s that go into a `kind: 3` event.
101    #[must_use]
102    pub fn to_tags(&self) -> Vec<Tag> {
103        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
104        self.contacts
105            .iter()
106            .map(|c| build_p_tag(&p_kind, c))
107            .collect()
108    }
109
110    /// Reconstruct a [`ContactList`] from a `kind: 3` [`Event`].
111    ///
112    /// Tags whose head is not `p` are silently ignored (forward-compat).
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ContactListError::UnexpectedKind`] if the event's kind
117    /// is not `3`, plus the matching parse error if any `p` tag is
118    /// malformed.
119    pub fn from_event(event: &Event) -> Result<Self, ContactListError> {
120        if event.kind != Kind::CONTACTS {
121            return Err(ContactListError::UnexpectedKind(event.kind.as_u16()));
122        }
123        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
124        let mut contacts = Vec::with_capacity(event.tags.as_slice().len());
125        for tag in &event.tags {
126            if tag.kind() != p_kind {
127                continue;
128            }
129            let mut values = tag.values().iter().skip(1);
130            let pubkey = values
131                .next()
132                .ok_or(ContactListError::MissingPubkey)?
133                .parse::<PublicKey>()?;
134            let relay_hint = match values.next() {
135                Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
136                _ => None,
137            };
138            let petname = match values.next() {
139                Some(s) if !s.is_empty() => Some(s.clone()),
140                _ => None,
141            };
142            contacts.push(Contact {
143                pubkey,
144                relay_hint,
145                petname,
146            });
147        }
148        Ok(Self { contacts })
149    }
150}
151
152impl EventBuilder {
153    /// Build a `kind: 3` contact list event from `list`.
154    ///
155    /// The event's `content` is empty per the modern interpretation of
156    /// NIP-02 (the legacy relay JSON is replaced by NIP-65).
157    #[must_use]
158    pub fn contact_list(list: &ContactList) -> Self {
159        Self::new(Kind::CONTACTS, "").tags(list.to_tags())
160    }
161}
162
163fn build_p_tag(p_kind: &TagKind, contact: &Contact) -> Tag {
164    let pubkey = contact.pubkey.to_hex();
165    let relay = contact
166        .relay_hint
167        .as_ref()
168        .map(|r| r.as_str().to_owned())
169        .unwrap_or_default();
170    let petname = contact.petname.clone().unwrap_or_default();
171
172    if !petname.is_empty() {
173        Tag::with(p_kind, [pubkey, relay, petname])
174    } else if !relay.is_empty() {
175        Tag::with(p_kind, [pubkey, relay])
176    } else {
177        Tag::with(p_kind, [pubkey])
178    }
179}
180
181/// Errors raised when parsing a NIP-02 contact list event.
182#[derive(Debug, Error)]
183#[non_exhaustive]
184pub enum ContactListError {
185    /// The event's kind was not `3`.
186    #[error("expected kind 3, got {0}")]
187    UnexpectedKind(u16),
188    /// A `p` tag was missing the pubkey value.
189    #[error("`p` tag is missing the pubkey value")]
190    MissingPubkey,
191    /// A `p` tag's pubkey did not parse.
192    #[error(transparent)]
193    InvalidPubkey(#[from] PublicKeyError),
194    /// A `p` tag's relay hint did not parse.
195    #[error(transparent)]
196    InvalidRelay(#[from] RelayUrlError),
197    /// The legacy `content` relay map was malformed JSON.
198    #[error("invalid legacy relay JSON: {0}")]
199    InvalidLegacyJson(#[from] serde_json::Error),
200}
201
202/// Per-relay read/write flags carried by the deprecated NIP-02 `content`
203/// JSON map.
204///
205/// The wire shape is `{"<relay-url>": {"read": bool, "write": bool}}`.
206#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
207struct LegacyRelayEntry {
208    #[serde(default)]
209    read: bool,
210    #[serde(default)]
211    write: bool,
212}
213
214impl ContactList {
215    /// Parse the *deprecated* NIP-02 `content` relay map.
216    ///
217    /// NIP-02 originally stored a JSON object inside an event's `content`
218    /// to advertise the user's preferred relays; that responsibility has
219    /// since moved to NIP-65 (`kind: 10002`). This helper exists for
220    /// backward-compatibility tools that still need to migrate or display
221    /// the legacy data.
222    ///
223    /// The wire format is:
224    ///
225    /// ```jsonc
226    /// {
227    ///   "wss://relay.example": { "read": true, "write": true }
228    /// }
229    /// ```
230    ///
231    /// Each entry is mapped to the closest [`RelayMarker`] equivalent:
232    ///
233    /// | read | write | marker |
234    /// |------|-------|--------|
235    /// | true | true  | [`RelayMarker::ReadWrite`] |
236    /// | true | false | [`RelayMarker::Read`] |
237    /// | false| true  | [`RelayMarker::Write`] |
238    /// | false| false | skipped (no useful marker) |
239    ///
240    /// Outer iteration order follows [`BTreeMap`] for deterministic
241    /// downstream processing.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`ContactListError::InvalidLegacyJson`] if `content` is
246    /// neither empty nor a JSON object of the documented shape, or
247    /// [`ContactListError::InvalidRelay`] if a key is not a valid
248    /// `ws://`/`wss://` URL.
249    pub fn legacy_relays(
250        content: &str,
251    ) -> Result<BTreeMap<RelayUrl, RelayMarker>, ContactListError> {
252        let trimmed = content.trim();
253        if trimmed.is_empty() {
254            return Ok(BTreeMap::new());
255        }
256        let raw: BTreeMap<String, LegacyRelayEntry> = serde_json::from_str(trimmed)?;
257        let mut out = BTreeMap::new();
258        for (url, entry) in raw {
259            let marker = match (entry.read, entry.write) {
260                (true, true) => RelayMarker::ReadWrite,
261                (true, false) => RelayMarker::Read,
262                (false, true) => RelayMarker::Write,
263                (false, false) => continue,
264            };
265            let parsed = RelayUrl::parse(&url)?;
266            out.insert(parsed, marker);
267        }
268        Ok(out)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::Keys;
276    use crate::types::Timestamp;
277
278    fn keys() -> Keys {
279        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
280    }
281
282    fn pk(seed: u8) -> PublicKey {
283        let mut bytes = [0u8; 32];
284        bytes[31] = seed;
285        let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
286        *Keys::from_secret_key(sk).public_key()
287    }
288
289    #[test]
290    fn empty_round_trip() {
291        let list = ContactList::new();
292        let event = EventBuilder::contact_list(&list)
293            .created_at(Timestamp::from_secs(1))
294            .sign_with_keys(&keys())
295            .unwrap();
296        event.verify().unwrap();
297        assert_eq!(event.kind, Kind::CONTACTS);
298        let parsed = ContactList::from_event(&event).unwrap();
299        assert_eq!(parsed, list);
300    }
301
302    #[test]
303    fn round_trip_with_full_metadata() {
304        let list = ContactList::new()
305            .follow(
306                Contact::new(pk(1))
307                    .with_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
308                    .with_petname("alice"),
309            )
310            .follow(Contact::new(pk(2)))
311            .follow(
312                Contact::new(pk(3)).with_relay_hint(RelayUrl::parse("wss://r.x.com/").unwrap()),
313            );
314        let event = EventBuilder::contact_list(&list)
315            .created_at(Timestamp::from_secs(2))
316            .sign_with_keys(&keys())
317            .unwrap();
318        let parsed = ContactList::from_event(&event).unwrap();
319        assert_eq!(parsed, list);
320    }
321
322    #[test]
323    fn order_is_preserved() {
324        let list = ContactList::new()
325            .follow(Contact::new(pk(3)))
326            .follow(Contact::new(pk(1)))
327            .follow(Contact::new(pk(2)));
328        let event = EventBuilder::contact_list(&list)
329            .created_at(Timestamp::from_secs(3))
330            .sign_with_keys(&keys())
331            .unwrap();
332        let parsed = ContactList::from_event(&event).unwrap();
333        assert_eq!(
334            parsed.contacts.iter().map(|c| c.pubkey).collect::<Vec<_>>(),
335            list.contacts.iter().map(|c| c.pubkey).collect::<Vec<_>>(),
336        );
337    }
338
339    #[test]
340    fn unknown_tags_are_ignored() {
341        let event = EventBuilder::new(Kind::CONTACTS, "")
342            .created_at(Timestamp::from_secs(4))
343            .tags([
344                Tag::new(["p", &pk(1).to_hex()]).unwrap(),
345                Tag::new(["alt", "ignored"]).unwrap(),
346            ])
347            .sign_with_keys(&keys())
348            .unwrap();
349        let parsed = ContactList::from_event(&event).unwrap();
350        assert_eq!(parsed.len(), 1);
351    }
352
353    #[test]
354    fn rejects_wrong_kind() {
355        let event = EventBuilder::text_note("not contacts")
356            .created_at(Timestamp::from_secs(5))
357            .sign_with_keys(&keys())
358            .unwrap();
359        let err = ContactList::from_event(&event).unwrap_err();
360        assert!(matches!(err, ContactListError::UnexpectedKind(1)));
361    }
362
363    #[test]
364    fn rejects_missing_pubkey() {
365        let event = EventBuilder::new(Kind::CONTACTS, "")
366            .created_at(Timestamp::from_secs(6))
367            .tag(Tag::new(["p"]).unwrap())
368            .sign_with_keys(&keys())
369            .unwrap();
370        let err = ContactList::from_event(&event).unwrap_err();
371        assert!(matches!(err, ContactListError::MissingPubkey));
372    }
373
374    #[test]
375    fn legacy_relays_empty_content_returns_empty_map() {
376        let map = ContactList::legacy_relays("").unwrap();
377        assert!(map.is_empty());
378    }
379
380    #[test]
381    fn legacy_relays_round_trip_full_matrix() {
382        let json = r#"{
383            "wss://both.example/": {"read": true, "write": true},
384            "wss://read.example/": {"read": true, "write": false},
385            "wss://write.example/": {"read": false, "write": true},
386            "wss://muted.example/": {"read": false, "write": false}
387        }"#;
388        let map = ContactList::legacy_relays(json).unwrap();
389        assert_eq!(map.len(), 3, "skipped: muted entry has no marker");
390        assert_eq!(
391            map.get(&RelayUrl::parse("wss://both.example/").unwrap()),
392            Some(&RelayMarker::ReadWrite),
393        );
394        assert_eq!(
395            map.get(&RelayUrl::parse("wss://read.example/").unwrap()),
396            Some(&RelayMarker::Read),
397        );
398        assert_eq!(
399            map.get(&RelayUrl::parse("wss://write.example/").unwrap()),
400            Some(&RelayMarker::Write),
401        );
402    }
403
404    #[test]
405    fn legacy_relays_rejects_invalid_json() {
406        let err = ContactList::legacy_relays("not json").unwrap_err();
407        assert!(matches!(err, ContactListError::InvalidLegacyJson(_)));
408    }
409
410    #[test]
411    fn legacy_relays_rejects_invalid_relay_url() {
412        let json = r#"{"https://not-a-relay.example": {"read": true, "write": true}}"#;
413        let err = ContactList::legacy_relays(json).unwrap_err();
414        assert!(matches!(err, ContactListError::InvalidRelay(_)));
415    }
416}