cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit f52866fac56e3ce5754655405322d6430c5a5a53
parent adadcdc5097eb604be12e0082f1911afe3cea074
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 23:05:22 +0000

cli: publish listings through direct relays

- wire listing publish and archive to the local-key relay publisher
- return listing event ids with relay delivery details
- include signed listing timestamps and signatures when events are printed
- keep listing update on the deferred unavailable path

Diffstat:
Msrc/domain/runtime.rs | 6++++++
Msrc/runtime/direct_relay.rs | 10++++++++++
Msrc/runtime/listing.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
3 files changed, 152 insertions(+), 23 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1695,6 +1695,12 @@ pub struct ListingMutationView { pub dry_run: bool, #[serde(default)] pub deduplicated: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_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>, #[serde(skip_serializing_if = "Option::is_none")] pub job_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs @@ -17,6 +17,8 @@ pub struct DirectRelayFailure { #[derive(Debug, Clone)] pub struct DirectRelayPublishReceipt { pub event_id: String, + pub created_at: u32, + pub signature: String, pub target_relays: Vec<String>, pub acknowledged_relays: Vec<String>, pub failed_relays: Vec<DirectRelayFailure>, @@ -74,6 +76,8 @@ async fn publish_parts_with_identity_async( .sign_with_keys(identity.keys()) .map_err(|error| DirectRelayPublishError::Sign(error.into()))?; let event_id = event.id.to_hex(); + let created_at = event_created_at_u32(&event); + let signature = event.sig.to_string(); let client = RadrootsNostrClient::from_identity(identity); for relay_url in relay_urls { @@ -110,6 +114,8 @@ async fn publish_parts_with_identity_async( Ok(DirectRelayPublishReceipt { event_id, + created_at, + signature, target_relays: relay_urls.to_vec(), acknowledged_relays: publish_output .success @@ -145,6 +151,10 @@ fn summarize_failures(failed_relays: &[DirectRelayFailure]) -> String { .join("; ") } +fn event_created_at_u32(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> u32 { + u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX) +} + #[cfg(test)] mod tests { use radroots_events_codec::wire::WireEventParts; diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -18,6 +18,7 @@ use radroots_events::listing::{ use radroots_events::trade::RadrootsTradeListingValidationError; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; +use radroots_events_codec::wire::WireEventParts; use radroots_replica_db::ReplicaSql; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; @@ -27,11 +28,14 @@ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, ListingMutationEventView, ListingMutationView, ListingNewView, ListingSummaryView, - ListingValidateView, ListingValidationIssueView, SyncFreshnessView, + ListingValidateView, ListingValidationIssueView, RelayFailureView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::direct_relay::{ + DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity, +}; use crate::runtime::farm_config; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::freshness_from_executor; @@ -42,9 +46,9 @@ use crate::runtime_args::{ const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; -const LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · pending implementation"; +const LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; const DIRECT_RELAY_UNAVAILABLE_REASON: &str = - "direct Nostr relay publishing is not implemented for listing mutations"; + "direct Nostr relay publishing is not implemented for listing update"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -162,6 +166,12 @@ struct CanonicalListingDraft { } #[derive(Debug, Clone)] +struct ListingMutationEventDraft { + event: ListingMutationEventView, + parts: WireEventParts, +} + +#[derive(Debug, Clone)] struct LoadedListingDraft { file: PathBuf, updated_at_unix: u64, @@ -772,7 +782,7 @@ fn mutate( }); } - let (event_preview, listing_addr) = build_listing_event_preview(&canonical)?; + let (event_draft, listing_addr) = build_listing_event_draft(&canonical)?; if config.output.dry_run && matches!( @@ -796,6 +806,9 @@ fn mutate( event_kind: KIND_LISTING, dry_run: true, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), job_id: None, job_status: None, signer_mode: None, @@ -806,7 +819,7 @@ fn mutate( requested_signer_session_id: args.signer_session_id.clone(), reason: Some("dry run requested; relay publish skipped".to_owned()), job: None, - event: args.print_event.then_some(event_preview), + event: args.print_event.then_some(event_draft.event), actions: vec![format!( "radroots listing {} {}", operation.as_str(), @@ -815,11 +828,34 @@ fn mutate( }); } - if matches!(config.signer.backend, SignerBackend::Local) { - validate_local_listing_signer(config, &canonical)?; + if matches!(operation, ListingMutationOperation::Update) { + return Ok(direct_relay_unavailable_view( + config, + args, + operation, + &canonical, + listing_addr, + event_draft.event, + )); + } + + let signing = if matches!(config.signer.backend, SignerBackend::Local) { + resolve_listing_signing_identity(config, &canonical)? } else { match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) { - Ok(_) => {} + Ok(_) => { + return Ok(binding_error_view( + config, + args, + operation, + &canonical, + listing_addr, + event_draft.event, + ActorWriteBindingError::Unconfigured( + "listing publish requires signer mode `local`".to_owned(), + ), + )); + } Err(error) => { return Ok(binding_error_view( config, @@ -827,20 +863,25 @@ fn mutate( operation, &canonical, listing_addr, - event_preview, + event_draft.event, error, )); } } - } + }; + + let receipt = + publish_parts_with_identity(&signing.identity, &config.relay.urls, event_draft.parts) + .map_err(|error| RuntimeError::Network(error.to_string()))?; - Ok(direct_relay_unavailable_view( + Ok(published_mutation_view( config, args, operation, &canonical, listing_addr, - event_preview, + event_draft.event, + receipt, )) } @@ -1150,9 +1191,9 @@ fn invalid_validation_view( } } -fn build_listing_event_preview( +fn build_listing_event_draft( canonical: &CanonicalListingDraft, -) -> Result<(ListingMutationEventView, String), RuntimeError> { +) -> Result<(ListingMutationEventDraft, String), RuntimeError> { let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; let validated = validate_listing_for_seller( @@ -1162,15 +1203,18 @@ fn build_listing_event_preview( ) .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; Ok(( - ListingMutationEventView { - kind: KIND_LISTING, - author: canonical.seller_pubkey.clone(), - created_at: None, - content: parts.content, - tags: parts.tags, - event_id: None, - signature: None, - event_addr: validated.listing_addr.clone(), + ListingMutationEventDraft { + event: ListingMutationEventView { + kind: KIND_LISTING, + author: canonical.seller_pubkey.clone(), + created_at: None, + content: parts.content.clone(), + tags: parts.tags.clone(), + event_id: None, + signature: None, + event_addr: validated.listing_addr.clone(), + }, + parts, }, validated.listing_addr, )) @@ -1195,6 +1239,9 @@ fn direct_relay_unavailable_view( event_kind: KIND_LISTING, dry_run: false, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), job_id: None, job_status: None, signer_mode: Some(config.signer.backend.as_str().to_owned()), @@ -1265,6 +1312,9 @@ fn binding_error_view( event_kind: KIND_LISTING, dry_run: false, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), job_id: None, job_status: None, signer_mode: Some(config.signer.backend.as_str().to_owned()), @@ -1280,6 +1330,69 @@ fn binding_error_view( } } +fn published_mutation_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + mut event: ListingMutationEventView, + receipt: DirectRelayPublishReceipt, +) -> ListingMutationView { + let DirectRelayPublishReceipt { + event_id, + created_at, + signature, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + event.event_id = Some(event_id.clone()); + event.created_at = Some(created_at); + event.signature = Some(signature); + ListingMutationView { + state: match operation { + ListingMutationOperation::Archive => "archived", + ListingMutationOperation::Publish | ListingMutationOperation::Update => "published", + } + .to_owned(), + operation: operation.as_str().to_owned(), + source: LISTING_WRITE_SOURCE.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, + acknowledged_relays, + failed_relays: relay_failures(failed_relays), + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + event_id: Some(event_id), + event_addr: Some(listing_addr), + idempotency_key: args.idempotency_key.clone(), + requested_signer_session_id: args.signer_session_id.clone(), + reason: None, + job: None, + event: args.print_event.then_some(event), + actions: Vec::new(), + } +} + +fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { + failures + .into_iter() + .map(|failure| RelayFailureView { + relay: failure.relay, + reason: failure.reason, + }) + .collect() +} + fn issue_from_trade_validation( error: RadrootsTradeListingValidationError, contents: &str,