sdk

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

commit 698ee92807ac1365058ed96ed19f78cdb7978e73
parent c2ee5ccab8eb19f8d996184a584997407f9e5bea
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 22:25:34 -0700

sdk: add order decision runtime

- add order decision prepare and enqueue DTOs plus public SDK exports
- enqueue accepted and declined decisions through the SDK event store and outbox
- require local request evidence and reject conflicting local decision state before mutation
- cover decision DTOs, actor checks, signer errors, and source-boundary validation

Diffstat:
Mcrates/sdk/src/lib.rs | 11++++++-----
Mcrates/sdk/src/orders_runtime.rs | 433++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/orders_runtime.rs | 566++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 998 insertions(+), 12 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -85,11 +85,12 @@ pub use crate::listings_runtime::{ }; #[cfg(feature = "runtime")] pub use crate::orders_runtime::{ - 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, + ORDER_DECISION_OPERATION_KIND, ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, + ORDER_SUBMIT_OPERATION_KIND, OrderDecisionEnqueueRequest, OrderDecisionPlan, + OrderDecisionPrepareRequest, OrderDecisionReceipt, 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 @@ -13,17 +13,17 @@ use radroots_events::{ RadrootsNostrEventPtr, contract::RadrootsActorRole, draft::RadrootsFrozenEventDraft, - ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId}, - order::{RadrootsOrderFulfillmentState, RadrootsOrderRequest}, + ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey}, + order::{RadrootsOrderDecision, RadrootsOrderFulfillmentState, RadrootsOrderRequest}, }; #[cfg(feature = "runtime")] use radroots_events_codec::wire::to_frozen_draft; #[cfg(feature = "runtime")] use radroots_trade::order::{ RadrootsOrderCanonicalizationError, RadrootsOrderIssue, RadrootsOrderPaymentState, - RadrootsOrderProjectionQueryResult, RadrootsOrderSettlementState, RadrootsOrderStatus, - RadrootsOrderStoreQueryError, canonicalize_order_request_for_signer, - order_projection_query_for_order_id, + RadrootsOrderProjection, RadrootsOrderProjectionQueryResult, RadrootsOrderSettlementState, + RadrootsOrderStatus, RadrootsOrderStoreQueryError, canonicalize_order_decision_for_signer, + canonicalize_order_request_for_signer, order_projection_query_for_order_id, }; #[cfg(feature = "runtime")] use serde::ser::SerializeStruct; @@ -34,9 +34,13 @@ pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500; 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")] +pub const ORDER_DECISION_OPERATION_KIND: &str = "order.decision.v1"; #[cfg(feature = "runtime")] const ORDER_REQUEST_CONTRACT_ID: &str = "radroots.order.request.v1"; +#[cfg(feature = "runtime")] +const ORDER_DECISION_CONTRACT_ID: &str = "radroots.order.decision.v1"; #[cfg(feature = "runtime")] #[derive(Clone, Debug)] @@ -190,6 +194,161 @@ pub struct OrderSubmitReceipt { } #[cfg(feature = "runtime")] +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct OrderDecisionPrepareRequest { + pub actor: RadrootsActorContext, + pub request_event: RadrootsNostrEventPtr, + pub decision: RadrootsOrderDecision, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderDecisionPrepareRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderDecisionPrepareRequest", 4)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("request_event", &self.request_event)?; + state.serialize_field("decision", &self.decision)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +impl OrderDecisionPrepareRequest { + pub fn new( + actor: RadrootsActorContext, + request_event: RadrootsNostrEventPtr, + decision: RadrootsOrderDecision, + ) -> Self { + Self { + actor, + request_event, + decision, + 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 OrderDecisionEnqueueRequest { + pub actor: RadrootsActorContext, + pub request_event: RadrootsNostrEventPtr, + pub decision: RadrootsOrderDecision, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderDecisionEnqueueRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderDecisionEnqueueRequest", 6)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("request_event", &self.request_event)?; + state.serialize_field("decision", &self.decision)?; + 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 OrderDecisionEnqueueRequest { + pub fn new( + actor: RadrootsActorContext, + request_event: RadrootsNostrEventPtr, + decision: RadrootsOrderDecision, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + request_event, + decision, + 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 OrderDecisionPlan { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub request_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 OrderDecisionReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub request_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)] #[non_exhaustive] pub struct OrderStatusRequest { @@ -562,6 +721,86 @@ impl<'sdk> OrdersClient<'sdk> { }) } + pub fn prepare_decision( + &self, + request: OrderDecisionPrepareRequest, + ) -> Result<OrderDecisionPlan, RadrootsSdkError> { + let created_at = self.resolved_created_at(request.created_at)?; + order_decision_plan( + &request.actor, + request.request_event, + request.decision, + created_at, + ) + } + + pub async fn enqueue_decision<S>( + &self, + request: OrderDecisionEnqueueRequest, + signer: &S, + ) -> Result<OrderDecisionReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + let OrderDecisionEnqueueRequest { + actor, + request_event, + decision, + target_relays, + idempotency_key, + created_at, + } = request; + let prepare_request = OrderDecisionPrepareRequest { + actor: actor.clone(), + request_event, + decision, + created_at, + }; + let plan = self.prepare_decision(prepare_request)?; + self.enqueue_prepared_decision(&actor, plan, target_relays, idempotency_key, signer) + .await + } + + pub async fn enqueue_prepared_decision<S>( + &self, + actor: &RadrootsActorContext, + plan: OrderDecisionPlan, + target_relays: SdkRelayTargetPolicy, + idempotency_key: Option<SdkIdempotencyKey>, + signer: &S, + ) -> Result<OrderDecisionReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + self.require_decision_preflight(&plan).await?; + let enqueue = enqueue_signed_workflow( + self.sdk, + SdkWorkflowEnqueueRequest { + operation_kind: ORDER_DECISION_OPERATION_KIND, + actor, + frozen_draft: &plan.frozen_draft, + target_relays, + idempotency_key, + }, + signer, + ) + .await?; + Ok(OrderDecisionReceipt { + order_id: plan.order_id, + listing_addr: plan.listing_addr, + buyer_pubkey: plan.buyer_pubkey, + seller_pubkey: plan.seller_pubkey, + request_event_id: plan.request_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, @@ -586,6 +825,20 @@ impl<'sdk> OrdersClient<'sdk> { None => self.sdk.now(), } } + + async fn require_decision_preflight( + &self, + plan: &OrderDecisionPlan, + ) -> Result<(), RadrootsSdkError> { + let query_result = order_projection_query_for_order_id( + &self.sdk._event_store, + &plan.order_id, + ORDER_STATUS_MAX_LIMIT, + ) + .await + .map_err(projection_error)?; + require_decision_request_evidence(plan, &query_result.projection) + } } #[cfg(feature = "runtime")] @@ -661,6 +914,141 @@ fn order_submit_plan( } #[cfg(feature = "runtime")] +fn order_decision_plan( + actor: &RadrootsActorContext, + request_event: RadrootsNostrEventPtr, + decision: RadrootsOrderDecision, + created_at: RadrootsSdkTimestamp, +) -> Result<OrderDecisionPlan, RadrootsSdkError> { + require_seller_actor(actor, "order.prepare_decision")?; + let request_event_id = request_event_id(&request_event)?; + if decision.seller_pubkey.as_str() != actor.pubkey().as_str() { + return Err(RadrootsSdkError::UnauthorizedActor { + operation: "order.prepare_decision".to_owned(), + reason: "actor pubkey must match order seller_pubkey".to_owned(), + }); + } + let decision = canonicalize_order_decision_for_signer(decision, actor.pubkey().as_str()) + .map_err(order_decision_canonicalization_error)?; + let created_at_nostr = created_at.try_into_nostr_created_at()?; + let order_id = decision.order_id.clone(); + let listing_addr = decision.listing_addr.clone(); + let buyer_pubkey = decision.buyer_pubkey.clone(); + let seller_pubkey = decision.seller_pubkey.clone(); + let draft = order::build_order_decision_draft(&request_event_id, &request_event_id, &decision) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order decision draft encode failed: {error}"), + })?; + let frozen_draft = to_frozen_draft( + draft.into_wire_parts(), + ORDER_DECISION_CONTRACT_ID, + decision.seller_pubkey.as_str(), + created_at_nostr, + ) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order decision 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 decision draft produced invalid event id: {error}"), + })?; + Ok(OrderDecisionPlan { + order_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + request_event_id, + expected_event_id, + frozen_draft, + created_at, + }) +} + +#[cfg(feature = "runtime")] +fn require_decision_request_evidence( + plan: &OrderDecisionPlan, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let Some(request_event_id) = &projection.request_event_id else { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "order decision requires local request evidence for order {}", + plan.order_id + ), + }); + }; + if request_event_id != &plan.request_event_id { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "order decision request evidence {} does not match local request {} for order {}", + plan.request_event_id, request_event_id, plan.order_id + ), + }); + } + if !matches!(&projection.status, RadrootsOrderStatus::Requested) { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "order decision requires requested local state for order {}; current state is {:?}", + plan.order_id, projection.status + ), + }); + } + if !projection.issues.is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "order decision request evidence for order {} has {} reducer issue(s)", + plan.order_id, + projection.issues.len() + ), + }); + } + require_projection_match( + "listing_addr", + projection.listing_addr.as_ref(), + &plan.listing_addr, + &plan.order_id, + )?; + require_projection_match( + "buyer_pubkey", + projection.buyer_pubkey.as_ref(), + &plan.buyer_pubkey, + &plan.order_id, + )?; + require_projection_match( + "seller_pubkey", + projection.seller_pubkey.as_ref(), + &plan.seller_pubkey, + &plan.order_id, + )?; + Ok(()) +} + +#[cfg(feature = "runtime")] +fn require_projection_match<T>( + field: &'static str, + actual: Option<&T>, + expected: &T, + order_id: &RadrootsOrderId, +) -> Result<(), RadrootsSdkError> +where + T: core::fmt::Display + PartialEq, +{ + match actual { + Some(actual) if actual == expected => Ok(()), + Some(actual) => Err(RadrootsSdkError::InvalidRequest { + message: format!( + "order decision {field} {expected} does not match local request {actual} for order {order_id}" + ), + }), + None => Err(RadrootsSdkError::InvalidRequest { + message: format!( + "order decision request evidence is missing {field} for order {order_id}" + ), + }), + } +} + +#[cfg(feature = "runtime")] fn require_buyer_actor( actor: &RadrootsActorContext, operation: &'static str, @@ -676,6 +1064,21 @@ fn require_buyer_actor( } #[cfg(feature = "runtime")] +fn require_seller_actor( + actor: &RadrootsActorContext, + operation: &'static str, +) -> Result<(), RadrootsSdkError> { + if actor.satisfies(RadrootsActorRole::Seller) { + Ok(()) + } else { + Err(RadrootsSdkError::UnauthorizedActor { + operation: operation.to_owned(), + reason: "missing role Seller".to_owned(), + }) + } +} + +#[cfg(feature = "runtime")] fn listing_event_id( listing_event: &RadrootsNostrEventPtr, ) -> Result<RadrootsEventId, RadrootsSdkError> { @@ -687,6 +1090,17 @@ fn listing_event_id( } #[cfg(feature = "runtime")] +fn request_event_id( + request_event: &RadrootsNostrEventPtr, +) -> Result<RadrootsEventId, RadrootsSdkError> { + RadrootsEventId::parse(request_event.id.as_str()).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("order request evidence event id is invalid: {error}"), + } + }) +} + +#[cfg(feature = "runtime")] fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> RadrootsSdkError { match error { RadrootsOrderCanonicalizationError::InvalidBuyerSigner => { @@ -702,6 +1116,15 @@ fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> Ra } #[cfg(feature = "runtime")] +fn order_decision_canonicalization_error( + error: RadrootsOrderCanonicalizationError, +) -> RadrootsSdkError { + RadrootsSdkError::InvalidRequest { + message: format!("order decision 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 @@ -12,7 +12,7 @@ use radroots_events::{ contract::RadrootsActorRole, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, ids::{RadrootsEventId, RadrootsOrderId}, - kinds::{KIND_LISTING, KIND_ORDER_REQUEST}, + kinds::{KIND_LISTING, KIND_ORDER_DECISION, KIND_ORDER_REQUEST}, }; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr, @@ -29,7 +29,8 @@ use radroots_sdk::protocol::order::{ }; use radroots_sdk::protocol::wire::WireEventParts; use radroots_sdk::{ - ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND, + ORDER_DECISION_OPERATION_KIND, ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, + ORDER_SUBMIT_OPERATION_KIND, OrderDecisionEnqueueRequest, OrderDecisionPrepareRequest, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPrepareRequest, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk, RadrootsSdkError, @@ -113,14 +114,26 @@ fn buyer_actor() -> RadrootsActorContext { RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") } +fn seller_actor() -> RadrootsActorContext { + RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") +} + fn other_buyer_actor() -> RadrootsActorContext { RadrootsActorContext::test(OTHER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") } +fn other_seller_actor() -> RadrootsActorContext { + RadrootsActorContext::test(OTHER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") +} + fn non_buyer_actor() -> RadrootsActorContext { RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]).expect("actor") } +fn non_seller_actor() -> RadrootsActorContext { + RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") +} + fn listing_address() -> RadrootsListingAddress { RadrootsListingAddress::parse(format!( "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" @@ -791,6 +804,13 @@ fn signed_order_request_event(raw_order_id: &str, created_at: u32) -> RadrootsNo signed_event(BUYER_SECRET_KEY_HEX, created_at, draft.into_wire_parts()) } +fn request_event_ptr(event: &RadrootsNostrEvent) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event.id.clone(), + relays: Some(RELAY.to_owned()), + } +} + fn signed_order_decision_event( raw_order_id: &str, root_event_id: &RadrootsEventId, @@ -806,6 +826,548 @@ fn signed_order_decision_event( } #[tokio::test] +async fn order_decision_prepare_accept_and_decline_are_side_effect_free() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event_id = deterministic_event_id("order-decision-prepare-request"); + let request_event = RadrootsNostrEventPtr { + id: request_event_id.as_str().to_owned(), + relays: Some(RELAY.to_owned()), + }; + let accepted_request = OrderDecisionPrepareRequest::new( + seller_actor(), + request_event.clone(), + order_decision("order-decision-prepare-accept"), + ); + + let accepted = sdk + .orders() + .prepare_decision(accepted_request) + .expect("accepted plan"); + + assert_eq!(accepted.order_id.as_str(), "order-decision-prepare-accept"); + assert_eq!(accepted.listing_addr, listing_address()); + assert_eq!(accepted.buyer_pubkey.as_str(), BUYER_PUBLIC_KEY_HEX); + assert_eq!(accepted.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); + assert_eq!(accepted.request_event_id, request_event_id); + assert_eq!(accepted.frozen_draft.kind, KIND_ORDER_DECISION); + assert_eq!(accepted.created_at.unix_seconds(), 1_700_000_000); + assert_eq!( + accepted.expected_event_id, + accepted.frozen_draft.expected_event_id + ); + + let mut declined_payload = order_decision("order-decision-prepare-decline"); + declined_payload.decision = RadrootsOrderDecisionOutcome::Declined { + reason: " out of stock ".to_owned(), + }; + let declined = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller_actor(), + request_event, + declined_payload, + )) + .expect("declined plan"); + + assert_eq!(declined.order_id.as_str(), "order-decision-prepare-decline"); + assert_eq!(declined.frozen_draft.kind, KIND_ORDER_DECISION); + 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_decision_prepare_rejects_invalid_actor_evidence_and_payload() { + let (_tempdir, sdk, _store) = directory_sdk_and_store().await; + let request_event = RadrootsNostrEventPtr { + id: deterministic_event_id("order-decision-invalid-request") + .as_str() + .to_owned(), + relays: Some(RELAY.to_owned()), + }; + + let non_seller = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + non_seller_actor(), + request_event.clone(), + order_decision("order-decision-non-seller"), + )) + .expect_err("non seller"); + assert!(matches!( + non_seller, + RadrootsSdkError::UnauthorizedActor { .. } + )); + + let wrong_actor = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + other_seller_actor(), + request_event.clone(), + order_decision("order-decision-wrong-seller"), + )) + .expect_err("wrong seller"); + assert!(matches!( + wrong_actor, + RadrootsSdkError::UnauthorizedActor { .. } + )); + + let invalid_evidence = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller_actor(), + RadrootsNostrEventPtr { + id: String::new(), + relays: Some(RELAY.to_owned()), + }, + order_decision("order-decision-invalid-evidence"), + )) + .expect_err("invalid evidence"); + assert!(matches!( + invalid_evidence, + RadrootsSdkError::InvalidRequest { .. } + )); + + let mut empty_commitments = order_decision("order-decision-empty-commitments"); + empty_commitments.decision = RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: Vec::new(), + }; + let commitment_error = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller_actor(), + request_event.clone(), + empty_commitments, + )) + .expect_err("missing commitments"); + assert!(matches!( + commitment_error, + RadrootsSdkError::InvalidRequest { .. } + )); + + let mut missing_reason = order_decision("order-decision-missing-reason"); + missing_reason.decision = RadrootsOrderDecisionOutcome::Declined { + reason: " ".to_owned(), + }; + let reason_error = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller_actor(), + request_event, + missing_reason, + )) + .expect_err("missing reason"); + assert!(matches!( + reason_error, + RadrootsSdkError::InvalidRequest { .. } + )); +} + +#[tokio::test] +async fn order_decision_runtime_dtos_serialize_deterministically() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); + let prepare_event_id = deterministic_event_id("order-decision-serialized-request"); + let prepare_request = OrderDecisionPrepareRequest::new( + seller_actor(), + RadrootsNostrEventPtr { + id: prepare_event_id.as_str().to_owned(), + relays: Some(RELAY.to_owned()), + }, + order_decision("order-decision-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": SELLER_PUBLIC_KEY_HEX, + "roles": ["seller"], + "account_id": null, + "source": "test" + }) + ); + assert_eq!( + prepare_json["request_event"], + serde_json::json!({ + "id": prepare_event_id.as_str(), + "relays": RELAY + }) + ); + assert_eq!( + prepare_json["decision"]["order_id"], + "order-decision-serialized" + ); + assert_eq!( + prepare_json["decision"]["seller_pubkey"], + SELLER_PUBLIC_KEY_HEX + ); + assert_eq!(prepare_json["created_at"], 1_700_000_321); + + let request_event = signed_order_request_event("order-decision-serialized-enqueue", 45); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 4_500)) + .await + .expect("ingest request"); + let enqueue_request = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-decision-serialized-enqueue"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) + .expect("target relays") + .try_with_idempotency_key("order-decision-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": 37 }) + ); + assert_eq!(enqueue_json["created_at"], 1_700_000_321); + assert!( + !enqueue_json + .to_string() + .contains("order-decision-serialized-idempotency") + ); + + let receipt = sdk + .orders() + .enqueue_decision(enqueue_request, &FixtureSigner::new(SELLER_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(), + "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, + "seller_pubkey": SELLER_PUBLIC_KEY_HEX, + "request_event_id": request_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": 2, + "outbox_operation_id": 1, + "outbox_event_id": 1, + "state": "stored_and_queued", + "idempotency_digest_prefix": receipt.idempotency_digest_prefix.as_deref() + }) + ); +} + +#[tokio::test] +async fn order_decision_enqueue_accept_stores_event_queues_outbox_and_updates_status() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-decision-accept", 40); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 4_000)) + .await + .expect("ingest request"); + let request = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-decision-accept"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays") + .try_with_idempotency_key("order-decision-accept-idempotency") + .expect("idempotency"); + + let receipt = sdk + .orders() + .enqueue_decision(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .await + .expect("enqueue"); + + assert_eq!(receipt.order_id.as_str(), "order-decision-accept"); + assert_eq!(receipt.listing_addr, listing_address()); + assert_eq!(receipt.buyer_pubkey.as_str(), BUYER_PUBLIC_KEY_HEX); + assert_eq!(receipt.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); + assert_eq!(receipt.request_event_id, request_event_id); + assert_eq!(receipt.signed_event_id, receipt.expected_event_id); + assert_eq!(receipt.local_event_seq, 2); + 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()); + + 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_DECISION); + assert_eq!( + stored_event.contract_id.as_deref(), + Some("radroots.order.decision.v1") + ); + + let paths = sdk.storage_paths().expect("paths"); + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + let operation = outbox + .get_operation(receipt.outbox_operation_id) + .await + .expect("outbox operation") + .expect("outbox operation"); + assert_eq!(operation.operation_kind, ORDER_DECISION_OPERATION_KIND); + 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_DECISION); + assert!(outbox_event.signed_event.is_some()); + + let status = sdk + .orders() + .status(status_request("order-decision-accept")) + .await + .expect("status"); + assert!(status.found); + assert_eq!(status.status, OrderStatusKind::Accepted); + assert_eq!(status.event_count, 2); + assert_eq!( + status + .request_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(request_event.id.as_str()) + ); + assert_eq!( + status + .decision_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(receipt.signed_event_id.as_str()) + ); + assert!(status.issues.is_empty()); +} + +#[tokio::test] +async fn order_decision_enqueue_decline_stores_event_and_status_sees_declined() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-decision-decline", 41); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 4_100)) + .await + .expect("ingest request"); + let mut decision = order_decision("order-decision-decline"); + decision.decision = RadrootsOrderDecisionOutcome::Declined { + reason: " unavailable ".to_owned(), + }; + let request = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + decision, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"); + + let receipt = sdk + .orders() + .enqueue_decision(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .await + .expect("enqueue"); + + assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); + let status = sdk + .orders() + .status(status_request("order-decision-decline")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Declined); + assert_eq!( + status + .decision_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(receipt.signed_event_id.as_str()) + ); + assert!(status.issues.is_empty()); +} + +#[tokio::test] +async fn order_decision_enqueue_rejects_missing_request_evidence_before_mutation() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let missing_request = RadrootsNostrEventPtr { + id: deterministic_event_id("missing-order-request") + .as_str() + .to_owned(), + relays: Some(RELAY.to_owned()), + }; + let request = OrderDecisionEnqueueRequest::new( + seller_actor(), + missing_request, + order_decision("order-decision-missing-request"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"); + + let error = sdk + .orders() + .enqueue_decision(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .await + .expect_err("missing request evidence"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + 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_decision_enqueue_returns_sanitized_signer_errors_before_decision_mutation() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-decision-wrong-signer", 42); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 4_200)) + .await + .expect("ingest request"); + let request = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-decision-wrong-signer"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"); + + let error = sdk + .orders() + .enqueue_decision(request, &FixtureSigner::new(BUYER_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, + 1 + ); +} + +#[tokio::test] +async fn order_decision_enqueue_rejects_existing_decision_state_before_mutation() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-decision-conflict", 43); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + let decision_event = + signed_order_decision_event("order-decision-conflict", &request_event_id, 44); + for (event, observed_at_ms) in [ + (request_event.clone(), 4_300), + (decision_event.clone(), 4_400), + ] { + store + .ingest_event(RadrootsEventIngest::new(event, observed_at_ms)) + .await + .expect("ingest"); + } + let mut decline = order_decision("order-decision-conflict"); + decline.decision = RadrootsOrderDecisionOutcome::Declined { + reason: "too late".to_owned(), + }; + let request = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + decline, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"); + + let error = sdk + .orders() + .enqueue_decision(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .await + .expect_err("existing decision"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 2 + ); + let status = sdk + .orders() + .status(status_request("order-decision-conflict")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Accepted); + assert_eq!( + status + .decision_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(decision_event.id.as_str()) + ); +} + +#[tokio::test] async fn order_status_returns_not_found_for_missing_local_order() { let (_tempdir, sdk, _store) = directory_sdk_and_store().await; let request = status_request("order-1");