cli

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

commit 7d53694afd973470826df868e67b8f5d01380be0
parent 2b38ca7345e0e59e5d29ce3e37f27cf01a8fa256
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 10:43:24 +0000

cli: harden buyer dry run preflight

- preflight market refresh through sync pull truth instead of synthetic success
- validate basket create dry runs against existing basket files
- preflight basket quotes through order draft validation without writes
- verify target cli and signer runtime mode integration tests

Diffstat:
Msrc/operation_basket.rs | 30+++++++++++++++++++++---------
Msrc/operation_market.rs | 23+++++------------------
Msrc/runtime/order.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 208 insertions(+), 27 deletions(-)

diff --git a/src/operation_basket.rs b/src/operation_basket.rs @@ -102,24 +102,24 @@ impl OperationService<BasketCreateRequest> for BasketOperationService<'_> { ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let basket_id = string_input(&request, "basket_id").unwrap_or_else(next_basket_id); let initial_item = optional_item_from_request(&request, None)?; + let file = basket_lookup_path(self.config, basket_id.as_str()); + if file.exists() { + return Err(invalid_input( + request.operation_id(), + format!("basket `{basket_id}` already exists"), + )); + } if request.context.dry_run { return json_operation_result::<BasketCreateResult>(json!({ "state": "dry_run", "source": BASKET_SOURCE, "basket_id": basket_id, + "file": file.display().to_string(), "item_count": initial_item.as_ref().map(|_| 1).unwrap_or(0), "actions": ["radroots basket create"], })); } - let file = basket_lookup_path(self.config, basket_id.as_str()); - if file.exists() { - return Err(invalid_input( - request.operation_id(), - format!("basket `{basket_id}` already exists"), - )); - } - let now = now_unix(); let document = BasketDocument { version: 1, @@ -360,11 +360,22 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { .expect("validated basket has one item") .clone(); if request.context.dry_run { + let order = map_runtime(crate::runtime::order::scaffold_preflight( + self.config, + &OrderDraftCreateArgs { + listing: item.listing.clone(), + listing_addr: item.listing_addr.clone(), + bin_id: Some(item.bin_id.clone()), + bin_count: Some(item.quantity), + }, + ))?; return json_operation_result::<BasketQuoteCreateResult>(json!({ "state": "dry_run", "source": BASKET_QUOTE_SOURCE, "basket_id": basket_id, + "file": loaded.file.display().to_string(), "item": item, + "order": order, "actions": ["radroots basket quote create"], })); } @@ -1040,7 +1051,8 @@ mod tests { assert_eq!(envelope.operation_id, "basket.quote.create"); assert_eq!(envelope.dry_run, true); assert_eq!(envelope.result["state"], "dry_run"); - assert!(envelope.result.get("order").is_none()); + assert_eq!(envelope.result["order"]["state"], "dry_run"); + assert!(!PathBuf::from(envelope.result["order"]["file"].as_str().unwrap()).exists()); } fn create_basket(service: &OperationAdapter<BasketOperationService<'_>>, basket_id: &str) { diff --git a/src/operation_market.rs b/src/operation_market.rs @@ -1,7 +1,7 @@ use radroots_events::kinds::KIND_LISTING; use radroots_events_codec::trade::RadrootsTradeListingAddress; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::Value; use crate::domain::runtime::{FindView, ListingGetView, SyncActionView}; use crate::operation_adapter::{ @@ -29,16 +29,8 @@ impl OperationService<MarketRefreshRequest> for MarketOperationService<'_> { fn execute( &self, - request: OperationRequest<MarketRefreshRequest>, + _request: OperationRequest<MarketRefreshRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if request.context.dry_run { - return json_operation_result::<MarketRefreshResult>(json!({ - "state": "dry_run", - "source": "market refresh target operation", - "actions": ["radroots sync status get"], - })); - } - let view = market_refresh_view(map_runtime(crate::runtime::sync::pull(self.config))?); serialized_operation_result::<MarketRefreshResult, _>(&view) } @@ -234,13 +226,6 @@ where OperationResult::new(R::from_serializable(value)?) } -fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - OperationResult::new(R::from_value(value)) -} - fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } @@ -328,7 +313,9 @@ mod tests { assert_eq!(envelope.operation_id, "market.refresh"); assert_eq!(envelope.dry_run, true); - assert_eq!(envelope.result["state"], "dry_run"); + assert_eq!(envelope.result["state"], "unconfigured"); + assert_eq!(envelope.result["replica_db"], "missing"); + assert_eq!(envelope.result["direction"], "pull"); } #[test] diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -231,6 +231,80 @@ pub fn scaffold( Ok(view) } +pub fn scaffold_preflight( + config: &RuntimeConfig, + args: &OrderDraftCreateArgs, +) -> Result<OrderNewView, RuntimeError> { + validate_scaffold_args(args)?; + + let listing_lookup = normalize_optional(args.listing.as_deref()); + let explicit_listing_addr = normalize_optional(args.listing_addr.as_deref()); + let resolved_listing = resolve_order_listing( + config, + listing_lookup.as_deref(), + explicit_listing_addr.as_deref(), + )?; + + let selected_account = accounts::resolve_account(config)?; + let buyer_account_id = selected_account + .as_ref() + .map(|account| account.record.account_id.to_string()); + let buyer_pubkey = selected_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.clone()) + .unwrap_or_default(); + + let listing_addr = resolved_listing + .as_ref() + .map(|listing| listing.listing_addr.clone()) + .unwrap_or_default(); + let seller_pubkey = resolved_listing + .as_ref() + .map(|listing| listing.seller_pubkey.clone()) + .unwrap_or_default(); + + let items = match normalize_optional(args.bin_id.as_deref()) { + Some(bin_id) => vec![OrderDraftItem { + bin_id, + bin_count: args.bin_count.unwrap_or(1), + }], + None => Vec::new(), + }; + + let order_id = next_order_id(); + let file = drafts_dir(config).join(format!("{order_id}.toml")); + let document = OrderDraftDocument { + version: 1, + kind: ORDER_DRAFT_KIND.to_owned(), + order: OrderDraft { + order_id: order_id.clone(), + listing_addr, + buyer_pubkey, + seller_pubkey, + items, + }, + listing_lookup, + buyer_account_id, + submission: None, + }; + + let mut view: OrderNewView = view_from_loaded( + config, + LoadedOrderDraft { + file, + updated_at_unix: now_unix(), + document, + }, + false, + ) + .into(); + view.state = "dry_run".to_owned(); + view.actions + .insert(0, format!("radroots order get {}", view.order_id)); + + Ok(view) +} + pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetView, RuntimeError> { let lookup = args.key.clone(); let file = draft_lookup_path(config, lookup.as_str()); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -776,6 +776,114 @@ fn seller_dry_runs_preflight_without_mutating_farm_or_listing_files() { } #[test] +fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + + let market = sandbox.json_success(&["--format", "json", "--dry-run", "market", "refresh"]); + assert_eq!(market["operation_id"], "market.refresh"); + assert_eq!(market["dry_run"], true); + assert_eq!(market["result"]["state"], "unconfigured"); + assert_eq!(market["result"]["replica_db"], "missing"); + + let sync_pull = sandbox.json_success(&["--format", "json", "--dry-run", "sync", "pull"]); + assert_eq!(sync_pull["operation_id"], "sync.pull"); + assert_eq!(sync_pull["dry_run"], true); + assert_eq!(sync_pull["result"]["state"], "unconfigured"); + assert_eq!(sync_pull["result"]["replica_db"], "missing"); + + let sync_push = sandbox.json_success(&["--format", "json", "--dry-run", "sync", "push"]); + assert_eq!(sync_push["operation_id"], "sync.push"); + assert_eq!(sync_push["dry_run"], true); + assert_eq!(sync_push["result"]["state"], "unconfigured"); + assert_eq!(sync_push["result"]["replica_db"], "missing"); + + let create_dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "basket", + "create", + "basket_probe", + ]); + let basket_file = create_dry_run["result"]["file"] + .as_str() + .expect("basket file"); + assert_eq!(create_dry_run["operation_id"], "basket.create"); + assert_eq!(create_dry_run["result"]["state"], "dry_run"); + assert!(!Path::new(basket_file).exists()); + + sandbox.json_success(&["--format", "json", "basket", "create", "basket_probe"]); + let (collision_output, collision) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "basket", + "create", + "basket_probe", + ]); + assert!(!collision_output.status.success()); + assert_eq!(collision["operation_id"], "basket.create"); + assert_eq!(collision["errors"][0]["code"], "invalid_input"); + + let before_add = sandbox.json_success(&["--format", "json", "basket", "get", "basket_probe"]); + let add = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "basket", + "item", + "add", + "basket_probe", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + assert_eq!(add["operation_id"], "basket.item.add"); + assert_eq!(add["result"]["state"], "dry_run"); + let after_add = sandbox.json_success(&["--format", "json", "basket", "get", "basket_probe"]); + assert_eq!(after_add["result"], before_add["result"]); + + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "basket_probe", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "basket", + "quote", + "create", + "basket_probe", + ]); + let order_file = quote["result"]["order"]["file"] + .as_str() + .expect("order file"); + assert_eq!(quote["operation_id"], "basket.quote.create"); + assert_eq!(quote["result"]["state"], "dry_run"); + assert_eq!(quote["result"]["order"]["state"], "dry_run"); + assert!(!Path::new(order_file).exists()); + + let basket_after_quote = + sandbox.json_success(&["--format", "json", "basket", "get", "basket_probe"]); + assert_eq!(basket_after_quote["result"]["quote"], Value::Null); +} + +#[test] fn required_approval_token_rejects_absent_empty_and_whitespace_values() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(61);