commit 4a4e7690529e855eeb5492e9cfa8757ec6827d2c
parent e40c353aa233ea422420f490767382589a08e45c
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 20:37:50 -0700
sdk: add order submit runtime
Diffstat:
3 files changed, 913 insertions(+), 14 deletions(-)
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -85,9 +85,11 @@ pub use crate::listings_runtime::{
};
#[cfg(feature = "runtime")]
pub use crate::orders_runtime::{
- ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderFulfillmentStatusKind,
- OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt,
- OrderStatusRequest, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource,
+ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND,
+ OrderFulfillmentStatusKind, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind,
+ OrderStatusReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPlan,
+ OrderSubmitPrepareRequest, OrderSubmitReceipt, SdkOrderStatusIssue, SdkOrderStatusIssueKind,
+ SdkOrderStatusSource,
};
#[cfg(feature = "runtime")]
pub use crate::product_clients::{FarmsClient, ListingsClient, OrdersClient, SyncClient};
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -1,14 +1,28 @@
#[cfg(feature = "runtime")]
-use crate::{OrdersClient, RadrootsSdkError};
+use crate::{
+ OrdersClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState,
+ SdkRelayTargetPolicy, SdkRelayUrlPolicy,
+ actor_json::SdkActorContextJson,
+ order,
+ workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow},
+};
+#[cfg(feature = "runtime")]
+use radroots_authority::{RadrootsActorContext, RadrootsEventSigner};
#[cfg(feature = "runtime")]
use radroots_events::{
- ids::{RadrootsEventId, RadrootsOrderId},
- order::RadrootsOrderFulfillmentState,
+ RadrootsNostrEventPtr,
+ contract::RadrootsActorRole,
+ draft::RadrootsFrozenEventDraft,
+ ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId},
+ order::{RadrootsOrderFulfillmentState, RadrootsOrderRequest},
};
#[cfg(feature = "runtime")]
+use radroots_events_codec::wire::to_frozen_draft;
+#[cfg(feature = "runtime")]
use radroots_trade::order::{
- RadrootsOrderIssue, RadrootsOrderPaymentState, RadrootsOrderProjectionQueryResult,
- RadrootsOrderSettlementState, RadrootsOrderStatus, RadrootsOrderStoreQueryError,
+ RadrootsOrderCanonicalizationError, RadrootsOrderIssue, RadrootsOrderPaymentState,
+ RadrootsOrderProjectionQueryResult, RadrootsOrderSettlementState, RadrootsOrderStatus,
+ RadrootsOrderStoreQueryError, canonicalize_order_request_for_signer,
order_projection_query_for_order_id,
};
#[cfg(feature = "runtime")]
@@ -18,6 +32,162 @@ use serde::ser::SerializeStruct;
pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500;
#[cfg(feature = "runtime")]
pub const ORDER_STATUS_MAX_LIMIT: u32 = 1_000;
+#[cfg(feature = "runtime")]
+pub const ORDER_SUBMIT_OPERATION_KIND: &str = "order.submit.v1";
+
+#[cfg(feature = "runtime")]
+const ORDER_REQUEST_CONTRACT_ID: &str = "radroots.order.request.v1";
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug)]
+#[non_exhaustive]
+pub struct OrderSubmitPrepareRequest {
+ pub actor: RadrootsActorContext,
+ pub listing_event: RadrootsNostrEventPtr,
+ pub order: RadrootsOrderRequest,
+ pub created_at: Option<RadrootsSdkTimestamp>,
+}
+
+#[cfg(feature = "runtime")]
+impl serde::Serialize for OrderSubmitPrepareRequest {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("OrderSubmitPrepareRequest", 4)?;
+ state.serialize_field("actor", &SdkActorContextJson(&self.actor))?;
+ state.serialize_field("listing_event", &self.listing_event)?;
+ state.serialize_field("order", &self.order)?;
+ state.serialize_field("created_at", &self.created_at)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl OrderSubmitPrepareRequest {
+ pub fn new(
+ actor: RadrootsActorContext,
+ listing_event: RadrootsNostrEventPtr,
+ order: RadrootsOrderRequest,
+ ) -> Self {
+ Self {
+ actor,
+ listing_event,
+ order,
+ created_at: None,
+ }
+ }
+
+ pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self {
+ self.created_at = Some(created_at);
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug)]
+#[non_exhaustive]
+pub struct OrderSubmitEnqueueRequest {
+ pub actor: RadrootsActorContext,
+ pub listing_event: RadrootsNostrEventPtr,
+ pub order: RadrootsOrderRequest,
+ pub target_relays: SdkRelayTargetPolicy,
+ pub idempotency_key: Option<SdkIdempotencyKey>,
+ pub created_at: Option<RadrootsSdkTimestamp>,
+}
+
+#[cfg(feature = "runtime")]
+impl serde::Serialize for OrderSubmitEnqueueRequest {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("OrderSubmitEnqueueRequest", 6)?;
+ state.serialize_field("actor", &SdkActorContextJson(&self.actor))?;
+ state.serialize_field("listing_event", &self.listing_event)?;
+ state.serialize_field("order", &self.order)?;
+ state.serialize_field("target_relays", &self.target_relays)?;
+ state.serialize_field("idempotency_key", &self.idempotency_key)?;
+ state.serialize_field("created_at", &self.created_at)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl OrderSubmitEnqueueRequest {
+ pub fn new(
+ actor: RadrootsActorContext,
+ listing_event: RadrootsNostrEventPtr,
+ order: RadrootsOrderRequest,
+ target_relays: SdkRelayTargetPolicy,
+ ) -> Self {
+ Self {
+ actor,
+ listing_event,
+ order,
+ target_relays,
+ idempotency_key: None,
+ created_at: None,
+ }
+ }
+
+ pub fn try_with_target_relays<I, S>(
+ mut self,
+ target_relays: I,
+ policy: SdkRelayUrlPolicy,
+ ) -> Result<Self, RadrootsSdkError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ {
+ self.target_relays = SdkRelayTargetPolicy::try_explicit(target_relays, policy)?;
+ Ok(self)
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+
+ pub fn try_with_idempotency_key(
+ mut self,
+ idempotency_key: impl AsRef<str>,
+ ) -> Result<Self, RadrootsSdkError> {
+ self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?);
+ Ok(self)
+ }
+
+ pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self {
+ self.created_at = Some(created_at);
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct OrderSubmitPlan {
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub listing_event_id: RadrootsEventId,
+ pub expected_event_id: RadrootsEventId,
+ pub frozen_draft: RadrootsFrozenEventDraft,
+ pub created_at: RadrootsSdkTimestamp,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct OrderSubmitReceipt {
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub listing_event_id: RadrootsEventId,
+ pub expected_event_id: RadrootsEventId,
+ pub signed_event_id: RadrootsEventId,
+ pub local_event_seq: i64,
+ pub outbox_operation_id: i64,
+ pub outbox_event_id: i64,
+ pub state: SdkMutationState,
+ pub idempotency_digest_prefix: Option<String>,
+}
#[cfg(feature = "runtime")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
@@ -315,6 +485,83 @@ impl SdkOrderStatusIssueKind {
#[cfg(feature = "runtime")]
impl<'sdk> OrdersClient<'sdk> {
+ pub fn prepare_submit(
+ &self,
+ request: OrderSubmitPrepareRequest,
+ ) -> Result<OrderSubmitPlan, RadrootsSdkError> {
+ let created_at = self.resolved_created_at(request.created_at)?;
+ order_submit_plan(
+ &request.actor,
+ request.listing_event,
+ request.order,
+ created_at,
+ )
+ }
+
+ pub async fn enqueue_submit<S>(
+ &self,
+ request: OrderSubmitEnqueueRequest,
+ signer: &S,
+ ) -> Result<OrderSubmitReceipt, RadrootsSdkError>
+ where
+ S: RadrootsEventSigner + ?Sized,
+ {
+ let OrderSubmitEnqueueRequest {
+ actor,
+ listing_event,
+ order,
+ target_relays,
+ idempotency_key,
+ created_at,
+ } = request;
+ let prepare_request = OrderSubmitPrepareRequest {
+ actor: actor.clone(),
+ listing_event,
+ order,
+ created_at,
+ };
+ let plan = self.prepare_submit(prepare_request)?;
+ self.enqueue_prepared_submit(&actor, plan, target_relays, idempotency_key, signer)
+ .await
+ }
+
+ pub async fn enqueue_prepared_submit<S>(
+ &self,
+ actor: &RadrootsActorContext,
+ plan: OrderSubmitPlan,
+ target_relays: SdkRelayTargetPolicy,
+ idempotency_key: Option<SdkIdempotencyKey>,
+ signer: &S,
+ ) -> Result<OrderSubmitReceipt, RadrootsSdkError>
+ where
+ S: RadrootsEventSigner + ?Sized,
+ {
+ let enqueue = enqueue_signed_workflow(
+ self.sdk,
+ SdkWorkflowEnqueueRequest {
+ operation_kind: ORDER_SUBMIT_OPERATION_KIND,
+ actor,
+ frozen_draft: &plan.frozen_draft,
+ target_relays,
+ idempotency_key,
+ },
+ signer,
+ )
+ .await?;
+ Ok(OrderSubmitReceipt {
+ order_id: plan.order_id,
+ listing_addr: plan.listing_addr,
+ listing_event_id: plan.listing_event_id,
+ expected_event_id: plan.expected_event_id,
+ signed_event_id: enqueue.signed_event_id,
+ local_event_seq: enqueue.local_event_seq,
+ outbox_operation_id: enqueue.outbox_operation_id,
+ outbox_event_id: enqueue.outbox_event_id,
+ state: enqueue.state.into(),
+ idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix),
+ })
+ }
+
pub async fn status(
&self,
request: OrderStatusRequest,
@@ -329,6 +576,16 @@ impl<'sdk> OrdersClient<'sdk> {
.map_err(projection_error)?;
Ok(OrderStatusReceipt::from_query_result(query_result))
}
+
+ fn resolved_created_at(
+ &self,
+ created_at: Option<RadrootsSdkTimestamp>,
+ ) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> {
+ match created_at {
+ Some(created_at) => Ok(created_at),
+ None => self.sdk.now(),
+ }
+ }
}
#[cfg(feature = "runtime")]
@@ -360,6 +617,91 @@ impl OrderStatusReceipt {
}
#[cfg(feature = "runtime")]
+fn order_submit_plan(
+ actor: &RadrootsActorContext,
+ listing_event: RadrootsNostrEventPtr,
+ order_request: RadrootsOrderRequest,
+ created_at: RadrootsSdkTimestamp,
+) -> Result<OrderSubmitPlan, RadrootsSdkError> {
+ require_buyer_actor(actor, "order.prepare_submit")?;
+ let listing_event_id = listing_event_id(&listing_event)?;
+ let order_request =
+ canonicalize_order_request_for_signer(order_request, actor.pubkey().as_str())
+ .map_err(order_canonicalization_error)?;
+ let created_at_nostr = created_at.try_into_nostr_created_at()?;
+ let order_id = order_request.order_id.clone();
+ let listing_addr = order_request.listing_addr.clone();
+ let draft =
+ order::build_order_request_draft(&listing_event, &order_request).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("order submit draft encode failed: {error}"),
+ }
+ })?;
+ let frozen_draft = to_frozen_draft(
+ draft.into_wire_parts(),
+ ORDER_REQUEST_CONTRACT_ID,
+ order_request.buyer_pubkey.as_str(),
+ created_at_nostr,
+ )
+ .map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!("order submit draft freeze failed: {error}"),
+ })?;
+ let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str())
+ .map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!("order submit draft produced invalid event id: {error}"),
+ })?;
+ Ok(OrderSubmitPlan {
+ order_id,
+ listing_addr,
+ listing_event_id,
+ expected_event_id,
+ frozen_draft,
+ created_at,
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn require_buyer_actor(
+ actor: &RadrootsActorContext,
+ operation: &'static str,
+) -> Result<(), RadrootsSdkError> {
+ if actor.satisfies(RadrootsActorRole::Buyer) {
+ Ok(())
+ } else {
+ Err(RadrootsSdkError::UnauthorizedActor {
+ operation: operation.to_owned(),
+ reason: "missing role Buyer".to_owned(),
+ })
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn listing_event_id(
+ listing_event: &RadrootsNostrEventPtr,
+) -> Result<RadrootsEventId, RadrootsSdkError> {
+ RadrootsEventId::parse(listing_event.id.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("listing evidence event id is invalid: {error}"),
+ }
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> RadrootsSdkError {
+ match error {
+ RadrootsOrderCanonicalizationError::InvalidBuyerSigner => {
+ RadrootsSdkError::UnauthorizedActor {
+ operation: "order.prepare_submit".to_owned(),
+ reason: "actor pubkey must match order buyer_pubkey".to_owned(),
+ }
+ }
+ error => RadrootsSdkError::InvalidRequest {
+ message: format!("order submit request is invalid: {error}"),
+ },
+ }
+}
+
+#[cfg(feature = "runtime")]
impl From<RadrootsOrderStatus> for OrderStatusKind {
fn from(status: RadrootsOrderStatus) -> Self {
match status {
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -1,18 +1,24 @@
#![cfg(feature = "runtime")]
+use radroots_authority::{
+ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity,
+};
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
};
use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore};
-use radroots_events::kinds::KIND_LISTING;
use radroots_events::{
RadrootsNostrEvent,
+ contract::RadrootsActorRole,
+ draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent},
ids::{RadrootsEventId, RadrootsOrderId},
+ kinds::{KIND_LISTING, KIND_ORDER_REQUEST},
};
use radroots_nostr::prelude::{
RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr,
- radroots_nostr_build_event,
+ radroots_nostr_build_event, radroots_nostr_sign_frozen_draft,
};
+use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState};
use radroots_sdk::protocol::events::RadrootsNostrEventPtr;
use radroots_sdk::protocol::order::{
RadrootsListingAddress, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
@@ -22,9 +28,12 @@ use radroots_sdk::protocol::order::{
};
use radroots_sdk::protocol::wire::WireEventParts;
use radroots_sdk::{
- ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderPaymentStateKind,
- OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, RadrootsSdk, RadrootsSdkError,
- RadrootsSdkTimestamp, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource,
+ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND,
+ OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest,
+ OrderSubmitEnqueueRequest, OrderSubmitPrepareRequest, RadrootsSdk, RadrootsSdkError,
+ RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp,
+ SdkMutationState, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource,
+ SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy,
};
const BUYER_SECRET_KEY_HEX: &str =
@@ -35,6 +44,45 @@ const SELLER_SECRET_KEY_HEX: &str =
"59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
const SELLER_PUBLIC_KEY_HEX: &str =
"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+const OTHER_PUBLIC_KEY_HEX: &str =
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
+const RELAY: &str = "wss://relay.radroots.test";
+const RELAY_B: &str = "wss://relay-b.radroots.test";
+
+#[derive(Clone)]
+struct FixtureSigner {
+ identity: RadrootsSignerIdentity,
+ keys: RadrootsNostrKeys,
+}
+
+impl FixtureSigner {
+ fn new(secret_key_hex: &str) -> Self {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let pubkey = keys.public_key().to_hex();
+ Self {
+ identity: RadrootsSignerIdentity::new(pubkey).expect("identity"),
+ keys,
+ }
+ }
+}
+
+impl RadrootsEventSigner for FixtureSigner {
+ fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey {
+ self.identity.pubkey()
+ }
+
+ fn sign_frozen_draft(
+ &self,
+ draft: &RadrootsFrozenEventDraft,
+ ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> {
+ radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| {
+ RadrootsSignerError::SigningFailed {
+ message: error.to_string(),
+ }
+ })
+ }
+}
async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsSdk, RadrootsEventStore) {
let tempdir = tempfile::tempdir().expect("tempdir");
@@ -59,6 +107,18 @@ fn status_request(raw: &str) -> OrderStatusRequest {
OrderStatusRequest::parse(raw).expect("order status request")
}
+fn buyer_actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor")
+}
+
+fn other_buyer_actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(OTHER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor")
+}
+
+fn non_buyer_actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]).expect("actor")
+}
+
fn listing_address() -> RadrootsListingAddress {
RadrootsListingAddress::parse(format!(
"{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg"
@@ -69,7 +129,7 @@ fn listing_address() -> RadrootsListingAddress {
fn listing_event_ptr() -> RadrootsNostrEventPtr {
RadrootsNostrEventPtr {
id: deterministic_event_id("listing-event").into_string(),
- relays: Some("wss://relay.radroots.test".to_owned()),
+ relays: Some(RELAY.to_owned()),
}
}
@@ -137,6 +197,501 @@ fn order_request(raw_order_id: &str) -> RadrootsOrderRequest {
}
}
+fn invalid_listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: String::new(),
+ relays: Some(RELAY.to_owned()),
+ }
+}
+
+#[tokio::test]
+async fn order_submit_prepare_is_side_effect_free() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let listing_event = listing_event_ptr();
+ let request = OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ listing_event.clone(),
+ order_request("order-submit-prepare"),
+ );
+
+ let prepared = sdk.orders().prepare_submit(request).expect("prepared");
+
+ assert_eq!(prepared.order_id.as_str(), "order-submit-prepare");
+ assert_eq!(prepared.listing_addr, listing_address());
+ assert_eq!(
+ prepared.listing_event_id.as_str(),
+ listing_event.id.as_str()
+ );
+ assert_eq!(prepared.frozen_draft.kind, KIND_ORDER_REQUEST);
+ assert_eq!(prepared.created_at.unix_seconds(), 1_700_000_000);
+ assert_eq!(
+ prepared.expected_event_id,
+ prepared.frozen_draft.expected_event_id
+ );
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+ assert!(
+ store
+ .get_event(prepared.expected_event_id.as_str())
+ .await
+ .expect("event lookup")
+ .is_none()
+ );
+
+ let paths = sdk.storage_paths().expect("paths");
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ assert!(
+ outbox
+ .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000)
+ .await
+ .expect("claim")
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn order_submit_prepare_rejects_missing_listing_evidence() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let request = OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ invalid_listing_event_ptr(),
+ order_request("order-submit-missing-listing"),
+ );
+
+ let error = sdk
+ .orders()
+ .prepare_submit(request)
+ .expect_err("missing listing evidence");
+
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+}
+
+#[tokio::test]
+async fn order_submit_prepare_rejects_invalid_actor_or_payload() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+
+ let non_buyer = sdk
+ .orders()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ non_buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-non-buyer"),
+ ))
+ .expect_err("non buyer");
+ assert!(matches!(
+ non_buyer,
+ RadrootsSdkError::UnauthorizedActor { .. }
+ ));
+
+ let wrong_actor = sdk
+ .orders()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ other_buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-wrong-actor"),
+ ))
+ .expect_err("wrong actor");
+ assert!(matches!(
+ wrong_actor,
+ RadrootsSdkError::UnauthorizedActor { .. }
+ ));
+
+ let mut seller_mismatch = order_request("order-submit-seller-mismatch");
+ seller_mismatch.seller_pubkey = OTHER_PUBLIC_KEY_HEX.parse().expect("seller pubkey");
+ let seller_error = sdk
+ .orders()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ seller_mismatch,
+ ))
+ .expect_err("seller mismatch");
+ assert!(matches!(
+ seller_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let mut empty_items = order_request("order-submit-empty-items");
+ empty_items.items.clear();
+ let empty_items_error = sdk
+ .orders()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ empty_items,
+ ))
+ .expect_err("empty items");
+ assert!(matches!(
+ empty_items_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let mut empty_economics = order_request("order-submit-empty-economics");
+ empty_economics.economics.items.clear();
+ let empty_economics_error = sdk
+ .orders()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ empty_economics,
+ ))
+ .expect_err("empty economics");
+ assert!(matches!(
+ empty_economics_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+}
+
+#[tokio::test]
+async fn order_submit_enqueue_stores_event_queues_outbox_and_status_sees_request() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let order = order_request("order-submit-enqueue");
+ let prepared = sdk
+ .orders()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order.clone(),
+ ))
+ .expect("prepared");
+ let request = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("target relays")
+ .try_with_idempotency_key("order-submit-enqueue-idempotency")
+ .expect("idempotency key");
+
+ let receipt = sdk
+ .orders()
+ .enqueue_submit(request, &FixtureSigner::new(BUYER_SECRET_KEY_HEX))
+ .await
+ .expect("enqueue");
+
+ assert_eq!(receipt.order_id, prepared.order_id);
+ assert_eq!(receipt.listing_addr, prepared.listing_addr);
+ assert_eq!(receipt.listing_event_id, prepared.listing_event_id);
+ assert_eq!(receipt.expected_event_id, prepared.expected_event_id);
+ assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
+ assert_eq!(receipt.local_event_seq, 1);
+ assert_eq!(receipt.outbox_operation_id, 1);
+ assert_eq!(receipt.outbox_event_id, 1);
+ assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
+ assert!(receipt.idempotency_digest_prefix.is_some());
+
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 1
+ );
+ let stored_event = store
+ .get_event(receipt.signed_event_id.as_str())
+ .await
+ .expect("event lookup")
+ .expect("stored event");
+ assert_eq!(stored_event.kind, KIND_ORDER_REQUEST);
+ assert_eq!(
+ stored_event.contract_id.as_deref(),
+ Some("radroots.order.request.v1")
+ );
+
+ let paths = sdk.storage_paths().expect("paths");
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ let outbox_event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("outbox event")
+ .expect("outbox event");
+ assert_eq!(outbox_event.state, RadrootsOutboxEventState::Signed);
+ assert_eq!(outbox_event.draft.kind, KIND_ORDER_REQUEST);
+ assert!(outbox_event.signed_event.is_some());
+
+ let status = sdk
+ .orders()
+ .status(status_request("order-submit-enqueue"))
+ .await
+ .expect("status");
+ assert!(status.found);
+ assert_eq!(status.status, OrderStatusKind::Requested);
+ assert_eq!(status.event_count, 1);
+ assert_eq!(
+ status
+ .request_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(receipt.signed_event_id.as_str())
+ );
+}
+
+#[tokio::test]
+async fn order_submit_enqueue_returns_sanitized_signer_errors_before_mutation() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-wrong-signer"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("target relays");
+
+ let error = sdk
+ .orders()
+ .enqueue_submit(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX))
+ .await
+ .expect_err("signer error");
+ let message = error.to_string();
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::SignerPubkeyMismatch { .. }
+ ));
+ assert!(!message.contains("raw"));
+ assert!(!message.contains("ffff"));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+
+ let paths = sdk.storage_paths().expect("paths");
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ assert!(
+ outbox
+ .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000)
+ .await
+ .expect("claim")
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn order_submit_enqueue_derives_order_independent_idempotency_key() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let first = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-idempotent"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY_B, RELAY, RELAY], SdkRelayUrlPolicy::Public)
+ .expect("first target relays");
+ let second = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-idempotent"),
+ SdkRelayTargetPolicy::explicit(
+ SdkRelayTargetSet::new([RELAY, RELAY_B], SdkRelayUrlPolicy::Public)
+ .expect("second target relays"),
+ ),
+ );
+
+ let first_receipt = sdk
+ .orders()
+ .enqueue_submit(first, &FixtureSigner::new(BUYER_SECRET_KEY_HEX))
+ .await
+ .expect("first enqueue");
+ let second_receipt = sdk
+ .orders()
+ .enqueue_submit(second, &FixtureSigner::new(BUYER_SECRET_KEY_HEX))
+ .await
+ .expect("second enqueue");
+
+ assert_eq!(
+ first_receipt.outbox_event_id,
+ second_receipt.outbox_event_id
+ );
+ assert_eq!(
+ first_receipt.idempotency_digest_prefix,
+ second_receipt.idempotency_digest_prefix
+ );
+ assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued);
+
+ let paths = sdk.storage_paths().expect("paths");
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ let relay_urls = outbox
+ .relay_statuses(first_receipt.outbox_event_id)
+ .await
+ .expect("relay statuses")
+ .into_iter()
+ .map(|status| status.relay_url)
+ .collect::<Vec<_>>();
+ assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]);
+}
+
+#[tokio::test]
+async fn order_submit_enqueue_reports_partial_local_mutation_after_outbox_conflict() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let first = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-conflict-a"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("first target relays")
+ .try_with_idempotency_key("order-submit-conflict-idempotency")
+ .expect("first idempotency key");
+ sdk.orders()
+ .enqueue_submit(first, &FixtureSigner::new(BUYER_SECRET_KEY_HEX))
+ .await
+ .expect("first enqueue");
+
+ let second = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-conflict-b"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("second target relays")
+ .try_with_idempotency_key("order-submit-conflict-idempotency")
+ .expect("second idempotency key");
+ let error = sdk
+ .orders()
+ .enqueue_submit(second, &FixtureSigner::new(BUYER_SECRET_KEY_HEX))
+ .await
+ .expect_err("partial");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::PartialLocalMutation(ref partial)
+ if partial.stored
+ && !partial.queued
+ && partial.event_id.is_some()
+ && partial.operation_kind == ORDER_SUBMIT_OPERATION_KIND
+ && partial.idempotency_digest_prefix.is_some()
+ && partial.failure == RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict
+ && partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey
+ ));
+ assert!(
+ !error
+ .to_string()
+ .contains("order-submit-conflict-idempotency")
+ );
+}
+
+#[tokio::test]
+async fn order_submit_runtime_dtos_serialize_deterministically() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123);
+ let prepare_request = OrderSubmitPrepareRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-serialized"),
+ )
+ .with_created_at(created_at);
+ let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json");
+
+ assert_eq!(
+ prepare_json["actor"],
+ serde_json::json!({
+ "pubkey": BUYER_PUBLIC_KEY_HEX,
+ "roles": ["buyer"],
+ "account_id": null,
+ "source": "test"
+ })
+ );
+ assert_eq!(
+ prepare_json["listing_event"],
+ serde_json::json!({
+ "id": deterministic_event_id("listing-event").as_str(),
+ "relays": RELAY
+ })
+ );
+ assert_eq!(prepare_json["order"]["order_id"], "order-submit-serialized");
+ assert_eq!(
+ prepare_json["order"]["listing_addr"],
+ listing_address().as_str()
+ );
+ assert_eq!(prepare_json["order"]["buyer_pubkey"], BUYER_PUBLIC_KEY_HEX);
+ assert_eq!(
+ prepare_json["order"]["seller_pubkey"],
+ SELLER_PUBLIC_KEY_HEX
+ );
+ assert_eq!(prepare_json["order"]["items"][0]["bin_id"], "bin-1");
+ assert_eq!(prepare_json["order"]["items"][0]["bin_count"], 2);
+ assert_eq!(prepare_json["created_at"], 1_700_000_123);
+
+ let enqueue_request = OrderSubmitEnqueueRequest::new(
+ buyer_actor(),
+ listing_event_ptr(),
+ order_request("order-submit-serialized-enqueue"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public)
+ .expect("relay targets")
+ .try_with_idempotency_key("order-serialized-idempotency")
+ .expect("idempotency")
+ .with_created_at(created_at);
+ let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json");
+
+ assert_eq!(
+ enqueue_json["target_relays"],
+ serde_json::json!({
+ "kind": "explicit",
+ "relays": [RELAY, RELAY_B],
+ "canonical_relays": [RELAY_B, RELAY]
+ })
+ );
+ assert_eq!(
+ enqueue_json["idempotency_key"],
+ serde_json::json!({ "value": "<redacted>", "len": 28 })
+ );
+ assert_eq!(enqueue_json["created_at"], 1_700_000_123);
+ assert!(
+ !enqueue_json
+ .to_string()
+ .contains("order-serialized-idempotency")
+ );
+
+ let receipt = sdk
+ .orders()
+ .enqueue_submit(enqueue_request, &FixtureSigner::new(BUYER_SECRET_KEY_HEX))
+ .await
+ .expect("enqueue");
+ let receipt_json = serde_json::to_value(&receipt).expect("receipt json");
+
+ assert_eq!(
+ receipt_json,
+ serde_json::json!({
+ "order_id": receipt.order_id.as_str(),
+ "listing_addr": receipt.listing_addr.as_str(),
+ "listing_event_id": receipt.listing_event_id.as_str(),
+ "expected_event_id": receipt.expected_event_id.as_str(),
+ "signed_event_id": receipt.signed_event_id.as_str(),
+ "local_event_seq": 1,
+ "outbox_operation_id": 1,
+ "outbox_event_id": 1,
+ "state": "stored_and_queued",
+ "idempotency_digest_prefix": receipt.idempotency_digest_prefix.as_deref()
+ })
+ );
+}
+
fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision {
RadrootsOrderDecision {
order_id: order_id(raw_order_id),