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