cli

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

commit 2e046b2f44fdd72147a3c120fa5ffe360732eeb8
parent 5615bfada01189636aa00b54fcc88e3442d0c76a
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 19:25:00 +0000

order: make submit dry-run truthful

Diffstat:
Msrc/operation_order.rs | 6+-----
Msrc/runtime/order.rs | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mtests/target_cli.rs | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
3 files changed, 233 insertions(+), 80 deletions(-)

diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -58,11 +58,7 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { let view = crate::runtime::order::submit(&config, &args).map_err(|error| { OperationAdapterError::runtime_failure(request.operation_id(), error) })?; - if request.context.dry_run && view.state == "unconfigured" && !view.issues.is_empty() { - serialized_target_result::<OrderSubmitResult, _>(&view) - } else { - submit_result::<OrderSubmitResult>(request.operation_id(), &view) - } + submit_result::<OrderSubmitResult>(request.operation_id(), &view) } } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -535,45 +535,6 @@ pub fn submit( }); } - if config.output.dry_run { - if let Err(error) = resolve_local_order_signing_identity( - config, - loaded.document.order.buyer_pubkey.as_str(), - ) { - return Ok(order_binding_error_view(config, &loaded, args, error)); - } - return Ok(OrderSubmitView { - state: "dry_run".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: true, - 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("dry run requested; relay order publication skipped".to_owned()), - job: None, - issues: Vec::new(), - actions: vec![format!( - "radroots order submit {}", - loaded.document.order.order_id - )], - }); - } - if let Some(view) = order_submit_listing_freshness_view(config, &loaded, args)? { return Ok(view); } @@ -581,12 +542,6 @@ pub fn submit( 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(), - )); - } - let signing = match resolve_local_order_signing_identity( config, loaded.document.order.buyer_pubkey.as_str(), @@ -604,12 +559,23 @@ pub fn submit( .as_str(), )?; + if config.relay.urls.is_empty() { + return Err(RuntimeError::Network( + "order submit requires at least one configured relay before publish preflight" + .to_owned(), + )); + } + if let Some(view) = order_submit_existing_request_preflight_view(config, &loaded, args, &payload)? { return Ok(view); } + if config.output.dry_run { + return Ok(order_submit_dry_run_view(config, &loaded, args)); + } + match publish_order_request(config, &loaded, args, signing, payload) { Ok(view) => Ok(view), Err(error) => Err(error), @@ -3703,7 +3669,7 @@ fn order_submit_deduplicated_view( seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: Some(request.request_event_id.clone()), event_kind: Some(KIND_TRADE_ORDER_REQUEST), - dry_run: false, + dry_run: config.output.dry_run, deduplicated: true, target_relays, acknowledged_relays: connected_relays, @@ -3722,6 +3688,45 @@ fn order_submit_deduplicated_view( } } +fn order_submit_dry_run_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, +) -> OrderSubmitView { + OrderSubmitView { + state: "dry_run".to_owned(), + source: ORDER_SUBMIT_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: true, + deduplicated: false, + target_relays: config.relay.urls.clone(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + requested_signer_session_id: None, + reason: Some( + "dry run requested; relay order publication skipped after submit preflight".to_owned(), + ), + job: None, + issues: Vec::new(), + actions: vec![format!( + "radroots order submit {}", + loaded.document.order.order_id + )], + } +} + fn order_submit_invalid_existing_request_view( config: &RuntimeConfig, loaded: &LoadedOrderDraft, @@ -4209,15 +4214,16 @@ mod tests { use tempfile::tempdir; use super::{ - LoadedOrderDraft, ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem, - OrderStatusContext, ResolvedSellerOrderRequest, SellerOrderRequestResolution, - accepted_order_decision_payload_from_request, active_request_record_from_resolved, - canonical_order_request_payload_from_loaded, collect_issues, - declined_order_decision_payload_from_request, inspect_document, next_order_id, - order_accept_inventory_preflight_view_from_projection, order_decision_dry_run_view, - order_decision_preflight_view_from_status, order_decision_view_from_resolution, - order_history_entry_from_event, order_history_from_receipt, order_request_filter, - order_status_from_receipt, order_status_from_receipt_with_context, + LoadedOrderDraft, ORDER_DRAFT_KIND, ORDER_SUBMIT_SOURCE, OrderDraft, OrderDraftDocument, + OrderDraftItem, OrderStatusContext, ResolvedSellerOrderRequest, + SellerOrderRequestResolution, accepted_order_decision_payload_from_request, + active_request_record_from_resolved, canonical_order_request_payload_from_loaded, + collect_issues, declined_order_decision_payload_from_request, inspect_document, + next_order_id, order_accept_inventory_preflight_view_from_projection, + order_decision_dry_run_view, order_decision_preflight_view_from_status, + order_decision_view_from_resolution, order_history_entry_from_event, + order_history_from_receipt, order_request_filter, order_status_from_receipt, + order_status_from_receipt_with_context, order_submit_dry_run_view, order_submit_existing_request_view_from_receipt, proposed_accept_decision_record, seller_order_request_resolution_from_receipt, }; @@ -4389,6 +4395,72 @@ mod tests { } #[test] + fn order_submit_dry_run_view_preserves_preflighted_no_publish_fields() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.output.dry_run = true; + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let loaded = loaded_order_draft_for_fixture(&fixture); + let args = OrderSubmitArgs { + key: fixture.order_id.clone(), + idempotency_key: Some("idem-dry-submit".to_owned()), + }; + + let view = order_submit_dry_run_view(&config, &loaded, &args); + + assert_eq!(view.state, "dry_run"); + assert_eq!(view.source, ORDER_SUBMIT_SOURCE); + assert_eq!(view.dry_run, true); + assert_eq!(view.deduplicated, false); + assert_eq!(view.event_id, None); + assert_eq!(view.event_kind, None); + assert_eq!(view.target_relays, vec!["ws://relay.test"]); + assert!(view.acknowledged_relays.is_empty()); + assert!(view.failed_relays.is_empty()); + assert_eq!(view.signer_mode.as_deref(), Some("local")); + assert_eq!(view.idempotency_key.as_deref(), Some("idem-dry-submit")); + } + + #[test] + fn order_submit_dry_run_deduplicates_identical_visible_request() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.output.dry_run = true; + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let loaded = loaded_order_draft_for_fixture(&fixture); + let payload = + canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str()) + .expect("canonical order request payload"); + let event_id = fixture.request_event.id.to_string(); + let args = OrderSubmitArgs { + key: fixture.order_id.clone(), + idempotency_key: Some("idem-dry-dedupe".to_owned()), + }; + let receipt = DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone()], + }; + + let view = order_submit_existing_request_view_from_receipt( + &config, &loaded, &args, &payload, receipt, + ) + .expect("submit existing request preflight") + .expect("deduplicated view"); + + assert_eq!(view.state, "submitted"); + assert_eq!(view.dry_run, true); + assert_eq!(view.deduplicated, true); + assert_eq!(view.event_id.as_deref(), Some(event_id.as_str())); + assert_eq!(view.event_kind, Some(3422)); + assert_eq!(view.acknowledged_relays, vec!["ws://relay.test"]); + assert_eq!(view.idempotency_key.as_deref(), Some("idem-dry-dedupe")); + } + + #[test] fn order_submit_existing_request_preflight_rejects_changed_request() { let dir = tempdir().expect("tempdir"); let mut config = sample_config(dir.path()); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1256,21 +1256,23 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_no_removed_command_reference(&orders, &["order", "list"]); assert_no_daemon_runtime_reference(&orders, &["order", "list"]); - let submit = - sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); + let (dry_output, submit) = + sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); + assert!(!dry_output.status.success()); + assert_eq!(dry_output.status.code(), Some(8)); assert_eq!(submit["operation_id"], "order.submit"); assert_eq!(submit["dry_run"], true); - assert_eq!(submit["result"]["state"], "dry_run"); - assert_eq!(submit["result"]["dry_run"], true); - assert_eq!(submit["result"]["order_id"], order_id); - assert_eq!(submit["result"]["buyer_account_id"], account_id); - assert_eq!(submit["result"]["listing_event_id"], listing_event_id); - assert_eq!(submit["result"]["event_id"], Value::Null); - assert_eq!(submit["result"]["event_kind"], Value::Null); - assert_eq!(submit["result"]["target_relays"], Value::Null); - assert_eq!(submit["result"]["acknowledged_relays"], Value::Null); - assert_eq!(submit["result"]["failed_relays"], Value::Null); - assert_eq!(submit["errors"].as_array().expect("errors").len(), 0); + assert_eq!(submit["result"], Value::Null); + assert_eq!(submit["errors"][0]["code"], "network_unavailable"); + assert_eq!(submit["errors"][0]["detail"]["class"], "network"); + assert!( + submit["errors"][0]["message"] + .as_str() + .expect("message") + .contains( + "order submit requires at least one configured relay before publish preflight" + ) + ); assert_no_removed_command_reference(&submit, &["order", "submit", "--dry-run"]); assert_no_daemon_runtime_reference(&submit, &["order", "submit", "--dry-run"]); @@ -1299,7 +1301,9 @@ fn buyer_target_flow_acceptance_uses_target_operations() { unavailable_submit["errors"][0]["message"] .as_str() .expect("message") - .contains("order submit requires at least one configured relay before signing") + .contains( + "order submit requires at least one configured relay before publish preflight" + ) ); assert_no_removed_command_reference(&unavailable_submit, &["order", "submit"]); assert_no_daemon_runtime_reference(&unavailable_submit, &["order", "submit"]); @@ -1355,6 +1359,33 @@ fn order_submit_requires_local_replica_freshness_before_signing() { } #[test] +fn order_submit_dry_run_requires_local_replica_freshness() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "dry_freshness_missing_db"); + fs::remove_file(sandbox.replica_db_path()).expect("remove replica db"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "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["dry_run"], true); + 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" + ); +} + +#[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"); @@ -1486,6 +1517,52 @@ fn order_submit_rejects_over_available_quantity_before_publish() { } #[test] +fn order_submit_dry_run_rejects_over_available_quantity_before_relay_preflight() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + seed_orderable_listing(&sandbox, LISTING_ADDR); + sandbox.json_success(&["--format", "json", "basket", "create", "dry_over_quantity"]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "dry_over_quantity", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "6", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "dry_over_quantity", + ]); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + + let (output, value) = + sandbox.json_output(&["--format", "json", "--dry-run", "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["dry_run"], true); + assert_eq!(value["errors"][0]["code"], "validation_failed"); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["code"], + "order_quantity_exceeds_available" + ); +} + +#[test] fn ready_order_submit_dry_run_validates_local_buyer_authority() { let sandbox = RadrootsCliSandbox::new(); let first = sandbox.json_success(&["--format", "json", "account", "create"]); @@ -1529,15 +1606,23 @@ fn ready_order_submit_dry_run_validates_local_buyer_authority() { listing_event_id ); - let dry_run = - sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); + let (dry_output, dry_run) = + sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); + assert!(!dry_output.status.success()); + assert_eq!(dry_output.status.code(), Some(8)); assert_eq!(dry_run["operation_id"], "order.submit"); assert_eq!(dry_run["dry_run"], true); - assert_eq!(dry_run["result"]["state"], "dry_run"); - assert_eq!(dry_run["result"]["dry_run"], true); - assert_eq!(dry_run["result"]["buyer_account_id"], first_account_id); - assert_eq!(dry_run["result"]["listing_event_id"], listing_event_id); + assert_eq!(dry_run["result"], Value::Null); + assert_eq!(dry_run["errors"][0]["code"], "network_unavailable"); + assert!( + dry_run["errors"][0]["message"] + .as_str() + .expect("message") + .contains( + "order submit requires at least one configured relay before publish preflight" + ) + ); assert_no_daemon_runtime_reference(&dry_run, &["order", "submit", "--dry-run"]); let second = sandbox.json_success(&["--format", "json", "account", "create"]);