commit 4a5997a2e48a2b082f277db8d6c135a16667614f
parent 29a7d1f5b96eabc36dbab20eacc3b2947a5f22ee
Author: triesap <tyson@radroots.org>
Date: Tue, 30 Jun 2026 03:45:32 +0000
sdk: return structured trade ambiguity
Diffstat:
4 files changed, 133 insertions(+), 10 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -2,6 +2,8 @@
use std::{fmt, path::PathBuf};
#[cfg(feature = "runtime")]
+use radroots_trade::identity::RadrootsTradeLocator;
+#[cfg(feature = "runtime")]
use serde_json::{Value, json};
#[cfg(feature = "runtime")]
@@ -37,6 +39,7 @@ pub enum RadrootsSdkRecoveryAction {
RetryAfterTransportFailure,
RetryGeoNamesDownload,
EnableRequiredFeature,
+ SelectTradeRoot,
}
#[cfg(feature = "runtime")]
@@ -147,6 +150,11 @@ pub enum RadrootsSdkError {
value: String,
message: String,
},
+ TradeAmbiguous {
+ operation: String,
+ locator: RadrootsTradeLocator,
+ candidates: Vec<RadrootsTradeLocator>,
+ },
ProductSyncUnsupported {
operation: &'static str,
required_feature: &'static str,
@@ -210,6 +218,7 @@ impl RadrootsSdkError {
Self::IdempotencyConflict { .. } => "idempotency_conflict",
Self::TradeStatusLimitInvalid { .. } => "trade_status_limit_invalid",
Self::InvalidTradeId { .. } => "invalid_trade_id",
+ Self::TradeAmbiguous { .. } => "trade_ambiguous",
Self::ProductSyncUnsupported { .. } => "product_sync_unsupported",
Self::ProductSyncRelaySetupFailure { .. } => "product_sync_relay_setup_failure",
Self::Authority { .. } => "authority",
@@ -263,6 +272,7 @@ impl RadrootsSdkError {
Self::IdempotencyConflict { .. }
| Self::TradeStatusLimitInvalid { .. }
| Self::InvalidTradeId { .. }
+ | Self::TradeAmbiguous { .. }
| Self::SignerProtocol { .. }
| Self::SignerAuthChallengePending { .. }
| Self::InvalidRequest { .. }
@@ -333,6 +343,7 @@ impl RadrootsSdkError {
Self::IdempotencyConflict { .. } => {
vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey]
}
+ Self::TradeAmbiguous { .. } => vec![RadrootsSdkRecoveryAction::SelectTradeRoot],
Self::ProductSyncUnsupported { .. } => {
vec![RadrootsSdkRecoveryAction::EnableRequiredFeature]
}
@@ -411,6 +422,15 @@ impl RadrootsSdkError {
Self::InvalidTradeId { value, message } => {
json!({ "value": value, "message": message })
}
+ Self::TradeAmbiguous {
+ operation,
+ locator,
+ candidates,
+ } => json!({
+ "operation": operation,
+ "locator": locator,
+ "candidates": candidates
+ }),
Self::ProductSyncUnsupported {
operation,
required_feature,
@@ -590,6 +610,16 @@ impl fmt::Display for RadrootsSdkError {
Self::InvalidTradeId { value, message } => {
write!(f, "sdk invalid order id `{value}`: {message}")
}
+ Self::TradeAmbiguous {
+ operation,
+ locator,
+ candidates,
+ } => write!(
+ f,
+ "sdk trade root is ambiguous for {operation}: trade_id={}, candidate_count={}",
+ locator.order_id().as_str(),
+ candidates.len()
+ ),
Self::ProductSyncUnsupported {
operation,
required_feature,
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -2923,18 +2923,17 @@ async fn trade_mutation_context(
operation: &'static str,
) -> Result<TradeProductMutationContext, RadrootsSdkError> {
let status = trades_client(sdk)
- .status(TradeStatusRequest::new(locator))
+ .status(TradeStatusRequest::new(locator.clone()))
.await?;
if status.status == TradeStatusKind::Ambiguous {
- let roots = status
- .ambiguity_candidates
- .iter()
- .filter_map(|candidate| candidate.locator.root_event_id.as_ref())
- .map(RadrootsEventId::as_str)
- .collect::<Vec<_>>()
- .join(",");
- return Err(RadrootsSdkError::InvalidRequest {
- message: format!("{operation} requires a unique trade root; candidate_roots={roots}"),
+ return Err(RadrootsSdkError::TradeAmbiguous {
+ operation: operation.to_owned(),
+ locator,
+ candidates: status
+ .ambiguity_candidates
+ .into_iter()
+ .map(|candidate| candidate.locator)
+ .collect(),
});
}
if !status.found {
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -3937,6 +3937,83 @@ async fn order_status_reports_root_ambiguity_for_reused_trade_ids() {
);
}
+#[cfg(feature = "signer-adapters")]
+#[tokio::test]
+async fn trade_product_mutation_returns_structured_ambiguity() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let first_request_event = signed_order_request_event("order-1", 27);
+ let second_request_event = signed_order_request_event("order-1", 28);
+
+ for (event, observed_at_ms) in [
+ (first_request_event.clone(), 2_700),
+ (second_request_event.clone(), 2_800),
+ ] {
+ store
+ .ingest_event(RadrootsEventIngest::new(event, observed_at_ms))
+ .await
+ .expect("ingest");
+ }
+
+ let error = sdk
+ .trades()
+ .seller()
+ .accept_trade(TradeAcceptRequest::new(
+ seller_actor(),
+ status_request("order-1").locator,
+ vec![RadrootsOrderInventoryCommitment {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 1,
+ }],
+ explicit_trade_relays(),
+ PublishMode::EnqueueOnly,
+ AckPolicy::NoWait,
+ ))
+ .await
+ .expect_err("ambiguous product mutation");
+
+ let RadrootsSdkError::TradeAmbiguous {
+ operation,
+ locator,
+ candidates,
+ } = &error
+ else {
+ panic!("expected structured trade ambiguity error");
+ };
+ assert_eq!(operation, "trade.accept");
+ assert_eq!(locator.order_id().as_str(), "order-1");
+ assert_eq!(candidates.len(), 2);
+ assert_eq!(
+ candidates
+ .iter()
+ .map(|candidate| {
+ candidate
+ .root_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str)
+ .expect("root event id")
+ })
+ .collect::<Vec<_>>(),
+ vec![
+ first_request_event.id.as_str(),
+ second_request_event.id.as_str()
+ ]
+ );
+ assert_eq!(
+ error.recovery_actions(),
+ vec![RadrootsSdkRecoveryAction::SelectTradeRoot]
+ );
+ let detail = error.detail_json();
+ assert_eq!(detail["code"], "trade_ambiguous");
+ assert_eq!(detail["detail"]["operation"], "trade.accept");
+ assert_eq!(
+ detail["detail"]["candidates"]
+ .as_array()
+ .expect("candidates")
+ .len(),
+ 2
+ );
+}
+
#[tokio::test]
async fn order_status_maps_malformed_local_data_to_sanitized_error() {
let (_tempdir, sdk, store) = directory_sdk_and_store().await;
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -1,5 +1,6 @@
#![cfg(feature = "runtime")]
+use radroots_events::ids::RadrootsOrderId;
use radroots_sdk::{
BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, RadrootsClient,
RadrootsSdkClock, RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind,
@@ -10,6 +11,7 @@ use radroots_sdk::{
SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt,
StorageStatusRequest,
};
+use radroots_trade::identity::RadrootsTradeLocator;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::path::{Path, PathBuf};
@@ -429,6 +431,21 @@ fn sdk_error_contract_methods_cover_all_variants() {
vec![RadrootsSdkRecoveryAction::FixRequest],
),
(
+ RadrootsSdkError::TradeAmbiguous {
+ operation: "trade.accept".to_owned(),
+ locator: RadrootsTradeLocator::from_order_id(
+ RadrootsOrderId::parse("trade-error").expect("order id"),
+ ),
+ candidates: vec![RadrootsTradeLocator::from_order_id(
+ RadrootsOrderId::parse("trade-error").expect("order id"),
+ )],
+ },
+ "trade_ambiguous",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::SelectTradeRoot],
+ ),
+ (
RadrootsSdkError::ProductSyncUnsupported {
operation: "sync.push_outbox",
required_feature: "relay-runtime",