Skip to main content

nula_core/nips/
nip18.rs

1//! [NIP-18] Reposts and quote reposts.
2//!
3//! Three flavours of "I want my followers to see this" exist on
4//! Nostr; this module models all three:
5//!
6//! - **Repost** — `kind: 6` ([`crate::Kind::REPOST`]). Reserved for
7//!   `kind: 1` text notes. The repost's `content` is the
8//!   stringified JSON of the reposted note (or empty for NIP-70
9//!   protected events). The `e` tag MUST include a relay URL in its
10//!   third slot, and a `p` tag SHOULD point at the original author.
11//!   Build with [`EventBuilder::repost`].
12//! - **Generic repost** — `kind: 16`
13//!   ([`crate::Kind::GENERIC_REPOST`]). Reposts a non-`kind: 1`
14//!   event. Carries a `k` tag with the reposted kind and either
15//!   the full event JSON in `content` (for non-replaceable events)
16//!   *or* an `a` tag pointing at the addressable coordinate for
17//!   replaceable events. Build with [`EventBuilder::generic_repost`].
18//! - **Quote repost** — *any* event kind that wants to reference
19//!   another event using NIP-21 entities. Surfaces on the wire as a
20//!   `q` tag (`["q", <id-or-coord>, <relay>, <pubkey?>]`). Build the
21//!   `q` tag with [`crate::Tag::q`] / [`crate::Tag::q_addressable`].
22//!
23//! Read helpers ([`reposted_event_id`], [`reposted_event_pubkey`],
24//! [`reposted_event_kind`], [`reposted_event_coordinate`]) walk the
25//! repost's tags so callers don't need to mirror the spec's
26//! quirks (relay-hint slot in `e`, `k`-tag presence-or-absence rules,
27//! addressable coordinate detection).
28//!
29//! [NIP-18]: https://github.com/nostr-protocol/nips/blob/master/18.md
30
31use crate::event::{
32    Alphabet, Coordinate, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind, Tags,
33};
34use crate::key::PublicKey;
35use crate::types::RelayUrl;
36
37/// Errors raised by the NIP-18 builders.
38///
39/// `Serialize` boxes its `serde_json::Error` payload so the enum
40/// stays small in the common-success path: the underlying error is
41/// 14 bytes whereas every other variant fits in two.
42#[derive(Debug, thiserror::Error)]
43#[non_exhaustive]
44#[allow(
45    variant_size_differences,
46    reason = "Serialize variant is already boxed; its 8-byte pointer is the smallest sound representation against the 2-byte NotATextNote variant"
47)]
48pub enum RepostError {
49    /// [`EventBuilder::repost`] was called with an event whose kind is
50    /// not [`Kind::TEXT_NOTE`]. NIP-18 reserves `kind: 6` for `kind: 1`
51    /// reposts; use [`EventBuilder::generic_repost`] for everything
52    /// else.
53    #[error("kind:6 reposts are reserved for kind:1 notes; got kind {0}")]
54    NotATextNote(u16),
55    /// Serialising the reposted event into the repost's `content`
56    /// field failed.
57    #[error(transparent)]
58    Serialize(Box<serde_json::Error>),
59}
60
61impl From<serde_json::Error> for RepostError {
62    fn from(value: serde_json::Error) -> Self {
63        Self::Serialize(Box::new(value))
64    }
65}
66
67impl EventBuilder {
68    /// Build a NIP-18 repost ([`Kind::REPOST`]) for a `kind: 1` note.
69    ///
70    /// The repost's `content` is the canonical JSON of the reposted
71    /// note (or empty when `target` carries the NIP-70 `["-"]`
72    /// protected marker). The mandatory `e` tag includes the relay
73    /// URL at index 2 — NIP-18 elevates the relay hint from "SHOULD"
74    /// to "MUST" specifically for reposts.
75    ///
76    /// # Errors
77    ///
78    /// - [`RepostError::NotATextNote`] when `target.kind != Kind::TEXT_NOTE`.
79    /// - [`RepostError::Serialize`] if `serde_json` cannot serialise
80    ///   `target` (in practice impossible because every signed
81    ///   `Event` round-trips through `serde_json` already).
82    pub fn repost(target: &Event, relay: &RelayUrl) -> Result<Self, RepostError> {
83        if target.kind != Kind::TEXT_NOTE {
84            return Err(RepostError::NotATextNote(target.kind.as_u16()));
85        }
86        let content = if target.is_protected() {
87            String::new()
88        } else {
89            serde_json::to_string(target)?
90        };
91        let mut builder = Self::new(Kind::REPOST, content);
92        builder = builder.tag(repost_e_tag(target.id, relay));
93        builder = builder.tag(Tag::p_with_relay(target.pubkey, relay));
94        Ok(builder)
95    }
96
97    /// Build a NIP-18 generic repost ([`Kind::GENERIC_REPOST`]) for a
98    /// non-`kind: 1` event.
99    ///
100    /// Carries a `k` tag with the original kind. For replaceable /
101    /// addressable events, an `a` tag is added with the
102    /// `(kind, author, d-tag)` coordinate and the `content` is left
103    /// empty per spec; for regular events the full JSON is stuffed
104    /// into `content` so the original is recoverable even if the
105    /// referenced relays drop it.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`RepostError::Serialize`] if `serde_json` cannot
110    /// serialise `target`.
111    pub fn generic_repost(target: &Event, relay: &RelayUrl) -> Result<Self, RepostError> {
112        let coord = if target.kind.is_addressable() {
113            extract_d_tag(&target.tags)
114                .map(|d| Coordinate::new(target.kind, target.pubkey, d.to_owned()))
115        } else {
116            None
117        };
118        let content = if coord.is_some() || target.is_protected() {
119            String::new()
120        } else {
121            serde_json::to_string(target)?
122        };
123        let mut builder = Self::new(Kind::GENERIC_REPOST, content);
124        builder = builder.tag(repost_e_tag(target.id, relay));
125        builder = builder.tag(Tag::p_with_relay(target.pubkey, relay));
126        builder = builder.tag(Tag::k(target.kind));
127        if let Some(coord) = coord {
128            builder = builder.tag(Tag::a_with_relay(&coord, relay));
129        }
130        Ok(builder)
131    }
132}
133
134fn repost_e_tag(event_id: EventId, relay: &RelayUrl) -> Tag {
135    // ["e", id, relay] - NIP-18 mandates the relay slot.
136    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
137    Tag::with(&head, [event_id.to_hex(), relay.as_str().to_owned()])
138}
139
140fn extract_d_tag(tags: &Tags) -> Option<&str> {
141    for tag in tags {
142        if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::D && !s.uppercase)
143        {
144            return tag.values().get(1).map(String::as_str);
145        }
146    }
147    None
148}
149
150/// Return the reposted event id from `tags` (the first `e` tag's
151/// second slot).
152///
153/// Unlike [`super::nip25::target_event_id`], NIP-18 does not allow
154/// thread-context `e` tags on a repost, so the **first** `e` tag is
155/// authoritative.
156#[must_use]
157pub fn reposted_event_id(tags: &Tags) -> Option<EventId> {
158    for tag in tags {
159        if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::E && !s.uppercase)
160        {
161            return tag.values().get(1).and_then(|v| EventId::parse(v).ok());
162        }
163    }
164    None
165}
166
167/// Return the reposted event author's public key from the first
168/// `p` tag, if any.
169#[must_use]
170pub fn reposted_event_pubkey(tags: &Tags) -> Option<PublicKey> {
171    for tag in tags {
172        if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::P && !s.uppercase)
173        {
174            return tag.values().get(1).and_then(|v| PublicKey::parse(v).ok());
175        }
176    }
177    None
178}
179
180/// Return the reposted event kind from the optional `k` tag (only
181/// emitted by `kind: 16` generic reposts).
182#[must_use]
183pub fn reposted_event_kind(tags: &Tags) -> Option<Kind> {
184    for tag in tags {
185        if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::K && !s.uppercase)
186        {
187            return tag
188                .values()
189                .get(1)
190                .and_then(|v| v.parse::<u16>().ok())
191                .map(Kind::new);
192        }
193    }
194    None
195}
196
197/// Return the reposted addressable coordinate from the optional
198/// `a` tag (only emitted for replaceable / addressable targets).
199#[must_use]
200pub fn reposted_event_coordinate(tags: &Tags) -> Option<Coordinate> {
201    for tag in tags {
202        let TagKind::SingleLetter(s) = tag.kind() else {
203            continue;
204        };
205        if s.character != Alphabet::A || s.uppercase {
206            continue;
207        }
208        if let Some(value) = tag.values().get(1) {
209            return Coordinate::parse(value).ok();
210        }
211    }
212    None
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::Keys;
219
220    fn fixture_keys() -> Keys {
221        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
222    }
223
224    fn fixture_relay() -> RelayUrl {
225        RelayUrl::parse("wss://relay.example/").unwrap()
226    }
227
228    #[test]
229    fn repost_rejects_non_text_note_kinds() {
230        let keys = fixture_keys();
231        let target = EventBuilder::new(Kind::CONTACTS, "")
232            .sign_with_keys(&keys)
233            .unwrap();
234
235        let err = EventBuilder::repost(&target, &fixture_relay()).unwrap_err();
236        assert!(matches!(err, RepostError::NotATextNote(3)));
237    }
238
239    #[test]
240    fn repost_carries_target_json_in_content_and_e_p_tags() {
241        let keys = fixture_keys();
242        let target = EventBuilder::text_note("hello, world")
243            .sign_with_keys(&keys)
244            .unwrap();
245
246        let repost = EventBuilder::repost(&target, &fixture_relay())
247            .unwrap()
248            .sign_with_keys(&keys)
249            .unwrap();
250
251        assert_eq!(repost.kind, Kind::REPOST);
252        assert!(
253            repost.content.contains(r#""content":"hello, world""#),
254            "repost content must embed the reposted note's JSON: {}",
255            repost.content,
256        );
257        assert_eq!(reposted_event_id(&repost.tags), Some(target.id));
258        assert_eq!(reposted_event_pubkey(&repost.tags), Some(target.pubkey));
259        // `kind: 6` reposts do not emit a `k` tag — that's NIP-18
260        // §Generic Reposts territory.
261        assert_eq!(reposted_event_kind(&repost.tags), None);
262        repost.verify().unwrap();
263    }
264
265    #[test]
266    fn repost_e_tag_carries_relay_at_index_2() {
267        let keys = fixture_keys();
268        let target = EventBuilder::text_note("post")
269            .sign_with_keys(&keys)
270            .unwrap();
271        let relay = fixture_relay();
272
273        let repost = EventBuilder::repost(&target, &relay)
274            .unwrap()
275            .sign_with_keys(&keys)
276            .unwrap();
277
278        let e_tag = repost
279            .tags
280            .iter()
281            .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::E))
282            .unwrap();
283        assert_eq!(e_tag.get(2), Some(relay.as_str()));
284    }
285
286    #[test]
287    fn generic_repost_emits_k_tag_for_arbitrary_kind() {
288        let keys = fixture_keys();
289        let target = EventBuilder::new(Kind::CONTACTS, "")
290            .sign_with_keys(&keys)
291            .unwrap();
292
293        let repost = EventBuilder::generic_repost(&target, &fixture_relay())
294            .unwrap()
295            .sign_with_keys(&keys)
296            .unwrap();
297
298        assert_eq!(repost.kind, Kind::GENERIC_REPOST);
299        assert_eq!(reposted_event_kind(&repost.tags), Some(Kind::CONTACTS));
300        // Non-addressable: full JSON in content.
301        assert!(repost.content.contains(r#""kind":3"#));
302    }
303
304    #[test]
305    fn generic_repost_uses_a_tag_and_empty_content_for_addressable() {
306        let keys = fixture_keys();
307        let target = EventBuilder::new(Kind::LONG_FORM_TEXT_NOTE, "post body")
308            .tag(Tag::d("article-1"))
309            .sign_with_keys(&keys)
310            .unwrap();
311
312        let repost = EventBuilder::generic_repost(&target, &fixture_relay())
313            .unwrap()
314            .sign_with_keys(&keys)
315            .unwrap();
316
317        // Addressable repost: empty content, coordinate carried in `a`.
318        assert_eq!(repost.content, "");
319        let coord = reposted_event_coordinate(&repost.tags).expect("a-tag must be present");
320        assert_eq!(coord.kind, Kind::LONG_FORM_TEXT_NOTE);
321        assert_eq!(coord.author, target.pubkey);
322        assert_eq!(coord.identifier, "article-1");
323    }
324
325    #[test]
326    fn quote_repost_q_tag_round_trips() {
327        let keys = fixture_keys();
328        let target = EventBuilder::text_note("quoted")
329            .sign_with_keys(&keys)
330            .unwrap();
331        let relay = fixture_relay();
332
333        let event = EventBuilder::text_note("see this:")
334            .tag(Tag::q(target.id, &relay, target.pubkey))
335            .sign_with_keys(&keys)
336            .unwrap();
337
338        let q_tag = event
339            .tags
340            .iter()
341            .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::Q))
342            .unwrap();
343        assert_eq!(q_tag.values().len(), 4);
344        assert_eq!(q_tag.get(1), Some(target.id.to_hex().as_str()));
345        assert_eq!(q_tag.get(2), Some(relay.as_str()));
346        assert_eq!(q_tag.get(3), Some(target.pubkey.to_hex().as_str()));
347    }
348
349    #[test]
350    fn quote_repost_addressable_q_tag_uses_coordinate() {
351        let keys = fixture_keys();
352        let target = EventBuilder::new(Kind::LONG_FORM_TEXT_NOTE, "post")
353            .tag(Tag::d("ident"))
354            .sign_with_keys(&keys)
355            .unwrap();
356        let coord = Coordinate::new(target.kind, target.pubkey, "ident");
357        let relay = fixture_relay();
358
359        let event = EventBuilder::text_note("see this article:")
360            .tag(Tag::q_addressable(&coord, &relay))
361            .sign_with_keys(&keys)
362            .unwrap();
363
364        let q_tag = event
365            .tags
366            .iter()
367            .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::Q))
368            .unwrap();
369        // Addressable form: 3 values (q, coordinate, relay) — no
370        // separate author column because it is implicit in the coord.
371        assert_eq!(q_tag.values().len(), 3);
372        assert!(q_tag.get(1).unwrap().starts_with("30023:"));
373        assert_eq!(q_tag.get(2), Some(relay.as_str()));
374    }
375}