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:
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");