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:
| M | src/discovery.rs | | | 68 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- |
| M | tests/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 =