1use thiserror::Error;
26
27use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
28
29pub const KIND_RELAY_DISCOVERY: Kind = Kind::RELAY_DISCOVERY;
31
32pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct RelayRequirement {
51 pub name: String,
53 pub enabled: bool,
56}
57
58impl RelayRequirement {
59 #[must_use]
61 pub fn enabled(name: impl Into<String>) -> Self {
62 Self {
63 name: name.into(),
64 enabled: true,
65 }
66 }
67
68 #[must_use]
70 pub fn disabled(name: impl Into<String>) -> Self {
71 Self {
72 name: name.into(),
73 enabled: false,
74 }
75 }
76
77 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub struct AcceptedKind {
106 pub kind: Kind,
108 pub accepted: bool,
110}
111
112impl AcceptedKind {
113 #[must_use]
115 pub const fn accepted(kind: Kind) -> Self {
116 Self {
117 kind,
118 accepted: true,
119 }
120 }
121
122 #[must_use]
124 pub const fn rejected(kind: Kind) -> Self {
125 Self {
126 kind,
127 accepted: false,
128 }
129 }
130
131 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
161pub struct RoundTripTime {
162 pub phase: String,
165 pub milliseconds: u64,
167}
168
169impl RoundTripTime {
170 #[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 #[must_use]
181 pub fn tag_name(&self) -> String {
182 format!("{RTT_PREFIX}{}", self.phase)
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub enum DiscoveryTarget {
190 Url(String),
192 Pubkey(String),
194}
195
196impl DiscoveryTarget {
197 #[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#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct RelayDiscovery {
213 pub target: DiscoveryTarget,
215 pub nip11_document: Option<String>,
217 pub network: Option<String>,
219 pub relay_type: Option<String>,
221 pub supported_nips: Vec<u16>,
223 pub requirements: Vec<RelayRequirement>,
225 pub topics: Vec<String>,
227 pub accepted_kinds: Vec<AcceptedKind>,
229 pub geohash: Option<String>,
231 pub round_trip_times: Vec<RoundTripTime>,
233 pub extra_tags: Vec<Tag>,
235}
236
237impl RelayDiscovery {
238 #[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 #[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 #[must_use]
265 pub fn network(mut self, network: impl Into<String>) -> Self {
266 self.network = Some(network.into());
267 self
268 }
269
270 #[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 #[must_use]
279 pub fn supported_nip(mut self, nip: u16) -> Self {
280 self.supported_nips.push(nip);
281 self
282 }
283
284 #[must_use]
286 pub fn requirement(mut self, requirement: RelayRequirement) -> Self {
287 self.requirements.push(requirement);
288 self
289 }
290
291 #[must_use]
293 pub fn topic(mut self, topic: impl Into<String>) -> Self {
294 self.topics.push(topic.into());
295 self
296 }
297
298 #[must_use]
300 pub fn accepted_kind(mut self, value: AcceptedKind) -> Self {
301 self.accepted_kinds.push(value);
302 self
303 }
304
305 #[must_use]
307 pub fn geohash(mut self, geohash: impl Into<String>) -> Self {
308 self.geohash = Some(geohash.into());
309 self
310 }
311
312 #[must_use]
314 pub fn rtt(mut self, rtt: RoundTripTime) -> Self {
315 self.round_trip_times.push(rtt);
316 self
317 }
318
319 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
422pub struct MonitorTimeout {
423 pub check: Option<String>,
427 pub milliseconds: u64,
429}
430
431impl MonitorTimeout {
432 #[must_use]
434 pub const fn new(milliseconds: u64) -> Self {
435 Self {
436 check: None,
437 milliseconds,
438 }
439 }
440
441 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
451pub struct RelayMonitor {
452 pub frequency_seconds: Option<u64>,
454 pub timeouts: Vec<MonitorTimeout>,
456 pub checks: Vec<String>,
458 pub geohash: Option<String>,
460 pub extra_tags: Vec<Tag>,
462}
463
464impl RelayMonitor {
465 #[must_use]
467 pub fn new() -> Self {
468 Self::default()
469 }
470
471 #[must_use]
473 pub const fn frequency_seconds(mut self, seconds: u64) -> Self {
474 self.frequency_seconds = Some(seconds);
475 self
476 }
477
478 #[must_use]
480 pub fn timeout(mut self, timeout: MonitorTimeout) -> Self {
481 self.timeouts.push(timeout);
482 self
483 }
484
485 #[must_use]
487 pub fn check(mut self, name: impl Into<String>) -> Self {
488 self.checks.push(name.into());
489 self
490 }
491
492 #[must_use]
494 pub fn geohash(mut self, geohash: impl Into<String>) -> Self {
495 self.geohash = Some(geohash.into());
496 self
497 }
498
499 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 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#[derive(Debug, Error)]
570#[non_exhaustive]
571pub enum RelayDiscoveryError {
572 #[error("unexpected kind for NIP-66 event: {}", .0.as_u16())]
574 WrongKind(Kind),
575 #[error("NIP-66 discovery event missing `d` tag")]
577 MissingIdentifier,
578 #[error("invalid supported NIP value: `{0}`")]
580 InvalidNip(String),
581 #[error("invalid accepted-kind value: `{0}`")]
583 InvalidAcceptedKind(String),
584 #[error("invalid round-trip value: `{0}`")]
586 InvalidRtt(String),
587 #[error("invalid frequency value: `{0}`")]
589 InvalidFrequency(String),
590 #[error("`timeout` tag is missing required columns")]
592 MalformedTimeout,
593 #[error("invalid timeout value: `{0}`")]
595 InvalidTimeout(String),
596}
597
598impl EventBuilder {
599 #[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 #[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 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}