Skip to main content

nula_core/nips/
nip66.rs

1//! [NIP-66] Relay Discovery & Liveness Monitoring.
2//!
3//! Two event kinds:
4//!
5//! - **`kind: 30166` Relay Discovery** — addressable event a monitor
6//!   publishes per relay it surveys. Its `d` tag is the relay's
7//!   normalised URL (or a hex pubkey for relays unreachable by URL),
8//!   `.content` MAY carry the relay's NIP-11 document, and a rich
9//!   tag-set documents network type, supported NIPs, requirements,
10//!   topics, accepted kinds, geohash, and round-trip times.
11//! - **`kind: 10166` Relay Monitor Announcement** — replaceable
12//!   advert from a monitor declaring the cadence and battery of
13//!   checks it runs.
14//!
15//! `R` tag values support the NIP-66 `!` prefix for "false" booleans
16//! (`!auth` = `auth == false`); we model them through the typed
17//! [`RelayRequirement`] struct. `k` tags follow the same convention
18//! via [`AcceptedKind`].
19//!
20//! Unknown tags survive a round-trip through `extra_tags` on both
21//! bundles.
22//!
23//! [NIP-66]: https://github.com/nostr-protocol/nips/blob/master/66.md
24
25use thiserror::Error;
26
27use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
28
29/// `kind: 30166` — relay discovery event.
30pub const KIND_RELAY_DISCOVERY: Kind = Kind::RELAY_DISCOVERY;
31
32/// `kind: 10166` — relay monitor announcement.
33pub const KIND_RELAY_MONITOR: Kind = Kind::RELAY_MONITOR;
34
35const NETWORK_TAG: &str = "n";
36const RELAY_TYPE_TAG: &str = "T";
37const NIP_TAG: &str = "N";
38const REQUIREMENT_TAG: &str = "R";
39const TOPIC_TAG: &str = "t";
40const KIND_TAG: &str = "k";
41const FREQUENCY_TAG: &str = "frequency";
42const TIMEOUT_TAG: &str = "timeout";
43const CHECK_TAG: &str = "c";
44const RTT_PREFIX: &str = "rtt-";
45
46/// A `R` tag value: the requirement name (`auth`, `writes`, `pow`,
47/// `payment`, custom) plus a boolean carrying the NIP-66 `!` prefix
48/// convention.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct RelayRequirement {
51    /// Requirement name without the `!` prefix.
52    pub name: String,
53    /// `true` when the requirement is enforced, `false` when the
54    /// relay explicitly disables it (`!name`).
55    pub enabled: bool,
56}
57
58impl RelayRequirement {
59    /// Construct an enforced requirement.
60    #[must_use]
61    pub fn enabled(name: impl Into<String>) -> Self {
62        Self {
63            name: name.into(),
64            enabled: true,
65        }
66    }
67
68    /// Construct an explicitly disabled requirement (`!name`).
69    #[must_use]
70    pub fn disabled(name: impl Into<String>) -> Self {
71        Self {
72            name: name.into(),
73            enabled: false,
74        }
75    }
76
77    /// Render to the wire token (`<name>` or `!<name>`).
78    #[must_use]
79    pub fn to_token(&self) -> String {
80        if self.enabled {
81            self.name.clone()
82        } else {
83            format!("!{}", self.name)
84        }
85    }
86
87    /// Parse a wire token.
88    #[must_use]
89    pub fn parse(token: &str) -> Self {
90        token.strip_prefix('!').map_or_else(
91            || Self {
92                name: token.to_owned(),
93                enabled: true,
94            },
95            |name| Self {
96                name: name.to_owned(),
97                enabled: false,
98            },
99        )
100    }
101}
102
103/// An accepted-kind token with the NIP-66 `!` convention.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub struct AcceptedKind {
106    /// Event kind.
107    pub kind: Kind,
108    /// `true` when accepted, `false` when explicitly rejected.
109    pub accepted: bool,
110}
111
112impl AcceptedKind {
113    /// Construct an accepted kind.
114    #[must_use]
115    pub const fn accepted(kind: Kind) -> Self {
116        Self {
117            kind,
118            accepted: true,
119        }
120    }
121
122    /// Construct an explicitly rejected kind.
123    #[must_use]
124    pub const fn rejected(kind: Kind) -> Self {
125        Self {
126            kind,
127            accepted: false,
128        }
129    }
130
131    /// Render to the wire token (`<kind>` or `!<kind>`).
132    #[must_use]
133    pub fn to_token(self) -> String {
134        if self.accepted {
135            self.kind.as_u16().to_string()
136        } else {
137            format!("!{}", self.kind.as_u16())
138        }
139    }
140
141    /// Parse a wire token.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`RelayDiscoveryError::InvalidAcceptedKind`] when the
146    /// numeric portion does not parse as a `u16`.
147    pub fn parse(token: &str) -> Result<Self, RelayDiscoveryError> {
148        let (accepted, raw) = token
149            .strip_prefix('!')
150            .map_or((true, token), |rest| (false, rest));
151        let kind = raw
152            .parse::<u16>()
153            .map(Kind::from)
154            .map_err(|_| RelayDiscoveryError::InvalidAcceptedKind(token.to_owned()))?;
155        Ok(Self { kind, accepted })
156    }
157}
158
159/// One `rtt-*` measurement.
160#[derive(Debug, Clone, PartialEq, Eq, Hash)]
161pub struct RoundTripTime {
162    /// Phase the measurement applies to (`open`, `read`, `write`,
163    /// custom).
164    pub phase: String,
165    /// Round-trip time in milliseconds (per spec).
166    pub milliseconds: u64,
167}
168
169impl RoundTripTime {
170    /// Construct a measurement.
171    #[must_use]
172    pub fn new(phase: impl Into<String>, milliseconds: u64) -> Self {
173        Self {
174            phase: phase.into(),
175            milliseconds,
176        }
177    }
178
179    /// Wire tag name (`rtt-<phase>`).
180    #[must_use]
181    pub fn tag_name(&self) -> String {
182        format!("{RTT_PREFIX}{}", self.phase)
183    }
184}
185
186/// What the monitor's `d` tag identifies. The spec lets monitors use
187/// either a relay URL or, for relays unreachable by URL, a hex pubkey.
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub enum DiscoveryTarget {
190    /// Relay URL (normalised per RFC 3986 §6).
191    Url(String),
192    /// Hex-encoded pubkey for relays unreachable by URL.
193    Pubkey(String),
194}
195
196impl DiscoveryTarget {
197    /// Wire string value.
198    #[must_use]
199    #[expect(
200        clippy::missing_const_for_fn,
201        reason = "borrows from an owned `String` inside each variant"
202    )]
203    pub fn as_str(&self) -> &str {
204        match self {
205            Self::Url(s) | Self::Pubkey(s) => s.as_str(),
206        }
207    }
208}
209
210/// Typed bundle for a `kind: 30166` relay discovery event.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct RelayDiscovery {
213    /// `d` tag — the relay being described.
214    pub target: DiscoveryTarget,
215    /// Optional NIP-11 document carried verbatim in `.content`.
216    pub nip11_document: Option<String>,
217    /// `n` — network type (`clearnet`, `tor`, `i2p`, `loki`, custom).
218    pub network: Option<String>,
219    /// `T` — `PascalCase` relay type.
220    pub relay_type: Option<String>,
221    /// `N` — supported NIP numbers.
222    pub supported_nips: Vec<u16>,
223    /// `R` — requirements with the `!` boolean convention.
224    pub requirements: Vec<RelayRequirement>,
225    /// `t` — topics.
226    pub topics: Vec<String>,
227    /// `k` — accepted/rejected kinds.
228    pub accepted_kinds: Vec<AcceptedKind>,
229    /// `g` — geohash.
230    pub geohash: Option<String>,
231    /// `rtt-*` — round-trip measurements.
232    pub round_trip_times: Vec<RoundTripTime>,
233    /// Forward-compatible passthrough for unknown tags.
234    pub extra_tags: Vec<Tag>,
235}
236
237impl RelayDiscovery {
238    /// Construct an empty discovery row for `target`.
239    #[must_use]
240    pub const fn new(target: DiscoveryTarget) -> Self {
241        Self {
242            target,
243            nip11_document: None,
244            network: None,
245            relay_type: None,
246            supported_nips: Vec::new(),
247            requirements: Vec::new(),
248            topics: Vec::new(),
249            accepted_kinds: Vec::new(),
250            geohash: None,
251            round_trip_times: Vec::new(),
252            extra_tags: Vec::new(),
253        }
254    }
255
256    /// Set the NIP-11 document.
257    #[must_use]
258    pub fn nip11_document(mut self, document: impl Into<String>) -> Self {
259        self.nip11_document = Some(document.into());
260        self
261    }
262
263    /// Set [`Self::network`].
264    #[must_use]
265    pub fn network(mut self, network: impl Into<String>) -> Self {
266        self.network = Some(network.into());
267        self
268    }
269
270    /// Set [`Self::relay_type`].
271    #[must_use]
272    pub fn relay_type(mut self, relay_type: impl Into<String>) -> Self {
273        self.relay_type = Some(relay_type.into());
274        self
275    }
276
277    /// Append a supported NIP.
278    #[must_use]
279    pub fn supported_nip(mut self, nip: u16) -> Self {
280        self.supported_nips.push(nip);
281        self
282    }
283
284    /// Append a requirement.
285    #[must_use]
286    pub fn requirement(mut self, requirement: RelayRequirement) -> Self {
287        self.requirements.push(requirement);
288        self
289    }
290
291    /// Append a topic.
292    #[must_use]
293    pub fn topic(mut self, topic: impl Into<String>) -> Self {
294        self.topics.push(topic.into());
295        self
296    }
297
298    /// Append an accepted/rejected kind.
299    #[must_use]
300    pub fn accepted_kind(mut self, value: AcceptedKind) -> Self {
301        self.accepted_kinds.push(value);
302        self
303    }
304
305    /// Set [`Self::geohash`].
306    #[must_use]
307    pub fn geohash(mut self, geohash: impl Into<String>) -> Self {
308        self.geohash = Some(geohash.into());
309        self
310    }
311
312    /// Append a round-trip measurement.
313    #[must_use]
314    pub fn rtt(mut self, rtt: RoundTripTime) -> Self {
315        self.round_trip_times.push(rtt);
316        self
317    }
318
319    /// Parse a `kind: 30166` event into a typed bundle.
320    ///
321    /// # Errors
322    ///
323    /// - [`RelayDiscoveryError::WrongKind`] for non-30166 events.
324    /// - [`RelayDiscoveryError::MissingIdentifier`] when the `d`
325    ///   tag is absent.
326    /// - Field-specific errors for malformed columns.
327    pub fn from_event(event: &Event) -> Result<Self, RelayDiscoveryError> {
328        if event.kind != KIND_RELAY_DISCOVERY {
329            return Err(RelayDiscoveryError::WrongKind(event.kind));
330        }
331        let d = d_value(&event.tags)
332            .ok_or(RelayDiscoveryError::MissingIdentifier)?
333            .to_owned();
334        let target = if is_hex_pubkey(&d) {
335            DiscoveryTarget::Pubkey(d)
336        } else {
337            DiscoveryTarget::Url(d)
338        };
339        let nip11_document = if event.content.is_empty() {
340            None
341        } else {
342            Some(event.content.clone())
343        };
344        let mut out = Self::new(target);
345        out.nip11_document = nip11_document;
346        for tag in &event.tags {
347            absorb_discovery_tag(tag, &mut out)?;
348        }
349        Ok(out)
350    }
351}
352
353fn absorb_discovery_tag(tag: &Tag, out: &mut RelayDiscovery) -> Result<(), RelayDiscoveryError> {
354    match tag.kind() {
355        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
356        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::G => {
357            out.geohash = tag.get(1).map(str::to_owned);
358        }
359        _ if tag.name() == NETWORK_TAG => out.network = tag.get(1).map(str::to_owned),
360        _ if tag.name() == RELAY_TYPE_TAG => out.relay_type = tag.get(1).map(str::to_owned),
361        _ if tag.name() == NIP_TAG => absorb_nip_tag(tag, &mut out.supported_nips)?,
362        _ if tag.name() == REQUIREMENT_TAG => {
363            if let Some(raw) = tag.get(1) {
364                out.requirements.push(RelayRequirement::parse(raw));
365            }
366        }
367        _ if tag.name() == TOPIC_TAG => {
368            if let Some(raw) = tag.get(1) {
369                out.topics.push(raw.to_owned());
370            }
371        }
372        _ if tag.name() == KIND_TAG => {
373            if let Some(raw) = tag.get(1) {
374                out.accepted_kinds.push(AcceptedKind::parse(raw)?);
375            }
376        }
377        _ if tag.name().starts_with(RTT_PREFIX) => {
378            absorb_rtt_tag(tag, &mut out.round_trip_times)?;
379        }
380        _ => out.extra_tags.push(tag.clone()),
381    }
382    Ok(())
383}
384
385fn absorb_nip_tag(tag: &Tag, out: &mut Vec<u16>) -> Result<(), RelayDiscoveryError> {
386    let Some(raw) = tag.get(1) else {
387        return Ok(());
388    };
389    let parsed = raw
390        .parse::<u16>()
391        .map_err(|_| RelayDiscoveryError::InvalidNip(raw.to_owned()))?;
392    out.push(parsed);
393    Ok(())
394}
395
396fn absorb_rtt_tag(tag: &Tag, out: &mut Vec<RoundTripTime>) -> Result<(), RelayDiscoveryError> {
397    let Some(raw) = tag.get(1) else {
398        return Ok(());
399    };
400    let ms = raw
401        .parse::<u64>()
402        .map_err(|_| RelayDiscoveryError::InvalidRtt(raw.to_owned()))?;
403    let phase = tag.name()[RTT_PREFIX.len()..].to_owned();
404    out.push(RoundTripTime {
405        phase,
406        milliseconds: ms,
407    });
408    Ok(())
409}
410
411fn is_hex_pubkey(input: &str) -> bool {
412    input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit())
413}
414
415/// One row inside a `kind: 10166` monitor's `timeout` matrix.
416///
417/// The spec leaves the column ordering slightly ambiguous: index 1
418/// MAY be the milliseconds value, and index 2 MAY be the check name
419/// the timeout applies to — or it MAY be reversed. The parser sniffs
420/// the numeric column so either ordering decodes cleanly.
421#[derive(Debug, Clone, PartialEq, Eq, Hash)]
422pub struct MonitorTimeout {
423    /// Optional check name the timeout applies to. `None` means the
424    /// timeout applies to every check the monitor performs (spec
425    /// §"Tags").
426    pub check: Option<String>,
427    /// Timeout in milliseconds.
428    pub milliseconds: u64,
429}
430
431impl MonitorTimeout {
432    /// Construct a global timeout (no check scope).
433    #[must_use]
434    pub const fn new(milliseconds: u64) -> Self {
435        Self {
436            check: None,
437            milliseconds,
438        }
439    }
440
441    /// Attach a check-name scope.
442    #[must_use]
443    pub fn for_check(mut self, check: impl Into<String>) -> Self {
444        self.check = Some(check.into());
445        self
446    }
447}
448
449/// Typed bundle for a `kind: 10166` relay monitor announcement.
450#[derive(Debug, Clone, PartialEq, Eq, Default)]
451pub struct RelayMonitor {
452    /// `frequency` — seconds between successive 30166 publications.
453    pub frequency_seconds: Option<u64>,
454    /// `timeout` rows.
455    pub timeouts: Vec<MonitorTimeout>,
456    /// `c` — lowercase check names (`open`, `read`, `write`, …).
457    pub checks: Vec<String>,
458    /// `g` — geohash.
459    pub geohash: Option<String>,
460    /// Forward-compatible passthrough for unknown tags.
461    pub extra_tags: Vec<Tag>,
462}
463
464impl RelayMonitor {
465    /// Construct an empty monitor announcement.
466    #[must_use]
467    pub fn new() -> Self {
468        Self::default()
469    }
470
471    /// Set [`Self::frequency_seconds`].
472    #[must_use]
473    pub const fn frequency_seconds(mut self, seconds: u64) -> Self {
474        self.frequency_seconds = Some(seconds);
475        self
476    }
477
478    /// Append a timeout row.
479    #[must_use]
480    pub fn timeout(mut self, timeout: MonitorTimeout) -> Self {
481        self.timeouts.push(timeout);
482        self
483    }
484
485    /// Append a check name.
486    #[must_use]
487    pub fn check(mut self, name: impl Into<String>) -> Self {
488        self.checks.push(name.into());
489        self
490    }
491
492    /// Set [`Self::geohash`].
493    #[must_use]
494    pub fn geohash(mut self, geohash: impl Into<String>) -> Self {
495        self.geohash = Some(geohash.into());
496        self
497    }
498
499    /// Parse a `kind: 10166` event into a typed bundle.
500    ///
501    /// # Errors
502    ///
503    /// - [`RelayDiscoveryError::WrongKind`] for non-10166 events.
504    /// - Field-specific errors for malformed columns.
505    pub fn from_event(event: &Event) -> Result<Self, RelayDiscoveryError> {
506        if event.kind != KIND_RELAY_MONITOR {
507            return Err(RelayDiscoveryError::WrongKind(event.kind));
508        }
509        let mut out = Self::new();
510        for tag in &event.tags {
511            absorb_monitor_tag(tag, &mut out)?;
512        }
513        Ok(out)
514    }
515}
516
517fn absorb_monitor_tag(tag: &Tag, out: &mut RelayMonitor) -> Result<(), RelayDiscoveryError> {
518    match tag.kind() {
519        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::G => {
520            out.geohash = tag.get(1).map(str::to_owned);
521        }
522        _ if tag.name() == FREQUENCY_TAG => {
523            if let Some(raw) = tag.get(1) {
524                let seconds = raw
525                    .parse::<u64>()
526                    .map_err(|_| RelayDiscoveryError::InvalidFrequency(raw.to_owned()))?;
527                out.frequency_seconds = Some(seconds);
528            }
529        }
530        _ if tag.name() == TIMEOUT_TAG => out.timeouts.push(parse_timeout(tag)?),
531        _ if tag.name() == CHECK_TAG => {
532            if let Some(raw) = tag.get(1) {
533                out.checks.push(raw.to_owned());
534            }
535        }
536        _ => out.extra_tags.push(tag.clone()),
537    }
538    Ok(())
539}
540
541fn parse_timeout(tag: &Tag) -> Result<MonitorTimeout, RelayDiscoveryError> {
542    let col1 = tag.get(1).ok_or(RelayDiscoveryError::MalformedTimeout)?;
543    let col2 = tag.get(2);
544    if let Ok(ms) = col1.parse::<u64>() {
545        let check = col2.filter(|s| !s.is_empty()).map(str::to_owned);
546        return Ok(MonitorTimeout {
547            check,
548            milliseconds: ms,
549        });
550    }
551    // Fall back to the spec-example ordering: `["timeout", "<check>",
552    // "<ms>"]`.
553    let ms_str = col2.ok_or(RelayDiscoveryError::MalformedTimeout)?;
554    let ms = ms_str
555        .parse::<u64>()
556        .map_err(|_| RelayDiscoveryError::InvalidTimeout(ms_str.to_owned()))?;
557    Ok(MonitorTimeout {
558        check: Some(col1.to_owned()),
559        milliseconds: ms,
560    })
561}
562
563fn d_value(tags: &Tags) -> Option<&str> {
564    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
565    tags.find_first(&head).and_then(|tag| tag.get(1))
566}
567
568/// Errors raised by NIP-66 parsers.
569#[derive(Debug, Error)]
570#[non_exhaustive]
571pub enum RelayDiscoveryError {
572    /// The event did not match the expected NIP-66 kind.
573    #[error("unexpected kind for NIP-66 event: {}", .0.as_u16())]
574    WrongKind(Kind),
575    /// `d` tag is absent on a 30166 event.
576    #[error("NIP-66 discovery event missing `d` tag")]
577    MissingIdentifier,
578    /// `N` tag value could not be parsed as a `u16`.
579    #[error("invalid supported NIP value: `{0}`")]
580    InvalidNip(String),
581    /// `k` tag value could not be parsed as a `u16`.
582    #[error("invalid accepted-kind value: `{0}`")]
583    InvalidAcceptedKind(String),
584    /// `rtt-*` value could not be parsed as a `u64`.
585    #[error("invalid round-trip value: `{0}`")]
586    InvalidRtt(String),
587    /// `frequency` value could not be parsed as a `u64`.
588    #[error("invalid frequency value: `{0}`")]
589    InvalidFrequency(String),
590    /// `timeout` tag is missing required columns.
591    #[error("`timeout` tag is missing required columns")]
592    MalformedTimeout,
593    /// `timeout` tag value could not be parsed as a `u64`.
594    #[error("invalid timeout value: `{0}`")]
595    InvalidTimeout(String),
596}
597
598impl EventBuilder {
599    /// Author a NIP-66 `kind: 30166` relay discovery event.
600    #[must_use]
601    pub fn relay_discovery(discovery: &RelayDiscovery) -> Self {
602        let content = discovery.nip11_document.clone().unwrap_or_default();
603        let mut builder = Self::new(KIND_RELAY_DISCOVERY, content);
604        builder = builder.tag(Tag::d(discovery.target.as_str()));
605        if let Some(n) = &discovery.network {
606            builder = builder.tag(Tag::with(&TagKind::from_wire(NETWORK_TAG), [n.clone()]));
607        }
608        if let Some(t) = &discovery.relay_type {
609            builder = builder.tag(Tag::with(&TagKind::from_wire(RELAY_TYPE_TAG), [t.clone()]));
610        }
611        for nip in &discovery.supported_nips {
612            builder = builder.tag(Tag::with(&TagKind::from_wire(NIP_TAG), [nip.to_string()]));
613        }
614        for req in &discovery.requirements {
615            builder = builder.tag(Tag::with(
616                &TagKind::from_wire(REQUIREMENT_TAG),
617                [req.to_token()],
618            ));
619        }
620        for topic in &discovery.topics {
621            builder = builder.tag(Tag::with(&TagKind::from_wire(TOPIC_TAG), [topic.clone()]));
622        }
623        for k in &discovery.accepted_kinds {
624            builder = builder.tag(Tag::with(&TagKind::from_wire(KIND_TAG), [k.to_token()]));
625        }
626        if let Some(g) = &discovery.geohash {
627            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G));
628            builder = builder.tag(Tag::with(&head, [g.clone()]));
629        }
630        for rtt in &discovery.round_trip_times {
631            builder = builder.tag(Tag::with(
632                &TagKind::from_wire(&rtt.tag_name()),
633                [rtt.milliseconds.to_string()],
634            ));
635        }
636        for tag in &discovery.extra_tags {
637            builder = builder.tag(tag.clone());
638        }
639        builder
640    }
641
642    /// Author a NIP-66 `kind: 10166` relay monitor announcement.
643    #[must_use]
644    pub fn relay_monitor(monitor: &RelayMonitor) -> Self {
645        let mut builder = Self::new(KIND_RELAY_MONITOR, "");
646        for timeout in &monitor.timeouts {
647            let tag = timeout.check.as_ref().map_or_else(
648                || {
649                    Tag::with(
650                        &TagKind::from_wire(TIMEOUT_TAG),
651                        [timeout.milliseconds.to_string()],
652                    )
653                },
654                |check| {
655                    Tag::with(
656                        &TagKind::from_wire(TIMEOUT_TAG),
657                        [check.clone(), timeout.milliseconds.to_string()],
658                    )
659                },
660            );
661            builder = builder.tag(tag);
662        }
663        if let Some(freq) = monitor.frequency_seconds {
664            builder = builder.tag(Tag::with(
665                &TagKind::from_wire(FREQUENCY_TAG),
666                [freq.to_string()],
667            ));
668        }
669        for check in &monitor.checks {
670            builder = builder.tag(Tag::with(&TagKind::from_wire(CHECK_TAG), [check.clone()]));
671        }
672        if let Some(g) = &monitor.geohash {
673            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G));
674            builder = builder.tag(Tag::with(&head, [g.clone()]));
675        }
676        for tag in &monitor.extra_tags {
677            builder = builder.tag(tag.clone());
678        }
679        builder
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::Keys;
687
688    fn keys() -> Keys {
689        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
690    }
691
692    #[test]
693    fn requirement_parse_round_trip() {
694        let rejected = RelayRequirement::parse("!payment");
695        assert!(!rejected.enabled);
696        assert_eq!(rejected.name, "payment");
697        assert_eq!(rejected.to_token(), "!payment");
698        let accepted = RelayRequirement::parse("auth");
699        assert!(accepted.enabled);
700        assert_eq!(accepted.to_token(), "auth");
701    }
702
703    #[test]
704    fn accepted_kind_parse_round_trip() {
705        let rejected = AcceptedKind::parse("!1").unwrap();
706        assert!(!rejected.accepted);
707        assert_eq!(rejected.kind, Kind::new(1));
708        assert_eq!(rejected.to_token(), "!1");
709        let accepted = AcceptedKind::parse("30023").unwrap();
710        assert!(accepted.accepted);
711        assert_eq!(accepted.kind, Kind::new(30_023));
712    }
713
714    #[test]
715    fn accepted_kind_rejects_non_numeric() {
716        assert!(matches!(
717            AcceptedKind::parse("abc"),
718            Err(RelayDiscoveryError::InvalidAcceptedKind(_))
719        ));
720    }
721
722    #[test]
723    fn relay_discovery_round_trip() {
724        let discovery = RelayDiscovery::new(DiscoveryTarget::Url("wss://some.relay/".into()))
725            .nip11_document(r#"{"name":"example"}"#)
726            .network("clearnet")
727            .relay_type("PrivateInbox")
728            .supported_nip(40)
729            .supported_nip(33)
730            .requirement(RelayRequirement::disabled("payment"))
731            .requirement(RelayRequirement::enabled("auth"))
732            .topic("nsfw")
733            .accepted_kind(AcceptedKind::accepted(Kind::new(1)))
734            .accepted_kind(AcceptedKind::rejected(Kind::new(5)))
735            .geohash("ww8p1r4t8")
736            .rtt(RoundTripTime::new("open", 234));
737        let event = EventBuilder::relay_discovery(&discovery)
738            .sign_with_keys(&keys())
739            .unwrap();
740        let parsed = RelayDiscovery::from_event(&event).unwrap();
741        assert_eq!(parsed, discovery);
742    }
743
744    #[test]
745    fn relay_discovery_hex_pubkey_target() {
746        let hex = "0".repeat(64);
747        let discovery = RelayDiscovery::new(DiscoveryTarget::Pubkey(hex.clone()));
748        let event = EventBuilder::relay_discovery(&discovery)
749            .sign_with_keys(&keys())
750            .unwrap();
751        let parsed = RelayDiscovery::from_event(&event).unwrap();
752        assert_eq!(parsed.target, DiscoveryTarget::Pubkey(hex));
753    }
754
755    #[test]
756    fn relay_discovery_wrong_kind_is_rejected() {
757        let event = EventBuilder::text_note("nope")
758            .sign_with_keys(&keys())
759            .unwrap();
760        assert!(matches!(
761            RelayDiscovery::from_event(&event),
762            Err(RelayDiscoveryError::WrongKind(_))
763        ));
764    }
765
766    #[test]
767    fn relay_discovery_missing_identifier_is_rejected() {
768        let event = EventBuilder::new(KIND_RELAY_DISCOVERY, "")
769            .sign_with_keys(&keys())
770            .unwrap();
771        assert!(matches!(
772            RelayDiscovery::from_event(&event),
773            Err(RelayDiscoveryError::MissingIdentifier)
774        ));
775    }
776
777    #[test]
778    fn relay_monitor_round_trip() {
779        let monitor = RelayMonitor::new()
780            .frequency_seconds(3600)
781            .timeout(MonitorTimeout::new(5000).for_check("open"))
782            .timeout(MonitorTimeout::new(3000).for_check("read"))
783            .check("ws")
784            .check("nip11")
785            .geohash("ww8p1r4t8");
786        let event = EventBuilder::relay_monitor(&monitor)
787            .sign_with_keys(&keys())
788            .unwrap();
789        let parsed = RelayMonitor::from_event(&event).unwrap();
790        assert_eq!(parsed, monitor);
791    }
792
793    #[test]
794    fn relay_monitor_tolerates_alternate_timeout_column_order() {
795        // Spec example uses `["timeout", "<check>", "<ms>"]`.
796        let event = EventBuilder::new(KIND_RELAY_MONITOR, "")
797            .tag(Tag::with(
798                &TagKind::from_wire(TIMEOUT_TAG),
799                ["open", "5000"],
800            ))
801            .sign_with_keys(&keys())
802            .unwrap();
803        let parsed = RelayMonitor::from_event(&event).unwrap();
804        assert_eq!(
805            parsed.timeouts,
806            vec![MonitorTimeout::new(5000).for_check("open")]
807        );
808    }
809
810    #[test]
811    fn monitor_wrong_kind_is_rejected() {
812        let event = EventBuilder::text_note("nope")
813            .sign_with_keys(&keys())
814            .unwrap();
815        assert!(matches!(
816            RelayMonitor::from_event(&event),
817            Err(RelayDiscoveryError::WrongKind(_))
818        ));
819    }
820}