cli

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

commit 0824578d8b52742101c2491830523f8c756fb839
parent dc1f751d8f27dd831f9630082c2c23c9783f22f5
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 17:37:16 +0000

order: check submit quantity

- add local replica availability preflight before non-dry order submit
- fail over-quantity submit attempts before signing or relay publish
- return structured validation details for invalid submit views
- cover over-available order submit rejection in target CLI tests

Diffstat:
Msrc/operation_order.rs | 42++++++++++++++++++++++++++++--------------
Msrc/runtime/order.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 257 insertions(+), 14 deletions(-)

diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -347,20 +347,34 @@ where .reason .clone() .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, - }), - )) + if !view.issues.is_empty() + && matches!( + disposition, + CommandDisposition::Unconfigured | CommandDisposition::ValidationFailed + ) + { + let detail = 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, + }); + if disposition == CommandDisposition::ValidationFailed { + Err(OperationAdapterError::validation_failed_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, diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -552,6 +552,9 @@ pub fn submit( if let Some(view) = order_submit_listing_freshness_view(config, &loaded, args)? { return Ok(view); } + if let Some(view) = order_submit_quantity_preflight_view(config, &loaded, args)? { + return Ok(view); + } if config.relay.urls.is_empty() { return Err(RuntimeError::Network( @@ -2371,6 +2374,131 @@ fn order_submit_listing_freshness_view( Ok(None) } +fn order_submit_quantity_preflight_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 current listing availability; 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 requested_count = + loaded + .document + .order + .items + .iter() + .enumerate() + .try_fold(0u64, |total, (index, item)| { + if item.bin_count == 0 { + return Err(RuntimeError::Config(format!( + "order item {index} quantity must be greater than zero" + ))); + } + total.checked_add(u64::from(item.bin_count)).ok_or_else(|| { + RuntimeError::Config("order quantity exceeds supported range".to_owned()) + }) + })?; + + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let product_rows = trade_product::find_many( + &executor, + &ITradeProductFindMany { + filter: Some(trade_product_listing_addr_filter( + loaded.document.order.listing_addr.as_str(), + )), + }, + ) + .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? + .results; + + let product = match product_rows.as_slice() { + [product] => product, + [] => { + 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()], + ))); + } + _ => { + return Err(RuntimeError::Config(format!( + "listing address `{}` matched {} active local listing rows", + loaded.document.order.listing_addr, + product_rows.len() + ))); + } + }; + + let available_count = match product.qty_avail { + Some(value) if value >= 0 => value as u64, + Some(value) => { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing availability is invalid in the local replica", + vec![issue_with_code( + "listing_inventory_availability_invalid", + "inventory.available", + format!("current local replica availability is negative: {value}"), + )], + ))); + } + None => { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing availability is missing in the local replica", + vec![issue_with_code( + "listing_inventory_availability_missing", + "inventory.available", + "current local replica listing availability is required before submit", + )], + ))); + } + }; + + if requested_count > available_count { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order requested quantity exceeds current local listing availability", + vec![issue_with_code( + "order_quantity_exceeds_available", + "order.items", + format!( + "requested quantity {requested_count} exceeds current local replica available quantity {available_count}" + ), + )], + ))); + } + + Ok(None) +} + fn order_submit_unconfigured_view( config: &RuntimeConfig, loaded: &LoadedOrderDraft, @@ -2413,6 +2541,45 @@ fn order_submit_unconfigured_view( } } +fn order_submit_invalid_quantity_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, +) -> OrderSubmitView { + OrderSubmitView { + state: "invalid".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: vec![ + "radroots market refresh".to_owned(), + format!("radroots order get {}", loaded.document.order.order_id), + ], + } +} + fn order_submit_existing_request_preflight_view( config: &RuntimeConfig, loaded: &LoadedOrderDraft, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1424,6 +1424,68 @@ fn order_submit_rejects_superseded_local_listing_event_before_publish() { } #[test] +fn order_submit_rejects_over_available_quantity_before_publish() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + seed_orderable_listing(&sandbox, LISTING_ADDR); + sandbox.json_success(&["--format", "json", "basket", "create", "over_quantity"]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "over_quantity", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "6", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "over_quantity", + ]); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--approval-token", + "approve", + "order", + "submit", + order_id, + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(10)); + assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["errors"][0]["code"], "validation_failed"); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["code"], + "order_quantity_exceeds_available" + ); + assert!( + value["errors"][0]["detail"]["issues"][0]["message"] + .as_str() + .expect("issue message") + .contains("available quantity 5") + ); + assert_no_removed_command_reference(&value, &["order", "submit"]); + assert_no_daemon_runtime_reference(&value, &["order", "submit"]); +} + +#[test] fn ready_order_submit_dry_run_validates_local_buyer_authority() { let sandbox = RadrootsCliSandbox::new(); let first = sandbox.json_success(&["--format", "json", "account", "create"]);