myc

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

commit ad27a00230f6122b5d6afa3c27106e03ccbb28d5
parent a1337b73dde2fcdc25d58d5e4285a0a18d05b431
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 15:38:04 +0000

discovery: target relay refresh repairs

- compute refresh targets from per-relay discovery state instead of publishing to every configured relay
- repair missing and drifted relays selectively while keeping force gates on unavailable or conflicted relay state
- add relay-backed proof that matched relays stay untouched during targeted refresh
- 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/discovery.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/nip46_e2e.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 190 insertions(+), 10 deletions(-)

diff --git a/src/discovery.rs b/src/discovery.rs @@ -467,11 +467,19 @@ pub async fn publish_nip89_event( runtime: &MycRuntime, ) -> Result<MycPublishedNip89Output, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; + publish_nip89_event_to_relays(runtime, &context, context.publish_relays()).await +} + +async fn publish_nip89_event_to_relays( + runtime: &MycRuntime, + context: &MycDiscoveryContext, + relays: &[RadrootsNostrRelayUrl], +) -> Result<MycPublishedNip89Output, MycError> { let event = context.build_signed_handler_event()?; let event_id = event.id.to_hex(); let publish_outcome = match MycNostrTransport::publish_event_once( context.app_identity(), - context.publish_relays(), + relays, context.connect_timeout_secs(), &event, ) @@ -487,7 +495,7 @@ pub async fn publish_nip89_event( error .publish_rejection_counts() .map(|(relay_count, _)| relay_count) - .unwrap_or(context.publish_relays().len()), + .unwrap_or(relays.len()), error .publish_rejection_counts() .map(|(_, acknowledged)| acknowledged) @@ -514,11 +522,7 @@ pub async fn publish_nip89_event( Ok(MycPublishedNip89Output { author_public_key_hex: context.app_identity().public_key_hex(), signer_public_key_hex: context.signer_identity().public_key_hex(), - publish_relays: context - .publish_relays() - .iter() - .map(ToString::to_string) - .collect(), + publish_relays: relays.iter().map(ToString::to_string).collect(), relay_count: publish_outcome.relay_count, acknowledged_relay_count: publish_outcome.acknowledged_relay_count, relay_outcome_summary: publish_outcome.relay_outcome_summary, @@ -605,7 +609,7 @@ pub async fn refresh_nip89( ))); } - if status == MycDiscoveryLiveStatus::Conflicted && !force { + if !relay_summary.conflicted_relays.is_empty() && !force { runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerRefresh, MycOperationAuditOutcome::Conflicted, @@ -622,7 +626,9 @@ pub async fn refresh_nip89( )); } - if status == MycDiscoveryLiveStatus::Matched && !force { + let refresh_relays = select_refresh_relays(&context, &relay_states, force)?; + + if refresh_relays.is_empty() { runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerRefresh, MycOperationAuditOutcome::Skipped, @@ -643,7 +649,7 @@ pub async fn refresh_nip89( }); } - let published = publish_nip89_event(runtime).await?; + let published = publish_nip89_event_to_relays(runtime, &context, &refresh_relays).await?; Ok(MycRefreshedNip89Output { status, force, @@ -655,6 +661,48 @@ pub async fn refresh_nip89( }) } +fn select_refresh_relays( + context: &MycDiscoveryContext, + relay_states: &[MycDiscoveryRelayState], + force: bool, +) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { + if context.publish_relays().len() != relay_states.len() { + return Err(MycError::InvalidOperation( + "discovery relay state count did not match configured publish relay count".to_owned(), + )); + } + + let mut repair_relays = Vec::new(); + let mut matched_relays = Vec::new(); + + for (relay, relay_state) in context.publish_relays().iter().zip(relay_states.iter()) { + if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable { + continue; + } + + match relay_state.live_status { + Some(MycDiscoveryLiveStatus::Missing | MycDiscoveryLiveStatus::Drifted) => { + repair_relays.push(relay.clone()); + } + Some(MycDiscoveryLiveStatus::Conflicted) => { + if force { + repair_relays.push(relay.clone()); + } + } + Some(MycDiscoveryLiveStatus::Matched) => { + matched_relays.push(relay.clone()); + } + None => {} + } + } + + if repair_relays.is_empty() && force { + Ok(matched_relays) + } else { + Ok(repair_relays) + } +} + async fn fetch_live_nip89_state_for_runtime( runtime: &MycRuntime, context: &MycDiscoveryContext, diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -1408,6 +1408,70 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<() } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relays() -> 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"), + )?; + + let matched_event = MycDiscoveryContext::from_runtime(&runtime)? + .build_signed_handler_event() + .expect("matched event"); + publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?; + relay_a + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + + relay_b + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + let refreshed = refresh_nip89(&runtime, false).await?; + let published = refreshed.published.expect("published output"); + + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Matched); + assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); + assert_eq!(published.relay_count, 1); + assert_eq!(published.acknowledged_relay_count, 1); + + relay_b + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + assert_eq!( + relay_a + .published_events_by_author(app_identity.public_key()) + .await + .len(), + 1 + ); + assert_eq!( + relay_b + .published_events_by_author(app_identity.public_key()) + .await + .len(), + 1 + ); + + let diff = diff_live_nip89(&runtime).await?; + assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched); + assert_eq!(diff.relay_summary.matched_relays.len(), 2); + assert!(diff.relay_summary.missing_relays.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> { let relay = TestRelay::spawn().await?; let test_runtime = @@ -1511,6 +1575,74 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_match() +-> 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"), + )?; + + let matched_event = MycDiscoveryContext::from_runtime(&runtime)? + .build_signed_handler_event() + .expect("matched event"); + publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?; + + let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]); + drifted_spec.identifier = Some("myc".to_owned()); + drifted_spec.relays = vec!["wss://stale.example.com".to_owned()]; + publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?; + + relay_a + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + relay_b + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + + relay_b + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + let refreshed = refresh_nip89(&runtime, false).await?; + let published = refreshed.published.expect("published output"); + + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Conflicted); + assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]); + assert_eq!(published.relay_count, 1); + assert_eq!(published.acknowledged_relay_count, 1); + + relay_b + .wait_for_published_events_by_author(app_identity.public_key(), 2) + .await?; + assert_eq!( + relay_a + .published_events_by_author(app_identity.public_key()) + .await + .len(), + 1 + ); + assert_eq!( + relay_b + .published_events_by_author(app_identity.public_key()) + .await + .len(), + 2 + ); + + 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 =