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:
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"]);