cli

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

commit a0ff183f006d88baafc89fdcd2b68e1ad6f29298
parent 54bc3375cbf9094bc938ec796424b329b5bedf12
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 16:35:34 +0000

cli: resolve seller order requests

- fetch seller-targeted order requests before decision writes
- decode active request events with relay count detail
- return missing and unavailable decision failures without signing
- cover request resolution and wrong-seller paths in tests

Diffstat:
Msrc/domain/runtime.rs | 12++++++++++++
Msrc/operation_order.rs | 47++++++++++++++++++++++++++++++++++++++++++-----
Msrc/runtime/order.rs | 455+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/runtime_args.rs | 7+++++++
4 files changed, 461 insertions(+), 60 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1216,6 +1216,10 @@ pub struct OrderDecisionView { pub seller_pubkey: Option<String>, pub decision: String, #[serde(skip_serializing_if = "Option::is_none")] + pub request_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub root_event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub prev_event_id: Option<String>, @@ -1228,9 +1232,17 @@ pub struct OrderDecisionView { #[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>, + #[serde(default)] + pub fetched_count: usize, + #[serde(default)] + pub decoded_count: usize, + #[serde(default)] + pub skipped_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub idempotency_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -231,15 +231,52 @@ where .reason .clone() .unwrap_or_else(|| format!("order decision finished with state `{}`", view.state)); - Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )) + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_decision_error_detail(view); + if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + message, + detail, + )) + } else { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + detail, + )) + } + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } } } } +fn order_decision_error_detail(view: &OrderDecisionView) -> Value { + json!({ + "state": &view.state, + "order_id": &view.order_id, + "listing_addr": &view.listing_addr, + "request_event_id": &view.request_event_id, + "root_event_id": &view.root_event_id, + "prev_event_id": &view.prev_event_id, + "buyer_pubkey": &view.buyer_pubkey, + "seller_pubkey": &view.seller_pubkey, + "target_relays": &view.target_relays, + "connected_relays": &view.connected_relays, + "failed_relays": &view.failed_relays, + "fetched_count": view.fetched_count, + "decoded_count": view.decoded_count, + "skipped_count": view.skipped_count, + "issues": &view.issues, + }) +} + fn status_result<R>( operation_id: &str, view: &OrderStatusView, diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -105,6 +105,27 @@ struct ResolvedOrderListing { seller_pubkey: String, } +#[derive(Debug, Clone)] +struct ResolvedSellerOrderRequest { + request_event_id: String, + listing_event_id: Option<String>, + order_id: String, + listing_addr: String, + buyer_pubkey: String, + seller_pubkey: String, +} + +#[derive(Debug, Clone)] +struct SellerOrderRequestResolution { + target_relays: Vec<String>, + connected_relays: Vec<String>, + failed_relays: Vec<DirectRelayFailure>, + fetched_count: usize, + decoded_count: usize, + skipped_count: usize, + requests: Vec<ResolvedSellerOrderRequest>, +} + pub fn scaffold( config: &RuntimeConfig, args: &OrderDraftCreateArgs, @@ -648,64 +669,69 @@ pub fn decide( .map(str::trim) .filter(|reason| !reason.is_empty()); if config.output.dry_run { - return Ok(OrderDecisionView { - state: "dry_run".to_owned(), - source: ORDER_DECISION_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - decision: args.decision.as_str().to_owned(), - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - dry_run: true, - target_relays: config.relay.urls.clone(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: Some(match decision_reason { - Some(reason) => format!( - "dry run requested; seller order decision publication skipped with reason `{reason}`" - ), - None => "dry run requested; seller order decision publication skipped".to_owned(), - }), - issues: Vec::new(), - actions: vec![format!("radroots order status get {}", args.key)], + let mut view = order_decision_base_view(config, args, "dry_run", true); + view.reason = Some(match decision_reason { + Some(reason) => format!( + "dry run requested; seller order decision publication skipped with reason `{reason}`" + ), + None => "dry run requested; seller order decision publication skipped".to_owned(), }); + view.actions = vec![format!("radroots order status get {}", args.key)]; + return Ok(view); } - Ok(OrderDecisionView { - state: "unavailable".to_owned(), - source: ORDER_DECISION_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - decision: args.decision.as_str().to_owned(), - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - dry_run: false, - target_relays: config.relay.urls.clone(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: Some(match decision_reason { - Some(reason) => { - format!( - "seller order decision publication is not implemented for reason `{reason}`" - ) - } - None => "seller order decision publication is not implemented".to_owned(), - }), - issues: Vec::new(), - actions: Vec::new(), - }) + if config.relay.urls.is_empty() { + let mut view = order_decision_base_view(config, args, "unconfigured", false); + view.reason = Some(format!( + "order {} requires at least one configured relay", + args.decision.command() + )); + return Ok(view); + } + + let seller = match accounts::resolve_account(config)? { + Some(account) => account, + None => { + let mut view = order_decision_base_view(config, args, "unconfigured", false); + view.reason = Some(format!( + "order {} requires a selected seller account", + args.decision.command() + )); + view.actions = vec!["radroots account create".to_owned()]; + return Ok(view); + } + }; + let seller_pubkey = seller.record.public_identity.public_key_hex; + let filter = order_request_filter(seller_pubkey.as_str(), Some(args.key.as_str()))?; + let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { + Ok(receipt) => receipt, + Err(DirectRelayFetchError::Connect { + reason, + target_relays, + failed_relays, + }) => { + let mut view = order_decision_base_view(config, args, "unavailable", false); + view.seller_pubkey = Some(seller_pubkey); + view.target_relays = target_relays; + view.failed_relays = relay_failures(failed_relays); + view.reason = Some(format!("direct relay connection failed: {reason}")); + return Ok(view); + } + Err(error) => return Err(RuntimeError::Network(error.to_string())), + }; + + let resolution = seller_order_request_resolution_from_receipt( + seller_pubkey.as_str(), + args.key.as_str(), + receipt, + )?; + Ok(order_decision_view_from_resolution( + config, + args, + decision_reason, + seller_pubkey, + resolution, + )) } pub fn status( @@ -911,6 +937,207 @@ fn order_history_from_receipt( } } +fn order_decision_base_view( + config: &RuntimeConfig, + args: &OrderDecisionArgs, + state: &str, + dry_run: bool, +) -> OrderDecisionView { + OrderDecisionView { + state: state.to_owned(), + source: ORDER_DECISION_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + decision: args.decision.as_str().to_owned(), + request_event_id: None, + listing_event_id: None, + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + dry_run, + target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: None, + issues: Vec::new(), + actions: Vec::new(), + } +} + +fn order_decision_view_from_resolution( + config: &RuntimeConfig, + args: &OrderDecisionArgs, + decision_reason: Option<&str>, + seller_pubkey: String, + resolution: SellerOrderRequestResolution, +) -> OrderDecisionView { + let SellerOrderRequestResolution { + target_relays, + connected_relays, + failed_relays, + fetched_count, + decoded_count, + skipped_count, + requests, + } = resolution; + let mut view = order_decision_base_view(config, args, "missing", false); + view.seller_pubkey = Some(seller_pubkey); + view.target_relays = target_relays; + view.connected_relays = connected_relays; + view.failed_relays = relay_failures(failed_relays); + view.fetched_count = fetched_count; + view.decoded_count = decoded_count; + view.skipped_count = skipped_count; + + match requests.as_slice() { + [] => { + view.reason = Some(format!( + "no seller-targeted order request event matched `{}`", + args.key + )); + view + } + [request] => { + view.state = "unavailable".to_owned(); + view.order_id = request.order_id.clone(); + view.listing_addr = Some(request.listing_addr.clone()); + view.buyer_pubkey = Some(request.buyer_pubkey.clone()); + view.seller_pubkey = Some(request.seller_pubkey.clone()); + view.request_event_id = Some(request.request_event_id.clone()); + view.listing_event_id = request.listing_event_id.clone(); + view.root_event_id = Some(request.request_event_id.clone()); + view.prev_event_id = Some(request.request_event_id.clone()); + view.reason = Some(match decision_reason { + Some(reason) => { + format!( + "seller order decision publication is not implemented for reason `{reason}`" + ) + } + None => "seller order decision publication is not implemented".to_owned(), + }); + view.actions = vec![format!("radroots order status get {}", request.order_id)]; + view + } + _ => { + view.state = "unavailable".to_owned(); + view.reason = Some(format!( + "multiple seller-targeted order request events matched `{}`; refusing to choose an order root", + args.key + )); + view.issues = vec![issue( + "order_id", + format!( + "matched {} request events for the same order id", + requests.len() + ), + )]; + view + } + } +} + +fn seller_order_request_resolution_from_receipt( + seller_pubkey: &str, + order_id: &str, + receipt: DirectRelayFetchReceipt, +) -> Result<SellerOrderRequestResolution, RuntimeError> { + let DirectRelayFetchReceipt { + target_relays, + connected_relays, + failed_relays, + events, + } = receipt; + let fetched_count = events.len(); + let mut skipped_count = 0usize; + let mut decoded_count = 0usize; + let mut requests = Vec::new(); + + for event in events { + match seller_order_request_from_event(&event, seller_pubkey, order_id) { + Ok(request) => { + decoded_count += 1; + requests.push(request); + } + Err(_) => skipped_count += 1, + } + } + + requests.sort_by(|left, right| left.request_event_id.cmp(&right.request_event_id)); + + Ok(SellerOrderRequestResolution { + target_relays, + connected_relays, + failed_relays, + fetched_count, + decoded_count, + skipped_count, + requests, + }) +} + +fn seller_order_request_from_event( + event: &RadrootsNostrEvent, + seller_pubkey: &str, + order_id: &str, +) -> Result<ResolvedSellerOrderRequest, RuntimeError> { + let event_kind = event_kind_u32(event); + if event_kind != KIND_TRADE_ORDER_REQUEST { + return Err(RuntimeError::Config(format!( + "order decision received unexpected kind `{event_kind}`" + ))); + } + + let event = radroots_event_from_nostr(event); + let envelope = active_trade_order_request_from_event(&event) + .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderRequested, + &event.tags, + ) + .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; + + if envelope.order_id != order_id || envelope.payload.order_id != order_id { + return Err(RuntimeError::Config( + "order request does not match requested order id".to_owned(), + )); + } + if context.counterparty_pubkey != seller_pubkey + || envelope.payload.seller_pubkey != seller_pubkey + { + return Err(RuntimeError::Config( + "order request is not targeted at the selected seller".to_owned(), + )); + } + let listing_addr = + parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) + })?; + if listing_addr.seller_pubkey != seller_pubkey { + return Err(RuntimeError::Config( + "order request listing address is outside selected seller authority".to_owned(), + )); + } + let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); + + Ok(ResolvedSellerOrderRequest { + request_event_id: event.id, + listing_event_id, + order_id: envelope.order_id, + listing_addr: envelope.payload.listing_addr, + buyer_pubkey: envelope.payload.buyer_pubkey, + seller_pubkey: envelope.payload.seller_pubkey, + }) +} + fn order_history_entry_from_event( event: &RadrootsNostrEvent, seller_pubkey: &str, @@ -1858,6 +2085,7 @@ mod tests { ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem, collect_issues, inspect_document, next_order_id, order_history_entry_from_event, order_history_from_receipt, order_request_filter, + seller_order_request_resolution_from_receipt, }; use crate::runtime::direct_relay::DirectRelayFetchReceipt; @@ -2037,6 +2265,123 @@ mod tests { assert_eq!(history.orders[0].id, first_order_id); } + #[test] + fn seller_order_request_resolution_matches_selected_seller_order() { + let seller = RadrootsIdentity::generate(); + let buyer = RadrootsIdentity::generate(); + let seller_pubkey = seller.public_key_hex(); + let buyer_pubkey = buyer.public_key_hex(); + let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; + let listing_event_id = "1".repeat(64); + let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); + let event = signed_order_request_event( + &buyer, + order_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + listing_event_id.as_str(), + ); + let event_id = event.id.to_string(); + let receipt = DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![event], + }; + + let resolution = + seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) + .expect("seller order request resolution"); + + assert_eq!(resolution.fetched_count, 1); + assert_eq!(resolution.decoded_count, 1); + assert_eq!(resolution.skipped_count, 0); + assert_eq!(resolution.requests.len(), 1); + assert_eq!(resolution.requests[0].request_event_id, event_id); + assert_eq!(resolution.requests[0].order_id, order_id); + assert_eq!( + resolution.requests[0].listing_event_id.as_deref(), + Some(listing_event_id.as_str()) + ); + assert_eq!(resolution.requests[0].listing_addr, listing_addr); + assert_eq!(resolution.requests[0].buyer_pubkey, buyer_pubkey); + assert_eq!(resolution.requests[0].seller_pubkey, seller_pubkey); + } + + #[test] + fn seller_order_request_resolution_skips_wrong_seller_request() { + let selected_seller = RadrootsIdentity::generate(); + let other_seller = RadrootsIdentity::generate(); + let buyer = RadrootsIdentity::generate(); + let selected_seller_pubkey = selected_seller.public_key_hex(); + let other_seller_pubkey = other_seller.public_key_hex(); + let buyer_pubkey = buyer.public_key_hex(); + let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; + let listing_event_id = "1".repeat(64); + let listing_addr = format!("30402:{other_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); + let receipt = DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![signed_order_request_event( + &buyer, + order_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + other_seller_pubkey.as_str(), + listing_event_id.as_str(), + )], + }; + + let resolution = seller_order_request_resolution_from_receipt( + selected_seller_pubkey.as_str(), + order_id, + receipt, + ) + .expect("seller order request resolution"); + + assert_eq!(resolution.fetched_count, 1); + assert_eq!(resolution.decoded_count, 0); + assert_eq!(resolution.skipped_count, 1); + assert!(resolution.requests.is_empty()); + } + + #[test] + fn seller_order_request_resolution_skips_listing_outside_seller_authority() { + let seller = RadrootsIdentity::generate(); + let listing_seller = RadrootsIdentity::generate(); + let buyer = RadrootsIdentity::generate(); + let seller_pubkey = seller.public_key_hex(); + let listing_seller_pubkey = listing_seller.public_key_hex(); + let buyer_pubkey = buyer.public_key_hex(); + let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; + let listing_event_id = "1".repeat(64); + let listing_addr = format!("30402:{listing_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); + let receipt = DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![signed_order_request_event( + &buyer, + order_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + listing_event_id.as_str(), + )], + }; + + let resolution = + seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) + .expect("seller order request resolution"); + + assert_eq!(resolution.fetched_count, 1); + assert_eq!(resolution.decoded_count, 0); + assert_eq!(resolution.skipped_count, 1); + assert!(resolution.requests.is_empty()); + } + fn signed_order_request_event( buyer: &RadrootsIdentity, order_id: &str, diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -189,6 +189,13 @@ impl OrderDecisionArg { Self::Decline => "declined", } } + + pub fn command(self) -> &'static str { + match self { + Self::Accept => "accept", + Self::Decline => "decline", + } + } } #[derive(Debug, Clone)]