transport_nostr_adapter.rs (22854B)
1 //! Private source-locked Nostr transport composition for the RHI runtime. 2 3 use core::fmt; 4 use std::{collections::BTreeMap, error::Error, sync::Arc}; 5 6 use radroots_event_codec::Codec; 7 use radroots_transport::{ 8 BoxFuture, DeliveryReceipt, DeliveryRequest, EventSink, EventSource, EventSubscriber, 9 FetchPage, FetchRequest, SinkFailure, SinkStatus, SourceStatus, Target, TargetSet, 10 outcome::{DeliveryOutcomeKind, FetchTargetState}, 11 policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, 12 sink::{DeliveryPayload, DeliveryTargetReceipt}, 13 source::{BoxSubscription, FetchBounds, FetchSelector, SubscriptionRequest}, 14 target::TargetFingerprint, 15 }; 16 use radroots_transport_nostr::{ 17 Config, NostrTransport, PreparedDelivery, RelayAccess, RelayEndpoint, RelayProfile, 18 RelayProfileKind, RelayUrlPolicy, 19 }; 20 21 use crate::{ 22 RhiConfigDocumentV1, RhiConfigProfile, RhiExactPresenceSink, RhiExactPublicationSink, 23 RhiPreparedPresenceAttempt, RhiPreparedPublicationAttempt, RhiPresenceAttemptOutcome, 24 RhiPublicationAttemptOutcome, RhiTransportAdapters, 25 }; 26 27 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 28 pub(crate) enum RhiNostrAdapterErrorKind { 29 Configuration, 30 Target, 31 Payload, 32 Preparation, 33 } 34 35 pub(crate) struct RhiNostrAdapterError { 36 kind: RhiNostrAdapterErrorKind, 37 } 38 39 impl RhiNostrAdapterError { 40 #[cfg(test)] 41 pub(crate) const fn kind(&self) -> RhiNostrAdapterErrorKind { 42 self.kind 43 } 44 } 45 46 impl fmt::Debug for RhiNostrAdapterError { 47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 48 formatter 49 .debug_struct("RhiNostrAdapterError") 50 .field("kind", &self.kind) 51 .finish() 52 } 53 } 54 55 impl fmt::Display for RhiNostrAdapterError { 56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 57 formatter.write_str("RHI Nostr adapter failed") 58 } 59 } 60 61 impl Error for RhiNostrAdapterError {} 62 63 const fn adapter_error(kind: RhiNostrAdapterErrorKind) -> RhiNostrAdapterError { 64 RhiNostrAdapterError { kind } 65 } 66 67 struct RelayDefinition { 68 id: Box<str>, 69 target: Target, 70 kind: RelayProfileKind, 71 } 72 73 struct RelayBinding { 74 transport: NostrTransport, 75 target: Target, 76 } 77 78 /// One private adapter that routes a bounded request to exactly one configured 79 /// public or simulator transport group. 80 struct RhiNostrTransport { 81 by_target: BTreeMap<TargetFingerprint, NostrTransport>, 82 } 83 84 impl RhiNostrTransport { 85 fn for_targets( 86 &self, 87 targets: &TargetSet, 88 ) -> Result<NostrTransport, radroots_transport::Error> { 89 let mut selected: Option<NostrTransport> = None; 90 for target in targets.targets() { 91 let transport = self 92 .by_target 93 .get(target.fingerprint()) 94 .ok_or(radroots_transport::Error::UnsupportedOperation)?; 95 if let Some(existing) = &selected { 96 if existing.config() != transport.config() { 97 return Err(radroots_transport::Error::UnsupportedOperation); 98 } 99 } else { 100 selected = Some(transport.clone()); 101 } 102 } 103 selected.ok_or(radroots_transport::Error::UnsupportedOperation) 104 } 105 } 106 107 impl EventSource for RhiNostrTransport { 108 fn status(&self) -> BoxFuture<'_, Result<SourceStatus, radroots_transport::Error>> { 109 Box::pin(async { Err(radroots_transport::Error::UnsupportedOperation) }) 110 } 111 112 fn fetch( 113 &self, 114 request: FetchRequest, 115 ) -> BoxFuture<'_, Result<FetchPage, radroots_transport::Error>> { 116 Box::pin(async move { 117 let transport = self.for_targets(request.target_set())?; 118 transport.fetch(request).await 119 }) 120 } 121 } 122 123 impl EventSubscriber for RhiNostrTransport { 124 fn subscribe( 125 &self, 126 request: SubscriptionRequest, 127 ) -> BoxFuture<'_, Result<BoxSubscription, radroots_transport::Error>> { 128 Box::pin(async move { 129 let transport = self.for_targets(request.target_set())?; 130 transport.subscribe(request).await 131 }) 132 } 133 } 134 135 impl EventSink for RhiNostrTransport { 136 fn status(&self) -> BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { 137 Box::pin(async { Err(radroots_transport::Error::UnsupportedOperation) }) 138 } 139 140 fn deliver( 141 &self, 142 request: DeliveryRequest, 143 ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { 144 Box::pin(async move { 145 let transport = self 146 .for_targets(request.target_set()) 147 .map_err(|_| SinkFailure::invalid_contract(&request))?; 148 transport.deliver(request).await 149 }) 150 } 151 } 152 153 pub(crate) struct RhiNostrExactSink { 154 relays: BTreeMap<Box<str>, RelayBinding>, 155 } 156 157 struct RhiPreparedNostrDelivery { 158 transport: NostrTransport, 159 prepared: PreparedDelivery, 160 } 161 162 impl fmt::Debug for RhiPreparedNostrDelivery { 163 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 164 formatter.write_str("RhiPreparedNostrDelivery([redacted])") 165 } 166 } 167 168 impl RhiNostrExactSink { 169 fn prepare( 170 &self, 171 relay_id: &str, 172 request_id: String, 173 exact_event_bytes: &[u8], 174 deadline_unix_ms: u64, 175 ) -> Result<RhiPreparedNostrDelivery, RhiNostrAdapterError> { 176 let binding = self 177 .relays 178 .get(relay_id) 179 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Target))?; 180 let raw = core::str::from_utf8(exact_event_bytes) 181 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Payload))?; 182 let signed = Codec::decode_signed_event(raw) 183 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Payload))?; 184 if signed.raw_json().as_bytes() != exact_event_bytes { 185 return Err(adapter_error(RhiNostrAdapterErrorKind::Payload)); 186 } 187 let targets = TargetSet::new(vec![binding.target.clone()]) 188 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?; 189 let request = DeliveryRequest::new( 190 request_id, 191 DeliveryPayload::new(signed), 192 targets, 193 SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), 194 deadline_unix_ms, 195 ) 196 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; 197 let prepared = binding 198 .transport 199 .prepare_delivery(request) 200 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; 201 Ok(RhiPreparedNostrDelivery { 202 transport: binding.transport.clone(), 203 prepared, 204 }) 205 } 206 207 async fn execute( 208 &self, 209 prepared: RhiPreparedNostrDelivery, 210 ) -> Result<DeliveryOutcomeKind, RhiNostrAdapterError> { 211 let RhiPreparedNostrDelivery { 212 transport, 213 prepared, 214 } = prepared; 215 let receipt = transport 216 .execute_prepared_delivery(prepared) 217 .await 218 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; 219 classify_receipt(receipt.target_receipts()) 220 } 221 } 222 223 impl RhiExactPublicationSink for RhiNostrExactSink { 224 fn submit_exact<'a>( 225 &'a self, 226 attempt: &'a RhiPreparedPublicationAttempt, 227 ) -> BoxFuture<'a, RhiPublicationAttemptOutcome> { 228 Box::pin(async move { 229 let request_id = request_id("publication", attempt.attempt_id().as_bytes()); 230 let prepared = match self.prepare( 231 attempt.relay_id(), 232 request_id, 233 attempt.exact_signed_event_bytes(), 234 attempt.deadline_at().get(), 235 ) { 236 Ok(prepared) => prepared, 237 Err(_) => return RhiPublicationAttemptOutcome::Failed, 238 }; 239 match self.execute(prepared).await { 240 Ok(DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered) => { 241 RhiPublicationAttemptOutcome::Accepted 242 } 243 Ok(DeliveryOutcomeKind::Rejected) => RhiPublicationAttemptOutcome::Rejected, 244 Ok(DeliveryOutcomeKind::Unavailable | DeliveryOutcomeKind::Failed) => { 245 RhiPublicationAttemptOutcome::Failed 246 } 247 Err(_) => RhiPublicationAttemptOutcome::Unknown, 248 } 249 }) 250 } 251 } 252 253 impl RhiExactPresenceSink for RhiNostrExactSink { 254 fn submit_exact<'a>( 255 &'a self, 256 attempt: &'a RhiPreparedPresenceAttempt, 257 ) -> BoxFuture<'a, RhiPresenceAttemptOutcome> { 258 Box::pin(async move { 259 let request_id = request_id("presence", attempt.attempt_id().as_bytes()); 260 let prepared = match self.prepare( 261 attempt.relay_id(), 262 request_id, 263 attempt.exact_signed_event_bytes(), 264 attempt.deadline_at().get(), 265 ) { 266 Ok(prepared) => prepared, 267 Err(_) => return RhiPresenceAttemptOutcome::Failed, 268 }; 269 match self.execute(prepared).await { 270 Ok(DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered) => { 271 RhiPresenceAttemptOutcome::Accepted 272 } 273 Ok(DeliveryOutcomeKind::Rejected) => RhiPresenceAttemptOutcome::Rejected, 274 Ok(DeliveryOutcomeKind::Unavailable | DeliveryOutcomeKind::Failed) => { 275 RhiPresenceAttemptOutcome::Failed 276 } 277 Err(_) => RhiPresenceAttemptOutcome::Unknown, 278 } 279 }) 280 } 281 } 282 283 fn classify_receipt( 284 receipts: &[DeliveryTargetReceipt], 285 ) -> Result<DeliveryOutcomeKind, RhiNostrAdapterError> { 286 let [receipt] = receipts else { 287 return Err(adapter_error(RhiNostrAdapterErrorKind::Preparation)); 288 }; 289 Ok(receipt.outcome().kind()) 290 } 291 292 fn request_id(prefix: &str, bytes: &[u8; 32]) -> String { 293 const HEX: &[u8; 16] = b"0123456789abcdef"; 294 let mut result = String::with_capacity(prefix.len() + 1 + bytes.len() * 2); 295 result.push_str(prefix); 296 result.push('-'); 297 for byte in bytes { 298 result.push(char::from(HEX[usize::from(byte >> 4)])); 299 result.push(char::from(HEX[usize::from(byte & 0x0f)])); 300 } 301 result 302 } 303 304 pub(crate) fn build_rhi_nostr_adapters( 305 configuration: &RhiConfigDocumentV1, 306 ) -> Result<(RhiTransportAdapters, Arc<RhiNostrExactSink>), RhiNostrAdapterError> { 307 let relays = configuration 308 .normalized() 309 .pointer("/relays") 310 .and_then(serde_json::Value::as_array) 311 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 312 let connect_timeout = configuration_integer(configuration, "/network/connect_deadline_ms")?; 313 let source_timeout = configuration 314 .normalized() 315 .pointer("/evidence/sources") 316 .and_then(serde_json::Value::as_array) 317 .and_then(|sources| { 318 sources 319 .iter() 320 .filter_map(|source| { 321 source 322 .pointer("/deadline_ms") 323 .and_then(serde_json::Value::as_u64) 324 }) 325 .max() 326 }) 327 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 328 let publication_timeout = configuration 329 .normalized() 330 .pointer("/publication/retry/attempt_deadline_ms") 331 .and_then(serde_json::Value::as_u64) 332 .unwrap_or(source_timeout); 333 let request_timeout = source_timeout.max(publication_timeout); 334 335 let mut public = Vec::new(); 336 let mut simulator = Vec::new(); 337 let mut definitions = Vec::with_capacity(relays.len()); 338 for relay in relays { 339 let id = relay 340 .pointer("/id") 341 .and_then(serde_json::Value::as_str) 342 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 343 let url = relay 344 .pointer("/url") 345 .and_then(serde_json::Value::as_str) 346 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 347 let read = relay 348 .pointer("/read") 349 .and_then(serde_json::Value::as_bool) 350 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 351 let write = relay 352 .pointer("/write") 353 .and_then(serde_json::Value::as_bool) 354 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 355 let access = if write { 356 RelayAccess::ReadWrite 357 } else if read { 358 RelayAccess::ReadOnly 359 } else { 360 return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); 361 }; 362 let (kind, policy) = if url.starts_with("wss://") { 363 (RelayProfileKind::Public, RelayUrlPolicy::Public) 364 } else if configuration.profile() == RhiConfigProfile::RepoLocal && url.starts_with("ws://") 365 { 366 (RelayProfileKind::Simulator, RelayUrlPolicy::Local) 367 } else { 368 return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); 369 }; 370 let endpoint = RelayEndpoint::new(url, policy, access) 371 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 372 match kind { 373 RelayProfileKind::Public => public.push(endpoint), 374 RelayProfileKind::Simulator => simulator.push(endpoint), 375 _ => return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)), 376 } 377 definitions.push(RelayDefinition { 378 id: Box::from(id), 379 target: Target::nostr_relay(url) 380 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?, 381 kind, 382 }); 383 } 384 385 let public_transport = build_transport( 386 RelayProfileKind::Public, 387 public, 388 connect_timeout, 389 request_timeout, 390 )?; 391 let simulator_transport = build_transport( 392 RelayProfileKind::Simulator, 393 simulator, 394 connect_timeout, 395 request_timeout, 396 )?; 397 let mut by_target = BTreeMap::new(); 398 let mut bindings = BTreeMap::new(); 399 for definition in definitions { 400 let transport = match definition.kind { 401 RelayProfileKind::Public => public_transport.clone(), 402 RelayProfileKind::Simulator => simulator_transport.clone(), 403 _ => None, 404 } 405 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 406 if by_target 407 .insert(definition.target.fingerprint().clone(), transport.clone()) 408 .is_some() 409 || bindings 410 .insert( 411 definition.id, 412 RelayBinding { 413 transport, 414 target: definition.target, 415 }, 416 ) 417 .is_some() 418 { 419 return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); 420 } 421 } 422 if by_target.is_empty() { 423 return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); 424 } 425 let transport = Arc::new(RhiNostrTransport { by_target }); 426 let adapters = RhiTransportAdapters::new( 427 transport.clone(), 428 transport.clone(), 429 transport as Arc<dyn EventSink>, 430 ); 431 Ok((adapters, Arc::new(RhiNostrExactSink { relays: bindings }))) 432 } 433 434 pub(crate) async fn probe_required_sources( 435 configuration: &RhiConfigDocumentV1, 436 deadline_unix_ms: u64, 437 ) -> Result<(), RhiNostrAdapterError> { 438 let relays = configuration 439 .normalized() 440 .pointer("/relays") 441 .and_then(serde_json::Value::as_array) 442 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 443 let sources = configuration 444 .normalized() 445 .pointer("/evidence/sources") 446 .and_then(serde_json::Value::as_array) 447 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 448 let connect_timeout = configuration_integer(configuration, "/network/connect_deadline_ms")?; 449 let request_timeout = sources 450 .iter() 451 .filter_map(|source| { 452 source 453 .pointer("/deadline_ms") 454 .and_then(serde_json::Value::as_u64) 455 }) 456 .max() 457 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 458 459 let mut public = Vec::new(); 460 let mut public_targets = Vec::new(); 461 let mut simulator = Vec::new(); 462 let mut simulator_targets = Vec::new(); 463 for source in sources.iter().filter(|source| { 464 source 465 .pointer("/required") 466 .and_then(serde_json::Value::as_bool) 467 == Some(true) 468 }) { 469 let relay_id = source 470 .pointer("/relay_id") 471 .and_then(serde_json::Value::as_str) 472 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 473 let relay = relays 474 .iter() 475 .find(|relay| { 476 relay.pointer("/id").and_then(serde_json::Value::as_str) == Some(relay_id) 477 }) 478 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 479 let url = relay 480 .pointer("/url") 481 .and_then(serde_json::Value::as_str) 482 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 483 let target = Target::nostr_relay(url) 484 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?; 485 let (kind, policy) = if url.starts_with("wss://") { 486 (RelayProfileKind::Public, RelayUrlPolicy::Public) 487 } else if configuration.profile() == RhiConfigProfile::RepoLocal && url.starts_with("ws://") 488 { 489 (RelayProfileKind::Simulator, RelayUrlPolicy::Local) 490 } else { 491 return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); 492 }; 493 let endpoint = RelayEndpoint::new(url, policy, RelayAccess::ReadOnly) 494 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 495 match kind { 496 RelayProfileKind::Public => { 497 public.push(endpoint); 498 public_targets.push(target); 499 } 500 RelayProfileKind::Simulator => { 501 simulator.push(endpoint); 502 simulator_targets.push(target); 503 } 504 _ => return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)), 505 } 506 } 507 if public_targets.is_empty() && simulator_targets.is_empty() { 508 return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); 509 } 510 probe_group( 511 RelayProfileKind::Public, 512 public, 513 public_targets, 514 connect_timeout, 515 request_timeout, 516 deadline_unix_ms, 517 "rhi-doctor-public", 518 ) 519 .await?; 520 probe_group( 521 RelayProfileKind::Simulator, 522 simulator, 523 simulator_targets, 524 connect_timeout, 525 request_timeout, 526 deadline_unix_ms, 527 "rhi-doctor-simulator", 528 ) 529 .await 530 } 531 532 async fn probe_group( 533 kind: RelayProfileKind, 534 endpoints: Vec<RelayEndpoint>, 535 targets: Vec<Target>, 536 connect_timeout: u64, 537 request_timeout: u64, 538 deadline_unix_ms: u64, 539 request_id: &'static str, 540 ) -> Result<(), RhiNostrAdapterError> { 541 if targets.is_empty() { 542 return Ok(()); 543 } 544 let transport = build_transport(kind, endpoints, connect_timeout, request_timeout)? 545 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 546 let target_set = 547 TargetSet::new(targets).map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?; 548 let selector = FetchSelector::all() 549 .with_since_unix_seconds(u64::MAX) 550 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 551 let request = FetchRequest::new( 552 request_id, 553 target_set, 554 FetchBounds::new(1, deadline_unix_ms) 555 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?, 556 ) 557 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))? 558 .with_selector(selector); 559 let page = transport 560 .fetch(request) 561 .await 562 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; 563 if page.target_outcomes().is_empty() 564 || page.target_outcomes().iter().any(|outcome| { 565 !matches!( 566 outcome.state(), 567 FetchTargetState::Complete | FetchTargetState::Partial 568 ) 569 }) 570 { 571 return Err(adapter_error(RhiNostrAdapterErrorKind::Preparation)); 572 } 573 Ok(()) 574 } 575 576 fn build_transport( 577 kind: RelayProfileKind, 578 endpoints: Vec<RelayEndpoint>, 579 connect_timeout: u64, 580 request_timeout: u64, 581 ) -> Result<Option<NostrTransport>, RhiNostrAdapterError> { 582 if endpoints.is_empty() { 583 return Ok(None); 584 } 585 let maximum_connections = endpoints.len().min(8); 586 let profile = RelayProfile::explicit(kind, endpoints) 587 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 588 let config = Config::from_profile(profile) 589 .with_timeouts(connect_timeout, request_timeout, connect_timeout) 590 .and_then(|config| config.with_max_connections(maximum_connections)) 591 .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; 592 Ok(Some(NostrTransport::new(config))) 593 } 594 595 fn configuration_integer( 596 configuration: &RhiConfigDocumentV1, 597 pointer: &str, 598 ) -> Result<u64, RhiNostrAdapterError> { 599 configuration 600 .normalized() 601 .pointer(pointer) 602 .and_then(serde_json::Value::as_u64) 603 .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration)) 604 } 605 606 #[cfg(test)] 607 mod tests { 608 use std::error::Error as _; 609 610 use super::*; 611 use crate::{RhiConfigProfile, parse_rhi_config_v1}; 612 613 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 614 615 #[test] 616 fn exact_configuration_builds_without_network_io_and_errors_are_redacted() { 617 let configuration = parse_rhi_config_v1(CONFIG.as_bytes(), RhiConfigProfile::Production) 618 .expect("configuration"); 619 let (_adapters, sink) = 620 build_rhi_nostr_adapters(&configuration).expect("offline composition"); 621 assert_eq!(sink.relays.len(), 2); 622 assert_eq!(request_id("publication", &[0xab; 32]).len(), 76); 623 for kind in [ 624 RhiNostrAdapterErrorKind::Configuration, 625 RhiNostrAdapterErrorKind::Target, 626 RhiNostrAdapterErrorKind::Payload, 627 RhiNostrAdapterErrorKind::Preparation, 628 ] { 629 let error = adapter_error(kind); 630 assert_eq!(error.kind(), kind); 631 assert!(error.source().is_none()); 632 assert!(!format!("{error} {error:?}").contains("relay.example")); 633 } 634 } 635 }