myc

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

commit 176f6899095595cb1413188df8a135a327f19b9c
parent aff65bf83ae026dde491e378cc6b5b3d86bae3d9
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 15:54:14 +0000

discovery: track targeted repair outcomes

- preserve per-relay publish results through the transport and discovery output models
- report targeted refresh repair_results and remaining_repair_relays with per-relay repair audit records
- add relay-backed proof for mixed-success targeted refresh while updating existing refresh expectations
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, cargo check --locked, and cargo test --locked on an isolated target dir

Diffstat:
Msrc/app/runtime.rs | 3+++
Msrc/audit.rs | 9+++++++++
Msrc/cli.rs | 1+
Msrc/discovery.rs | 198++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/error.rs | 10++++++++++
Msrc/lib.rs | 13+++++++------
Msrc/transport.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/transport/nip46.rs | 7+++++--
Mtests/nip46_e2e.rs | 190++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
9 files changed, 490 insertions(+), 41 deletions(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -238,6 +238,7 @@ impl MycSignerContext { tracing::error!( operation = ?record.operation, outcome = ?record.outcome, + relay_url = record.relay_url.as_deref().unwrap_or(""), connection_id = record.connection_id.as_deref().unwrap_or(""), request_id = record.request_id.as_deref().unwrap_or(""), relay_count = record.relay_count, @@ -301,6 +302,7 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { | crate::audit::MycOperationAuditOutcome::Skipped => tracing::info!( operation = ?record.operation, outcome = ?record.outcome, + relay_url = record.relay_url.as_deref().unwrap_or(""), connection_id = record.connection_id.as_deref().unwrap_or(""), request_id = record.request_id.as_deref().unwrap_or(""), relay_count = record.relay_count, @@ -315,6 +317,7 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { | crate::audit::MycOperationAuditOutcome::Conflicted => tracing::warn!( operation = ?record.operation, outcome = ?record.outcome, + relay_url = record.relay_url.as_deref().unwrap_or(""), connection_id = record.connection_id.as_deref().unwrap_or(""), request_id = record.request_id.as_deref().unwrap_or(""), relay_count = record.relay_count, diff --git a/src/audit.rs b/src/audit.rs @@ -24,6 +24,7 @@ pub enum MycOperationAuditKind { DiscoveryHandlerPublish, DiscoveryHandlerCompare, DiscoveryHandlerRefresh, + DiscoveryHandlerRepair, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -46,6 +47,8 @@ pub struct MycOperationAuditRecord { pub operation: MycOperationAuditKind, pub outcome: MycOperationAuditOutcome, #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay_url: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub connection_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub request_id: Option<String>, @@ -74,6 +77,7 @@ impl MycOperationAuditRecord { recorded_at_unix: now_unix_secs(), operation, outcome, + relay_url: None, connection_id: connection_id.map(ToString::to_string), request_id: request_id.map(ToOwned::to_owned), relay_count, @@ -81,6 +85,11 @@ impl MycOperationAuditRecord { relay_outcome_summary: relay_outcome_summary.into(), } } + + pub fn with_relay_url(mut self, relay_url: impl Into<String>) -> Self { + self.relay_url = Some(relay_url.into()); + self + } } impl MycOperationAuditStore { diff --git a/src/cli.rs b/src/cli.rs @@ -522,6 +522,7 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String { MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(), MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare".to_owned(), MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh".to_owned(), + MycOperationAuditKind::DiscoveryHandlerRepair => "discovery_handler_repair".to_owned(), } } diff --git a/src/discovery.rs b/src/discovery.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -18,7 +18,7 @@ use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::MycDiscoveryMetadataConfig; use crate::error::MycError; -use crate::transport::MycNostrTransport; +use crate::transport::{MycNostrTransport, MycRelayPublishResult}; const NIP46_RPC_KIND: u32 = 24_133; const DISCOVERY_BUNDLE_VERSION: u32 = 1; @@ -78,9 +78,27 @@ pub struct MycPublishedNip89Output { pub relay_count: usize, pub acknowledged_relay_count: usize, pub relay_outcome_summary: String, + pub relay_results: Vec<MycRelayPublishResult>, pub event: RadrootsNostrEvent, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycDiscoveryRepairOutcome { + Repaired, + Failed, + Unchanged, + Skipped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycDiscoveryRelayRepairResult { + pub relay_url: String, + pub outcome: MycDiscoveryRepairOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option<String>, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MycDiscoveryLiveStatus { @@ -182,6 +200,8 @@ pub struct MycRefreshedNip89Output { pub live_groups: Vec<MycLiveNip89Group>, pub relay_states: Vec<MycDiscoveryRelayState>, pub relay_summary: MycDiscoveryRelaySummary, + pub repair_results: Vec<MycDiscoveryRelayRepairResult>, + pub remaining_repair_relays: Vec<String>, pub published: Option<MycPublishedNip89Output>, } @@ -526,6 +546,7 @@ async fn publish_nip89_event_to_relays( relay_count: publish_outcome.relay_count, acknowledged_relay_count: publish_outcome.acknowledged_relay_count, relay_outcome_summary: publish_outcome.relay_outcome_summary, + relay_results: publish_outcome.relay_results, event, }) } @@ -629,6 +650,7 @@ pub async fn refresh_nip89( let refresh_relays = select_refresh_relays(&context, &relay_states, force)?; if refresh_relays.is_empty() { + let repair_results = build_repair_results(&context, &relay_states, &[], None, None); runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerRefresh, MycOperationAuditOutcome::Skipped, @@ -645,20 +667,46 @@ pub async fn refresh_nip89( live_groups, relay_states, relay_summary, + repair_results, + remaining_repair_relays: Vec::new(), published: None, }); } - let published = publish_nip89_event_to_relays(runtime, &context, &refresh_relays).await?; - Ok(MycRefreshedNip89Output { - status, - force, - differing_fields, - live_groups, - relay_states, - relay_summary, - published: Some(published), - }) + match publish_nip89_event_to_relays(runtime, &context, &refresh_relays).await { + Ok(published) => { + let repair_results = build_repair_results( + &context, + &relay_states, + &refresh_relays, + Some(published.relay_results.as_slice()), + None, + ); + record_refresh_repair_audit( + runtime, + Some(published.event.id.to_hex()), + &repair_results, + ); + let remaining_repair_relays = remaining_repair_relays(&repair_results); + return Ok(MycRefreshedNip89Output { + status, + force, + differing_fields, + live_groups, + relay_states, + relay_summary, + repair_results, + remaining_repair_relays, + published: Some(published), + }); + } + Err(error) => { + let repair_results = + build_repair_results(&context, &relay_states, &refresh_relays, None, Some(&error)); + record_refresh_repair_audit(runtime, None, &repair_results); + return Err(error); + } + } } fn select_refresh_relays( @@ -703,6 +751,132 @@ fn select_refresh_relays( } } +fn build_repair_results( + context: &MycDiscoveryContext, + relay_states: &[MycDiscoveryRelayState], + refresh_relays: &[RadrootsNostrRelayUrl], + publish_results: Option<&[MycRelayPublishResult]>, + publish_error: Option<&MycError>, +) -> Vec<MycDiscoveryRelayRepairResult> { + let selected_relays = refresh_relays + .iter() + .map(ToString::to_string) + .collect::<BTreeSet<_>>(); + let publish_results_by_relay = publish_results + .unwrap_or_default() + .iter() + .map(|result| (result.relay_url.clone(), result)) + .collect::<BTreeMap<_, _>>(); + let rejected_relays = publish_error + .and_then(MycError::publish_rejected_relays) + .unwrap_or_default() + .iter() + .cloned() + .collect::<BTreeSet<_>>(); + + context + .publish_relays() + .iter() + .zip(relay_states.iter()) + .map(|(relay, relay_state)| { + let relay_url = relay.to_string(); + if selected_relays.contains(&relay_url) { + if let Some(result) = publish_results_by_relay.get(&relay_url) { + return MycDiscoveryRelayRepairResult { + relay_url, + outcome: if result.acknowledged { + MycDiscoveryRepairOutcome::Repaired + } else { + MycDiscoveryRepairOutcome::Failed + }, + detail: result.detail.clone(), + }; + } + + if rejected_relays.contains(&relay_url) { + return MycDiscoveryRelayRepairResult { + relay_url, + outcome: MycDiscoveryRepairOutcome::Failed, + detail: Some( + publish_error + .and_then(MycError::publish_rejection_details) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "targeted refresh publish failed".to_owned()), + ), + }; + } + + return MycDiscoveryRelayRepairResult { + relay_url, + outcome: MycDiscoveryRepairOutcome::Failed, + detail: Some("no relay publish result was reported".to_owned()), + }; + } + + if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { + return MycDiscoveryRelayRepairResult { + relay_url, + outcome: MycDiscoveryRepairOutcome::Skipped, + detail: relay_state.fetch_error.clone(), + }; + } + + match relay_state.live_status { + Some(MycDiscoveryLiveStatus::Matched) => MycDiscoveryRelayRepairResult { + relay_url, + outcome: MycDiscoveryRepairOutcome::Unchanged, + detail: None, + }, + _ => MycDiscoveryRelayRepairResult { + relay_url, + outcome: MycDiscoveryRepairOutcome::Skipped, + detail: None, + }, + } + }) + .collect() +} + +fn remaining_repair_relays(repair_results: &[MycDiscoveryRelayRepairResult]) -> Vec<String> { + repair_results + .iter() + .filter(|result| result.outcome == MycDiscoveryRepairOutcome::Failed) + .map(|result| result.relay_url.clone()) + .collect() +} + +fn record_refresh_repair_audit( + runtime: &MycRuntime, + request_id: Option<String>, + repair_results: &[MycDiscoveryRelayRepairResult], +) { + for result in repair_results { + let Some((outcome, acknowledged_relay_count)) = (match result.outcome { + MycDiscoveryRepairOutcome::Repaired => Some((MycOperationAuditOutcome::Succeeded, 1)), + MycDiscoveryRepairOutcome::Failed => Some((MycOperationAuditOutcome::Rejected, 0)), + MycDiscoveryRepairOutcome::Unchanged | MycDiscoveryRepairOutcome::Skipped => None, + }) else { + continue; + }; + + runtime.record_operation_audit( + &MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerRepair, + outcome, + None, + request_id.as_deref(), + 1, + acknowledged_relay_count, + result + .detail + .clone() + .unwrap_or_else(|| result.relay_url.clone()), + ) + .with_relay_url(result.relay_url.clone()), + ); + } +} + async fn fetch_live_nip89_state_for_runtime( runtime: &MycRuntime, context: &MycDiscoveryContext, diff --git a/src/error.rs b/src/error.rs @@ -99,6 +99,7 @@ pub enum MycError { relay_count: usize, acknowledged_relay_count: usize, details: String, + rejected_relays: Vec<String>, }, #[error( "configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}" @@ -129,4 +130,13 @@ impl MycError { _ => None, } } + + pub fn publish_rejected_relays(&self) -> Option<&[String]> { + match self { + Self::PublishRejected { + rejected_relays, .. + } => Some(rejected_relays.as_slice()), + _ => None, + } + } } diff --git a/src/lib.rs b/src/lib.rs @@ -24,14 +24,15 @@ pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus, - MycDiscoveryRelayState, MycDiscoveryRelaySummary, MycFetchedLiveNip89Output, MycLiveNip89Event, - MycLiveNip89Group, MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection, - MycNip89HandlerDocument, MycNormalizedNip89Handler, MycPublishedNip89Output, - MycRefreshedNip89Output, MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89, - fetch_live_nip89, publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle, + MycDiscoveryRelayRepairResult, MycDiscoveryRelayState, MycDiscoveryRelaySummary, + MycDiscoveryRepairOutcome, MycFetchedLiveNip89Output, MycLiveNip89Event, MycLiveNip89Group, + MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument, + MycNormalizedNip89Handler, MycPublishedNip89Output, MycRefreshedNip89Output, + MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, + publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle, }; pub use error::MycError; -pub use transport::{MycNostrTransport, MycTransportSnapshot}; +pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; pub async fn run() -> Result<(), MycError> { let config = MycConfig::load_from_default_path_if_exists()?; diff --git a/src/transport.rs b/src/transport.rs @@ -1,5 +1,6 @@ pub mod nip46; +use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; use radroots_identity::RadrootsIdentity; @@ -7,6 +8,7 @@ use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrOutput, RadrootsNostrRelayUrl, }; +use serde::Serialize; use crate::config::MycTransportConfig; use crate::error::MycError; @@ -32,6 +34,15 @@ pub struct MycPublishOutcome { pub relay_count: usize, pub acknowledged_relay_count: usize, pub relay_outcome_summary: String, + pub relay_results: Vec<MycRelayPublishResult>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycRelayPublishResult { + pub relay_url: String, + pub acknowledged: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option<String>, } impl MycNostrTransport { @@ -94,7 +105,7 @@ impl MycNostrTransport { .wait_for_connection(Duration::from_secs(connect_timeout_secs)) .await; let output = client.send_event_builder(event).await?; - ensure_publish_confirmed(output, "one-shot Nostr publish") + ensure_publish_confirmed(relays, output, "one-shot Nostr publish") } pub async fn publish_event_once( @@ -118,7 +129,7 @@ impl MycNostrTransport { .wait_for_connection(Duration::from_secs(connect_timeout_secs)) .await; let output = client.send_event(event).await?; - ensure_publish_confirmed(output, "one-shot Nostr publish") + ensure_publish_confirmed(relays, output, "one-shot Nostr publish") } pub fn snapshot(&self) -> MycTransportSnapshot { @@ -131,21 +142,27 @@ impl MycNostrTransport { } pub(crate) fn ensure_publish_confirmed<T>( + relays: &[RadrootsNostrRelayUrl], output: RadrootsNostrOutput<T>, operation: &str, ) -> Result<MycPublishOutcome, MycError> where T: std::fmt::Debug, { - let relay_count = output.success.len() + output.failed.len(); - let acknowledged_relay_count = output.success.len(); - let relay_outcome_summary = summarize_publish_output(&output); + let relay_results = build_publish_relay_results(relays, &output); + let relay_count = relay_results.len(); + let acknowledged_relay_count = relay_results + .iter() + .filter(|result| result.acknowledged) + .count(); + let relay_outcome_summary = summarize_publish_results(&relay_results); if !output.success.is_empty() { return Ok(MycPublishOutcome { relay_count, acknowledged_relay_count, relay_outcome_summary, + relay_results, }); } @@ -154,30 +171,81 @@ where relay_count, acknowledged_relay_count, details: relay_outcome_summary, + rejected_relays: relay_results + .iter() + .filter(|result| !result.acknowledged) + .map(|result| result.relay_url.clone()) + .collect(), }) } -fn summarize_publish_output<T>(output: &RadrootsNostrOutput<T>) -> String +fn build_publish_relay_results<T>( + relays: &[RadrootsNostrRelayUrl], + output: &RadrootsNostrOutput<T>, +) -> Vec<MycRelayPublishResult> where T: std::fmt::Debug, { - let relay_count = output.success.len() + output.failed.len(); - let acknowledged_relay_count = output.success.len(); + let acknowledged_relays = output + .success + .iter() + .map(ToString::to_string) + .collect::<BTreeSet<_>>(); + let failed_relays = output + .failed + .iter() + .map(|(relay, error)| (relay.to_string(), error.to_string())) + .collect::<BTreeMap<_, _>>(); + + relays + .iter() + .map(|relay| { + let relay_url = relay.to_string(); + if acknowledged_relays.contains(&relay_url) { + MycRelayPublishResult { + relay_url, + acknowledged: true, + detail: None, + } + } else { + MycRelayPublishResult { + relay_url: relay_url.clone(), + acknowledged: false, + detail: Some( + failed_relays + .get(&relay_url) + .cloned() + .unwrap_or_else(|| "no relay acknowledgement reported".to_owned()), + ), + } + } + }) + .collect() +} + +fn summarize_publish_results(relay_results: &[MycRelayPublishResult]) -> String { + let relay_count = relay_results.len(); + let acknowledged_relay_count = relay_results + .iter() + .filter(|result| result.acknowledged) + .count(); if relay_count == 0 { return "no relay acknowledged the publish".to_owned(); } let mut summary = format!("{acknowledged_relay_count}/{relay_count} relays acknowledged publish"); - if !output.failed.is_empty() { - let failures = output - .failed - .iter() - .map(|(relay, error)| format!("{relay}: {error}")) - .collect::<Vec<_>>() - .join("; "); + let failures = relay_results + .iter() + .filter(|result| !result.acknowledged) + .map(|result| match result.detail.as_deref() { + Some(detail) => format!("{}: {detail}", result.relay_url), + None => result.relay_url.clone(), + }) + .collect::<Vec<_>>(); + if !failures.is_empty() { summary.push_str("; failures: "); - summary.push_str(&failures); + summary.push_str(&failures.join("; ")); } summary } diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -518,8 +518,11 @@ impl MycNip46Service { continue; } }; - if let Err(error) = ensure_publish_confirmed(publish_output, "NIP-46 response publish") - { + if let Err(error) = ensure_publish_confirmed( + self.transport.relays(), + publish_output, + "NIP-46 response publish", + ) { self.handler .signer .record_operation_audit(&MycOperationAuditRecord::new( diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -7,9 +7,9 @@ use futures_util::{SinkExt, StreamExt}; use myc::control; use myc::{ MycConfig, MycConnectionApproval, MycDiscoveryContext, MycDiscoveryLiveStatus, - MycDiscoveryRelayFetchStatus, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, MycRuntime, diff_live_nip89, fetch_live_nip89, publish_nip89_event, - refresh_nip89, + MycDiscoveryRelayFetchStatus, MycDiscoveryRepairOutcome, MycOperationAuditKind, + MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, diff_live_nip89, + fetch_live_nip89, publish_nip89_event, refresh_nip89, }; use nostr::filter::MatchEventOptions; use nostr::nips::nip44; @@ -1391,8 +1391,14 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<() assert_eq!(refreshed.differing_fields, vec!["live_groups".to_owned()]); assert!(refreshed.live_groups.is_empty()); assert!(refreshed.published.is_some()); + assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); + assert_eq!(refreshed.repair_results.len(), 1); + assert_eq!( + refreshed.repair_results[0].outcome, + MycDiscoveryRepairOutcome::Repaired + ); - let audit = wait_for_operation_audit_count(&runtime, 2).await?; + let audit = wait_for_operation_audit_count(&runtime, 3).await?; assert_eq!( audit[0].operation, MycOperationAuditKind::DiscoveryHandlerCompare @@ -1403,6 +1409,11 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<() MycOperationAuditKind::DiscoveryHandlerPublish ); assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); + assert_eq!( + audit[2].operation, + MycOperationAuditKind::DiscoveryHandlerRepair + ); + assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded); Ok(()) } @@ -1444,6 +1455,26 @@ async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relay assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); assert_eq!(published.relay_count, 1); assert_eq!(published.acknowledged_relay_count, 1); + assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); + assert_eq!(refreshed.repair_results.len(), 2); + assert_eq!( + refreshed + .repair_results + .iter() + .find(|result| result.relay_url == relay_a.url()) + .expect("matched relay repair result") + .outcome, + MycDiscoveryRepairOutcome::Unchanged + ); + assert_eq!( + refreshed + .repair_results + .iter() + .find(|result| result.relay_url == relay_b.url()) + .expect("repaired relay result") + .outcome, + MycDiscoveryRepairOutcome::Repaired + ); relay_b .wait_for_published_events_by_author(app_identity.public_key(), 1) @@ -1500,6 +1531,12 @@ async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> { assert!(refreshed.differing_fields.is_empty()); assert_eq!(refreshed.live_groups.len(), 1); assert!(refreshed.published.is_none()); + assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); + assert_eq!(refreshed.repair_results.len(), 1); + assert_eq!( + refreshed.repair_results[0].outcome, + MycDiscoveryRepairOutcome::Unchanged + ); let audit = wait_for_operation_audit_count(&runtime, 3).await?; assert_eq!( @@ -1552,6 +1589,12 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Drifted); assert_eq!(refreshed.live_groups.len(), 1); assert!(refreshed.published.is_some()); + assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); + assert_eq!(refreshed.repair_results.len(), 1); + assert_eq!( + refreshed.repair_results[0].outcome, + MycDiscoveryRepairOutcome::Repaired + ); assert!( refreshed .differing_fields @@ -1559,7 +1602,7 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> .any(|field| field == "relays" || field == "nostrconnect_url" || field == "metadata") ); - let audit = wait_for_operation_audit_count(&runtime, 2).await?; + let audit = wait_for_operation_audit_count(&runtime, 3).await?; assert_eq!( audit[0].operation, MycOperationAuditKind::DiscoveryHandlerCompare @@ -1570,6 +1613,11 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> MycOperationAuditKind::DiscoveryHandlerPublish ); assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); + assert_eq!( + audit[2].operation, + MycOperationAuditKind::DiscoveryHandlerRepair + ); + assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded); Ok(()) } @@ -1620,6 +1668,26 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); assert_eq!(published.relay_count, 1); assert_eq!(published.acknowledged_relay_count, 1); + assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new()); + assert_eq!(refreshed.repair_results.len(), 2); + assert_eq!( + refreshed + .repair_results + .iter() + .find(|result| result.relay_url == relay_a.url()) + .expect("matched relay result") + .outcome, + MycDiscoveryRepairOutcome::Unchanged + ); + assert_eq!( + refreshed + .repair_results + .iter() + .find(|result| result.relay_url == relay_b.url()) + .expect("repaired relay result") + .outcome, + MycDiscoveryRepairOutcome::Repaired + ); relay_b .wait_for_published_events_by_author(app_identity.public_key(), 2) @@ -1643,6 +1711,118 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_reports_remaining_relays_after_mixed_targeted_repair() -> TestResult<()> { + let relay_a = TestRelay::spawn().await?; + let relay_b = TestRelay::spawn().await?; + let test_runtime = MycTestRuntime::new_with_discovery_relays( + &[relay_a.url(), relay_b.url()], + MycConnectionApproval::ExplicitUser, + ); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + relay_a + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + relay_b + .queue_publish_outcomes(app_identity.public_key(), &[false]) + .await; + + let refreshed = refresh_nip89(&runtime, false).await?; + let published = refreshed.published.expect("published output"); + + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing); + assert_eq!( + published.publish_relays, + vec![relay_a.url().to_owned(), relay_b.url().to_owned()] + ); + assert_eq!(published.relay_count, 2); + assert_eq!(published.acknowledged_relay_count, 1); + assert_eq!(published.relay_results.len(), 2); + assert_eq!(refreshed.repair_results.len(), 2); + assert_eq!( + refreshed.remaining_repair_relays, + vec![relay_b.url().to_owned()] + ); + + let repaired = refreshed + .repair_results + .iter() + .find(|result| result.relay_url == relay_a.url()) + .expect("repaired relay result"); + assert_eq!(repaired.outcome, MycDiscoveryRepairOutcome::Repaired); + + let failed = refreshed + .repair_results + .iter() + .find(|result| result.relay_url == relay_b.url()) + .expect("failed relay result"); + assert_eq!(failed.outcome, MycDiscoveryRepairOutcome::Failed); + assert!( + failed + .detail + .as_deref() + .unwrap_or_default() + .contains("blocked by test relay") + ); + + relay_a + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + assert_eq!( + relay_b + .published_events_by_author(app_identity.public_key()) + .await + .len(), + 0 + ); + + let diff = diff_live_nip89(&runtime).await?; + assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched); + assert_eq!( + diff.relay_summary.matched_relays, + vec![relay_a.url().to_owned()] + ); + assert_eq!( + diff.relay_summary.missing_relays, + vec![relay_b.url().to_owned()] + ); + + let audit = wait_for_operation_audit_count(&runtime, 4).await?; + assert_eq!( + audit[0].operation, + MycOperationAuditKind::DiscoveryHandlerCompare + ); + assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Missing); + assert_eq!( + audit[1].operation, + MycOperationAuditKind::DiscoveryHandlerPublish + ); + assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); + assert_eq!( + audit[2].operation, + MycOperationAuditKind::DiscoveryHandlerRepair + ); + assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded); + assert_eq!(audit[2].relay_url.as_deref(), Some(relay_a.url())); + assert_eq!( + audit[3].operation, + MycOperationAuditKind::DiscoveryHandlerRepair + ); + assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Rejected); + assert_eq!(audit[3].relay_url.as_deref(), Some(relay_b.url())); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn diff_live_nip89_reports_conflicted_when_live_groups_disagree() -> TestResult<()> { let relay = TestRelay::spawn().await?; let test_runtime =