sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 4a5997a2e48a2b082f277db8d6c135a16667614f
parent 29a7d1f5b96eabc36dbab20eacc3b2947a5f22ee
Author: triesap <tyson@radroots.org>
Date:   Tue, 30 Jun 2026 03:45:32 +0000

sdk: return structured trade ambiguity

Diffstat:
Mcrates/sdk/src/error.rs | 30++++++++++++++++++++++++++++++
Mcrates/sdk/src/orders_runtime.rs | 19+++++++++----------
Mcrates/sdk/tests/orders_runtime.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/runtime_foundation.rs | 17+++++++++++++++++
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",