myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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 }