cli

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

commit cc1fda1d865c8be8e7807340ea9f53a037ea2e03
parent 79d0d32b5cc23f1efefdc9ef0aae72001bad5bcb
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 13:43:32 +0000

cli: validate order submit listing freshness

Diffstat:
Msrc/operation_adapter.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_order.rs | 34+++++++++++++++++++++++++++-------
Msrc/runtime/order.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/support/mod.rs | 39++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 361 insertions(+), 9 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -381,6 +381,15 @@ pub enum OperationAdapterError { operation_id: String, message: String, }, + #[error("operation `{operation_id}` failed: {message}")] + DetailedFailure { + operation_id: String, + code: String, + class: String, + message: String, + exit_code: CliExitCode, + detail_json: String, + }, #[error("operation runtime error: {0}")] Runtime(String), } @@ -422,6 +431,21 @@ impl OperationAdapterError { ) } + pub fn operation_unavailable_with_detail( + operation_id: &str, + message: String, + detail: Value, + ) -> Self { + Self::DetailedFailure { + operation_id: operation_id.to_owned(), + code: "operation_unavailable".to_owned(), + class: "operation".to_owned(), + message, + exit_code: CliExitCode::RuntimeUnavailable, + detail_json: detail.to_string(), + } + } + pub fn unavailable(operation_id: &str, message: String) -> Self { classify_runtime_failure( operation_id, @@ -620,6 +644,21 @@ impl OperationAdapterError { message, CliExitCode::RuntimeUnavailable, ), + Self::DetailedFailure { + operation_id, + code, + class, + message, + exit_code, + detail_json, + } => runtime_output_error_with_detail( + code.as_str(), + operation_id, + class, + message, + *exit_code, + detail_json, + ), Self::UnknownOperation(operation_id) => OutputError::new( "unknown_operation", format!("unknown operation `{operation_id}`"), @@ -789,6 +828,25 @@ fn runtime_output_error( error } +fn runtime_output_error_with_detail( + code: &str, + operation_id: &str, + class: &str, + message: &str, + exit_code: CliExitCode, + detail_json: &str, +) -> OutputError { + let mut error = OutputError::new(code, message.to_owned(), exit_code); + let mut detail = serde_json::from_str::<Map<String, Value>>(detail_json).unwrap_or_default(); + detail.insert( + "operation_id".to_owned(), + Value::from(operation_id.to_owned()), + ); + detail.insert("class".to_owned(), Value::from(class.to_owned())); + error.detail = Some(Value::Object(detail)); + error +} + macro_rules! target_operation_contracts { ($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => { #[derive(Debug, Clone, PartialEq)] diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -1,5 +1,5 @@ use serde::Serialize; -use serde_json::Value; +use serde_json::{Value, json}; use crate::domain::runtime::{CommandDisposition, OrderSubmitView}; use crate::operation_adapter::{ @@ -136,13 +136,33 @@ where { match view.disposition() { CommandDisposition::Success => serialized_target_result::<R, _>(view), - disposition => Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - view.reason + disposition => { + let message = view + .reason .clone() - .unwrap_or_else(|| format!("order submit finished with state `{}`", view.state)), - )), + .unwrap_or_else(|| format!("order submit finished with state `{}`", view.state)); + if disposition == CommandDisposition::Unconfigured && !view.issues.is_empty() { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + json!({ + "state": &view.state, + "order_id": &view.order_id, + "file": &view.file, + "listing_addr": &view.listing_addr, + "listing_event_id": &view.listing_event_id, + "issues": &view.issues, + "actions": &view.actions, + }), + )) + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } } } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -509,6 +509,10 @@ pub fn submit( }); } + if let Some(view) = order_submit_listing_freshness_view(config, &loaded, args)? { + return Ok(view); + } + if config.relay.urls.is_empty() { return Err(RuntimeError::Network( "order submit requires at least one configured relay before signing".to_owned(), @@ -1224,6 +1228,109 @@ fn actions_for_document( actions } +fn order_submit_listing_freshness_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, +) -> Result<Option<OrderSubmitView>, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order submit requires local market data to confirm the listing is still active; run `radroots store init` and `radroots market refresh` before submitting", + vec![issue( + "order.listing_addr", + "local replica database is missing; run `radroots store init` and `radroots market refresh` before submitting", + )], + vec![ + "radroots store init".to_owned(), + "radroots market refresh".to_owned(), + ], + ))); + } + + let listing_addr = loaded.document.order.listing_addr.as_str(); + let parsed = parse_listing_addr(listing_addr) + .map_err(|error| RuntimeError::Config(format!("order listing_addr is invalid: {error}")))?; + let active_event_id = match resolve_active_listing_event_id(config, listing_addr, &parsed)? { + Some(event_id) => event_id, + None => { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order listing is not active in the local replica; run `radroots market refresh` and create a new order from current market data", + vec![issue( + "order.listing_addr", + "listing is missing, archived, or superseded in the local replica", + )], + vec!["radroots market refresh".to_owned()], + ))); + } + }; + + if !active_event_id.eq_ignore_ascii_case(loaded.document.order.listing_event_id.as_str()) { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order listing event is no longer current in the local replica; run `radroots market refresh` and create a new order from current market data", + vec![issue( + "order.listing_event_id", + format!( + "draft listing_event_id does not match latest local listing event `{active_event_id}`" + ), + )], + vec!["radroots market refresh".to_owned()], + ))); + } + + Ok(None) +} + +fn order_submit_unconfigured_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, + mut actions: Vec<String>, +) -> OrderSubmitView { + actions.push(format!( + "radroots order get {}", + loaded.document.order.order_id + )); + + OrderSubmitView { + state: "unconfigured".to_owned(), + source: ORDER_SOURCE.to_owned(), + order_id: loaded.document.order.order_id.clone(), + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + buyer_account_id: loaded.document.buyer_account_id.clone(), + buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), + seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), + event_id: None, + event_kind: None, + dry_run: config.output.dry_run, + deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: None, + signer_session_id: None, + requested_signer_session_id: None, + reason: Some(reason.into()), + job: None, + issues, + actions, + } +} + fn publish_order_request( config: &RuntimeConfig, loaded: &LoadedOrderDraft, diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -11,7 +11,7 @@ use radroots_events::kinds::{KIND_FARM, KIND_LISTING}; use radroots_events_codec::trade::RadrootsTradeListingAddress; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; -use radroots_sql_core::SqliteExecutor; +use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::Value; use tempfile::TempDir; @@ -105,6 +105,12 @@ impl RadrootsCliSandbox { path } + pub fn replica_db_path(&self) -> PathBuf { + self.root + .path() + .join("data/apps/cli/replica/replica.sqlite") + } + #[cfg(unix)] pub fn write_fake_myc(&self, name: &str, body: &str) -> PathBuf { let path = self.root.path().join("bin").join(name); @@ -270,6 +276,37 @@ pub fn seed_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str) event_id } +pub fn remove_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str) { + let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); + let params = serde_json::to_string(&vec![listing_addr]).expect("delete listing params"); + executor + .exec( + "DELETE FROM trade_product WHERE listing_addr = ?;", + params.as_str(), + ) + .expect("delete listing row"); +} + +pub fn replace_latest_listing_event_id( + sandbox: &RadrootsCliSandbox, + listing_addr: &str, + event_id: &str, +) { + let parsed = RadrootsTradeListingAddress::parse(listing_addr).expect("listing addr"); + let key = format!( + "{}:{}:{}", + KIND_LISTING, parsed.seller_pubkey, parsed.listing_id + ); + let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); + let params = serde_json::to_string(&vec![event_id, key.as_str()]).expect("update params"); + executor + .exec( + "UPDATE nostr_event_state SET last_event_id = ? WHERE key = ?;", + params.as_str(), + ) + .expect("update latest listing event id"); +} + pub fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf { let listing_file = sandbox.root().join(format!("{key}.toml")); let listing_file_arg = listing_file.to_string_lossy(); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -8,7 +8,8 @@ use serde_json::Value; use support::{ RadrootsCliSandbox, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, identity_public, make_listing_publishable, ndjson_from_stdout, radroots, - seed_orderable_listing, write_public_identity_profile, + remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, + write_public_identity_profile, }; const LISTING_ADDR: &str = @@ -1037,6 +1038,31 @@ fn order_submit_missing_order_returns_not_found_while_read_view_stays_successful assert_no_daemon_runtime_reference(&submit, &["order", "submit"]); } +fn create_ready_order(sandbox: &RadrootsCliSandbox, basket_id: &str) -> String { + sandbox.json_success(&["--format", "json", "account", "create"]); + seed_orderable_listing(sandbox, LISTING_ADDR); + sandbox.json_success(&["--format", "json", "basket", "create", basket_id]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + basket_id, + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + let quote = sandbox.json_success(&["--format", "json", "basket", "quote", "create", basket_id]); + quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id") + .to_owned() +} + #[test] fn buyer_target_flow_acceptance_uses_target_operations() { let sandbox = RadrootsCliSandbox::new(); @@ -1189,6 +1215,110 @@ fn buyer_target_flow_acceptance_uses_target_operations() { } #[test] +fn order_submit_requires_local_replica_freshness_before_signing() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "freshness_missing_db"); + fs::remove_file(sandbox.replica_db_path()).expect("remove replica db"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--approval-token", + "approve", + "order", + "submit", + order_id.as_str(), + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(value["errors"][0]["detail"]["state"], "unconfigured"); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["field"], + "order.listing_addr" + ); + assert!( + value["errors"][0]["message"] + .as_str() + .expect("message") + .contains("run `radroots store init` and `radroots market refresh`") + ); +} + +#[test] +fn order_submit_rejects_missing_or_archived_local_listing_before_publish() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "freshness_missing_listing"); + remove_orderable_listing(&sandbox, LISTING_ADDR); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--approval-token", + "approve", + "order", + "submit", + order_id.as_str(), + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["field"], + "order.listing_addr" + ); + assert!( + value["errors"][0]["message"] + .as_str() + .expect("message") + .contains("listing is not active") + ); +} + +#[test] +fn order_submit_rejects_superseded_local_listing_event_before_publish() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "freshness_superseded_listing"); + let replacement_event_id = "3".repeat(64); + replace_latest_listing_event_id(&sandbox, LISTING_ADDR, replacement_event_id.as_str()); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--approval-token", + "approve", + "order", + "submit", + order_id.as_str(), + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["field"], + "order.listing_event_id" + ); + assert!( + value["errors"][0]["detail"]["issues"][0]["message"] + .as_str() + .expect("issue message") + .contains(replacement_event_id.as_str()) + ); +} + +#[test] fn ready_order_submit_dry_run_validates_local_buyer_authority() { let sandbox = RadrootsCliSandbox::new(); let first = sandbox.json_success(&["--format", "json", "account", "create"]);