1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::SoftchatError;
9
10pub const MAX_RELAY_CATALOG_ENTRIES: usize = 256;
12pub const MAX_DNS_RELAY_RECORDS: usize = 256;
14pub const DEFAULT_FALLBACK_RELAY_TTL_SECONDS: u32 = 3_600;
16pub const MAX_RELAY_TTL_SECONDS: u32 = 7 * 24 * 60 * 60;
18pub const MAX_RELAY_RETRY_DELAY_MS: u64 = 8_000;
20pub const MAX_RELAY_RETRY_JITTER_MS: u64 = 250;
22
23#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(rename_all = "camelCase")]
26#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
27pub enum RelayCatalogSource {
28 Automatic,
30 Custom,
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
38pub struct RelayCatalogEntry {
39 pub url: String,
41 pub created_at: i64,
43 pub is_active: bool,
45 pub source: RelayCatalogSource,
47 pub is_dns_discovered: bool,
49 pub is_user_excluded: bool,
51 pub dns_priority: i32,
53 pub dns_weight: i32,
55 pub dns_expires_at: i64,
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(rename_all = "camelCase", deny_unknown_fields)]
62#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
63pub struct DnsRelayRecord {
64 pub target: String,
66 pub priority: u32,
68 pub weight: u32,
70 pub ttl_seconds: u32,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76#[serde(rename_all = "camelCase")]
77#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
78pub struct RelayEndpointPlan {
79 pub configured_url: String,
81 pub network_url: String,
83 pub noise_remote_static_key: Vec<u8>,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
89#[serde(rename_all = "camelCase")]
90#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
91pub struct RelayCatalogPlan {
92 pub entries: Vec<RelayCatalogEntry>,
94 pub active_relay_url: String,
96 pub next_refresh_at: i64,
98 pub used_fallback: bool,
100}
101
102#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "camelCase")]
105#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
106pub enum RelayFailureKind {
107 NetworkUnavailable,
109 Temporary,
111 RateLimited,
113 Authentication,
115 ProtocolPermanent,
117}
118
119#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
121#[serde(rename_all = "camelCase")]
122#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
123pub enum RelayRetryAction {
124 WaitSameRelay,
126 RotateRelay,
128}
129
130#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
132#[serde(rename_all = "camelCase")]
133#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
134pub struct RelayRetryPlan {
135 pub action: RelayRetryAction,
137 pub delay_ms: u64,
139 pub jitter_max_ms: u64,
141}
142
143#[derive(Clone, Copy, Debug, Default)]
145pub struct RelayCatalogReducer;
146
147impl RelayCatalogReducer {
148 pub fn reconcile(
159 current: Vec<RelayCatalogEntry>,
160 discovered: Vec<DnsRelayRecord>,
161 refreshed_at: i64,
162 fallback_url: String,
163 dns_authenticated: bool,
164 ) -> Result<RelayCatalogPlan, SoftchatError> {
165 if refreshed_at < 0
166 || current.len() > MAX_RELAY_CATALOG_ENTRIES
167 || discovered.len() > MAX_DNS_RELAY_RECORDS
168 {
169 return Err(SoftchatError::InvalidRelayCatalog);
170 }
171 let fallback_url = canonical_relay_url(&fallback_url)?;
172 let mut rows = BTreeMap::new();
173 for mut entry in current {
174 validate_entry(&entry)?;
175 entry.url = canonical_relay_url(&entry.url)?;
176 if rows.insert(entry.url.clone(), entry).is_some() {
177 return Err(SoftchatError::InvalidRelayCatalog);
178 }
179 }
180
181 for entry in rows.values_mut() {
182 entry.is_dns_discovered = false;
183 entry.dns_priority = -1;
184 entry.dns_weight = -1;
185 entry.dns_expires_at = -1;
186 }
187
188 let preferred = if dns_authenticated && !discovered.is_empty() {
189 preferred_dns_records(discovered)?
190 } else {
191 vec![DnsRelayRecord {
192 target: fallback_url.clone(),
193 priority: 0,
194 weight: 0,
195 ttl_seconds: DEFAULT_FALLBACK_RELAY_TTL_SECONDS,
196 }]
197 };
198 let used_fallback = !(dns_authenticated && !preferred.is_empty());
199
200 for record in preferred {
201 let target = canonical_relay_url(&record.target)?;
202 validate_dns_record(&record)?;
203 let expires_at = refreshed_at
204 .checked_add(i64::from(record.ttl_seconds))
205 .ok_or(SoftchatError::InvalidRelayCatalog)?;
206 let entry = rows
207 .entry(target.clone())
208 .or_insert_with(|| RelayCatalogEntry {
209 url: target,
210 created_at: refreshed_at,
211 is_active: false,
212 source: RelayCatalogSource::Automatic,
213 is_dns_discovered: false,
214 is_user_excluded: false,
215 dns_priority: -1,
216 dns_weight: -1,
217 dns_expires_at: -1,
218 });
219 entry.is_dns_discovered = true;
220 entry.dns_priority =
221 i32::try_from(record.priority).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
222 entry.dns_weight =
223 i32::try_from(record.weight).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
224 entry.dns_expires_at = expires_at;
225 }
226
227 rows.retain(|_, entry| {
228 entry.source == RelayCatalogSource::Custom
229 || entry.is_dns_discovered
230 || entry.is_user_excluded
231 });
232 if rows.len() > MAX_RELAY_CATALOG_ENTRIES {
233 return Err(SoftchatError::InvalidRelayCatalog);
234 }
235
236 let previous_active = rows
237 .values()
238 .find(|entry| entry.is_active && is_effective(entry))
239 .map(|entry| entry.url.clone());
240 for entry in rows.values_mut() {
241 entry.is_active = false;
242 }
243 let ordered = ordered_effective_entries(rows.values().cloned().collect())?;
244 let active_relay_url = previous_active
245 .or_else(|| {
246 ordered
247 .iter()
248 .find(|entry| entry.url == fallback_url)
249 .map(|entry| entry.url.clone())
250 })
251 .or_else(|| ordered.first().map(|entry| entry.url.clone()))
252 .ok_or(SoftchatError::InvalidRelayCatalog)?;
253 let active = rows
254 .get_mut(&active_relay_url)
255 .ok_or(SoftchatError::InvalidRelayCatalog)?;
256 active.is_active = true;
257
258 let mut entries = rows.into_values().collect::<Vec<_>>();
259 entries.sort_by(entry_storage_order);
260 let next_refresh_at = entries
261 .iter()
262 .filter(|entry| entry.is_dns_discovered)
263 .map(|entry| entry.dns_expires_at)
264 .min()
265 .ok_or(SoftchatError::InvalidRelayCatalog)?;
266 Ok(RelayCatalogPlan {
267 entries,
268 active_relay_url,
269 next_refresh_at,
270 used_fallback,
271 })
272 }
273
274 pub fn add_custom(
280 current: Vec<RelayCatalogEntry>,
281 relay_url: String,
282 created_at: i64,
283 ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
284 if created_at < 0 || current.len() >= MAX_RELAY_CATALOG_ENTRIES {
285 return Err(SoftchatError::InvalidRelayCatalog);
286 }
287 let relay_url = canonical_relay_url(&relay_url)?;
288 let mut rows = validated_rows(current)?;
289 let entry = rows
290 .entry(relay_url.clone())
291 .or_insert_with(|| RelayCatalogEntry {
292 url: relay_url,
293 created_at,
294 is_active: false,
295 source: RelayCatalogSource::Custom,
296 is_dns_discovered: false,
297 is_user_excluded: false,
298 dns_priority: -1,
299 dns_weight: -1,
300 dns_expires_at: -1,
301 });
302 entry.source = RelayCatalogSource::Custom;
303 entry.is_user_excluded = false;
304 Ok(sorted_rows(rows))
305 }
306
307 pub fn exclude(
313 current: Vec<RelayCatalogEntry>,
314 relay_url: String,
315 ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
316 let relay_url = canonical_relay_url(&relay_url)?;
317 let mut rows = validated_rows(current)?;
318 let entry = rows
319 .get_mut(&relay_url)
320 .ok_or(SoftchatError::InvalidRelayCatalog)?;
321 entry.source = RelayCatalogSource::Automatic;
322 entry.is_user_excluded = true;
323 entry.is_active = false;
324 if !entry.is_dns_discovered {
325 rows.remove(&relay_url);
326 }
327 ensure_one_active(&mut rows)?;
328 Ok(sorted_rows(rows))
329 }
330
331 pub fn restore_automatic(
337 current: Vec<RelayCatalogEntry>,
338 ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
339 let mut rows = validated_rows(current)?;
340 for entry in rows.values_mut() {
341 if entry.is_dns_discovered {
342 entry.is_user_excluded = false;
343 }
344 }
345 ensure_one_active(&mut rows)?;
346 Ok(sorted_rows(rows))
347 }
348
349 pub fn set_active(
355 current: Vec<RelayCatalogEntry>,
356 relay_url: String,
357 ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
358 let relay_url = canonical_relay_url(&relay_url)?;
359 let mut rows = validated_rows(current)?;
360 if rows
361 .get(&relay_url)
362 .is_none_or(|entry| !is_effective(entry))
363 {
364 return Err(SoftchatError::InvalidRelayCatalog);
365 }
366 for entry in rows.values_mut() {
367 entry.is_active = entry.url == relay_url;
368 }
369 Ok(sorted_rows(rows))
370 }
371
372 pub fn failover_candidates(
381 current: Vec<RelayCatalogEntry>,
382 failed_url: String,
383 ) -> Result<Vec<String>, SoftchatError> {
384 let failed_url = canonical_relay_url(&failed_url)?;
385 let rows = validated_rows(current)?;
386 let mut urls = ordered_effective_entries(rows.into_values().collect())?
387 .into_iter()
388 .map(|entry| entry.url)
389 .collect::<Vec<_>>();
390 if let Some(index) = urls.iter().position(|url| url == &failed_url) {
391 let failed = urls.remove(index);
392 urls.push(failed);
393 }
394 Ok(urls)
395 }
396}
397
398#[must_use]
400pub fn plan_relay_retry(
401 attempt: u32,
402 failure: RelayFailureKind,
403 bypass_delay_once: bool,
404) -> RelayRetryPlan {
405 let action = match failure {
406 RelayFailureKind::Authentication | RelayFailureKind::ProtocolPermanent => {
407 RelayRetryAction::RotateRelay
408 }
409 RelayFailureKind::NetworkUnavailable
410 | RelayFailureKind::Temporary
411 | RelayFailureKind::RateLimited => RelayRetryAction::WaitSameRelay,
412 };
413 let delay_ms = if bypass_delay_once
414 || matches!(
415 failure,
416 RelayFailureKind::Authentication | RelayFailureKind::ProtocolPermanent
417 ) {
418 0
419 } else {
420 let exponent = attempt.min(4);
421 let calculated = 500_u64.saturating_mul(1_u64 << exponent);
422 if calculated > MAX_RELAY_RETRY_DELAY_MS {
423 MAX_RELAY_RETRY_DELAY_MS
424 } else {
425 calculated
426 }
427 };
428 RelayRetryPlan {
429 action,
430 delay_ms,
431 jitter_max_ms: MAX_RELAY_RETRY_JITTER_MS,
432 }
433}
434
435fn preferred_dns_records(
436 records: Vec<DnsRelayRecord>,
437) -> Result<Vec<DnsRelayRecord>, SoftchatError> {
438 let mut preferred = BTreeMap::new();
439 for mut record in records {
440 validate_dns_record(&record)?;
441 record.target = canonical_relay_url(&record.target)?;
442 match preferred.get(&record.target) {
443 None => {
444 preferred.insert(record.target.clone(), record);
445 }
446 Some(existing) if is_preferred(&record, existing) => {
447 preferred.insert(record.target.clone(), record);
448 }
449 Some(_) => {}
450 }
451 }
452 Ok(preferred.into_values().collect())
453}
454
455fn is_preferred(candidate: &DnsRelayRecord, existing: &DnsRelayRecord) -> bool {
456 candidate.priority < existing.priority
457 || (candidate.priority == existing.priority && candidate.weight > existing.weight)
458 || (candidate.priority == existing.priority
459 && candidate.weight == existing.weight
460 && candidate.ttl_seconds < existing.ttl_seconds)
461}
462
463fn validate_dns_record(record: &DnsRelayRecord) -> Result<(), SoftchatError> {
464 if record.priority > u32::from(u16::MAX)
465 || record.weight > u32::from(u16::MAX)
466 || record.ttl_seconds == 0
467 || record.ttl_seconds > MAX_RELAY_TTL_SECONDS
468 {
469 Err(SoftchatError::InvalidRelayCatalog)
470 } else {
471 Ok(())
472 }
473}
474
475fn validate_entry(entry: &RelayCatalogEntry) -> Result<(), SoftchatError> {
476 canonical_relay_url(&entry.url)?;
477 if entry.created_at < 0
478 || !(-1..=i32::from(u16::MAX)).contains(&entry.dns_priority)
479 || !(-1..=i32::from(u16::MAX)).contains(&entry.dns_weight)
480 || (entry.is_dns_discovered
481 && (entry.dns_priority < 0
482 || entry.dns_weight < 0
483 || entry.dns_expires_at < entry.created_at))
484 || (!entry.is_dns_discovered
485 && (entry.dns_priority != -1 || entry.dns_weight != -1 || entry.dns_expires_at != -1))
486 || (entry.source == RelayCatalogSource::Custom && entry.is_user_excluded)
487 {
488 Err(SoftchatError::InvalidRelayCatalog)
489 } else {
490 Ok(())
491 }
492}
493
494fn validated_rows(
495 entries: Vec<RelayCatalogEntry>,
496) -> Result<BTreeMap<String, RelayCatalogEntry>, SoftchatError> {
497 if entries.len() > MAX_RELAY_CATALOG_ENTRIES {
498 return Err(SoftchatError::InvalidRelayCatalog);
499 }
500 let mut rows = BTreeMap::new();
501 let mut active = 0_u8;
502 for mut entry in entries {
503 validate_entry(&entry)?;
504 entry.url = canonical_relay_url(&entry.url)?;
505 if entry.is_active {
506 active = active
507 .checked_add(1)
508 .ok_or(SoftchatError::InvalidRelayCatalog)?;
509 }
510 if rows.insert(entry.url.clone(), entry).is_some() {
511 return Err(SoftchatError::InvalidRelayCatalog);
512 }
513 }
514 if active > 1 {
515 return Err(SoftchatError::InvalidRelayCatalog);
516 }
517 Ok(rows)
518}
519
520fn ensure_one_active(rows: &mut BTreeMap<String, RelayCatalogEntry>) -> Result<(), SoftchatError> {
521 if rows
522 .values()
523 .any(|entry| entry.is_active && is_effective(entry))
524 {
525 return Ok(());
526 }
527 for entry in rows.values_mut() {
528 entry.is_active = false;
529 }
530 let selected = ordered_effective_entries(rows.values().cloned().collect())?
531 .first()
532 .map(|entry| entry.url.clone())
533 .ok_or(SoftchatError::InvalidRelayCatalog)?;
534 rows.get_mut(&selected)
535 .ok_or(SoftchatError::InvalidRelayCatalog)?
536 .is_active = true;
537 Ok(())
538}
539
540fn ordered_effective_entries(
541 mut entries: Vec<RelayCatalogEntry>,
542) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
543 let mut unique = BTreeSet::new();
544 entries.retain(is_effective);
545 for entry in &entries {
546 validate_entry(entry)?;
547 if !unique.insert(entry.url.clone()) {
548 return Err(SoftchatError::InvalidRelayCatalog);
549 }
550 }
551 entries.sort_by(|left, right| {
552 let left_priority = (left.dns_priority >= 0).then_some(left.dns_priority);
553 let right_priority = (right.dns_priority >= 0).then_some(right.dns_priority);
554 left_priority
555 .is_none()
556 .cmp(&right_priority.is_none())
557 .then_with(|| left_priority.cmp(&right_priority))
558 .then_with(|| left.url.cmp(&right.url))
559 });
560 Ok(entries)
561}
562
563fn entry_storage_order(left: &RelayCatalogEntry, right: &RelayCatalogEntry) -> std::cmp::Ordering {
564 left.url.cmp(&right.url)
565}
566
567fn sorted_rows(rows: BTreeMap<String, RelayCatalogEntry>) -> Vec<RelayCatalogEntry> {
568 rows.into_values().collect()
569}
570
571fn is_effective(entry: &RelayCatalogEntry) -> bool {
572 entry.source == RelayCatalogSource::Custom
573 || (entry.is_dns_discovered && !entry.is_user_excluded)
574}
575
576fn canonical_relay_url(value: &str) -> Result<String, SoftchatError> {
577 Ok(parse_relay_endpoint(value)?.configured_url)
578}
579
580pub fn parse_relay_endpoint(value: &str) -> Result<RelayEndpointPlan, SoftchatError> {
591 if value.len() > 2_048 {
592 return Err(SoftchatError::InvalidRelayCatalog);
593 }
594 let configured = Url::parse(value).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
595 if !matches!(configured.scheme(), "wss" | "wss+noise")
596 || !configured.username().is_empty()
597 || configured.password().is_some()
598 || configured.host_str().is_none()
599 {
600 return Err(SoftchatError::InvalidRelayCatalog);
601 }
602 let requires_noise = configured.scheme() == "wss+noise" || configured.fragment().is_some();
603 let noise_remote_static_key = if requires_noise {
604 let encoded = configured
605 .fragment()
606 .filter(|fragment| {
607 fragment.len() == 64 && fragment.bytes().all(|byte| byte.is_ascii_hexdigit())
608 })
609 .ok_or(SoftchatError::InvalidRelayCatalog)?
610 .to_ascii_lowercase();
611 hex::decode(encoded).map_err(|_| SoftchatError::InvalidRelayCatalog)?
612 } else {
613 Vec::new()
614 };
615 let network_value = if configured.scheme() == "wss+noise" {
616 configured.as_str().replacen("wss+noise:", "wss:", 1)
617 } else {
618 configured.to_string()
619 };
620 let mut network = Url::parse(&network_value).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
621 network.set_fragment(None);
622 let configured_url = if requires_noise {
623 let key = hex::encode(&noise_remote_static_key);
624 let mut pinned = network.clone();
625 pinned.set_fragment(Some(&key));
626 pinned.to_string().replacen("wss:", "wss+noise:", 1)
627 } else {
628 network.to_string()
629 };
630 Ok(RelayEndpointPlan {
631 configured_url,
632 network_url: network.to_string(),
633 noise_remote_static_key,
634 })
635}
636
637#[cfg(test)]
638#[allow(clippy::expect_used)]
639mod tests {
640 use super::*;
641
642 fn custom(url: &str, created_at: i64, active: bool) -> RelayCatalogEntry {
643 RelayCatalogEntry {
644 url: url.to_owned(),
645 created_at,
646 is_active: active,
647 source: RelayCatalogSource::Custom,
648 is_dns_discovered: false,
649 is_user_excluded: false,
650 dns_priority: -1,
651 dns_weight: -1,
652 dns_expires_at: -1,
653 }
654 }
655
656 fn dns(target: &str, priority: u32, weight: u32, ttl_seconds: u32) -> DnsRelayRecord {
657 DnsRelayRecord {
658 target: target.to_owned(),
659 priority,
660 weight,
661 ttl_seconds,
662 }
663 }
664
665 #[test]
666 fn reconciles_custom_discovered_excluded_and_fallback_state() {
667 let plan = RelayCatalogReducer::reconcile(
668 vec![custom("wss://custom.example", 10, true)],
669 vec![
670 dns("wss://two.example", 20, 1, 600),
671 dns("wss://one.example", 10, 1, 300),
672 ],
673 100,
674 "wss://fallback.example".to_owned(),
675 true,
676 )
677 .expect("valid catalog");
678 assert!(!plan.used_fallback);
679 assert_eq!(plan.active_relay_url, "wss://custom.example/");
680 assert_eq!(plan.next_refresh_at, 400);
681 assert_eq!(
682 RelayCatalogReducer::failover_candidates(
683 plan.entries.clone(),
684 "wss://one.example".to_owned()
685 )
686 .expect("valid failover"),
687 vec![
688 "wss://two.example/".to_owned(),
689 "wss://custom.example/".to_owned(),
690 "wss://one.example/".to_owned()
691 ]
692 );
693
694 let fallback = RelayCatalogReducer::reconcile(
695 plan.entries,
696 Vec::new(),
697 1_000,
698 "wss://fallback.example".to_owned(),
699 false,
700 )
701 .expect("fallback catalog");
702 assert!(fallback.used_fallback);
703 assert!(
704 fallback
705 .entries
706 .iter()
707 .any(|entry| entry.url == "wss://fallback.example/")
708 );
709 }
710
711 #[test]
712 fn deduplicates_dns_targets_by_priority_weight_then_shorter_ttl() {
713 let plan = RelayCatalogReducer::reconcile(
714 Vec::new(),
715 vec![
716 dns("wss://relay.example", 20, 100, 600),
717 dns("wss://relay.example", 10, 1, 500),
718 dns("wss://relay.example", 10, 2, 700),
719 dns("wss://relay.example", 10, 2, 300),
720 ],
721 100,
722 "wss://fallback.example".to_owned(),
723 true,
724 )
725 .expect("valid catalog");
726 let entry = plan.entries.first().expect("one relay");
727 assert_eq!(entry.dns_priority, 10);
728 assert_eq!(entry.dns_weight, 2);
729 assert_eq!(entry.dns_expires_at, 400);
730 }
731
732 #[test]
733 fn retry_policy_is_bounded_and_rotates_permanent_failures() {
734 assert_eq!(
735 plan_relay_retry(0, RelayFailureKind::Temporary, false),
736 RelayRetryPlan {
737 action: RelayRetryAction::WaitSameRelay,
738 delay_ms: 500,
739 jitter_max_ms: 250,
740 }
741 );
742 assert_eq!(
743 plan_relay_retry(99, RelayFailureKind::RateLimited, false).delay_ms,
744 8_000
745 );
746 assert_eq!(
747 plan_relay_retry(2, RelayFailureKind::Authentication, false),
748 RelayRetryPlan {
749 action: RelayRetryAction::RotateRelay,
750 delay_ms: 0,
751 jitter_max_ms: 250,
752 }
753 );
754 assert_eq!(
755 plan_relay_retry(2, RelayFailureKind::NetworkUnavailable, true).delay_ms,
756 0
757 );
758 }
759
760 #[test]
761 fn canonicalizes_the_released_noise_endpoint_spellings() {
762 let key = "01".repeat(32);
763 let explicit = parse_relay_endpoint(&format!("wss+noise://relay.example/#{key}"))
764 .expect("valid explicit endpoint");
765 let legacy = parse_relay_endpoint(&format!("wss://relay.example/#{key}"))
766 .expect("valid legacy endpoint");
767 assert_eq!(explicit, legacy);
768 assert_eq!(explicit.network_url, "wss://relay.example/");
769 assert_eq!(explicit.noise_remote_static_key, vec![1; 32]);
770 assert!(
771 parse_relay_endpoint("wss+noise://relay.example/").is_err(),
772 "Noise must never silently run without its responder pin"
773 );
774 }
775}