Skip to main content

nula_core/nips/
nip51.rs

1//! [NIP-51] Lists.
2//!
3//! Curated lists of "things" — pubkeys, events, hashtags, relays,
4//! emojis, addressable coordinates, … — under specific kind numbers.
5//! Two flavours:
6//!
7//! - **Standard lists** — replaceable kinds in `10000..=10999`,
8//!   exactly one per (pubkey, kind);
9//! - **Sets** — addressable kinds in `30000..=39999`, indexed by an
10//!   additional `d` tag so a user can keep many.
11//!
12//! NIP-51 is also the only list spec that adds **per-item privacy**:
13//! each list event SHOULD be allowed to carry both a public tag set
14//! and an encrypted JSON-of-tags blob in `.content`. The encrypted
15//! payload uses the author's *own* keys for both ECDH halves
16//! (NIP-44 v2 default; NIP-04 legacy fallback recognised on
17//! reading).
18//!
19//! # Why a typed module
20//!
21//! Upstream `rust-nostr` ships a thin `From<List> for Vec<Tag>` for
22//! a handful of list kinds and **leaves the encryption out
23//! entirely**. We model:
24//!
25//! 1. **Every spec'd kind** as a typed constant on [`Kind`]
26//!    ([`crate::Kind::MUTE_LIST`], `BOOKMARK_SET`, …).
27//! 2. A typed [`ListItem`] enum that maps the eight item shapes
28//!    NIP-51 uses (pubkey, event, address, hashtag, word, relay,
29//!    server, emoji, group). Forward-compat passthrough lives at
30//!    [`ListItem::Other`].
31//! 3. A unified [`List`] bundle with set metadata
32//!    (`title` / `description` / `image`), public items, and a
33//!    private item list that goes through NIP-44 / NIP-04 as
34//!    needed.
35//! 4. End-to-end builders / readers via [`EventBuilder::list`] and
36//!    [`List::from_event`] (encrypted contents stay sealed unless
37//!    the caller hands in the secret key — at which point
38//!    [`List::decrypt_private`] populates `private_items`).
39//!
40//! # Encryption discipline
41//!
42//! NIP-51 §"Encryption process pseudocode" hands the author's own
43//! secret key to *both* sides of the ECDH so a list owner can
44//! always decrypt their own private items without involving a peer.
45//! On reading we follow spec §"For backward compatibility":
46//! NIP-04's tell-tale `?iv=` separator → NIP-04, otherwise NIP-44
47//! v2.
48//!
49//! [NIP-51]: https://github.com/nostr-protocol/nips/blob/master/51.md
50
51use thiserror::Error;
52
53use crate::event::{
54    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
55    SingleLetterTag, Tag, TagError, TagKind, Tags,
56};
57#[cfg(feature = "nip44")]
58use crate::key::SecretKey;
59use crate::key::{PublicKey, PublicKeyError};
60use crate::types::{RelayUrl, RelayUrlError};
61
62/// One typed item that lives on a NIP-51 list or set.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum ListItem {
66    /// `p` tag — pubkey, with optional relay hint and petname.
67    Pubkey {
68        /// Pubkey.
69        pubkey: PublicKey,
70        /// Optional relay hint.
71        relay_hint: Option<RelayUrl>,
72        /// Optional petname.
73        petname: Option<String>,
74    },
75    /// `e` tag — event id, with optional relay hint.
76    Event {
77        /// Event id.
78        id: EventId,
79        /// Optional relay hint.
80        relay_hint: Option<RelayUrl>,
81    },
82    /// `a` tag — addressable event coordinate.
83    Address {
84        /// Coordinate.
85        coordinate: Coordinate,
86        /// Optional relay hint.
87        relay_hint: Option<RelayUrl>,
88    },
89    /// `t` tag — hashtag (lowercased per NIP-24).
90    Hashtag(String),
91    /// `word` tag — muted word (NIP-51 §"Mute list").
92    Word(String),
93    /// `relay` tag — generic relay URL.
94    Relay(RelayUrl),
95    /// `server` tag — Blossom blob server URL.
96    Server(String),
97    /// `emoji` tag — NIP-30 custom-emoji entry.
98    Emoji {
99        /// Shortcode.
100        shortcode: String,
101        /// Image URL.
102        url: String,
103    },
104    /// `group` tag — NIP-29 group reference.
105    Group {
106        /// Group id.
107        id: String,
108        /// Relay URL.
109        relay: RelayUrl,
110        /// Optional group name.
111        name: Option<String>,
112    },
113    /// Forward-compatible passthrough: any other tag.
114    Other(Tag),
115}
116
117impl ListItem {
118    /// Convert to the wire `Tag`.
119    #[must_use]
120    pub fn to_tag(&self) -> Tag {
121        match self {
122            Self::Pubkey {
123                pubkey,
124                relay_hint,
125                petname,
126            } => {
127                let mut values: Vec<String> = Vec::with_capacity(3);
128                values.push(pubkey.to_hex());
129                values.push(relay_hint_value(relay_hint.as_ref()));
130                if let Some(name) = petname {
131                    values.push(name.clone());
132                }
133                letter_tag(Alphabet::P, values)
134            }
135            Self::Event { id, relay_hint } => {
136                let mut values: Vec<String> = Vec::with_capacity(2);
137                values.push(id.to_hex());
138                if let Some(r) = relay_hint {
139                    values.push(r.as_str().to_owned());
140                }
141                letter_tag(Alphabet::E, values)
142            }
143            Self::Address {
144                coordinate,
145                relay_hint,
146            } => {
147                let mut values: Vec<String> = Vec::with_capacity(2);
148                values.push(coordinate.to_wire());
149                if let Some(r) = relay_hint {
150                    values.push(r.as_str().to_owned());
151                }
152                letter_tag(Alphabet::A, values)
153            }
154            Self::Hashtag(t) => letter_tag(Alphabet::T, [t.clone()]),
155            Self::Word(w) => custom_tag("word", [w.clone()]),
156            Self::Relay(url) => custom_tag("relay", [url.as_str().to_owned()]),
157            Self::Server(url) => custom_tag("server", [url.clone()]),
158            Self::Emoji { shortcode, url } => custom_tag("emoji", [shortcode.clone(), url.clone()]),
159            Self::Group { id, relay, name } => {
160                let mut values: Vec<String> = Vec::with_capacity(3);
161                values.push(id.clone());
162                values.push(relay.as_str().to_owned());
163                if let Some(n) = name {
164                    values.push(n.clone());
165                }
166                custom_tag("group", values)
167            }
168            Self::Other(tag) => tag.clone(),
169        }
170    }
171
172    /// Parse a wire tag into a typed item.
173    ///
174    /// Unknown tag kinds round-trip through [`Self::Other`] so a
175    /// list event never silently drops data.
176    ///
177    /// # Errors
178    ///
179    /// Forwarded from [`PublicKey::parse`] / [`EventId::parse`] /
180    /// [`Coordinate::parse`] / [`RelayUrl::parse`] for the typed
181    /// shapes.
182    pub fn from_tag(tag: &Tag) -> Result<Self, ListItemError> {
183        match tag.kind() {
184            TagKind::SingleLetter(s) if !s.uppercase => match s.character {
185                Alphabet::P => parse_pubkey(tag),
186                Alphabet::E => parse_event(tag),
187                Alphabet::A => parse_address(tag),
188                Alphabet::T => parse_hashtag(tag),
189                _ => Ok(Self::Other(tag.clone())),
190            },
191            TagKind::Custom(name) if name == "word" => parse_word(tag),
192            TagKind::Custom(name) if name == "relay" => parse_relay(tag),
193            TagKind::Custom(name) if name == "server" => parse_server(tag),
194            TagKind::Custom(name) if name == "emoji" => parse_emoji(tag),
195            TagKind::Custom(name) if name == "group" => parse_group(tag),
196            _ => Ok(Self::Other(tag.clone())),
197        }
198    }
199}
200
201fn relay_hint_value(hint: Option<&RelayUrl>) -> String {
202    hint.map(|r| r.as_str().to_owned()).unwrap_or_default()
203}
204
205fn parse_pubkey(tag: &Tag) -> Result<ListItem, ListItemError> {
206    let pk_hex = tag.get(1).ok_or(ListItemError::MissingValue("p"))?;
207    let pubkey = PublicKey::parse(pk_hex).map_err(ListItemError::InvalidPublicKey)?;
208    let relay_hint = match tag.get(2) {
209        Some(s) if !s.is_empty() => {
210            Some(RelayUrl::parse(s).map_err(ListItemError::InvalidRelayUrl)?)
211        }
212        _ => None,
213    };
214    let petname = tag.get(3).map(str::to_owned);
215    Ok(ListItem::Pubkey {
216        pubkey,
217        relay_hint,
218        petname,
219    })
220}
221
222fn parse_event(tag: &Tag) -> Result<ListItem, ListItemError> {
223    let id_hex = tag.get(1).ok_or(ListItemError::MissingValue("e"))?;
224    let id = EventId::parse(id_hex).map_err(ListItemError::InvalidEventId)?;
225    let relay_hint = match tag.get(2) {
226        Some(s) if !s.is_empty() => {
227            Some(RelayUrl::parse(s).map_err(ListItemError::InvalidRelayUrl)?)
228        }
229        _ => None,
230    };
231    Ok(ListItem::Event { id, relay_hint })
232}
233
234fn parse_address(tag: &Tag) -> Result<ListItem, ListItemError> {
235    let coord_str = tag.get(1).ok_or(ListItemError::MissingValue("a"))?;
236    let coordinate = Coordinate::parse(coord_str).map_err(ListItemError::InvalidCoordinate)?;
237    let relay_hint = match tag.get(2) {
238        Some(s) if !s.is_empty() => {
239            Some(RelayUrl::parse(s).map_err(ListItemError::InvalidRelayUrl)?)
240        }
241        _ => None,
242    };
243    Ok(ListItem::Address {
244        coordinate,
245        relay_hint,
246    })
247}
248
249fn parse_hashtag(tag: &Tag) -> Result<ListItem, ListItemError> {
250    Ok(ListItem::Hashtag(
251        tag.get(1)
252            .ok_or(ListItemError::MissingValue("t"))?
253            .to_owned(),
254    ))
255}
256
257fn parse_word(tag: &Tag) -> Result<ListItem, ListItemError> {
258    Ok(ListItem::Word(
259        tag.get(1)
260            .ok_or(ListItemError::MissingValue("word"))?
261            .to_owned(),
262    ))
263}
264
265fn parse_relay(tag: &Tag) -> Result<ListItem, ListItemError> {
266    let url_str = tag.get(1).ok_or(ListItemError::MissingValue("relay"))?;
267    let url = RelayUrl::parse(url_str).map_err(ListItemError::InvalidRelayUrl)?;
268    Ok(ListItem::Relay(url))
269}
270
271fn parse_server(tag: &Tag) -> Result<ListItem, ListItemError> {
272    Ok(ListItem::Server(
273        tag.get(1)
274            .ok_or(ListItemError::MissingValue("server"))?
275            .to_owned(),
276    ))
277}
278
279fn parse_emoji(tag: &Tag) -> Result<ListItem, ListItemError> {
280    Ok(ListItem::Emoji {
281        shortcode: tag
282            .get(1)
283            .ok_or(ListItemError::MissingValue("emoji"))?
284            .to_owned(),
285        url: tag
286            .get(2)
287            .ok_or(ListItemError::MissingValue("emoji"))?
288            .to_owned(),
289    })
290}
291
292fn parse_group(tag: &Tag) -> Result<ListItem, ListItemError> {
293    let id = tag
294        .get(1)
295        .ok_or(ListItemError::MissingValue("group"))?
296        .to_owned();
297    let relay_str = tag.get(2).ok_or(ListItemError::MissingValue("group"))?;
298    let relay = RelayUrl::parse(relay_str).map_err(ListItemError::InvalidRelayUrl)?;
299    let name = tag.get(3).map(str::to_owned);
300    Ok(ListItem::Group { id, relay, name })
301}
302
303fn letter_tag<I, S>(alphabet: Alphabet, args: I) -> Tag
304where
305    I: IntoIterator<Item = S>,
306    S: Into<String>,
307{
308    let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
309    Tag::with(&head, args)
310}
311
312fn custom_tag<I, S>(name: &str, args: I) -> Tag
313where
314    I: IntoIterator<Item = S>,
315    S: Into<String>,
316{
317    Tag::with(&TagKind::Custom(name.to_owned()), args)
318}
319
320/// Errors raised while parsing one [`ListItem`] from a tag.
321#[derive(Debug, Error)]
322#[non_exhaustive]
323pub enum ListItemError {
324    /// A required column was missing.
325    #[error("`{0}` tag missing value")]
326    MissingValue(&'static str),
327    /// Pubkey hex did not parse.
328    #[error("invalid public key: {0}")]
329    InvalidPublicKey(#[source] PublicKeyError),
330    /// Event id hex did not parse.
331    #[error("invalid event id: {0}")]
332    InvalidEventId(#[source] EventIdError),
333    /// Coordinate string did not parse.
334    #[error("invalid coordinate: {0}")]
335    InvalidCoordinate(#[source] CoordinateError),
336    /// Relay URL did not parse.
337    #[error("invalid relay URL: {0}")]
338    InvalidRelayUrl(#[source] RelayUrlError),
339}
340
341/// Typed bundle covering every NIP-51 list / set kind.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct List {
344    /// Event kind — pick from the spec'd `Kind::*` constants
345    /// ([`crate::Kind::MUTE_LIST`], `BOOKMARK_SET`, …).
346    pub kind: Kind,
347    /// `d` tag for sets in `30000..=39999`. Spec mandates exactly
348    /// one `d` tag per set; standard lists in `10000..=10999` SHOULD
349    /// leave this `None`.
350    pub identifier: Option<String>,
351    /// `title` tag (sets only — spec §"Sets").
352    pub title: Option<String>,
353    /// `description` tag (sets only).
354    pub description: Option<String>,
355    /// `image` tag (sets only).
356    pub image: Option<String>,
357    /// Public items — one per spec'd item tag.
358    pub public_items: Vec<ListItem>,
359    /// Encrypted-on-the-wire items. Unset for unencrypted lists, or
360    /// after a successful [`Self::decrypt_private`].
361    pub private_items: Vec<ListItem>,
362    /// Raw encrypted blob from `.content` when the list shipped one
363    /// but the caller has not yet decrypted it.
364    pub encrypted_payload: Option<String>,
365}
366
367impl List {
368    /// Construct an empty list at the given kind.
369    #[must_use]
370    pub const fn new(kind: Kind) -> Self {
371        Self {
372            kind,
373            identifier: None,
374            title: None,
375            description: None,
376            image: None,
377            public_items: Vec::new(),
378            private_items: Vec::new(),
379            encrypted_payload: None,
380        }
381    }
382
383    /// Set the `d`-tag identifier (required for sets).
384    #[must_use]
385    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
386        self.identifier = Some(identifier.into());
387        self
388    }
389
390    /// Set the `title` tag.
391    #[must_use]
392    pub fn title(mut self, title: impl Into<String>) -> Self {
393        self.title = Some(title.into());
394        self
395    }
396
397    /// Set the `description` tag.
398    #[must_use]
399    pub fn description(mut self, description: impl Into<String>) -> Self {
400        self.description = Some(description.into());
401        self
402    }
403
404    /// Set the `image` tag.
405    #[must_use]
406    pub fn image(mut self, image: impl Into<String>) -> Self {
407        self.image = Some(image.into());
408        self
409    }
410
411    /// Append a public item.
412    #[must_use]
413    pub fn public_item(mut self, item: ListItem) -> Self {
414        self.public_items.push(item);
415        self
416    }
417
418    /// Append a private item. These are encrypted into the
419    /// `.content` blob at build time via [`Self::encrypt_private`].
420    #[must_use]
421    pub fn private_item(mut self, item: ListItem) -> Self {
422        self.private_items.push(item);
423        self
424    }
425
426    /// Render the public tags (everything that goes into
427    /// `event.tags`, in spec order: identifier → title → image →
428    /// description → items).
429    #[must_use]
430    pub fn to_public_tags(&self) -> Vec<Tag> {
431        let mut tags: Vec<Tag> = Vec::with_capacity(4 + self.public_items.len());
432        if let Some(id) = &self.identifier {
433            tags.push(Tag::d(id));
434        }
435        if let Some(title) = &self.title {
436            tags.push(Tag::title(title));
437        }
438        if let Some(image) = &self.image {
439            tags.push(custom_tag("image", [image.clone()]));
440        }
441        if let Some(desc) = &self.description {
442            tags.push(custom_tag("description", [desc.clone()]));
443        }
444        for item in &self.public_items {
445            tags.push(item.to_tag());
446        }
447        tags
448    }
449
450    /// Encrypt [`Self::private_items`] (if any) into a NIP-44 v2
451    /// payload bound to the *author's own* keys per NIP-51
452    /// §"Encryption process pseudocode".
453    ///
454    /// Returns `Ok(Some(payload))` when there were items to
455    /// encrypt, `Ok(None)` when the private list was empty.
456    ///
457    /// # Errors
458    ///
459    /// Forwarded from [`crate::nips::nip44::encrypt`].
460    #[cfg(feature = "nip44")]
461    #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
462    pub fn encrypt_private(
463        &self,
464        owner_secret: &SecretKey,
465        owner_public: &PublicKey,
466    ) -> Result<Option<String>, ListEncryptionError> {
467        if self.private_items.is_empty() {
468            return Ok(None);
469        }
470        let json =
471            serialize_items(&self.private_items).map_err(ListEncryptionError::InvalidJson)?;
472        let payload = crate::nips::nip44::encrypt(owner_secret, owner_public, &json)
473            .map_err(ListEncryptionError::Encrypt)?;
474        Ok(Some(payload))
475    }
476
477    /// Decrypt the previously-stored [`Self::encrypted_payload`]
478    /// into [`Self::private_items`]. The payload is auto-detected
479    /// as NIP-04 (legacy `?iv=` form) or NIP-44 v2.
480    ///
481    /// Idempotent: the payload is consumed only on success.
482    ///
483    /// # Errors
484    ///
485    /// - [`ListEncryptionError::NoPayload`] when nothing has been
486    ///   stashed.
487    /// - [`ListEncryptionError::Decrypt`] from the underlying
488    ///   primitive.
489    /// - [`ListEncryptionError::InvalidJson`] when the decrypted
490    ///   plaintext is not a JSON array of tag arrays.
491    #[cfg(feature = "nip44")]
492    #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
493    pub fn decrypt_private(
494        &mut self,
495        owner_secret: &SecretKey,
496        owner_public: &PublicKey,
497    ) -> Result<(), ListEncryptionError> {
498        let Some(payload) = self.encrypted_payload.take() else {
499            return Err(ListEncryptionError::NoPayload);
500        };
501        let json = if payload.contains("?iv=") {
502            decrypt_nip04(owner_secret, owner_public, &payload)?
503        } else {
504            crate::nips::nip44::decrypt(owner_secret, owner_public, &payload)
505                .map_err(ListEncryptionError::Decrypt)?
506        };
507        self.private_items = deserialize_items(&json).map_err(ListEncryptionError::InvalidJson)?;
508        Ok(())
509    }
510
511    /// Parse a NIP-51 list event back into a typed bundle.
512    ///
513    /// Encrypted private payloads stay as `Some(...)` in
514    /// [`Self::encrypted_payload`]; call [`Self::decrypt_private`]
515    /// to surface them.
516    ///
517    /// # Errors
518    ///
519    /// - [`ListError::InvalidItem`] if any tag is shaped wrong.
520    pub fn from_event(event: &Event) -> Result<Self, ListError> {
521        Self::from_tags_and_content(event.kind, &event.tags, &event.content)
522    }
523
524    fn from_tags_and_content(kind: Kind, tags: &Tags, content: &str) -> Result<Self, ListError> {
525        let mut list = Self::new(kind);
526        for tag in tags {
527            match tag.kind() {
528                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {
529                    list.identifier = tag.get(1).map(str::to_owned);
530                }
531                TagKind::Custom(name) if name == "title" => {
532                    list.title = tag.get(1).map(str::to_owned);
533                }
534                TagKind::Custom(name) if name == "description" => {
535                    list.description = tag.get(1).map(str::to_owned);
536                }
537                TagKind::Custom(name) if name == "image" => {
538                    list.image = tag.get(1).map(str::to_owned);
539                }
540                _ => {
541                    let item = ListItem::from_tag(tag).map_err(ListError::InvalidItem)?;
542                    list.public_items.push(item);
543                }
544            }
545        }
546        if !content.is_empty() {
547            list.encrypted_payload = Some(content.to_owned());
548        }
549        Ok(list)
550    }
551}
552
553#[cfg(feature = "nip44")]
554fn serialize_items(items: &[ListItem]) -> Result<String, serde_json::Error> {
555    let raw: Vec<Vec<String>> = items.iter().map(|i| i.to_tag().values().to_vec()).collect();
556    serde_json::to_string(&raw)
557}
558
559#[cfg(feature = "nip44")]
560fn deserialize_items(json: &str) -> Result<Vec<ListItem>, serde_json::Error> {
561    let raw: Vec<Vec<String>> = serde_json::from_str(json)?;
562    raw.into_iter()
563        .map(|values| {
564            let tag = Tag::new(values).map_err(serde_json::Error::custom)?;
565            let item = ListItem::from_tag(&tag).map_err(serde_json::Error::custom)?;
566            Ok(item)
567        })
568        .collect()
569}
570
571#[cfg(feature = "nip44")]
572trait CustomError {
573    fn custom<E: std::fmt::Display>(err: E) -> Self;
574}
575
576#[cfg(feature = "nip44")]
577impl CustomError for serde_json::Error {
578    fn custom<E: std::fmt::Display>(err: E) -> Self {
579        <Self as serde::de::Error>::custom(err.to_string())
580    }
581}
582
583#[cfg(all(feature = "nip44", feature = "nip04"))]
584fn decrypt_nip04(
585    owner_secret: &SecretKey,
586    owner_public: &PublicKey,
587    payload: &str,
588) -> Result<String, ListEncryptionError> {
589    crate::nips::nip04::decrypt(owner_secret, owner_public, payload)
590        .map_err(ListEncryptionError::Nip04)
591}
592
593#[cfg(all(feature = "nip44", not(feature = "nip04")))]
594const fn decrypt_nip04(
595    _owner_secret: &SecretKey,
596    _owner_public: &PublicKey,
597    _payload: &str,
598) -> Result<String, ListEncryptionError> {
599    Err(ListEncryptionError::Nip04Unavailable)
600}
601
602/// Errors raised while parsing or building a [`List`].
603#[derive(Debug, Error)]
604#[non_exhaustive]
605pub enum ListError {
606    /// One of the tags carried a malformed item value.
607    #[error("invalid list item: {0}")]
608    InvalidItem(#[source] ListItemError),
609    /// A built-in tag value (`Tag::new`) was malformed.
610    #[error("invalid tag: {0}")]
611    InvalidTag(#[from] TagError),
612}
613
614/// Errors raised by the encryption helpers.
615#[derive(Debug, Error)]
616#[non_exhaustive]
617#[cfg(feature = "nip44")]
618#[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
619pub enum ListEncryptionError {
620    /// No payload was stashed in [`List::encrypted_payload`].
621    #[error("no encrypted payload available; call from_event first")]
622    NoPayload,
623    /// NIP-44 encryption failed.
624    #[error("NIP-44 encrypt failed: {0}")]
625    Encrypt(#[source] crate::nips::nip44::Nip44Error),
626    /// NIP-44 decryption failed.
627    #[error("NIP-44 decrypt failed: {0}")]
628    Decrypt(#[source] crate::nips::nip44::Nip44Error),
629    /// JSON encode/decode failed.
630    #[error("invalid JSON inside encrypted payload: {0}")]
631    InvalidJson(#[source] serde_json::Error),
632    /// NIP-04 fallback was needed but the feature is disabled.
633    #[cfg(not(feature = "nip04"))]
634    #[error("NIP-04 fallback required but the `nip04` feature is disabled")]
635    Nip04Unavailable,
636    /// NIP-04 decryption failed.
637    #[cfg(feature = "nip04")]
638    #[error("NIP-04 decrypt failed: {0}")]
639    Nip04(#[source] crate::nips::nip04::Nip04Error),
640}
641
642impl EventBuilder {
643    /// Author a NIP-51 list event from a [`List`] bundle (no
644    /// encryption applied).
645    ///
646    /// The resulting event has empty `.content`. Use
647    /// [`Self::list_with_private_items`] to encrypt private items
648    /// in one shot.
649    #[must_use]
650    pub fn list(list: &List) -> Self {
651        let mut builder = Self::new(list.kind, "");
652        for tag in list.to_public_tags() {
653            builder = builder.tag(tag);
654        }
655        builder
656    }
657
658    /// Author a NIP-51 list event with encrypted private items.
659    ///
660    /// `owner_secret` / `owner_public` MUST belong to the same
661    /// keypair (NIP-51 §"Encryption process pseudocode").
662    ///
663    /// # Errors
664    ///
665    /// Forwarded from [`List::encrypt_private`].
666    #[cfg(feature = "nip44")]
667    #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
668    pub fn list_with_private_items(
669        list: &List,
670        owner_secret: &SecretKey,
671        owner_public: &PublicKey,
672    ) -> Result<Self, ListEncryptionError> {
673        let payload = list
674            .encrypt_private(owner_secret, owner_public)?
675            .unwrap_or_default();
676        let mut builder = Self::new(list.kind, payload);
677        for tag in list.to_public_tags() {
678            builder = builder.tag(tag);
679        }
680        Ok(builder)
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::Keys;
688
689    fn keys() -> Keys {
690        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
691    }
692
693    #[test]
694    fn mute_list_round_trips_public_items() {
695        let pk = *keys().public_key();
696        let list = List::new(Kind::MUTE_LIST)
697            .public_item(ListItem::Pubkey {
698                pubkey: pk,
699                relay_hint: None,
700                petname: None,
701            })
702            .public_item(ListItem::Hashtag("spam".to_owned()))
703            .public_item(ListItem::Word("crypto".to_owned()))
704            .public_item(ListItem::Event {
705                id: EventId::from_byte_array([0xab; 32]),
706                relay_hint: None,
707            });
708        let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
709        assert_eq!(event.kind, Kind::MUTE_LIST);
710        let parsed = List::from_event(&event).unwrap();
711        assert_eq!(parsed.public_items, list.public_items);
712        assert!(parsed.encrypted_payload.is_none());
713    }
714
715    #[test]
716    fn bookmark_set_round_trips_metadata() {
717        let list = List::new(Kind::BOOKMARK_SET)
718            .identifier("yaks")
719            .title("Yaks")
720            .description("articles about yaks")
721            .image("https://example.com/yak.png")
722            .public_item(ListItem::Address {
723                coordinate: Coordinate::new(
724                    Kind::LONG_FORM_TEXT_NOTE,
725                    *keys().public_key(),
726                    "yak-1",
727                ),
728                relay_hint: None,
729            });
730        let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
731        let parsed = List::from_event(&event).unwrap();
732        assert_eq!(parsed.identifier.as_deref(), Some("yaks"));
733        assert_eq!(parsed.title.as_deref(), Some("Yaks"));
734        assert_eq!(parsed.description.as_deref(), Some("articles about yaks"));
735        assert_eq!(parsed.image.as_deref(), Some("https://example.com/yak.png"));
736        assert_eq!(parsed.public_items.len(), 1);
737    }
738
739    #[test]
740    fn relay_set_round_trips_relays() {
741        let list = List::new(Kind::RELAY_SET)
742            .identifier("default")
743            .public_item(ListItem::Relay(
744                RelayUrl::parse("wss://relay.example/").unwrap(),
745            ));
746        let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
747        let parsed = List::from_event(&event).unwrap();
748        assert_eq!(parsed.public_items.len(), 1);
749        assert!(matches!(&parsed.public_items[0], ListItem::Relay(_)));
750    }
751
752    #[test]
753    fn unknown_tags_round_trip_as_other() {
754        let list = List::new(Kind::INTEREST_SET)
755            .identifier("cust")
756            .public_item(ListItem::Other(Tag::with(
757                &TagKind::Custom("vendor".to_owned()),
758                ["xyz"],
759            )));
760        let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
761        let parsed = List::from_event(&event).unwrap();
762        assert_eq!(parsed.public_items.len(), 1);
763        match &parsed.public_items[0] {
764            ListItem::Other(tag) => assert_eq!(tag.name(), "vendor"),
765            other => panic!("expected Other, got {other:?}"),
766        }
767    }
768
769    #[cfg(feature = "nip44")]
770    #[test]
771    fn private_items_round_trip_through_nip44() {
772        let owner = keys();
773        let list = List::new(Kind::MUTE_LIST)
774            .public_item(ListItem::Hashtag("public".to_owned()))
775            .private_item(ListItem::Hashtag("secret".to_owned()))
776            .private_item(ListItem::Pubkey {
777                pubkey: *owner.public_key(),
778                relay_hint: None,
779                petname: None,
780            });
781        let event =
782            EventBuilder::list_with_private_items(&list, owner.secret_key(), owner.public_key())
783                .unwrap()
784                .sign_with_keys(&owner)
785                .unwrap();
786
787        // Public side parsing.
788        let mut parsed = List::from_event(&event).unwrap();
789        assert_eq!(parsed.public_items.len(), 1);
790        assert!(parsed.encrypted_payload.is_some());
791
792        // Private side decrypts cleanly.
793        parsed
794            .decrypt_private(owner.secret_key(), owner.public_key())
795            .unwrap();
796        assert_eq!(parsed.private_items.len(), 2);
797        assert!(parsed.encrypted_payload.is_none());
798    }
799
800    #[test]
801    fn pubkey_with_petname_round_trips() {
802        let pk = *keys().public_key();
803        let list = List::new(Kind::FOLLOW_SET)
804            .identifier("close-friends")
805            .public_item(ListItem::Pubkey {
806                pubkey: pk,
807                relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
808                petname: Some("alice".to_owned()),
809            });
810        let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
811        let parsed = List::from_event(&event).unwrap();
812        match &parsed.public_items[0] {
813            ListItem::Pubkey {
814                pubkey,
815                relay_hint,
816                petname,
817            } => {
818                assert_eq!(*pubkey, pk);
819                assert!(relay_hint.is_some());
820                assert_eq!(petname.as_deref(), Some("alice"));
821            }
822            other => panic!("expected Pubkey, got {other:?}"),
823        }
824    }
825}