nula-core 0.2.2

Nostr protocol core: events, filters, keys, messages, NIP primitives.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
//! [NIP-10] Replies and Mentions in Text Notes.
//!
//! NIP-10 specifies how `kind: 1` notes reference one another to build
//! threads. The recommended ("preferred") form attaches a marker to each
//! `e` tag:
//!
//! ```text
//! ["e", "<event-id>", "<relay-hint>", "<marker>", "<author-pubkey>?"]
//! ```
//!
//! - `root` — the top of the thread.
//! - `reply` — the parent note this one is replying to.
//! - `mention` — a quoted reference, not a reply.
//!
//! `p` tags carry the pubkeys mentioned in the thread (typically the
//! authors of all referenced events). NIP-10 also describes a legacy
//! positional form; this module emits the marker form on the way out and
//! tolerates both on the way in.
//!
//! [NIP-10]: https://github.com/nostr-protocol/nips/blob/master/10.md

use std::fmt;
use std::str::FromStr;

use thiserror::Error;

use crate::event::{
    Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
};
use crate::key::{PublicKey, PublicKeyError};
use crate::types::{RelayUrl, RelayUrlError};

/// NIP-10 marker for an `e` tag.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum NoteMarker {
    /// Top of the thread.
    Root,
    /// The parent note this one replies to.
    Reply,
    /// Quoted (not replied to).
    Mention,
}

impl NoteMarker {
    /// Static wire string used in the third column of an `e` tag.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Root => "root",
            Self::Reply => "reply",
            Self::Mention => "mention",
        }
    }
}

impl fmt::Display for NoteMarker {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Errors raised when parsing a [`NoteMarker`].
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum NoteMarkerError {
    /// The marker string was not one of `root`, `reply`, `mention`.
    #[error("unknown NIP-10 marker `{0}`")]
    Unknown(String),
}

impl FromStr for NoteMarker {
    type Err = NoteMarkerError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "root" => Ok(Self::Root),
            "reply" => Ok(Self::Reply),
            "mention" => Ok(Self::Mention),
            other => Err(NoteMarkerError::Unknown(other.to_owned())),
        }
    }
}

/// Reference to another event from inside a thread.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EventReference {
    /// The id of the referenced event.
    pub event_id: EventId,
    /// Optional relay hint where the event can be fetched.
    pub relay_hint: Option<RelayUrl>,
    /// Optional NIP-10 marker.
    pub marker: Option<NoteMarker>,
    /// Optional hint of the referenced event's author.
    pub author_hint: Option<PublicKey>,
}

impl EventReference {
    /// Construct a reference with no hints or marker.
    #[must_use]
    pub const fn new(event_id: EventId) -> Self {
        Self {
            event_id,
            relay_hint: None,
            marker: None,
            author_hint: None,
        }
    }

    /// Set the relay hint.
    #[must_use]
    pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
        self.relay_hint = Some(relay);
        self
    }

    /// Set the NIP-10 marker.
    #[must_use]
    pub const fn with_marker(mut self, marker: NoteMarker) -> Self {
        self.marker = Some(marker);
        self
    }

    /// Set the author hint.
    #[must_use]
    pub const fn with_author_hint(mut self, author: PublicKey) -> Self {
        self.author_hint = Some(author);
        self
    }
}

/// NIP-10 thread metadata for a `kind: 1` note.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ThreadContext {
    /// Every `e` tag, in the order they appear on the wire.
    pub events: Vec<EventReference>,
    /// Pubkeys collected from the `p` tags.
    pub mentioned_pubkeys: Vec<PublicKey>,
}

impl ThreadContext {
    /// Construct an empty context.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Append an event reference and return `self`.
    #[must_use]
    pub fn reference(mut self, reference: EventReference) -> Self {
        self.events.push(reference);
        self
    }

    /// Append a mentioned pubkey.
    #[must_use]
    pub fn mention(mut self, pubkey: PublicKey) -> Self {
        self.mentioned_pubkeys.push(pubkey);
        self
    }

    /// First reference whose marker is [`NoteMarker::Root`], if any.
    #[must_use]
    pub fn root(&self) -> Option<&EventReference> {
        self.events
            .iter()
            .find(|r| r.marker == Some(NoteMarker::Root))
    }

