transport.rs (24640B)
1 pub mod nip46; 2 3 use std::collections::{BTreeMap, BTreeSet}; 4 use std::time::Duration; 5 6 use radroots_nostr::prelude::{ 7 RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrOutput, 8 RadrootsNostrRelayUrl, 9 }; 10 use serde::Serialize; 11 use tokio::time::sleep; 12 13 use crate::config::{MycTransportConfig, MycTransportDeliveryPolicy}; 14 use crate::custody::MycActiveIdentity; 15 use crate::error::MycError; 16 17 pub use nip46::{MycNip46Handler, MycNip46Service}; 18 19 #[derive(Clone)] 20 pub struct MycNostrTransport { 21 client: RadrootsNostrClient, 22 relays: Vec<RadrootsNostrRelayUrl>, 23 connect_timeout_secs: u64, 24 delivery_policy: MycTransportDeliveryPolicy, 25 delivery_quorum: Option<usize>, 26 publish_max_attempts: usize, 27 publish_initial_backoff_millis: u64, 28 publish_max_backoff_millis: u64, 29 } 30 31 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 32 pub struct MycTransportSnapshot { 33 pub enabled: bool, 34 pub relay_count: usize, 35 pub connect_timeout_secs: u64, 36 pub delivery_policy: MycTransportDeliveryPolicy, 37 pub delivery_quorum: Option<usize>, 38 pub publish_max_attempts: usize, 39 pub publish_initial_backoff_millis: u64, 40 pub publish_max_backoff_millis: u64, 41 } 42 43 #[derive(Debug, Clone, PartialEq, Eq)] 44 pub struct MycPublishOutcome { 45 pub relay_count: usize, 46 pub acknowledged_relay_count: usize, 47 pub required_acknowledged_relay_count: usize, 48 pub delivery_policy: MycTransportDeliveryPolicy, 49 pub attempt_count: usize, 50 pub relay_outcome_summary: String, 51 pub relay_results: Vec<MycRelayPublishResult>, 52 pub attempt_summaries: Vec<String>, 53 } 54 55 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 56 pub struct MycRelayPublishResult { 57 pub relay_url: String, 58 pub acknowledged: bool, 59 #[serde(default, skip_serializing_if = "Option::is_none")] 60 pub detail: Option<String>, 61 } 62 63 #[derive(Debug, Clone, PartialEq, Eq)] 64 struct MycPublishSettings { 65 delivery_policy: MycTransportDeliveryPolicy, 66 delivery_quorum: Option<usize>, 67 publish_max_attempts: usize, 68 publish_initial_backoff_millis: u64, 69 publish_max_backoff_millis: u64, 70 } 71 72 impl MycNostrTransport { 73 pub fn bootstrap( 74 config: &MycTransportConfig, 75 signer_identity: &MycActiveIdentity, 76 ) -> Result<Option<Self>, MycError> { 77 if !config.enabled { 78 return Ok(None); 79 } 80 81 Ok(Some(Self { 82 client: signer_identity.nostr_client(), 83 relays: config.parse_relays()?, 84 connect_timeout_secs: config.connect_timeout_secs, 85 delivery_policy: config.delivery_policy, 86 delivery_quorum: config.delivery_quorum, 87 publish_max_attempts: config.publish_max_attempts, 88 publish_initial_backoff_millis: config.publish_initial_backoff_millis, 89 publish_max_backoff_millis: config.publish_max_backoff_millis, 90 })) 91 } 92 93 pub fn client(&self) -> &RadrootsNostrClient { 94 &self.client 95 } 96 97 pub fn relays(&self) -> &[RadrootsNostrRelayUrl] { 98 self.relays.as_slice() 99 } 100 101 pub fn connect_timeout_secs(&self) -> u64 { 102 self.connect_timeout_secs 103 } 104 105 pub fn delivery_policy(&self) -> MycTransportDeliveryPolicy { 106 self.delivery_policy 107 } 108 109 pub async fn connect(&self) -> Result<(), MycError> { 110 for relay in &self.relays { 111 let _ = self.client.add_relay(relay.as_str()).await?; 112 } 113 self.client.connect().await; 114 self.client 115 .wait_for_connection(Duration::from_secs(self.connect_timeout_secs)) 116 .await; 117 Ok(()) 118 } 119 120 pub async fn publish_once( 121 signer_identity: &MycActiveIdentity, 122 relays: &[RadrootsNostrRelayUrl], 123 config: &MycTransportConfig, 124 operation: &str, 125 event: RadrootsNostrEventBuilder, 126 ) -> Result<MycPublishOutcome, MycError> { 127 if relays.is_empty() { 128 return Err(MycError::InvalidOperation( 129 "cannot publish without at least one relay".to_owned(), 130 )); 131 } 132 133 let event = signer_identity.sign_event_builder(event, "publish")?; 134 Self::publish_event_once(signer_identity, relays, config, operation, &event).await 135 } 136 137 pub async fn publish_event_once( 138 signer_identity: &MycActiveIdentity, 139 relays: &[RadrootsNostrRelayUrl], 140 config: &MycTransportConfig, 141 operation: &str, 142 event: &RadrootsNostrEvent, 143 ) -> Result<MycPublishOutcome, MycError> { 144 if relays.is_empty() { 145 return Err(MycError::InvalidOperation( 146 "cannot publish without at least one relay".to_owned(), 147 )); 148 } 149 150 let settings = MycPublishSettings::from_config(config); 151 publish_with_policy(relays, &settings, operation, || async { 152 let client = signer_identity.nostr_client(); 153 for relay in relays { 154 client 155 .add_relay(relay.as_str()) 156 .await 157 .map_err(|error| error.to_string())?; 158 } 159 client.connect().await; 160 client 161 .wait_for_connection(Duration::from_secs(config.connect_timeout_secs)) 162 .await; 163 client 164 .send_event(event) 165 .await 166 .map_err(|error| error.to_string()) 167 }) 168 .await 169 } 170 171 pub async fn publish_event( 172 &self, 173 operation: &str, 174 event: &RadrootsNostrEvent, 175 ) -> Result<MycPublishOutcome, MycError> { 176 publish_with_policy( 177 self.relays(), 178 &self.publish_settings(), 179 operation, 180 || async { 181 self.client 182 .send_event(event) 183 .await 184 .map_err(|error| error.to_string()) 185 }, 186 ) 187 .await 188 } 189 190 pub fn snapshot(&self) -> MycTransportSnapshot { 191 MycTransportSnapshot { 192 enabled: true, 193 relay_count: self.relays.len(), 194 connect_timeout_secs: self.connect_timeout_secs, 195 delivery_policy: self.delivery_policy, 196 delivery_quorum: self.delivery_quorum, 197 publish_max_attempts: self.publish_max_attempts, 198 publish_initial_backoff_millis: self.publish_initial_backoff_millis, 199 publish_max_backoff_millis: self.publish_max_backoff_millis, 200 } 201 } 202 203 fn publish_settings(&self) -> MycPublishSettings { 204 MycPublishSettings { 205 delivery_policy: self.delivery_policy, 206 delivery_quorum: self.delivery_quorum, 207 publish_max_attempts: self.publish_max_attempts, 208 publish_initial_backoff_millis: self.publish_initial_backoff_millis, 209 publish_max_backoff_millis: self.publish_max_backoff_millis, 210 } 211 } 212 } 213 214 async fn publish_with_policy<T, F, Fut>( 215 relays: &[RadrootsNostrRelayUrl], 216 settings: &MycPublishSettings, 217 operation: &str, 218 mut send_attempt: F, 219 ) -> Result<MycPublishOutcome, MycError> 220 where 221 T: std::fmt::Debug, 222 F: FnMut() -> Fut, 223 Fut: std::future::Future<Output = Result<RadrootsNostrOutput<T>, String>>, 224 { 225 let relay_count = relays.len(); 226 let required_acknowledged_relay_count = 227 settings.required_acknowledged_relay_count(relay_count)?; 228 let mut attempt_results = Vec::new(); 229 230 for attempt_number in 1..=settings.publish_max_attempts { 231 let attempt = match send_attempt().await { 232 Ok(output) => build_publish_attempt_result(relays, attempt_number, &output), 233 Err(error) => build_failed_publish_attempt_result(relays, attempt_number, error), 234 }; 235 let threshold_reached = 236 attempt.acknowledged_relay_count >= required_acknowledged_relay_count; 237 attempt_results.push(attempt); 238 239 if threshold_reached { 240 let final_attempt = attempt_results 241 .last() 242 .expect("publish attempt results contain the successful attempt"); 243 return Ok(MycPublishOutcome { 244 relay_count, 245 acknowledged_relay_count: final_attempt.acknowledged_relay_count, 246 required_acknowledged_relay_count, 247 delivery_policy: settings.delivery_policy, 248 attempt_count: attempt_results.len(), 249 relay_outcome_summary: summarize_delivery_policy_result( 250 settings.delivery_policy, 251 required_acknowledged_relay_count, 252 &attempt_results, 253 ), 254 relay_results: final_attempt.relay_results.clone(), 255 attempt_summaries: attempt_results 256 .iter() 257 .map(|attempt| attempt.relay_outcome_summary.clone()) 258 .collect(), 259 }); 260 } 261 262 if attempt_number < settings.publish_max_attempts { 263 sleep(Duration::from_millis( 264 settings.backoff_for_attempt(attempt_number), 265 )) 266 .await; 267 } 268 } 269 270 let final_attempt = attempt_results 271 .last() 272 .expect("publish attempt results contain at least one attempt"); 273 Err(MycError::PublishRejected { 274 operation: operation.to_owned(), 275 relay_count, 276 acknowledged_relay_count: final_attempt.acknowledged_relay_count, 277 required_acknowledged_relay_count, 278 delivery_policy: settings.delivery_policy, 279 attempt_count: attempt_results.len(), 280 details: summarize_delivery_policy_result( 281 settings.delivery_policy, 282 required_acknowledged_relay_count, 283 &attempt_results, 284 ), 285 rejected_relays: final_attempt 286 .relay_results 287 .iter() 288 .filter(|result| !result.acknowledged) 289 .map(|result| result.relay_url.clone()) 290 .collect(), 291 }) 292 } 293 294 fn build_publish_relay_results<T>( 295 relays: &[RadrootsNostrRelayUrl], 296 output: &RadrootsNostrOutput<T>, 297 ) -> Vec<MycRelayPublishResult> 298 where 299 T: std::fmt::Debug, 300 { 301 let acknowledged_relays = output 302 .success 303 .iter() 304 .map(ToString::to_string) 305 .collect::<BTreeSet<_>>(); 306 let failed_relays = output 307 .failed 308 .iter() 309 .map(|(relay, error)| (relay.to_string(), error.to_string())) 310 .collect::<BTreeMap<_, _>>(); 311 312 relays 313 .iter() 314 .map(|relay| { 315 let relay_url = relay.to_string(); 316 if acknowledged_relays.contains(&relay_url) { 317 MycRelayPublishResult { 318 relay_url, 319 acknowledged: true, 320 detail: None, 321 } 322 } else { 323 MycRelayPublishResult { 324 relay_url: relay_url.clone(), 325 acknowledged: false, 326 detail: Some( 327 failed_relays 328 .get(&relay_url) 329 .cloned() 330 .unwrap_or_else(|| "no relay acknowledgement reported".to_owned()), 331 ), 332 } 333 } 334 }) 335 .collect() 336 } 337 338 fn build_publish_attempt_result<T>( 339 relays: &[RadrootsNostrRelayUrl], 340 attempt_number: usize, 341 output: &RadrootsNostrOutput<T>, 342 ) -> MycPublishAttemptResult 343 where 344 T: std::fmt::Debug, 345 { 346 let relay_results = build_publish_relay_results(relays, output); 347 let acknowledged_relay_count = relay_results 348 .iter() 349 .filter(|result| result.acknowledged) 350 .count(); 351 MycPublishAttemptResult { 352 attempt_number, 353 acknowledged_relay_count, 354 relay_outcome_summary: summarize_publish_results(&relay_results), 355 relay_results, 356 } 357 } 358 359 fn build_failed_publish_attempt_result( 360 relays: &[RadrootsNostrRelayUrl], 361 attempt_number: usize, 362 error: String, 363 ) -> MycPublishAttemptResult { 364 let relay_results = relays 365 .iter() 366 .map(|relay| MycRelayPublishResult { 367 relay_url: relay.to_string(), 368 acknowledged: false, 369 detail: Some(error.clone()), 370 }) 371 .collect::<Vec<_>>(); 372 MycPublishAttemptResult { 373 attempt_number, 374 acknowledged_relay_count: 0, 375 relay_outcome_summary: summarize_publish_results(&relay_results), 376 relay_results, 377 } 378 } 379 380 fn summarize_publish_results(relay_results: &[MycRelayPublishResult]) -> String { 381 let relay_count = relay_results.len(); 382 let acknowledged_relay_count = relay_results 383 .iter() 384 .filter(|result| result.acknowledged) 385 .count(); 386 if relay_count == 0 { 387 return "no relay acknowledged the publish".to_owned(); 388 } 389 390 let mut summary = 391 format!("{acknowledged_relay_count}/{relay_count} relays acknowledged publish"); 392 let acknowledged = relay_results 393 .iter() 394 .filter(|result| result.acknowledged) 395 .map(|result| result.relay_url.clone()) 396 .collect::<Vec<_>>(); 397 if !acknowledged.is_empty() { 398 summary.push_str("; acknowledged: "); 399 summary.push_str(&acknowledged.join(", ")); 400 } 401 let failures = relay_results 402 .iter() 403 .filter(|result| !result.acknowledged) 404 .map(|result| match result.detail.as_deref() { 405 Some(detail) => format!("{}: {detail}", result.relay_url), 406 None => result.relay_url.clone(), 407 }) 408 .collect::<Vec<_>>(); 409 if !failures.is_empty() { 410 summary.push_str("; failures: "); 411 summary.push_str(&failures.join("; ")); 412 } 413 summary 414 } 415 416 fn summarize_delivery_policy_result( 417 delivery_policy: MycTransportDeliveryPolicy, 418 required_acknowledged_relay_count: usize, 419 attempt_results: &[MycPublishAttemptResult], 420 ) -> String { 421 let attempt_count = attempt_results.len(); 422 let final_attempt = attempt_results 423 .last() 424 .expect("delivery policy summary requires at least one attempt"); 425 let mut summary = format!( 426 "delivery policy {} required {required_acknowledged_relay_count} acknowledgements across {attempt_count} attempt(s); final attempt {}: {}", 427 delivery_policy.as_str(), 428 final_attempt.attempt_number, 429 final_attempt.relay_outcome_summary, 430 ); 431 if attempt_results.len() > 1 { 432 let attempt_summaries = attempt_results 433 .iter() 434 .map(|attempt| { 435 format!( 436 "attempt {}: {}", 437 attempt.attempt_number, attempt.relay_outcome_summary 438 ) 439 }) 440 .collect::<Vec<_>>(); 441 summary.push_str("; "); 442 summary.push_str(&attempt_summaries.join(" | ")); 443 } 444 summary 445 } 446 447 impl MycTransportSnapshot { 448 pub fn disabled() -> Self { 449 Self { 450 enabled: false, 451 relay_count: 0, 452 connect_timeout_secs: 0, 453 delivery_policy: MycTransportDeliveryPolicy::Any, 454 delivery_quorum: None, 455 publish_max_attempts: 1, 456 publish_initial_backoff_millis: 250, 457 publish_max_backoff_millis: 2_000, 458 } 459 } 460 } 461 462 #[derive(Debug, Clone, PartialEq, Eq)] 463 struct MycPublishAttemptResult { 464 attempt_number: usize, 465 acknowledged_relay_count: usize, 466 relay_outcome_summary: String, 467 relay_results: Vec<MycRelayPublishResult>, 468 } 469 470 impl MycPublishSettings { 471 fn from_config(config: &MycTransportConfig) -> Self { 472 Self { 473 delivery_policy: config.delivery_policy, 474 delivery_quorum: config.delivery_quorum, 475 publish_max_attempts: config.publish_max_attempts, 476 publish_initial_backoff_millis: config.publish_initial_backoff_millis, 477 publish_max_backoff_millis: config.publish_max_backoff_millis, 478 } 479 } 480 481 fn required_acknowledged_relay_count(&self, relay_count: usize) -> Result<usize, MycError> { 482 match self.delivery_policy { 483 MycTransportDeliveryPolicy::Any => Ok(1), 484 MycTransportDeliveryPolicy::All => Ok(relay_count), 485 MycTransportDeliveryPolicy::Quorum => { 486 let delivery_quorum = self.delivery_quorum.ok_or_else(|| { 487 MycError::InvalidConfig( 488 "transport.delivery_quorum must be set when transport.delivery_policy is `quorum`" 489 .to_owned(), 490 ) 491 })?; 492 if delivery_quorum > relay_count { 493 return Err(MycError::InvalidOperation(format!( 494 "transport.delivery_quorum `{delivery_quorum}` cannot be satisfied by `{relay_count}` target relays" 495 ))); 496 } 497 Ok(delivery_quorum) 498 } 499 } 500 } 501 502 fn backoff_for_attempt(&self, completed_attempt_number: usize) -> u64 { 503 let exponent = completed_attempt_number.saturating_sub(1) as u32; 504 let scaled = self 505 .publish_initial_backoff_millis 506 .saturating_mul(2_u64.saturating_pow(exponent)); 507 scaled.min(self.publish_max_backoff_millis) 508 } 509 } 510 511 #[cfg(test)] 512 mod tests { 513 use std::collections::{HashMap, HashSet}; 514 use std::sync::{Arc, Mutex}; 515 516 use radroots_nostr::prelude::{ 517 RadrootsNostrEventId, RadrootsNostrOutput, RadrootsNostrRelayUrl, 518 }; 519 use tokio::time::Instant; 520 521 use crate::config::{MycTransportConfig, MycTransportDeliveryPolicy}; 522 use crate::custody::MycActiveIdentity; 523 524 use super::{MycNostrTransport, MycPublishSettings, MycTransportSnapshot, publish_with_policy}; 525 526 fn signer_identity() -> MycActiveIdentity { 527 MycActiveIdentity::new( 528 radroots_identity::RadrootsIdentity::from_secret_key_str( 529 "1111111111111111111111111111111111111111111111111111111111111111", 530 ) 531 .expect("identity"), 532 ) 533 } 534 535 #[test] 536 fn bootstrap_returns_none_when_transport_disabled() { 537 let config = MycTransportConfig::default(); 538 539 let transport = 540 MycNostrTransport::bootstrap(&config, &signer_identity()).expect("disabled transport"); 541 542 assert!(transport.is_none()); 543 } 544 545 #[test] 546 fn bootstrap_builds_transport_snapshot_when_enabled() { 547 let mut config = MycTransportConfig::default(); 548 config.enabled = true; 549 config.connect_timeout_secs = 15; 550 config.relays = vec![ 551 "wss://relay.example.com".to_owned(), 552 "wss://relay2.example.com".to_owned(), 553 ]; 554 config.delivery_policy = MycTransportDeliveryPolicy::Quorum; 555 config.delivery_quorum = Some(2); 556 config.publish_max_attempts = 3; 557 config.publish_initial_backoff_millis = 125; 558 config.publish_max_backoff_millis = 500; 559 560 let transport = MycNostrTransport::bootstrap(&config, &signer_identity()) 561 .expect("transport") 562 .expect("enabled transport"); 563 564 assert_eq!(transport.relays().len(), 2); 565 assert_eq!(transport.connect_timeout_secs(), 15); 566 assert_eq!( 567 transport.snapshot(), 568 MycTransportSnapshot { 569 enabled: true, 570 relay_count: 2, 571 connect_timeout_secs: 15, 572 delivery_policy: MycTransportDeliveryPolicy::Quorum, 573 delivery_quorum: Some(2), 574 publish_max_attempts: 3, 575 publish_initial_backoff_millis: 125, 576 publish_max_backoff_millis: 500, 577 } 578 ); 579 } 580 581 #[tokio::test] 582 async fn publish_with_policy_retries_until_threshold_is_met() { 583 let relays = vec![ 584 RadrootsNostrRelayUrl::parse("wss://relay-a.example.com").expect("relay-a"), 585 RadrootsNostrRelayUrl::parse("wss://relay-b.example.com").expect("relay-b"), 586 ]; 587 let settings = MycPublishSettings { 588 delivery_policy: MycTransportDeliveryPolicy::All, 589 delivery_quorum: None, 590 publish_max_attempts: 2, 591 publish_initial_backoff_millis: 10, 592 publish_max_backoff_millis: 10, 593 }; 594 let attempts = Arc::new(Mutex::new(vec![ 595 publish_output( 596 "1111111111111111111111111111111111111111111111111111111111111111", 597 &["wss://relay-a.example.com"], 598 &[("wss://relay-b.example.com", "blocked")], 599 ), 600 publish_output( 601 "2222222222222222222222222222222222222222222222222222222222222222", 602 &["wss://relay-a.example.com", "wss://relay-b.example.com"], 603 &[], 604 ), 605 ])); 606 607 let start = Instant::now(); 608 let outcome = publish_with_policy(&relays, &settings, "test publish", || { 609 let attempts = Arc::clone(&attempts); 610 async move { 611 let output = attempts.lock().expect("attempts lock").remove(0); 612 Ok(output) 613 } 614 }) 615 .await 616 .expect("publish succeeds on retry"); 617 618 assert_eq!(outcome.delivery_policy, MycTransportDeliveryPolicy::All); 619 assert_eq!(outcome.required_acknowledged_relay_count, 2); 620 assert_eq!(outcome.attempt_count, 2); 621 assert_eq!(outcome.acknowledged_relay_count, 2); 622 assert_eq!(outcome.relay_results.len(), 2); 623 assert_eq!(outcome.attempt_summaries.len(), 2); 624 assert!( 625 outcome 626 .relay_outcome_summary 627 .contains("delivery policy all") 628 ); 629 assert!(outcome.relay_outcome_summary.contains("attempt 1")); 630 assert!(start.elapsed() >= std::time::Duration::from_millis(10)); 631 } 632 633 #[tokio::test] 634 async fn publish_with_policy_reports_threshold_failure() { 635 let relays = vec![ 636 RadrootsNostrRelayUrl::parse("wss://relay-a.example.com").expect("relay-a"), 637 RadrootsNostrRelayUrl::parse("wss://relay-b.example.com").expect("relay-b"), 638 ]; 639 let settings = MycPublishSettings { 640 delivery_policy: MycTransportDeliveryPolicy::Quorum, 641 delivery_quorum: Some(2), 642 publish_max_attempts: 2, 643 publish_initial_backoff_millis: 1, 644 publish_max_backoff_millis: 1, 645 }; 646 647 let error = publish_with_policy::<RadrootsNostrEventId, _, _>( 648 &relays, 649 &settings, 650 "test publish", 651 || async { 652 Ok(publish_output( 653 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 654 &["wss://relay-a.example.com"], 655 &[("wss://relay-b.example.com", "blocked")], 656 )) 657 }, 658 ) 659 .await 660 .expect_err("quorum should fail without both acknowledgements"); 661 662 assert_eq!( 663 error.publish_delivery_policy(), 664 Some(MycTransportDeliveryPolicy::Quorum) 665 ); 666 assert_eq!(error.publish_required_acknowledged_relay_count(), Some(2)); 667 assert_eq!(error.publish_attempt_count(), Some(2)); 668 assert!(error.to_string().contains("delivery policy quorum")); 669 } 670 671 #[test] 672 fn publish_settings_reject_impossible_quorum_for_target_relays() { 673 let settings = MycPublishSettings { 674 delivery_policy: MycTransportDeliveryPolicy::Quorum, 675 delivery_quorum: Some(3), 676 publish_max_attempts: 1, 677 publish_initial_backoff_millis: 10, 678 publish_max_backoff_millis: 100, 679 }; 680 681 let error = settings 682 .required_acknowledged_relay_count(2) 683 .expect_err("impossible quorum"); 684 assert!( 685 error 686 .to_string() 687 .contains("cannot be satisfied by `2` target relays") 688 ); 689 } 690 691 fn publish_output( 692 event_id_hex: &str, 693 succeeded_relays: &[&str], 694 failed_relays: &[(&str, &str)], 695 ) -> RadrootsNostrOutput<RadrootsNostrEventId> { 696 let success = succeeded_relays 697 .iter() 698 .map(|relay| RadrootsNostrRelayUrl::parse(*relay).expect("success relay")) 699 .collect::<HashSet<_>>(); 700 let failed = failed_relays 701 .iter() 702 .map(|(relay, error)| { 703 ( 704 RadrootsNostrRelayUrl::parse(*relay).expect("failed relay"), 705 (*error).to_owned(), 706 ) 707 }) 708 .collect::<HashMap<_, _>>(); 709 710 RadrootsNostrOutput { 711 val: RadrootsNostrEventId::parse(event_id_hex).expect("event id"), 712 success, 713 failed, 714 } 715 } 716 }