myc

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

commit 05960f02354cff20a71159e491482a8dc9719397
parent 92d0edf6f4f802070d1678e621996df6007e8f1b
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 16:38:00 +0000

error: surface refresh attempt ids on failure

Diffstat:
Msrc/discovery.rs | 36+++++++++++++++++++++++-------------
Msrc/error.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 6++++++
Mtests/discovery_cli.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 226 insertions(+), 13 deletions(-)

diff --git a/src/discovery.rs b/src/discovery.rs @@ -639,9 +639,12 @@ pub async fn refresh_nip89( return Err(MycError::DiscoveryFetchUnavailable { relay_count, details, - }); + } + .with_discovery_refresh_attempt_id(attempt_id)); + } + Err(error) => { + return Err(error.with_discovery_refresh_attempt_id(attempt_id)); } - Err(error) => return Err(error), }; let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states); let relay_summary = summarize_relay_diffs(&relay_states); @@ -681,10 +684,13 @@ pub async fn refresh_nip89( ) .with_attempt_id(attempt_id.clone()), ); - return Err(MycError::InvalidOperation(format!( - "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}", - relay_summary.unavailable_relays.join(", ") - ))); + return Err( + MycError::InvalidOperation(format!( + "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}", + relay_summary.unavailable_relays.join(", ") + )) + .with_discovery_refresh_attempt_id(attempt_id), + ); } if !relay_summary.conflicted_relays.is_empty() && !force { @@ -701,13 +707,17 @@ pub async fn refresh_nip89( ) .with_attempt_id(attempt_id.clone()), ); - return Err(MycError::InvalidOperation( - "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override" - .to_owned(), - )); + return Err( + MycError::InvalidOperation( + "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override" + .to_owned(), + ) + .with_discovery_refresh_attempt_id(attempt_id), + ); } - let refresh_relays = select_refresh_relays(&context, &relay_states, force)?; + let refresh_relays = select_refresh_relays(&context, &relay_states, force) + .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?; if refresh_relays.is_empty() { let repair_results = build_repair_results(&context, &relay_states, &[], None, None); @@ -828,9 +838,9 @@ pub async fn refresh_nip89( repair_summary.skipped ), ) - .with_attempt_id(attempt_id), + .with_attempt_id(attempt_id.clone()), ); - return Err(error); + return Err(error.with_discovery_refresh_attempt_id(attempt_id)); } } } diff --git a/src/error.rs b/src/error.rs @@ -77,6 +77,12 @@ pub enum MycError { "failed to fetch discovery state from all configured relays ({relay_count}): {details}" )] DiscoveryFetchUnavailable { relay_count: usize, details: String }, + #[error("discovery refresh attempt {attempt_id} failed: {source}")] + DiscoveryRefreshFailed { + attempt_id: String, + #[source] + source: Box<MycError>, + }, #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] @@ -113,9 +119,27 @@ pub enum MycError { } impl MycError { + pub fn with_discovery_refresh_attempt_id(self, attempt_id: impl Into<String>) -> Self { + match self { + Self::DiscoveryRefreshFailed { .. } => self, + source => Self::DiscoveryRefreshFailed { + attempt_id: attempt_id.into(), + source: Box::new(source), + }, + } + } + + pub fn discovery_refresh_attempt_id(&self) -> Option<&str> { + match self { + Self::DiscoveryRefreshFailed { attempt_id, .. } => Some(attempt_id.as_str()), + _ => None, + } + } + pub fn publish_rejection_details(&self) -> Option<&str> { match self { Self::PublishRejected { details, .. } => Some(details.as_str()), + Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_details(), _ => None, } } @@ -127,6 +151,7 @@ impl MycError { acknowledged_relay_count, .. } => Some((*relay_count, *acknowledged_relay_count)), + Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_counts(), _ => None, } } @@ -136,7 +161,36 @@ impl MycError { Self::PublishRejected { rejected_relays, .. } => Some(rejected_relays.as_slice()), + Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejected_relays(), _ => None, } } } + +#[cfg(test)] +mod tests { + use super::MycError; + + #[test] + fn discovery_refresh_wrapper_preserves_attempt_id_and_publish_details() { + let wrapped = MycError::PublishRejected { + operation: "discovery refresh".to_owned(), + relay_count: 2, + acknowledged_relay_count: 0, + details: "relay-a: blocked".to_owned(), + rejected_relays: vec!["wss://relay-a.example.com".to_owned()], + } + .with_discovery_refresh_attempt_id("attempt-1"); + + assert_eq!(wrapped.discovery_refresh_attempt_id(), Some("attempt-1")); + assert_eq!( + wrapped.publish_rejection_details(), + Some("relay-a: blocked") + ); + assert_eq!(wrapped.publish_rejection_counts(), Some((2, 0))); + assert_eq!( + wrapped.publish_rejected_relays(), + Some(["wss://relay-a.example.com".to_owned()].as_slice()) + ); + } +} diff --git a/src/main.rs b/src/main.rs @@ -4,6 +4,12 @@ async fn main() { if let Err(err) = myc::run_cli().await { eprintln!("myc: {err}"); + if let Some(attempt_id) = err.discovery_refresh_attempt_id() { + eprintln!("myc: discovery repair attempt id: {attempt_id}"); + eprintln!( + "myc: inspect with `myc audit discovery-repair-attempt --attempt-id {attempt_id}`" + ); + } std::process::exit(1); } } diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -381,6 +381,12 @@ fn run_myc(config_path: &Path, args: &[&str]) -> TestResult<Output> { .output()?) } +fn extract_discovery_attempt_id(stderr: &str) -> Option<&str> { + stderr + .lines() + .find_map(|line| line.strip_prefix("myc: discovery repair attempt id: ")) +} + fn unavailable_relay_url() -> TestResult<String> { let listener = StdTcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?; @@ -686,6 +692,31 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { "unexpected refresh stderr: {}", String::from_utf8_lossy(&refresh.stderr) ); + let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); + let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + let attempt = run_myc( + &config_path, + &[ + "audit", + "discovery-repair-attempt", + "--attempt-id", + attempt_id, + ], + )?; + assert!( + attempt.status.success(), + "discovery-repair-attempt failed: {}", + String::from_utf8_lossy(&attempt.stderr) + ); + let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; + assert_eq!( + attempt_output["attempt_id"], + Value::String(attempt_id.to_owned()) + ); + assert_eq!( + attempt_output["refresh_outcome"], + Value::String("conflicted".to_owned()) + ); let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; assert!( @@ -803,6 +834,93 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let temp = tempfile::tempdir()?; + let config_path = temp.path().join("config.toml"); + let state_dir = temp.path().join("state"); + let signer_identity_path = temp.path().join("signer.json"); + let user_identity_path = temp.path().join("user.json"); + let app_identity_path = temp.path().join("app.json"); + let app_identity = RadrootsIdentity::from_secret_key_str( + "3333333333333333333333333333333333333333333333333333333333333333", + )?; + + write_identity( + &signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + app_identity.save_json(&app_identity_path)?; + write_config( + &config_path, + &state_dir, + &signer_identity_path, + &user_identity_path, + &app_identity_path, + &[relay.url()], + ); + + relay + .queue_publish_outcomes(app_identity.public_key(), &[false]) + .await; + + let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + assert!( + !refresh.status.success(), + "refresh-nip89 unexpectedly succeeded: {}", + String::from_utf8_lossy(&refresh.stdout) + ); + let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); + assert!( + refresh_stderr.contains("Nostr publish failed"), + "unexpected refresh stderr: {refresh_stderr}" + ); + let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + + let attempt = run_myc( + &config_path, + &[ + "audit", + "discovery-repair-attempt", + "--attempt-id", + attempt_id, + ], + )?; + assert!( + attempt.status.success(), + "discovery-repair-attempt failed: {}", + String::from_utf8_lossy(&attempt.stderr) + ); + let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; + assert_eq!( + attempt_output["attempt_id"], + Value::String(attempt_id.to_owned()) + ); + assert_eq!( + attempt_output["refresh_outcome"], + Value::String("rejected".to_owned()) + ); + assert_eq!( + attempt_output["aggregate_publish_outcome"], + Value::String("rejected".to_owned()) + ); + assert_eq!( + attempt_output["repair_summary"]["failed"], + Value::from(1_u64) + ); + assert_eq!( + attempt_output["remaining_repair_relays"], + Value::Array(vec![Value::String(relay.url().to_owned())]) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> TestResult<()> { let relay_a = TestRelay::spawn().await?; let relay_b = TestRelay::spawn().await?; @@ -1189,6 +1307,31 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th "unexpected refresh stderr: {}", String::from_utf8_lossy(&refresh.stderr) ); + let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); + let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); + let attempt = run_myc( + &config_path, + &[ + "audit", + "discovery-repair-attempt", + "--attempt-id", + attempt_id, + ], + )?; + assert!( + attempt.status.success(), + "discovery-repair-attempt failed: {}", + String::from_utf8_lossy(&attempt.stderr) + ); + let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; + assert_eq!( + attempt_output["attempt_id"], + Value::String(attempt_id.to_owned()) + ); + assert_eq!( + attempt_output["refresh_outcome"], + Value::String("unavailable".to_owned()) + ); let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; assert!(