    /// First reference whose marker is [`NoteMarker::Reply`], if any.
    #[must_use]
    pub fn reply(&self) -> Option<&EventReference> {
        self.events
            .iter()
            .find(|r| r.marker == Some(NoteMarker::Reply))
    }

    /// Every reference whose marker is [`NoteMarker::Mention`].
    pub fn mentions(&self) -> impl Iterator<Item = &EventReference> {
        self.events
            .iter()
            .filter(|r| r.marker == Some(NoteMarker::Mention))
    }

    /// Fill in markers on `e` references that came from the *deprecated
    /// positional form* of NIP-10.
    ///
    /// Per NIP-10 §"deprecated positional form": when an event carries
    /// `e` tags without explicit markers, the position determines the
    /// role:
    ///
    /// - 0 unmarked references: nothing to do
    /// - 1 unmarked reference: it is the [`NoteMarker::Root`]
    /// - 2+ unmarked references: first is [`NoteMarker::Root`], last is
    ///   [`NoteMarker::Reply`], every entry in between is
    ///   [`NoteMarker::Mention`]
    ///
    /// Existing markers are never overwritten — references that already
    /// have a marker keep it. This makes the operation safe to call on
    /// any [`ThreadContext`], including ones produced by
    /// [`ThreadContext::from_event`] on a legacy thread mixed with
    /// modern markers.
    #[must_use]
    pub fn infer_legacy_markers(mut self) -> Self {
        let unmarked: Vec<usize> = self
            .events
            .iter()
            .enumerate()
            .filter_map(|(i, r)| if r.marker.is_none() { Some(i) } else { None })
            .collect();
        let assign = |slot: &mut Self, idx: usize, marker: NoteMarker| {
            if let Some(r) = slot.events.get_mut(idx) {
                r.marker = Some(marker);
            }
        };
        match unmarked.as_slice() {
            [] => {}
            [only] => assign(&mut self, *only, NoteMarker::Root),
            [first, middle @ .., last] => {
                assign(&mut self, *first, NoteMarker::Root);
                assign(&mut self, *last, NoteMarker::Reply);
                for &idx in middle {
                    assign(&mut self, idx, NoteMarker::Mention);
                }
            }
        }
        self
    }

    /// Render the context as the [`Tag`]s that go into a `kind: 1` note.
    #[must_use]
    pub fn to_tags(&self) -> Vec<Tag> {
        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));

        let mut tags = Vec::with_capacity(self.events.len() + self.mentioned_pubkeys.len());
        for r in &self.events {
            tags.push(build_e_tag(&e_kind, r));
        }
        for pk in &self.mentioned_pubkeys {
            tags.push(Tag::with(&p_kind, [pk.to_hex()]));
        }
        tags
    }

    /// Reconstruct a [`ThreadContext`] from `event`'s tags.
    ///
    /// The parser is tolerant: malformed `e`/`p` tags are skipped instead
    /// of failing the whole event, since real-world clients have produced
    /// many variations over the years. Use [`EventReference::from_tag`]
    /// directly for the strict, fail-fast version.
    #[must_use]
    pub fn from_event(event: &Event) -> Self {
        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));

        let mut context = Self::new();
        for tag in &event.tags {
            let head = tag.kind();
            if head == e_kind
                && let Ok(reference) = EventReference::from_tag(tag)
            {
                context.events.push(reference);
            } else if head == p_kind
                && let Some(pk) = tag
                    .values()
                    .get(1)
                    .and_then(|s| s.parse::<PublicKey>().ok())
            {
                context.mentioned_pubkeys.push(pk);
            }
        }
        context
    }
}

impl EventBuilder {
    /// Build a `kind: 1` text note carrying the supplied [`ThreadContext`]
    /// (i.e. a NIP-10 reply or mention).
    #[must_use]
    pub fn note_with_context<S: Into<String>>(content: S, context: &ThreadContext) -> Self {
        Self::new(Kind::TEXT_NOTE, content).tags(context.to_tags())
    }
}

