sdk

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

commit 4a4e7690529e855eeb5492e9cfa8757ec6827d2c
parent e40c353aa233ea422420f490767382589a08e45c
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 20:37:50 -0700

sdk: add order submit runtime

Diffstat:
Mcrates/sdk/src/lib.rs | 8+++++---
Mcrates/sdk/src/orders_runtime.rs | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/orders_runtime.rs | 567++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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),