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