fn build_e_tag(e_kind: &TagKind, reference: &EventReference) -> Tag {
    let event_id = reference.event_id.to_hex();
    let relay = reference
        .relay_hint
        .as_ref()
        .map(|r| r.as_str().to_owned())
        .unwrap_or_default();
    let marker = reference
        .marker
        .map(|m| m.as_str().to_owned())
        .unwrap_or_default();
    let author = reference
        .author_hint
        .map(PublicKey::to_hex)
        .unwrap_or_default();

    if !author.is_empty() {
        Tag::with(e_kind, [event_id, relay, marker, author])
    } else if !marker.is_empty() {
        Tag::with(e_kind, [event_id, relay, marker])
    } else if !relay.is_empty() {
        Tag::with(e_kind, [event_id, relay])
    } else {
        Tag::with(e_kind, [event_id])
    }
}

/// Errors that decoding strict-mode (i.e. fail-fast) NIP-10 references can
/// produce. Currently only used by [`EventReference::from_tag`].
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum ThreadError {
    /// The tag head was not `e`.
    #[error("expected `e` tag, got `{0}`")]
    NotEventTag(String),
    /// The tag had no event id.
    #[error("`e` tag is missing the event id")]
    MissingEventId,
    /// The event id did not parse.
    #[error(transparent)]
    InvalidEventId(#[from] EventIdError),
    /// The relay hint did not parse.
    #[error(transparent)]
    InvalidRelay(#[from] RelayUrlError),
    /// The marker did not parse.
    #[error(transparent)]
    InvalidMarker(#[from] NoteMarkerError),
    /// The author hint did not parse.
    #[error(transparent)]
    InvalidAuthor(#[from] PublicKeyError),
}

impl EventReference {
    /// Strict, fail-fast version of the per-tag parser used by
    /// [`ThreadContext::from_event`]. Use this when you want to surface
    /// malformed `e` tags instead of silently dropping them.
    ///
    /// # Errors
    ///
    /// Returns the matching [`ThreadError`] for any malformed component.
    pub fn from_tag(tag: &Tag) -> Result<Self, ThreadError> {
        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
        if tag.kind() != e_kind {
            return Err(ThreadError::NotEventTag(tag.kind().as_str().to_owned()));
        }
        let mut values = tag.values().iter().skip(1);
        let id = values
            .next()
            .ok_or(ThreadError::MissingEventId)?
            .parse::<EventId>()?;
        let relay_hint = match values.next() {
            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
            _ => None,
        };
        let marker = match values.next() {
            Some(s) if !s.is_empty() => Some(s.parse::<NoteMarker>()?),
            _ => None,
        };
        let author_hint = match values.next() {
            Some(s) if !s.is_empty() => Some(s.parse::<PublicKey>()?),
            _ => None,
        };
        Ok(Self {
            event_id: id,
            relay_hint,
            marker,
            author_hint,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Keys;
    use crate::types::Timestamp;

    fn keys() -> Keys {
        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
    }

    fn event_id(seed: u8) -> EventId {
        EventId::from_byte_array([seed; 32])
    }

    fn pk(seed: u8) -> PublicKey {
        let mut bytes = [0u8; 32];
        bytes[31] = seed;
        let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
        *Keys::from_secret_key(sk).public_key()
    }

    #[test]
    fn marker_round_trip() {
        for marker in [NoteMarker::Root, NoteMarker::Reply, NoteMarker::Mention] {
            let s = marker.as_str();
            assert_eq!(s.parse::<NoteMarker>().unwrap(), marker);
        }
    }

    #[test]
    fn marker_rejects_unknown() {
        let err = "thread".parse::<NoteMarker>().unwrap_err();
        assert!(matches!(err, NoteMarkerError::Unknown(_)));
    }

    #[test]
    fn round_trip_through_event() {
        let context = ThreadContext::new()
            .reference(
                EventReference::new(event_id(0xaa))
                    .with_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
                    .with_marker(NoteMarker::Root)
                    .with_author_hint(pk(1)),
            )
            .reference(
                EventReference::new(event_id(0xbb))
                    .with_marker(NoteMarker::Reply)
                    .with_author_hint(pk(2)),
            )
            .reference(EventReference::new(event_id(0xcc)).with_marker(NoteMarker::Mention))
            .mention(pk(3));

        let event = EventBuilder::note_with_context("hi thread", &context)
            .created_at(Timestamp::from_secs(1))
            .sign_with_keys(&keys())
            .unwrap();
        event.verify().unwrap();
        let parsed = ThreadContext::from_event(&event);
        assert_eq!(parsed, context);

        assert_eq!(parsed.root().unwrap().event_id, event_id(0xaa));
        assert_eq!(parsed.reply().unwrap().event_id, event_id(0xbb));
        let mentions: Vec<_> = parsed.mentions().collect();
        assert_eq!(mentions.len(), 1);
        assert_eq!(mentions[0].event_id, event_id(0xcc));
    }

    #[test]
    fn legacy_positional_tags_decode_without_marker() {
        // No marker columns; only the event id.
        let event = EventBuilder::text_note("legacy thread")
            .created_at(Timestamp::from_secs(2))
            .tag(Tag::new(["e", &event_id(0xaa).to_hex()]).unwrap())
            .sign_with_keys(&keys())
            .unwrap();
        let parsed = ThreadContext::from_event(&event);
        assert_eq!(parsed.events.len(), 1);
        assert!(parsed.events[0].marker.is_none());
        assert!(parsed.root().is_none());
    }

    #[test]
    fn malformed_e_tag_is_skipped_in_lenient_parse() {
        let event = EventBuilder::text_note("bad ref")
            .created_at(Timestamp::from_secs(3))
            .tags([
                Tag::new(["e", "not-a-hex-id"]).unwrap(),
                Tag::new(["e", &event_id(0x10).to_hex()]).unwrap(),
            ])
            .sign_with_keys(&keys())
            .unwrap();
        let parsed = ThreadContext::from_event(&event);
        // The bad one is silently dropped, the good one survives.
        assert_eq!(parsed.events.len(), 1);
    }

    #[test]
    fn from_tag_strict_returns_errors() {
        let bad = Tag::new(["e", "not-a-hex-id"]).unwrap();
        let err = EventReference::from_tag(&bad).unwrap_err();
        assert!(matches!(err, ThreadError::InvalidEventId(_)));
    }

    #[test]
    fn from_tag_rejects_non_e_tag() {
        let tag = Tag::new(["p", &pk(1).to_hex()]).unwrap();
        let err = EventReference::from_tag(&tag).unwrap_err();
        assert!(matches!(err, ThreadError::NotEventTag(_)));
    }

    #[test]
    fn legacy_positional_single_e_tag_becomes_root() {
        let context = ThreadContext::new()
            .reference(EventReference::new(event_id(0xaa)))
            .infer_legacy_markers();
        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
        assert!(context.reply().is_none());
    }

    #[test]
    fn legacy_positional_multi_e_tag_assigns_root_reply_mention() {
        let context = ThreadContext::new()
            .reference(EventReference::new(event_id(0xaa)))
            .reference(EventReference::new(event_id(0xbb)))
            .reference(EventReference::new(event_id(0xcc)))
            .reference(EventReference::new(event_id(0xdd)))
            .infer_legacy_markers();
        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
        assert_eq!(context.events[1].marker, Some(NoteMarker::Mention));
        assert_eq!(context.events[2].marker, Some(NoteMarker::Mention));
        assert_eq!(context.events[3].marker, Some(NoteMarker::Reply));
    }

    #[test]
    fn legacy_positional_two_e_tags_become_root_and_reply() {
        let context = ThreadContext::new()
            .reference(EventReference::new(event_id(0xaa)))
            .reference(EventReference::new(event_id(0xbb)))
            .infer_legacy_markers();
        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
        assert_eq!(context.events[1].marker, Some(NoteMarker::Reply));
    }

    #[test]
    fn legacy_positional_inference_preserves_existing_markers() {
        // Mixed thread: an explicit Root plus an unmarked tail. Inference
        // must not overwrite the explicit marker; it labels only the
        // unmarked entries (here: only one, which becomes Root by the
        // single-unmarked rule).
        let context = ThreadContext::new()
            .reference(EventReference::new(event_id(0xaa)).with_marker(NoteMarker::Root))
            .reference(EventReference::new(event_id(0xbb)))
            .infer_legacy_markers();
        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
        assert_eq!(context.events[1].marker, Some(NoteMarker::Root));
    }

    #[test]
    fn legacy_positional_no_e_tags_is_a_noop() {
        let context = ThreadContext::new().infer_legacy_markers();
        assert!(context.events.is_empty());
    }
}