myc

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

commit 07ca1e0661b18012e9586dd435684b0a579a5666
parent 0bf913ebf05b1283248e2dc078548f03b8fc895d
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:39:17 +0000

discovery: detect conflicted live handler state

- group matching live nip89 handler events by normalized semantic handler state
- surface conflicted live discovery state through fetch and diff outputs instead of selecting one event implicitly
- require --force before refresh publishes over conflicting live handler state and audit that decision
- validate with cargo check --locked cargo test --locked cargo metadata --format-version 1 --no-deps and cargo fmt --check

Diffstat:
Msrc/app/runtime.rs | 3++-
Msrc/audit.rs | 1+
Msrc/cli.rs | 2++
Msrc/discovery.rs | 169++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/lib.rs | 8++++----
5 files changed, 142 insertions(+), 41 deletions(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -310,7 +310,8 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { ), crate::audit::MycOperationAuditOutcome::Rejected | crate::audit::MycOperationAuditOutcome::Restored - | crate::audit::MycOperationAuditOutcome::Drifted => tracing::warn!( + | crate::audit::MycOperationAuditOutcome::Drifted + | crate::audit::MycOperationAuditOutcome::Conflicted => tracing::warn!( operation = ?record.operation, outcome = ?record.outcome, connection_id = record.connection_id.as_deref().unwrap_or(""), diff --git a/src/audit.rs b/src/audit.rs @@ -34,6 +34,7 @@ pub enum MycOperationAuditOutcome { Missing, Matched, Drifted, + Conflicted, Skipped, } diff --git a/src/cli.rs b/src/cli.rs @@ -175,6 +175,7 @@ pub struct MycOperationOutcomeCounts { pub missing: usize, pub matched: usize, pub drifted: usize, + pub conflicted: usize, pub skipped: usize, } @@ -498,6 +499,7 @@ fn increment_outcome_counts( MycOperationAuditOutcome::Missing => counts.missing += 1, MycOperationAuditOutcome::Matched => counts.matched += 1, MycOperationAuditOutcome::Drifted => counts.drifted += 1, + MycOperationAuditOutcome::Conflicted => counts.conflicted += 1, MycOperationAuditOutcome::Skipped => counts.skipped += 1, } } diff --git a/src/discovery.rs b/src/discovery.rs @@ -85,6 +85,7 @@ pub enum MycDiscoveryLiveStatus { Missing, Matched, Drifted, + Conflicted, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -106,19 +107,25 @@ pub struct MycLiveNip89Event { pub handler: MycNormalizedNip89Handler, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MycLiveNip89Group { + pub handler: MycNormalizedNip89Handler, + pub events: Vec<MycLiveNip89Event>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycFetchedLiveNip89Output { pub author_public_key_hex: String, pub publish_relays: Vec<String>, pub handler_identifier: String, - pub live_event: Option<MycLiveNip89Event>, + pub live_groups: Vec<MycLiveNip89Group>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycDiscoveryDiffOutput { pub status: MycDiscoveryLiveStatus, pub local_handler: MycNormalizedNip89Handler, - pub live_event: Option<MycLiveNip89Event>, + pub live_groups: Vec<MycLiveNip89Group>, pub differing_fields: Vec<String>, } @@ -127,7 +134,7 @@ pub struct MycRefreshedNip89Output { pub status: MycDiscoveryLiveStatus, pub force: bool, pub differing_fields: Vec<String>, - pub live_event: Option<MycLiveNip89Event>, + pub live_groups: Vec<MycLiveNip89Group>, pub published: Option<MycPublishedNip89Output>, } @@ -455,7 +462,7 @@ pub async fn publish_nip89_event( pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip89Output, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; - let live_event = fetch_latest_live_nip89_event(&context).await?; + let live_groups = fetch_live_nip89_groups(&context).await?; Ok(MycFetchedLiveNip89Output { author_public_key_hex: context.app_identity().public_key_hex(), publish_relays: context @@ -464,19 +471,19 @@ pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip8 .map(ToString::to_string) .collect(), handler_identifier: context.handler_identifier().to_owned(), - live_event, + live_groups, }) } pub async fn diff_live_nip89(runtime: &MycRuntime) -> Result<MycDiscoveryDiffOutput, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; let local_handler = context.render_normalized_nip89_handler(); - let live_event = fetch_latest_live_nip89_event(&context).await?; - let (status, differing_fields) = compare_live_handler(&local_handler, live_event.as_ref()); + let live_groups = fetch_live_nip89_groups(&context).await?; + let (status, differing_fields) = compare_live_handler(&local_handler, &live_groups); Ok(MycDiscoveryDiffOutput { status, local_handler, - live_event, + live_groups, differing_fields, }) } @@ -487,11 +494,11 @@ pub async fn refresh_nip89( ) -> Result<MycRefreshedNip89Output, MycError> { let context = MycDiscoveryContext::from_runtime(runtime)?; let local_handler = context.render_normalized_nip89_handler(); - let live_event = fetch_latest_live_nip89_event(&context).await?; - let (status, differing_fields) = compare_live_handler(&local_handler, live_event.as_ref()); + let live_groups = fetch_live_nip89_groups(&context).await?; + let (status, differing_fields) = compare_live_handler(&local_handler, &live_groups); let relay_count = context.publish_relays().len(); - let compare_request_id = live_event.as_ref().map(|event| event.event_id_hex.as_str()); - let compare_summary = describe_compare_status(status, &differing_fields); + let compare_request_id = latest_live_event_id(&live_groups); + let compare_summary = describe_compare_status(status, &differing_fields, &live_groups); runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerCompare, @@ -503,6 +510,23 @@ pub async fn refresh_nip89( compare_summary, )); + if status == MycDiscoveryLiveStatus::Conflicted && !force { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::DiscoveryHandlerRefresh, + MycOperationAuditOutcome::Conflicted, + None, + compare_request_id, + relay_count, + relay_count, + "live discovery handler state is conflicted; rerun refresh with --force to override" + .to_owned(), + )); + return Err(MycError::InvalidOperation( + "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override" + .to_owned(), + )); + } + if status == MycDiscoveryLiveStatus::Matched && !force { runtime.record_operation_audit(&MycOperationAuditRecord::new( MycOperationAuditKind::DiscoveryHandlerRefresh, @@ -517,7 +541,7 @@ pub async fn refresh_nip89( status, force, differing_fields, - live_event, + live_groups, published: None, }); } @@ -527,7 +551,7 @@ pub async fn refresh_nip89( status, force, differing_fields, - live_event, + live_groups, published: Some(published), }) } @@ -554,9 +578,9 @@ pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleO Ok(bundle) } -async fn fetch_latest_live_nip89_event( +async fn fetch_live_nip89_groups( context: &MycDiscoveryContext, -) -> Result<Option<MycLiveNip89Event>, MycError> { +) -> Result<Vec<MycLiveNip89Group>, MycError> { let client = RadrootsNostrClient::from_identity(context.app_identity()); for relay in context.publish_relays() { let _ = client.add_relay(relay.as_str()).await?; @@ -581,45 +605,45 @@ async fn fetch_latest_live_nip89_event( .cmp(&right.created_at.as_secs()) .then_with(|| left.id.to_hex().cmp(&right.id.to_hex())) }); - let Some(event) = events.pop() else { - return Ok(None); - }; - - Ok(Some(MycLiveNip89Event { - event_id_hex: event.id.to_hex(), - created_at_unix: event.created_at.as_secs(), - handler: normalize_live_nip89_handler(&event)?, - })) + group_live_nip89_events(events) } fn compare_live_handler( local_handler: &MycNormalizedNip89Handler, - live_event: Option<&MycLiveNip89Event>, + live_groups: &[MycLiveNip89Group], ) -> (MycDiscoveryLiveStatus, Vec<String>) { - let Some(live_event) = live_event else { + if live_groups.is_empty() { return ( MycDiscoveryLiveStatus::Missing, - vec!["live_event".to_owned()], + vec!["live_groups".to_owned()], ); - }; + } + if live_groups.len() > 1 { + return ( + MycDiscoveryLiveStatus::Conflicted, + vec!["live_groups".to_owned()], + ); + } + + let live_group = &live_groups[0]; let mut differing_fields = Vec::new(); - if live_event.handler.author_public_key_hex != local_handler.author_public_key_hex { + if live_group.handler.author_public_key_hex != local_handler.author_public_key_hex { differing_fields.push("author_public_key_hex".to_owned()); } - if live_event.handler.kinds != local_handler.kinds { + if live_group.handler.kinds != local_handler.kinds { differing_fields.push("kinds".to_owned()); } - if live_event.handler.identifier != local_handler.identifier { + if live_group.handler.identifier != local_handler.identifier { differing_fields.push("identifier".to_owned()); } - if live_event.handler.relays != local_handler.relays { + if live_group.handler.relays != local_handler.relays { differing_fields.push("relays".to_owned()); } - if live_event.handler.nostrconnect_url != local_handler.nostrconnect_url { + if live_group.handler.nostrconnect_url != local_handler.nostrconnect_url { differing_fields.push("nostrconnect_url".to_owned()); } - if live_event.handler.metadata != local_handler.metadata { + if live_group.handler.metadata != local_handler.metadata { differing_fields.push("metadata".to_owned()); } @@ -635,10 +659,15 @@ fn compare_status_to_audit_outcome(status: MycDiscoveryLiveStatus) -> MycOperati MycDiscoveryLiveStatus::Missing => MycOperationAuditOutcome::Missing, MycDiscoveryLiveStatus::Matched => MycOperationAuditOutcome::Matched, MycDiscoveryLiveStatus::Drifted => MycOperationAuditOutcome::Drifted, + MycDiscoveryLiveStatus::Conflicted => MycOperationAuditOutcome::Conflicted, } } -fn describe_compare_status(status: MycDiscoveryLiveStatus, differing_fields: &[String]) -> String { +fn describe_compare_status( + status: MycDiscoveryLiveStatus, + differing_fields: &[String], + live_groups: &[MycLiveNip89Group], +) -> String { match status { MycDiscoveryLiveStatus::Missing => { "no live NIP-89 handler was found for the configured discovery identity".to_owned() @@ -650,6 +679,14 @@ fn describe_compare_status(status: MycDiscoveryLiveStatus, differing_fields: &[S "local discovery handler differs from live state in: {}", differing_fields.join(", ") ), + MycDiscoveryLiveStatus::Conflicted => format!( + "found {} conflicting live NIP-89 handler states across {} events", + live_groups.len(), + live_groups + .iter() + .map(|group| group.events.len()) + .sum::<usize>() + ), } } @@ -730,6 +767,66 @@ fn normalize_live_nip89_handler( }) } +fn group_live_nip89_events( + events: Vec<RadrootsNostrEvent>, +) -> Result<Vec<MycLiveNip89Group>, MycError> { + let mut groups = Vec::<MycLiveNip89Group>::new(); + for event in events { + let live_event = MycLiveNip89Event { + event_id_hex: event.id.to_hex(), + created_at_unix: event.created_at.as_secs(), + handler: normalize_live_nip89_handler(&event)?, + }; + if let Some(existing) = groups + .iter_mut() + .find(|group| group.handler == live_event.handler) + { + existing.events.push(live_event); + } else { + groups.push(MycLiveNip89Group { + handler: live_event.handler.clone(), + events: vec![live_event], + }); + } + } + + for group in &mut groups { + group.events.sort_by(|left, right| { + left.created_at_unix + .cmp(&right.created_at_unix) + .then_with(|| left.event_id_hex.cmp(&right.event_id_hex)) + }); + } + + groups.sort_by(|left, right| { + latest_group_sort_key(right) + .cmp(&latest_group_sort_key(left)) + .then_with(|| left.handler.identifier.cmp(&right.handler.identifier)) + .then_with(|| { + left.handler + .author_public_key_hex + .cmp(&right.handler.author_public_key_hex) + }) + }); + + Ok(groups) +} + +fn latest_group_sort_key(group: &MycLiveNip89Group) -> (u64, &str) { + group + .events + .last() + .map(|event| (event.created_at_unix, event.event_id_hex.as_str())) + .unwrap_or((0, "")) +} + +fn latest_live_event_id(live_groups: &[MycLiveNip89Group]) -> Option<&str> { + live_groups + .first() + .and_then(|group| group.events.last()) + .map(|event| event.event_id_hex.as_str()) +} + fn build_metadata(config: &MycDiscoveryMetadataConfig) -> Option<RadrootsNostrMetadata> { let mut metadata = RadrootsNostrMetadata::default(); metadata.name = sanitize_optional_string(config.name.as_deref()); diff --git a/src/lib.rs b/src/lib.rs @@ -24,10 +24,10 @@ pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycFetchedLiveNip89Output, MycLiveNip89Event, - MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument, MycNormalizedNip89Handler, - MycPublishedNip89Output, MycRefreshedNip89Output, MycRenderedNip05Output, - MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89, - render_nip05_output, verify_bundle, + MycLiveNip89Group, 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};