commit 15ae628874eb77b7a7086fbea8577b782e235016
parent bbfc88403c0a88f8ea65faa3a82dfcb7d14f3939
Author: triesap <tyson@radroots.org>
Date: Tue, 28 Apr 2026 18:40:28 +0000
cli: harden order decision preflight
Diffstat:
4 files changed, 176 insertions(+), 33 deletions(-)
diff --git a/src/main.rs b/src/main.rs
@@ -351,7 +351,9 @@ fn validate_network_contract(
match request.context().network_mode {
OperationNetworkMode::Default => Ok(()),
OperationNetworkMode::Offline => {
- if external && !request.context().dry_run {
+ if external
+ && (!request.context().dry_run || dry_run_requires_network(spec.operation_id))
+ {
return Err(OperationAdapterError::OfflineForbidden {
operation_id: spec.operation_id.to_owned(),
message: format!(
@@ -377,6 +379,10 @@ fn validate_network_contract(
}
}
+fn dry_run_requires_network(operation_id: &str) -> bool {
+ matches!(operation_id, "order.accept" | "order.decline")
+}
+
fn external_network_operation(operation_id: &str) -> bool {
matches!(
operation_id,
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -461,6 +461,21 @@ impl OperationAdapterError {
}
}
+ pub fn validation_failed_with_detail(
+ operation_id: &str,
+ message: String,
+ detail: Value,
+ ) -> Self {
+ Self::DetailedFailure {
+ operation_id: operation_id.to_owned(),
+ code: "validation_failed".to_owned(),
+ class: "validation".to_owned(),
+ message,
+ exit_code: CliExitCode::ValidationFailed,
+ detail_json: detail.to_string(),
+ }
+ }
+
pub fn unavailable(operation_id: &str, message: String) -> Self {
classify_runtime_failure(
operation_id,
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -227,6 +227,20 @@ fn decision_result<R>(
where
R: OperationResultData,
{
+ if matches!(view.state.as_str(), "already_decided" | "invalid") {
+ let message = view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "order decision failed validation with state `{}`",
+ view.state
+ )
+ });
+ return Err(OperationAdapterError::validation_failed_with_detail(
+ operation_id,
+ message,
+ order_decision_error_detail(view),
+ ));
+ }
+
match view.disposition() {
CommandDisposition::Success => serialized_target_result::<R, _>(view),
disposition => {
@@ -268,15 +282,21 @@ fn order_decision_error_detail(view: &OrderDecisionView) -> Value {
"request_event_id": &view.request_event_id,
"root_event_id": &view.root_event_id,
"prev_event_id": &view.prev_event_id,
+ "event_id": &view.event_id,
+ "event_kind": view.event_kind,
"buyer_pubkey": &view.buyer_pubkey,
"seller_pubkey": &view.seller_pubkey,
+ "decision": &view.decision,
+ "dry_run": view.dry_run,
"target_relays": &view.target_relays,
"connected_relays": &view.connected_relays,
+ "acknowledged_relays": &view.acknowledged_relays,
"failed_relays": &view.failed_relays,
"fetched_count": view.fetched_count,
"decoded_count": view.decoded_count,
"skipped_count": view.skipped_count,
"issues": &view.issues,
+ "actions": &view.actions,
})
}
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -671,25 +671,9 @@ pub fn decide(
config: &RuntimeConfig,
args: &OrderDecisionArgs,
) -> Result<OrderDecisionView, RuntimeError> {
- let decision_reason = args
- .reason
- .as_deref()
- .map(str::trim)
- .filter(|reason| !reason.is_empty());
- if config.output.dry_run {
- let mut view = order_decision_base_view(config, args, "dry_run", true);
- view.reason = Some(match decision_reason {
- Some(reason) => format!(
- "dry run requested; seller order decision publication skipped with reason `{reason}`"
- ),
- None => "dry run requested; seller order decision publication skipped".to_owned(),
- });
- view.actions = vec![format!("radroots order status get {}", args.key)];
- return Ok(view);
- }
-
if config.relay.urls.is_empty() {
- let mut view = order_decision_base_view(config, args, "unconfigured", false);
+ let mut view =
+ order_decision_base_view(config, args, "unconfigured", config.output.dry_run);
view.reason = Some(format!(
"order {} requires at least one configured relay",
args.decision.command()
@@ -700,7 +684,8 @@ pub fn decide(
let seller = match accounts::resolve_account(config)? {
Some(account) => account,
None => {
- let mut view = order_decision_base_view(config, args, "unconfigured", false);
+ let mut view =
+ order_decision_base_view(config, args, "unconfigured", config.output.dry_run);
view.reason = Some(format!(
"order {} requires a selected seller account",
args.decision.command()
@@ -718,7 +703,8 @@ pub fn decide(
target_relays,
failed_relays,
}) => {
- let mut view = order_decision_base_view(config, args, "unavailable", false);
+ let mut view =
+ order_decision_base_view(config, args, "unavailable", config.output.dry_run);
view.seller_pubkey = Some(seller_pubkey);
view.target_relays = target_relays;
view.failed_relays = relay_failures(failed_relays);
@@ -747,7 +733,39 @@ pub fn decide(
));
}
};
- return publish_order_decision(config, args, request, resolution, signing);
+ let payload = {
+ let signer_pubkey = signing
+ .account
+ .record
+ .public_identity
+ .public_key_hex
+ .as_str();
+ canonical_order_decision_payload(args, &request, signer_pubkey)?
+ };
+ let status_view = status(
+ config,
+ &OrderStatusArgs {
+ key: args.key.clone(),
+ },
+ )?;
+ if let Some(view) = order_decision_preflight_view_from_status(
+ config,
+ args,
+ &request,
+ &resolution,
+ &status_view,
+ ) {
+ return Ok(view);
+ }
+ if config.output.dry_run {
+ return Ok(order_decision_dry_run_view(
+ config,
+ args,
+ &request,
+ &status_view,
+ ));
+ }
+ return publish_order_decision(config, args, request, resolution, signing, payload);
}
Ok(order_decision_view_from_resolution(
config,
@@ -1151,7 +1169,7 @@ fn order_decision_view_from_resolution(
skipped_count,
requests,
} = resolution;
- let mut view = order_decision_base_view(config, args, "missing", false);
+ let mut view = order_decision_base_view(config, args, "missing", config.output.dry_run);
view.seller_pubkey = Some(seller_pubkey);
view.target_relays = target_relays;
view.connected_relays = connected_relays;
@@ -1212,6 +1230,88 @@ fn apply_order_decision_request(
view.prev_event_id = Some(request.request_event_id.clone());
}
+fn apply_order_decision_status(view: &mut OrderDecisionView, status: &OrderStatusView) {
+ view.target_relays = status.target_relays.clone();
+ view.connected_relays = status.connected_relays.clone();
+ view.failed_relays = status.failed_relays.clone();
+ view.fetched_count = status.fetched_count;
+ view.decoded_count = status.decoded_count;
+ view.skipped_count = status.skipped_count;
+ view.issues = status.reducer_issues.clone();
+}
+
+fn order_decision_preflight_view_from_status(
+ config: &RuntimeConfig,
+ args: &OrderDecisionArgs,
+ request: &ResolvedSellerOrderRequest,
+ resolution: &SellerOrderRequestResolution,
+ status: &OrderStatusView,
+) -> Option<OrderDecisionView> {
+ let state = match status.state.as_str() {
+ "accepted" | "declined" => "already_decided",
+ "invalid" => "invalid",
+ "unavailable" => "unavailable",
+ "unconfigured" => "unconfigured",
+ _ => return None,
+ };
+ let mut view = order_decision_base_view(config, args, state, config.output.dry_run);
+ apply_order_decision_resolution(&mut view, resolution);
+ apply_order_decision_request(&mut view, request);
+ apply_order_decision_status(&mut view, status);
+ if let Some(decision_event_id) = &status.decision_event_id {
+ view.event_id = Some(decision_event_id.clone());
+ view.event_kind = Some(KIND_TRADE_ORDER_DECISION);
+ }
+ view.reason = Some(match status.state.as_str() {
+ "accepted" | "declined" => format!(
+ "order {} refused because order `{}` already has a visible `{}` seller decision",
+ args.decision.command(),
+ request.order_id,
+ status.state
+ ),
+ "invalid" => status.reason.clone().unwrap_or_else(|| {
+ format!(
+ "order {} refused because active order events for `{}` are invalid",
+ args.decision.command(),
+ request.order_id
+ )
+ }),
+ _ => status.reason.clone().unwrap_or_else(|| {
+ format!(
+ "order {} status preflight failed with state `{}`",
+ args.decision.command(),
+ status.state
+ )
+ }),
+ });
+ view.actions = vec![format!("radroots order status get {}", request.order_id)];
+ Some(view)
+}
+
+fn order_decision_dry_run_view(
+ config: &RuntimeConfig,
+ args: &OrderDecisionArgs,
+ request: &ResolvedSellerOrderRequest,
+ status: &OrderStatusView,
+) -> OrderDecisionView {
+ let decision_reason = args
+ .reason
+ .as_deref()
+ .map(str::trim)
+ .filter(|reason| !reason.is_empty());
+ let mut view = order_decision_base_view(config, args, "dry_run", true);
+ apply_order_decision_request(&mut view, request);
+ apply_order_decision_status(&mut view, status);
+ view.reason = Some(match decision_reason {
+ Some(reason) => format!(
+ "dry run requested; seller order decision publication skipped with reason `{reason}`"
+ ),
+ None => "dry run requested; seller order decision publication skipped".to_owned(),
+ });
+ view.actions = vec![format!("radroots order status get {}", request.order_id)];
+ view
+}
+
fn seller_order_request_resolution_from_receipt(
seller_pubkey: &str,
order_id: &str,
@@ -1312,16 +1412,8 @@ fn publish_order_decision(
request: ResolvedSellerOrderRequest,
resolution: SellerOrderRequestResolution,
signing: accounts::AccountSigningIdentity,
+ payload: RadrootsTradeOrderDecisionEvent,
) -> Result<OrderDecisionView, RuntimeError> {
- let signer_pubkey = signing
- .account
- .record
- .public_identity
- .public_key_hex
- .as_str();
- let payload = order_decision_payload_from_request(args, &request)?;
- let payload = canonicalize_active_order_decision_for_signer(payload, signer_pubkey)
- .map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}")))?;
let parts = active_trade_order_decision_event_build(
request.request_event_id.as_str(),
request.request_event_id.as_str(),
@@ -1337,6 +1429,16 @@ fn publish_order_decision(
))
}
+fn canonical_order_decision_payload(
+ args: &OrderDecisionArgs,
+ request: &ResolvedSellerOrderRequest,
+ signer_pubkey: &str,
+) -> Result<RadrootsTradeOrderDecisionEvent, RuntimeError> {
+ let payload = order_decision_payload_from_request(args, request)?;
+ canonicalize_active_order_decision_for_signer(payload, signer_pubkey)
+ .map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}")))
+}
+
fn order_decision_payload_from_request(
args: &OrderDecisionArgs,
request: &ResolvedSellerOrderRequest,
@@ -1439,7 +1541,7 @@ fn order_decision_binding_error_view(
vec!["run radroots signer status get".to_owned()],
),
};
- let mut view = order_decision_base_view(config, args, state.as_str(), false);
+ let mut view = order_decision_base_view(config, args, state.as_str(), config.output.dry_run);
apply_order_decision_resolution(&mut view, &resolution);
apply_order_decision_request(&mut view, &request);
view.reason = Some(reason);