commit 3f7964c064e12aea3eb736547822abcfbdb66d0f
parent 99317d2f2c676f3186a6a5dae1aee2836b23be23
Author: triesap <tyson@radroots.org>
Date: Thu, 7 May 2026 04:29:15 +0000
cli: expose listing relay delivery truth
- carry connected relay state through direct publish receipts
- return structured listing relay failure detail
- surface connected relays in listing mutation output
- cover listing relay failure detail in process tests
Diffstat:
7 files changed, 237 insertions(+), 15 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -835,11 +835,13 @@ pub struct FarmPublishComponentView {
pub rpc_method: String,
pub event_kind: u32,
pub deduplicated: bool,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
pub target_relays: Vec<String>,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
+ pub connected_relays: Vec<String>,
+ #[serde(default)]
pub acknowledged_relays: Vec<String>,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
pub failed_relays: Vec<RelayFailureView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
@@ -1217,6 +1219,8 @@ pub struct OrderSubmitView {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub target_relays: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub connected_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acknowledged_relays: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failed_relays: Vec<RelayFailureView>,
@@ -2572,11 +2576,13 @@ pub struct ListingMutationView {
pub dry_run: bool,
#[serde(default)]
pub deduplicated: bool,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
pub target_relays: Vec<String>,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
+ pub connected_relays: Vec<String>,
+ #[serde(default)]
pub acknowledged_relays: Vec<String>,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
pub failed_relays: Vec<RelayFailureView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -238,6 +238,18 @@ where
{
match view.disposition() {
CommandDisposition::Success => serialized_operation_result::<R, _>(view),
+ CommandDisposition::ExternalUnavailable if listing_relay_unavailable(view) => {
+ Err(OperationAdapterError::network_unavailable_with_detail(
+ operation_id,
+ view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "listing {} finished with state `{}`",
+ view.operation, view.state
+ )
+ }),
+ serde_json::to_value(view).unwrap_or(Value::Null),
+ ))
+ }
disposition => Err(OperationAdapterError::from_command_disposition(
operation_id,
disposition,
@@ -251,6 +263,15 @@ where
}
}
+fn listing_relay_unavailable(view: &ListingMutationView) -> bool {
+ view.source == "direct Nostr relay publish ยท local key"
+ && (view.reason.as_deref().is_some_and(|reason| {
+ reason.contains("configured relay") || reason.contains("direct relay connection failed")
+ }) || !view.target_relays.is_empty()
+ || !view.connected_relays.is_empty()
+ || !view.failed_relays.is_empty())
+}
+
fn map_runtime<T>(
operation_id: &str,
result: Result<T, RuntimeError>,
diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs
@@ -22,6 +22,7 @@ pub struct DirectRelayPublishReceipt {
pub created_at: u32,
pub signature: String,
pub target_relays: Vec<String>,
+ pub connected_relays: Vec<String>,
pub acknowledged_relays: Vec<String>,
pub failed_relays: Vec<DirectRelayFailure>,
}
@@ -50,10 +51,21 @@ pub enum DirectRelayPublishError {
#[source]
source: RadrootsNostrError,
},
- #[error("direct relay connection failed: {0}")]
- Connect(String),
+ #[error("direct relay connection failed: {reason}")]
+ Connect {
+ reason: String,
+ target_relays: Vec<String>,
+ connected_relays: Vec<String>,
+ failed_relays: Vec<DirectRelayFailure>,
+ },
#[error("direct relay publish failed for event `{event_id}`: {reason}")]
- Publish { event_id: String, reason: String },
+ Publish {
+ event_id: String,
+ reason: String,
+ target_relays: Vec<String>,
+ connected_relays: Vec<String>,
+ failed_relays: Vec<DirectRelayFailure>,
+ },
}
#[derive(Debug, thiserror::Error)]
@@ -195,10 +207,19 @@ async fn publish_parts_with_identity_async(
}
let connection_output = client.try_connect(RELAY_CONNECT_TIMEOUT).await;
+ let connected_relays = connection_output
+ .success
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>();
+ let connection_failed_relays = relay_failures_from_output(&connection_output);
if connection_output.success.is_empty() {
- return Err(DirectRelayPublishError::Connect(summarize_failures(
- &relay_failures_from_output(&connection_output),
- )));
+ return Err(DirectRelayPublishError::Connect {
+ reason: summarize_failures(&connection_failed_relays),
+ target_relays: relay_urls.to_vec(),
+ connected_relays,
+ failed_relays: connection_failed_relays,
+ });
}
let publish_output =
@@ -208,12 +229,18 @@ async fn publish_parts_with_identity_async(
.map_err(|source| DirectRelayPublishError::Publish {
event_id: event_id.clone(),
reason: source.to_string(),
+ target_relays: relay_urls.to_vec(),
+ connected_relays: connected_relays.clone(),
+ failed_relays: Vec::new(),
})?;
let failed_relays = relay_failures_from_output(&publish_output);
if publish_output.success.is_empty() {
return Err(DirectRelayPublishError::Publish {
event_id: event_id.clone(),
reason: summarize_failures(&failed_relays),
+ target_relays: relay_urls.to_vec(),
+ connected_relays,
+ failed_relays,
});
}
@@ -222,6 +249,7 @@ async fn publish_parts_with_identity_async(
created_at,
signature,
target_relays: relay_urls.to_vec(),
+ connected_relays,
acknowledged_relays: publish_output
.success
.iter()
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -606,6 +606,7 @@ fn preview_component(
event_kind,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
job_id: None,
@@ -692,6 +693,7 @@ fn published_component(
event_kind,
deduplicated: false,
target_relays: receipt.target_relays,
+ connected_relays: receipt.connected_relays,
acknowledged_relays: receipt.acknowledged_relays,
failed_relays: relay_failures(receipt.failed_relays),
job_id: None,
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -35,7 +35,8 @@ use crate::runtime::RuntimeError;
use crate::runtime::accounts;
use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend};
use crate::runtime::direct_relay::{
- DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity,
+ DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt,
+ publish_parts_with_identity,
};
use crate::runtime::farm_config;
use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
@@ -895,6 +896,7 @@ fn mutate(
dry_run: true,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
job_id: None,
@@ -987,8 +989,27 @@ fn mutate_via_direct_relay(
};
let receipt =
- publish_parts_with_identity(&signing.identity, &config.relay.urls, event_draft.parts)
- .map_err(|error| RuntimeError::Network(error.to_string()))?;
+ match publish_parts_with_identity(&signing.identity, &config.relay.urls, event_draft.parts)
+ {
+ Ok(receipt) => receipt,
+ Err(
+ error @ (DirectRelayPublishError::MissingRelays
+ | DirectRelayPublishError::RelayConfig { .. }
+ | DirectRelayPublishError::Connect { .. }
+ | DirectRelayPublishError::Publish { .. }),
+ ) => {
+ return Ok(direct_relay_error_view(
+ config,
+ args,
+ operation,
+ canonical,
+ listing_addr,
+ event_draft.event,
+ error,
+ ));
+ }
+ Err(error) => return Err(RuntimeError::Network(error.to_string())),
+ };
Ok(published_mutation_view(
config,
@@ -1479,6 +1500,7 @@ fn direct_relay_unavailable_view(
dry_run: false,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
job_id: None,
@@ -1516,6 +1538,7 @@ fn radrootsd_unavailable_view(
dry_run: false,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
job_id: None,
@@ -1533,6 +1556,89 @@ fn radrootsd_unavailable_view(
}
}
+fn direct_relay_error_view(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ operation: ListingMutationOperation,
+ canonical: &CanonicalListingDraft,
+ listing_addr: String,
+ event_preview: ListingMutationEventView,
+ error: DirectRelayPublishError,
+) -> ListingMutationView {
+ let (reason, target_relays, connected_relays, failed_relays) = match error {
+ DirectRelayPublishError::MissingRelays => (
+ "direct relay publish requires at least one configured relay".to_owned(),
+ config.relay.urls.clone(),
+ Vec::new(),
+ Vec::new(),
+ ),
+ DirectRelayPublishError::RelayConfig { relay, source } => (
+ format!("failed to configure relay `{relay}` for direct relay publish: {source}"),
+ config.relay.urls.clone(),
+ Vec::new(),
+ vec![RelayFailureView {
+ relay,
+ reason: source.to_string(),
+ }],
+ ),
+ DirectRelayPublishError::Connect {
+ reason,
+ target_relays,
+ connected_relays,
+ failed_relays,
+ } => (
+ format!("direct relay connection failed: {reason}"),
+ target_relays,
+ connected_relays,
+ relay_failures(failed_relays),
+ ),
+ DirectRelayPublishError::Publish {
+ event_id,
+ reason,
+ target_relays,
+ connected_relays,
+ failed_relays,
+ } => (
+ format!("direct relay publish failed for event `{event_id}`: {reason}"),
+ target_relays,
+ connected_relays,
+ relay_failures(failed_relays),
+ ),
+ DirectRelayPublishError::Runtime(_)
+ | DirectRelayPublishError::Build(_)
+ | DirectRelayPublishError::Sign(_) => unreachable!(),
+ };
+
+ ListingMutationView {
+ state: "unavailable".to_owned(),
+ operation: operation.as_str().to_owned(),
+ source: listing_write_source(config).to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr: listing_addr.clone(),
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ event_kind: KIND_LISTING,
+ dry_run: false,
+ deduplicated: false,
+ target_relays,
+ connected_relays,
+ acknowledged_relays: Vec::new(),
+ failed_relays,
+ job_id: None,
+ job_status: None,
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ event_id: None,
+ event_addr: Some(listing_addr),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_session_id: None,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ reason: Some(reason),
+ job: None,
+ event: args.print_event.then_some(event_preview),
+ actions: Vec::new(),
+ }
+}
+
fn validate_local_listing_signer(
config: &RuntimeConfig,
canonical: &CanonicalListingDraft,
@@ -1586,6 +1692,7 @@ fn binding_error_view(
dry_run: false,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
job_id: None,
@@ -1617,6 +1724,7 @@ fn published_mutation_view(
created_at,
signature,
target_relays,
+ connected_relays,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -1639,6 +1747,7 @@ fn published_mutation_view(
dry_run: false,
deduplicated: false,
target_relays,
+ connected_relays,
acknowledged_relays,
failed_relays: relay_failures(failed_relays),
job_id: None,
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -569,6 +569,7 @@ pub fn submit(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
@@ -604,6 +605,7 @@ pub fn submit(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
@@ -641,6 +643,7 @@ pub fn submit(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
@@ -7423,6 +7426,7 @@ fn published_order_revision_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -7432,6 +7436,7 @@ fn published_order_revision_view(
view.event_id = Some(event_id);
view.event_kind = Some(event_kind);
view.target_relays = target_relays;
+ view.connected_relays = connected_relays;
view.acknowledged_relays = acknowledged_relays;
view.failed_relays = relay_failures(failed_relays);
view
@@ -7451,6 +7456,7 @@ fn published_order_revision_decision_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -7576,6 +7582,7 @@ fn published_order_fulfillment_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -7603,6 +7610,7 @@ fn published_order_cancellation_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -7629,6 +7637,7 @@ fn published_order_receipt_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -7663,6 +7672,7 @@ fn published_order_payment_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -7690,6 +7700,7 @@ fn published_order_settlement_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -8048,6 +8059,7 @@ fn published_order_decision_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays: _,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -9273,6 +9285,7 @@ fn order_submit_unconfigured_view(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
@@ -9309,6 +9322,7 @@ fn order_submit_invalid_quantity_view(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
@@ -9544,6 +9558,7 @@ fn order_submit_deduplicated_view(
dry_run: config.output.dry_run,
deduplicated: true,
target_relays,
+ connected_relays: connected_relays.clone(),
acknowledged_relays: connected_relays,
failed_relays: relay_failures(failed_relays),
idempotency_key: args.idempotency_key.clone(),
@@ -9581,6 +9596,7 @@ fn order_submit_dry_run_view(
dry_run: true,
deduplicated: false,
target_relays: config.relay.urls.clone(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
@@ -9624,6 +9640,7 @@ fn order_submit_invalid_existing_request_view(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays,
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: relay_failures(failed_relays),
idempotency_key: args.idempotency_key.clone(),
@@ -9703,6 +9720,7 @@ fn published_order_submit_view(
created_at: _,
signature: _,
target_relays,
+ connected_relays,
acknowledged_relays,
failed_relays,
} = receipt;
@@ -9723,6 +9741,7 @@ fn published_order_submit_view(
dry_run: false,
deduplicated: false,
target_relays,
+ connected_relays,
acknowledged_relays,
failed_relays: relay_failures(failed_relays),
idempotency_key: args.idempotency_key.clone(),
@@ -9766,6 +9785,7 @@ fn order_binding_error_view(
dry_run: config.output.dry_run,
deduplicated: false,
target_relays: Vec::new(),
+ connected_relays: Vec::new(),
acknowledged_relays: Vec::new(),
failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -1003,6 +1003,24 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() {
"listing.publish",
&["listing", "publish"],
);
+ assert_eq!(
+ publish_value["errors"][0]["detail"]["target_relays"][0],
+ relay
+ );
+ assert_eq!(
+ publish_value["errors"][0]["detail"]["connected_relays"]
+ .as_array()
+ .expect("connected relays")
+ .len(),
+ 0
+ );
+ assert_eq!(
+ publish_value["errors"][0]["detail"]["failed_relays"]
+ .as_array()
+ .expect("failed relays")
+ .len(),
+ 1
+ );
let (archive_output, archive_value) = sandbox.json_output(&[
"--format",
@@ -1021,6 +1039,24 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() {
"listing.archive",
&["listing", "archive"],
);
+ assert_eq!(
+ archive_value["errors"][0]["detail"]["target_relays"][0],
+ relay
+ );
+ assert_eq!(
+ archive_value["errors"][0]["detail"]["connected_relays"]
+ .as_array()
+ .expect("connected relays")
+ .len(),
+ 0
+ );
+ assert_eq!(
+ archive_value["errors"][0]["detail"]["failed_relays"]
+ .as_array()
+ .expect("failed relays")
+ .len(),
+ 1
+ );
seed_orderable_listing(&sandbox, LISTING_ADDR);
sandbox.json_success(&["--format", "json", "basket", "create", "direct_order"]);