sdk

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

commit 40f83f485fb5af7c4763ad4e8662ae601e1277ab
parent c146bca18a56a55d6364a008e4914e07ae4665dc
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 23:22:46 -0700

sdk: add order lifecycle runtimes

- add typed revision, cancellation, fulfillment, and receipt runtime APIs
- enforce local projection preflight before lifecycle mutation
- preserve idempotent replay for already stored prepared events
- cover lifecycle enqueue, status projection, and invalid-state rejection

Diffstat:
Mcrates/sdk/src/lib.rs | 23+++++++++++++++++------
Mcrates/sdk/src/orders_runtime.rs | 2794++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/tests/orders_runtime.rs | 565+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 2990 insertions(+), 392 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -85,13 +85,24 @@ pub use crate::listings_runtime::{ }; #[cfg(feature = "runtime")] pub use crate::orders_runtime::{ - ORDER_DECISION_OPERATION_KIND, ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, - ORDER_SUBMIT_OPERATION_KIND, OrderDecisionEnqueueRequest, OrderDecisionPlan, + ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, + ORDER_FULFILLMENT_UPDATE_OPERATION_KIND, ORDER_RECEIPT_RECORD_OPERATION_KIND, + ORDER_REVISION_DECISION_OPERATION_KIND, ORDER_REVISION_PROPOSAL_OPERATION_KIND, + ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND, + OrderCancellationEnqueueRequest, OrderCancellationPlan, OrderCancellationPrepareRequest, + OrderCancellationReceipt, OrderDecisionEnqueueRequest, OrderDecisionPlan, OrderDecisionPrepareRequest, OrderDecisionReceipt, OrderFulfillmentStatusKind, - OrderPaymentStateKind, OrderRequestEvidenceIngestReceipt, OrderRequestEvidenceIngestRequest, - OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt, OrderStatusRequest, - OrderSubmitEnqueueRequest, OrderSubmitPlan, OrderSubmitPrepareRequest, OrderSubmitReceipt, - SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, + OrderFulfillmentUpdateEnqueueRequest, OrderFulfillmentUpdatePlan, + OrderFulfillmentUpdatePrepareRequest, OrderFulfillmentUpdateReceipt, OrderPaymentStateKind, + OrderReceiptRecordEnqueueRequest, OrderReceiptRecordPlan, OrderReceiptRecordPrepareRequest, + OrderReceiptRecordReceipt, OrderRequestEvidenceIngestReceipt, + OrderRequestEvidenceIngestRequest, OrderRevisionDecisionEnqueueRequest, + OrderRevisionDecisionPlan, OrderRevisionDecisionPrepareRequest, OrderRevisionDecisionReceipt, + OrderRevisionProposalEnqueueRequest, OrderRevisionProposalPlan, + OrderRevisionProposalPrepareRequest, OrderRevisionProposalReceipt, 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 @@ -16,10 +16,14 @@ use radroots_events::{ contract::RadrootsActorRole, draft::RadrootsFrozenEventDraft, ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey}, - order::{RadrootsOrderDecision, RadrootsOrderFulfillmentState, RadrootsOrderRequest}, + order::{ + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderFulfillmentState, + RadrootsOrderFulfillmentUpdate, RadrootsOrderReceipt, RadrootsOrderRequest, + RadrootsOrderRevisionDecision, RadrootsOrderRevisionProposal, + }, }; #[cfg(feature = "runtime")] -use radroots_events_codec::wire::to_frozen_draft; +use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; #[cfg(feature = "runtime")] use radroots_trade::order::{ RadrootsOrderCanonicalizationError, RadrootsOrderIssue, RadrootsOrderPaymentState, @@ -38,11 +42,31 @@ pub const ORDER_STATUS_MAX_LIMIT: u32 = 1_000; 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")] +pub const ORDER_REVISION_PROPOSAL_OPERATION_KIND: &str = "order.revision.proposal.v1"; +#[cfg(feature = "runtime")] +pub const ORDER_REVISION_DECISION_OPERATION_KIND: &str = "order.revision.decision.v1"; +#[cfg(feature = "runtime")] +pub const ORDER_CANCELLATION_OPERATION_KIND: &str = "order.cancellation.v1"; +#[cfg(feature = "runtime")] +pub const ORDER_FULFILLMENT_UPDATE_OPERATION_KIND: &str = "order.fulfillment.update.v1"; +#[cfg(feature = "runtime")] +pub const ORDER_RECEIPT_RECORD_OPERATION_KIND: &str = "order.receipt.record.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")] +const ORDER_REVISION_PROPOSAL_CONTRACT_ID: &str = "radroots.order.revision_proposal.v1"; +#[cfg(feature = "runtime")] +const ORDER_REVISION_DECISION_CONTRACT_ID: &str = "radroots.order.revision_decision.v1"; +#[cfg(feature = "runtime")] +const ORDER_CANCELLATION_CONTRACT_ID: &str = "radroots.order.cancellation.v1"; +#[cfg(feature = "runtime")] +const ORDER_FULFILLMENT_UPDATE_CONTRACT_ID: &str = "radroots.order.fulfillment_update.v1"; +#[cfg(feature = "runtime")] +const ORDER_RECEIPT_CONTRACT_ID: &str = "radroots.order.receipt.v1"; #[cfg(feature = "runtime")] #[derive(Clone, Debug)] @@ -399,382 +423,1663 @@ pub struct OrderDecisionReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Debug)] #[non_exhaustive] -pub struct OrderStatusRequest { - pub order_id: RadrootsOrderId, - pub limit: u32, +pub struct OrderRevisionProposalPrepareRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub proposal: RadrootsOrderRevisionProposal, + pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] -impl OrderStatusRequest { - pub fn new(order_id: RadrootsOrderId) -> Self { - Self { - order_id, - limit: ORDER_STATUS_DEFAULT_LIMIT, - } +impl serde::Serialize for OrderRevisionProposalPrepareRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderRevisionProposalPrepareRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("proposal", &self.proposal)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() } +} - pub fn parse(order_id: &str) -> Result<Self, RadrootsSdkError> { - RadrootsOrderId::parse(order_id) - .map(Self::new) - .map_err(|error| RadrootsSdkError::invalid_order_id(order_id, error.to_string())) +#[cfg(feature = "runtime")] +impl OrderRevisionProposalPrepareRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + proposal: RadrootsOrderRevisionProposal, + ) -> Self { + Self { + actor, + root_event, + previous_event, + proposal, + created_at: None, + } } - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); self } - - fn validate(&self) -> Result<(), RadrootsSdkError> { - if self.limit == 0 || self.limit > ORDER_STATUS_MAX_LIMIT { - return Err(RadrootsSdkError::order_status_limit_invalid( - self.limit, - 1, - ORDER_STATUS_MAX_LIMIT, - )); - } - Ok(()) - } } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct OrderStatusReceipt { - pub order_id: RadrootsOrderId, - pub source: SdkOrderStatusSource, - pub found: bool, - pub event_count: usize, - pub limit_applied: u32, - pub status: OrderStatusKind, - pub fulfillment_status: Option<OrderFulfillmentStatusKind>, - pub payment_state: OrderPaymentStateKind, - pub settlement_state: OrderSettlementStateKind, - pub lifecycle_terminal: bool, - pub event_ids: Vec<RadrootsEventId>, - pub request_event_id: Option<RadrootsEventId>, - pub decision_event_id: Option<RadrootsEventId>, - pub fulfillment_event_id: Option<RadrootsEventId>, - pub cancellation_event_id: Option<RadrootsEventId>, - pub receipt_event_id: Option<RadrootsEventId>, - pub last_event_id: Option<RadrootsEventId>, - pub issues: Vec<SdkOrderStatusIssue>, +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct OrderRevisionProposalEnqueueRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub proposal: RadrootsOrderRevisionProposal, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum SdkOrderStatusSource { - LocalEventStore, +impl serde::Serialize for OrderRevisionProposalEnqueueRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderRevisionProposalEnqueueRequest", 7)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("proposal", &self.proposal)?; + 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")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum OrderStatusKind { - Missing, - Requested, - Accepted, - Declined, - Cancelled, - Completed, - Disputed, - Invalid, +impl OrderRevisionProposalEnqueueRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + proposal: RadrootsOrderRevisionProposal, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + root_event, + previous_event, + proposal, + 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, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum OrderFulfillmentStatusKind { - AcceptedNotFulfilled, - Preparing, - ReadyForPickup, - OutForDelivery, - Delivered, - SellerCancelled, +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderRevisionProposalPlan { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_event_id: RadrootsEventId, + pub expected_event_id: RadrootsEventId, + pub frozen_draft: RadrootsFrozenEventDraft, + pub created_at: RadrootsSdkTimestamp, } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum OrderPaymentStateKind { - NotRecorded, - Recorded, - Settled, - Rejected, - Invalid, +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderRevisionProposalReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] +#[derive(Clone, Debug)] #[non_exhaustive] -pub enum OrderSettlementStateKind { - NotRequired, - Pending, - Accepted, - Rejected, - Invalid, +pub struct OrderRevisionDecisionPrepareRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub decision: RadrootsOrderRevisionDecision, + pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SdkOrderStatusIssue { - pub kind: SdkOrderStatusIssueKind, - pub event_ids: Vec<RadrootsEventId>, +impl serde::Serialize for OrderRevisionDecisionPrepareRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderRevisionDecisionPrepareRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("decision", &self.decision)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } } #[cfg(feature = "runtime")] -impl SdkOrderStatusIssue { - fn new(kind: SdkOrderStatusIssueKind, event_ids: Vec<RadrootsEventId>) -> Self { - Self { kind, event_ids } +impl OrderRevisionDecisionPrepareRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + decision: RadrootsOrderRevisionDecision, + ) -> Self { + Self { + actor, + root_event, + previous_event, + decision, + created_at: None, + } } - fn single(kind: SdkOrderStatusIssueKind, event_id: RadrootsEventId) -> Self { - Self::new(kind, vec![event_id]) + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self } +} - pub fn code(&self) -> String { - self.kind.code() - } +#[cfg(feature = "runtime")] +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct OrderRevisionDecisionEnqueueRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub decision: RadrootsOrderRevisionDecision, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] -impl serde::Serialize for SdkOrderStatusIssue { +impl serde::Serialize for OrderRevisionDecisionEnqueueRequest { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, { - let mut state = serializer.serialize_struct("SdkOrderStatusIssue", 3)?; - state.serialize_field("code", &self.code())?; - state.serialize_field("kind", &self.kind)?; - state.serialize_field("event_ids", &self.event_ids)?; + let mut state = serializer.serialize_struct("OrderRevisionDecisionEnqueueRequest", 7)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_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")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum SdkOrderStatusIssueKind { - MissingRequest, - MultipleRequests, - RequestPayloadInvalid, - RequestOrderIdMismatch, - RequestAuthorMismatch, - RequestListingAddressInvalid, - RequestSellerListingMismatch, - DecisionPayloadInvalid, - DecisionOrderIdMismatch, - DecisionAuthorMismatch, - DecisionCounterpartyMismatch, - DecisionBuyerMismatch, - DecisionSellerMismatch, - DecisionListingAddressInvalid, - DecisionListingMismatch, - DecisionRootMismatch, - DecisionPreviousMismatch, - DecisionMissingInventoryCommitments, - DecisionInventoryCommitmentMismatch, - DecisionMissingReason, - ConflictingDecisions, - RevisionProposalWithoutAcceptedDecision, - RevisionProposalPayloadInvalid, - RevisionProposalOrderIdMismatch, - RevisionProposalAuthorMismatch, - RevisionProposalCounterpartyMismatch, - RevisionProposalBuyerMismatch, - RevisionProposalSellerMismatch, - RevisionProposalListingAddressInvalid, - RevisionProposalListingMismatch, - RevisionProposalRootMismatch, - RevisionProposalPreviousMismatch, - RevisionDecisionWithoutProposal, - RevisionDecisionPayloadInvalid, - RevisionDecisionOrderIdMismatch, - RevisionDecisionAuthorMismatch, - RevisionDecisionCounterpartyMismatch, - RevisionDecisionBuyerMismatch, - RevisionDecisionSellerMismatch, - RevisionDecisionListingAddressInvalid, - RevisionDecisionListingMismatch, - RevisionDecisionRootMismatch, - RevisionDecisionPreviousMismatch, - RevisionDecisionRevisionIdMismatch, - FulfillmentWithoutAcceptedDecision, - FulfillmentPayloadInvalid, - FulfillmentOrderIdMismatch, - FulfillmentAuthorMismatch, - FulfillmentCounterpartyMismatch, - FulfillmentBuyerMismatch, - FulfillmentSellerMismatch, - FulfillmentListingAddressInvalid, - FulfillmentListingMismatch, - FulfillmentRootMismatch, - FulfillmentPreviousMismatch, - FulfillmentStatusNotPublishable, - FulfillmentUnsupportedTransition, - ForkedFulfillments, - CancellationWithoutCancellableOrder, - CancellationPayloadInvalid, - CancellationOrderIdMismatch, - CancellationAuthorMismatch, - CancellationCounterpartyMismatch, - CancellationBuyerMismatch, - CancellationSellerMismatch, - CancellationListingAddressInvalid, - CancellationListingMismatch, - CancellationRootMismatch, - CancellationPreviousMismatch, - CancellationAfterFulfillment, - ReceiptWithoutEligibleFulfillment, - ReceiptPayloadInvalid, - ReceiptOrderIdMismatch, - ReceiptAuthorMismatch, - ReceiptCounterpartyMismatch, - ReceiptBuyerMismatch, - ReceiptSellerMismatch, - ReceiptListingAddressInvalid, - ReceiptListingMismatch, - ReceiptRootMismatch, - ReceiptPreviousMismatch, - PaymentWithoutAcceptedAgreement, - PaymentPayloadInvalid, - PaymentOrderIdMismatch, - PaymentAuthorMismatch, - PaymentCounterpartyMismatch, - PaymentBuyerMismatch, - PaymentSellerMismatch, - PaymentListingAddressInvalid, - PaymentListingMismatch, - PaymentRootMismatch, - PaymentPreviousMismatch, - PaymentAgreementMismatch, - PaymentQuoteMismatch, - PaymentQuoteVersionMismatch, - PaymentEconomicsDigestMismatch, - PaymentAmountMismatch, - PaymentCurrencyMismatch, - PaymentAfterCancellation, - RevisionAfterPayment, - DuplicatePayments, - SettlementWithoutValidPayment, - SettlementPayloadInvalid, - SettlementOrderIdMismatch, - SettlementAuthorMismatch, - SettlementCounterpartyMismatch, - SettlementBuyerMismatch, - SettlementSellerMismatch, - SettlementListingAddressInvalid, - SettlementListingMismatch, - SettlementRootMismatch, - SettlementPreviousMismatch, - SettlementPaymentEventMismatch, - SettlementAgreementMismatch, - SettlementQuoteMismatch, - SettlementQuoteVersionMismatch, - SettlementEconomicsDigestMismatch, - SettlementAmountMismatch, - SettlementCurrencyMismatch, - DuplicateSettlements, - ForkedLifecycle, +impl OrderRevisionDecisionEnqueueRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + decision: RadrootsOrderRevisionDecision, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + root_event, + previous_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")] -impl SdkOrderStatusIssueKind { - pub fn code(self) -> String { - camel_to_snake(format!("{self:?}").as_str()) +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderRevisionDecisionPlan { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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 OrderRevisionDecisionReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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)] +#[non_exhaustive] +pub struct OrderCancellationPrepareRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub cancellation: RadrootsOrderCancellation, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderCancellationPrepareRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderCancellationPrepareRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("cancellation", &self.cancellation)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() } } #[cfg(feature = "runtime")] -impl<'sdk> OrdersClient<'sdk> { - pub async fn ingest_request_evidence( - &self, - request: OrderRequestEvidenceIngestRequest, - ) -> Result<OrderRequestEvidenceIngestReceipt, RadrootsSdkError> { - let evidence = parse_order_request_evidence(&request.event)?; - let observed_at = self.resolved_created_at(request.observed_at)?; - let observed_at_ms = sdk_timestamp_ms(observed_at)?; - let receipt = self - .sdk - ._event_store - .ingest_event(RadrootsEventIngest::new(request.event, observed_at_ms)) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; - Ok(OrderRequestEvidenceIngestReceipt { - order_id: evidence.order_id, - listing_addr: evidence.listing_addr, - buyer_pubkey: evidence.buyer_pubkey, - seller_pubkey: evidence.seller_pubkey, - request_event_id: evidence.request_event_id, - local_event_seq: receipt.seq, - inserted: receipt.inserted, +impl OrderCancellationPrepareRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + cancellation: RadrootsOrderCancellation, + ) -> Self { + Self { + actor, + root_event, + previous_event, + cancellation, + 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 OrderCancellationEnqueueRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub cancellation: RadrootsOrderCancellation, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderCancellationEnqueueRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderCancellationEnqueueRequest", 7)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("cancellation", &self.cancellation)?; + 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 OrderCancellationEnqueueRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + cancellation: RadrootsOrderCancellation, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + root_event, + previous_event, + cancellation, + 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 OrderCancellationPlan { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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 OrderCancellationReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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)] +#[non_exhaustive] +pub struct OrderFulfillmentUpdatePrepareRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub fulfillment: RadrootsOrderFulfillmentUpdate, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderFulfillmentUpdatePrepareRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderFulfillmentUpdatePrepareRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("fulfillment", &self.fulfillment)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +impl OrderFulfillmentUpdatePrepareRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + fulfillment: RadrootsOrderFulfillmentUpdate, + ) -> Self { + Self { + actor, + root_event, + previous_event, + fulfillment, + 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 OrderFulfillmentUpdateEnqueueRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub fulfillment: RadrootsOrderFulfillmentUpdate, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderFulfillmentUpdateEnqueueRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderFulfillmentUpdateEnqueueRequest", 7)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("fulfillment", &self.fulfillment)?; + 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 OrderFulfillmentUpdateEnqueueRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + fulfillment: RadrootsOrderFulfillmentUpdate, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + root_event, + previous_event, + fulfillment, + 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 OrderFulfillmentUpdatePlan { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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 OrderFulfillmentUpdateReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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)] +#[non_exhaustive] +pub struct OrderReceiptRecordPrepareRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub receipt: RadrootsOrderReceipt, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderReceiptRecordPrepareRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderReceiptRecordPrepareRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("receipt", &self.receipt)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +impl OrderReceiptRecordPrepareRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + receipt: RadrootsOrderReceipt, + ) -> Self { + Self { + actor, + root_event, + previous_event, + receipt, + 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 OrderReceiptRecordEnqueueRequest { + pub actor: RadrootsActorContext, + pub root_event: RadrootsNostrEventPtr, + pub previous_event: RadrootsNostrEventPtr, + pub receipt: RadrootsOrderReceipt, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderReceiptRecordEnqueueRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderReceiptRecordEnqueueRequest", 7)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("root_event", &self.root_event)?; + state.serialize_field("previous_event", &self.previous_event)?; + state.serialize_field("receipt", &self.receipt)?; + 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 OrderReceiptRecordEnqueueRequest { + pub fn new( + actor: RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + receipt: RadrootsOrderReceipt, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + root_event, + previous_event, + receipt, + 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 OrderReceiptRecordPlan { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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 OrderReceiptRecordReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub root_event_id: RadrootsEventId, + pub previous_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 { + pub order_id: RadrootsOrderId, + pub limit: u32, +} + +#[cfg(feature = "runtime")] +impl OrderStatusRequest { + pub fn new(order_id: RadrootsOrderId) -> Self { + Self { + order_id, + limit: ORDER_STATUS_DEFAULT_LIMIT, + } + } + + pub fn parse(order_id: &str) -> Result<Self, RadrootsSdkError> { + RadrootsOrderId::parse(order_id) + .map(Self::new) + .map_err(|error| RadrootsSdkError::invalid_order_id(order_id, error.to_string())) + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + fn validate(&self) -> Result<(), RadrootsSdkError> { + if self.limit == 0 || self.limit > ORDER_STATUS_MAX_LIMIT { + return Err(RadrootsSdkError::order_status_limit_invalid( + self.limit, + 1, + ORDER_STATUS_MAX_LIMIT, + )); + } + Ok(()) + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderStatusReceipt { + pub order_id: RadrootsOrderId, + pub source: SdkOrderStatusSource, + pub found: bool, + pub event_count: usize, + pub limit_applied: u32, + pub status: OrderStatusKind, + pub fulfillment_status: Option<OrderFulfillmentStatusKind>, + pub payment_state: OrderPaymentStateKind, + pub settlement_state: OrderSettlementStateKind, + pub lifecycle_terminal: bool, + pub event_ids: Vec<RadrootsEventId>, + pub request_event_id: Option<RadrootsEventId>, + pub decision_event_id: Option<RadrootsEventId>, + pub fulfillment_event_id: Option<RadrootsEventId>, + pub cancellation_event_id: Option<RadrootsEventId>, + pub receipt_event_id: Option<RadrootsEventId>, + pub last_event_id: Option<RadrootsEventId>, + pub issues: Vec<SdkOrderStatusIssue>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SdkOrderStatusSource { + LocalEventStore, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum OrderStatusKind { + Missing, + Requested, + Accepted, + Declined, + Cancelled, + Completed, + Disputed, + Invalid, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum OrderFulfillmentStatusKind { + AcceptedNotFulfilled, + Preparing, + ReadyForPickup, + OutForDelivery, + Delivered, + SellerCancelled, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum OrderPaymentStateKind { + NotRecorded, + Recorded, + Settled, + Rejected, + Invalid, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum OrderSettlementStateKind { + NotRequired, + Pending, + Accepted, + Rejected, + Invalid, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkOrderStatusIssue { + pub kind: SdkOrderStatusIssueKind, + pub event_ids: Vec<RadrootsEventId>, +} + +#[cfg(feature = "runtime")] +impl SdkOrderStatusIssue { + fn new(kind: SdkOrderStatusIssueKind, event_ids: Vec<RadrootsEventId>) -> Self { + Self { kind, event_ids } + } + + fn single(kind: SdkOrderStatusIssueKind, event_id: RadrootsEventId) -> Self { + Self::new(kind, vec![event_id]) + } + + pub fn code(&self) -> String { + self.kind.code() + } +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for SdkOrderStatusIssue { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("SdkOrderStatusIssue", 3)?; + state.serialize_field("code", &self.code())?; + state.serialize_field("kind", &self.kind)?; + state.serialize_field("event_ids", &self.event_ids)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SdkOrderStatusIssueKind { + MissingRequest, + MultipleRequests, + RequestPayloadInvalid, + RequestOrderIdMismatch, + RequestAuthorMismatch, + RequestListingAddressInvalid, + RequestSellerListingMismatch, + DecisionPayloadInvalid, + DecisionOrderIdMismatch, + DecisionAuthorMismatch, + DecisionCounterpartyMismatch, + DecisionBuyerMismatch, + DecisionSellerMismatch, + DecisionListingAddressInvalid, + DecisionListingMismatch, + DecisionRootMismatch, + DecisionPreviousMismatch, + DecisionMissingInventoryCommitments, + DecisionInventoryCommitmentMismatch, + DecisionMissingReason, + ConflictingDecisions, + RevisionProposalWithoutAcceptedDecision, + RevisionProposalPayloadInvalid, + RevisionProposalOrderIdMismatch, + RevisionProposalAuthorMismatch, + RevisionProposalCounterpartyMismatch, + RevisionProposalBuyerMismatch, + RevisionProposalSellerMismatch, + RevisionProposalListingAddressInvalid, + RevisionProposalListingMismatch, + RevisionProposalRootMismatch, + RevisionProposalPreviousMismatch, + RevisionDecisionWithoutProposal, + RevisionDecisionPayloadInvalid, + RevisionDecisionOrderIdMismatch, + RevisionDecisionAuthorMismatch, + RevisionDecisionCounterpartyMismatch, + RevisionDecisionBuyerMismatch, + RevisionDecisionSellerMismatch, + RevisionDecisionListingAddressInvalid, + RevisionDecisionListingMismatch, + RevisionDecisionRootMismatch, + RevisionDecisionPreviousMismatch, + RevisionDecisionRevisionIdMismatch, + FulfillmentWithoutAcceptedDecision, + FulfillmentPayloadInvalid, + FulfillmentOrderIdMismatch, + FulfillmentAuthorMismatch, + FulfillmentCounterpartyMismatch, + FulfillmentBuyerMismatch, + FulfillmentSellerMismatch, + FulfillmentListingAddressInvalid, + FulfillmentListingMismatch, + FulfillmentRootMismatch, + FulfillmentPreviousMismatch, + FulfillmentStatusNotPublishable, + FulfillmentUnsupportedTransition, + ForkedFulfillments, + CancellationWithoutCancellableOrder, + CancellationPayloadInvalid, + CancellationOrderIdMismatch, + CancellationAuthorMismatch, + CancellationCounterpartyMismatch, + CancellationBuyerMismatch, + CancellationSellerMismatch, + CancellationListingAddressInvalid, + CancellationListingMismatch, + CancellationRootMismatch, + CancellationPreviousMismatch, + CancellationAfterFulfillment, + ReceiptWithoutEligibleFulfillment, + ReceiptPayloadInvalid, + ReceiptOrderIdMismatch, + ReceiptAuthorMismatch, + ReceiptCounterpartyMismatch, + ReceiptBuyerMismatch, + ReceiptSellerMismatch, + ReceiptListingAddressInvalid, + ReceiptListingMismatch, + ReceiptRootMismatch, + ReceiptPreviousMismatch, + PaymentWithoutAcceptedAgreement, + PaymentPayloadInvalid, + PaymentOrderIdMismatch, + PaymentAuthorMismatch, + PaymentCounterpartyMismatch, + PaymentBuyerMismatch, + PaymentSellerMismatch, + PaymentListingAddressInvalid, + PaymentListingMismatch, + PaymentRootMismatch, + PaymentPreviousMismatch, + PaymentAgreementMismatch, + PaymentQuoteMismatch, + PaymentQuoteVersionMismatch, + PaymentEconomicsDigestMismatch, + PaymentAmountMismatch, + PaymentCurrencyMismatch, + PaymentAfterCancellation, + RevisionAfterPayment, + DuplicatePayments, + SettlementWithoutValidPayment, + SettlementPayloadInvalid, + SettlementOrderIdMismatch, + SettlementAuthorMismatch, + SettlementCounterpartyMismatch, + SettlementBuyerMismatch, + SettlementSellerMismatch, + SettlementListingAddressInvalid, + SettlementListingMismatch, + SettlementRootMismatch, + SettlementPreviousMismatch, + SettlementPaymentEventMismatch, + SettlementAgreementMismatch, + SettlementQuoteMismatch, + SettlementQuoteVersionMismatch, + SettlementEconomicsDigestMismatch, + SettlementAmountMismatch, + SettlementCurrencyMismatch, + DuplicateSettlements, + ForkedLifecycle, +} + +#[cfg(feature = "runtime")] +impl SdkOrderStatusIssueKind { + pub fn code(self) -> String { + camel_to_snake(format!("{self:?}").as_str()) + } +} + +#[cfg(feature = "runtime")] +impl<'sdk> OrdersClient<'sdk> { + pub async fn ingest_request_evidence( + &self, + request: OrderRequestEvidenceIngestRequest, + ) -> Result<OrderRequestEvidenceIngestReceipt, RadrootsSdkError> { + let evidence = parse_order_request_evidence(&request.event)?; + let observed_at = self.resolved_created_at(request.observed_at)?; + let observed_at_ms = sdk_timestamp_ms(observed_at)?; + let receipt = self + .sdk + ._event_store + .ingest_event(RadrootsEventIngest::new(request.event, observed_at_ms)) + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + Ok(OrderRequestEvidenceIngestReceipt { + order_id: evidence.order_id, + listing_addr: evidence.listing_addr, + buyer_pubkey: evidence.buyer_pubkey, + seller_pubkey: evidence.seller_pubkey, + request_event_id: evidence.request_event_id, + local_event_seq: receipt.seq, + inserted: receipt.inserted, + }) + } + + 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 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, + { + if !self + .prepared_order_event_exists(&plan.expected_event_id) + .await? + { + 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 fn prepare_revision_proposal( + &self, + request: OrderRevisionProposalPrepareRequest, + ) -> Result<OrderRevisionProposalPlan, RadrootsSdkError> { + let created_at = self.resolved_created_at(request.created_at)?; + order_revision_proposal_plan( + &request.actor, + request.root_event, + request.previous_event, + request.proposal, + created_at, + ) + } + + pub async fn enqueue_revision_proposal<S>( + &self, + request: OrderRevisionProposalEnqueueRequest, + signer: &S, + ) -> Result<OrderRevisionProposalReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + let OrderRevisionProposalEnqueueRequest { + actor, + root_event, + previous_event, + proposal, + target_relays, + idempotency_key, + created_at, + } = request; + let prepare_request = OrderRevisionProposalPrepareRequest { + actor: actor.clone(), + root_event, + previous_event, + proposal, + created_at, + }; + let plan = self.prepare_revision_proposal(prepare_request)?; + self.enqueue_prepared_revision_proposal( + &actor, + plan, + target_relays, + idempotency_key, + signer, + ) + .await + } + + pub async fn enqueue_prepared_revision_proposal<S>( + &self, + actor: &RadrootsActorContext, + plan: OrderRevisionProposalPlan, + target_relays: SdkRelayTargetPolicy, + idempotency_key: Option<SdkIdempotencyKey>, + signer: &S, + ) -> Result<OrderRevisionProposalReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + if !self + .prepared_order_event_exists(&plan.expected_event_id) + .await? + { + self.require_revision_proposal_preflight(&plan).await?; + } + let enqueue = enqueue_signed_workflow( + self.sdk, + SdkWorkflowEnqueueRequest { + operation_kind: ORDER_REVISION_PROPOSAL_OPERATION_KIND, + actor, + frozen_draft: &plan.frozen_draft, + target_relays, + idempotency_key, + }, + signer, + ) + .await?; + Ok(OrderRevisionProposalReceipt { + order_id: plan.order_id, + listing_addr: plan.listing_addr, + buyer_pubkey: plan.buyer_pubkey, + seller_pubkey: plan.seller_pubkey, + root_event_id: plan.root_event_id, + previous_event_id: plan.previous_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 fn prepare_revision_decision( + &self, + request: OrderRevisionDecisionPrepareRequest, + ) -> Result<OrderRevisionDecisionPlan, RadrootsSdkError> { + let created_at = self.resolved_created_at(request.created_at)?; + order_revision_decision_plan( + &request.actor, + request.root_event, + request.previous_event, + request.decision, + created_at, + ) + } + + pub async fn enqueue_revision_decision<S>( + &self, + request: OrderRevisionDecisionEnqueueRequest, + signer: &S, + ) -> Result<OrderRevisionDecisionReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + let OrderRevisionDecisionEnqueueRequest { + actor, + root_event, + previous_event, + decision, + target_relays, + idempotency_key, + created_at, + } = request; + let prepare_request = OrderRevisionDecisionPrepareRequest { + actor: actor.clone(), + root_event, + previous_event, + decision, + created_at, + }; + let plan = self.prepare_revision_decision(prepare_request)?; + self.enqueue_prepared_revision_decision( + &actor, + plan, + target_relays, + idempotency_key, + signer, + ) + .await + } + + pub async fn enqueue_prepared_revision_decision<S>( + &self, + actor: &RadrootsActorContext, + plan: OrderRevisionDecisionPlan, + target_relays: SdkRelayTargetPolicy, + idempotency_key: Option<SdkIdempotencyKey>, + signer: &S, + ) -> Result<OrderRevisionDecisionReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + if !self + .prepared_order_event_exists(&plan.expected_event_id) + .await? + { + self.require_revision_decision_preflight(&plan).await?; + } + let enqueue = enqueue_signed_workflow( + self.sdk, + SdkWorkflowEnqueueRequest { + operation_kind: ORDER_REVISION_DECISION_OPERATION_KIND, + actor, + frozen_draft: &plan.frozen_draft, + target_relays, + idempotency_key, + }, + signer, + ) + .await?; + Ok(OrderRevisionDecisionReceipt { + order_id: plan.order_id, + listing_addr: plan.listing_addr, + buyer_pubkey: plan.buyer_pubkey, + seller_pubkey: plan.seller_pubkey, + root_event_id: plan.root_event_id, + previous_event_id: plan.previous_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 fn prepare_cancellation( + &self, + request: OrderCancellationPrepareRequest, + ) -> Result<OrderCancellationPlan, RadrootsSdkError> { + let created_at = self.resolved_created_at(request.created_at)?; + order_cancellation_plan( + &request.actor, + request.root_event, + request.previous_event, + request.cancellation, + created_at, + ) + } + + pub async fn enqueue_cancellation<S>( + &self, + request: OrderCancellationEnqueueRequest, + signer: &S, + ) -> Result<OrderCancellationReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + let OrderCancellationEnqueueRequest { + actor, + root_event, + previous_event, + cancellation, + target_relays, + idempotency_key, + created_at, + } = request; + let prepare_request = OrderCancellationPrepareRequest { + actor: actor.clone(), + root_event, + previous_event, + cancellation, + created_at, + }; + let plan = self.prepare_cancellation(prepare_request)?; + self.enqueue_prepared_cancellation(&actor, plan, target_relays, idempotency_key, signer) + .await + } + + pub async fn enqueue_prepared_cancellation<S>( + &self, + actor: &RadrootsActorContext, + plan: OrderCancellationPlan, + target_relays: SdkRelayTargetPolicy, + idempotency_key: Option<SdkIdempotencyKey>, + signer: &S, + ) -> Result<OrderCancellationReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + if !self + .prepared_order_event_exists(&plan.expected_event_id) + .await? + { + self.require_cancellation_preflight(&plan).await?; + } + let enqueue = enqueue_signed_workflow( + self.sdk, + SdkWorkflowEnqueueRequest { + operation_kind: ORDER_CANCELLATION_OPERATION_KIND, + actor, + frozen_draft: &plan.frozen_draft, + target_relays, + idempotency_key, + }, + signer, + ) + .await?; + Ok(OrderCancellationReceipt { + order_id: plan.order_id, + listing_addr: plan.listing_addr, + buyer_pubkey: plan.buyer_pubkey, + seller_pubkey: plan.seller_pubkey, + root_event_id: plan.root_event_id, + previous_event_id: plan.previous_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 fn prepare_submit( + pub fn prepare_fulfillment_update( &self, - request: OrderSubmitPrepareRequest, - ) -> Result<OrderSubmitPlan, RadrootsSdkError> { + request: OrderFulfillmentUpdatePrepareRequest, + ) -> Result<OrderFulfillmentUpdatePlan, RadrootsSdkError> { let created_at = self.resolved_created_at(request.created_at)?; - order_submit_plan( + order_fulfillment_update_plan( &request.actor, - request.listing_event, - request.order, + request.root_event, + request.previous_event, + request.fulfillment, created_at, ) } - pub async fn enqueue_submit<S>( + pub async fn enqueue_fulfillment_update<S>( &self, - request: OrderSubmitEnqueueRequest, + request: OrderFulfillmentUpdateEnqueueRequest, signer: &S, - ) -> Result<OrderSubmitReceipt, RadrootsSdkError> + ) -> Result<OrderFulfillmentUpdateReceipt, RadrootsSdkError> where S: RadrootsEventSigner + ?Sized, { - let OrderSubmitEnqueueRequest { + let OrderFulfillmentUpdateEnqueueRequest { actor, - listing_event, - order, + root_event, + previous_event, + fulfillment, target_relays, idempotency_key, created_at, } = request; - let prepare_request = OrderSubmitPrepareRequest { + let prepare_request = OrderFulfillmentUpdatePrepareRequest { actor: actor.clone(), - listing_event, - order, + root_event, + previous_event, + fulfillment, created_at, }; - let plan = self.prepare_submit(prepare_request)?; - self.enqueue_prepared_submit(&actor, plan, target_relays, idempotency_key, signer) - .await + let plan = self.prepare_fulfillment_update(prepare_request)?; + self.enqueue_prepared_fulfillment_update( + &actor, + plan, + target_relays, + idempotency_key, + signer, + ) + .await } - pub async fn enqueue_prepared_submit<S>( + pub async fn enqueue_prepared_fulfillment_update<S>( &self, actor: &RadrootsActorContext, - plan: OrderSubmitPlan, + plan: OrderFulfillmentUpdatePlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, signer: &S, - ) -> Result<OrderSubmitReceipt, RadrootsSdkError> + ) -> Result<OrderFulfillmentUpdateReceipt, RadrootsSdkError> where S: RadrootsEventSigner + ?Sized, { + if !self + .prepared_order_event_exists(&plan.expected_event_id) + .await? + { + self.require_fulfillment_update_preflight(&plan).await?; + } let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { - operation_kind: ORDER_SUBMIT_OPERATION_KIND, + operation_kind: ORDER_FULFILLMENT_UPDATE_OPERATION_KIND, actor, frozen_draft: &plan.frozen_draft, target_relays, @@ -783,10 +2088,13 @@ impl<'sdk> OrdersClient<'sdk> { signer, ) .await?; - Ok(OrderSubmitReceipt { + Ok(OrderFulfillmentUpdateReceipt { order_id: plan.order_id, listing_addr: plan.listing_addr, - listing_event_id: plan.listing_event_id, + buyer_pubkey: plan.buyer_pubkey, + seller_pubkey: plan.seller_pubkey, + root_event_id: plan.root_event_id, + previous_event_id: plan.previous_event_id, expected_event_id: plan.expected_event_id, signed_event_id: enqueue.signed_event_id, local_event_seq: enqueue.local_event_seq, @@ -797,62 +2105,70 @@ impl<'sdk> OrdersClient<'sdk> { }) } - pub fn prepare_decision( + pub fn prepare_receipt_record( &self, - request: OrderDecisionPrepareRequest, - ) -> Result<OrderDecisionPlan, RadrootsSdkError> { + request: OrderReceiptRecordPrepareRequest, + ) -> Result<OrderReceiptRecordPlan, RadrootsSdkError> { let created_at = self.resolved_created_at(request.created_at)?; - order_decision_plan( + order_receipt_record_plan( &request.actor, - request.request_event, - request.decision, + request.root_event, + request.previous_event, + request.receipt, created_at, ) } - pub async fn enqueue_decision<S>( + pub async fn enqueue_receipt_record<S>( &self, - request: OrderDecisionEnqueueRequest, + request: OrderReceiptRecordEnqueueRequest, signer: &S, - ) -> Result<OrderDecisionReceipt, RadrootsSdkError> + ) -> Result<OrderReceiptRecordReceipt, RadrootsSdkError> where S: RadrootsEventSigner + ?Sized, { - let OrderDecisionEnqueueRequest { + let OrderReceiptRecordEnqueueRequest { actor, - request_event, - decision, + root_event, + previous_event, + receipt, target_relays, idempotency_key, created_at, } = request; - let prepare_request = OrderDecisionPrepareRequest { + let prepare_request = OrderReceiptRecordPrepareRequest { actor: actor.clone(), - request_event, - decision, + root_event, + previous_event, + receipt, created_at, }; - let plan = self.prepare_decision(prepare_request)?; - self.enqueue_prepared_decision(&actor, plan, target_relays, idempotency_key, signer) + let plan = self.prepare_receipt_record(prepare_request)?; + self.enqueue_prepared_receipt_record(&actor, plan, target_relays, idempotency_key, signer) .await } - pub async fn enqueue_prepared_decision<S>( + pub async fn enqueue_prepared_receipt_record<S>( &self, actor: &RadrootsActorContext, - plan: OrderDecisionPlan, + plan: OrderReceiptRecordPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, signer: &S, - ) -> Result<OrderDecisionReceipt, RadrootsSdkError> + ) -> Result<OrderReceiptRecordReceipt, RadrootsSdkError> where S: RadrootsEventSigner + ?Sized, { - self.require_decision_preflight(&plan).await?; + if !self + .prepared_order_event_exists(&plan.expected_event_id) + .await? + { + self.require_receipt_record_preflight(&plan).await?; + } let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { - operation_kind: ORDER_DECISION_OPERATION_KIND, + operation_kind: ORDER_RECEIPT_RECORD_OPERATION_KIND, actor, frozen_draft: &plan.frozen_draft, target_relays, @@ -861,12 +2177,13 @@ impl<'sdk> OrdersClient<'sdk> { signer, ) .await?; - Ok(OrderDecisionReceipt { + Ok(OrderReceiptRecordReceipt { 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, + root_event_id: plan.root_event_id, + previous_event_id: plan.previous_event_id, expected_event_id: plan.expected_event_id, signed_event_id: enqueue.signed_event_id, local_event_seq: enqueue.local_event_seq, @@ -906,14 +2223,75 @@ impl<'sdk> OrdersClient<'sdk> { &self, plan: &OrderDecisionPlan, ) -> Result<(), RadrootsSdkError> { - let query_result = order_projection_query_for_order_id( + let query_result = self.query_order_projection(&plan.order_id).await?; + require_decision_request_evidence(plan, &query_result.projection) + } + + async fn require_revision_proposal_preflight( + &self, + plan: &OrderRevisionProposalPlan, + ) -> Result<(), RadrootsSdkError> { + let query_result = self.query_order_projection(&plan.order_id).await?; + require_revision_proposal_state(plan, &query_result.projection) + } + + async fn require_revision_decision_preflight( + &self, + plan: &OrderRevisionDecisionPlan, + ) -> Result<(), RadrootsSdkError> { + let query_result = self.query_order_projection(&plan.order_id).await?; + require_revision_decision_state(plan, &query_result.projection) + } + + async fn require_cancellation_preflight( + &self, + plan: &OrderCancellationPlan, + ) -> Result<(), RadrootsSdkError> { + let query_result = self.query_order_projection(&plan.order_id).await?; + require_cancellation_state(plan, &query_result.projection) + } + + async fn require_fulfillment_update_preflight( + &self, + plan: &OrderFulfillmentUpdatePlan, + ) -> Result<(), RadrootsSdkError> { + let query_result = self.query_order_projection(&plan.order_id).await?; + require_fulfillment_update_state(plan, &query_result.projection) + } + + async fn require_receipt_record_preflight( + &self, + plan: &OrderReceiptRecordPlan, + ) -> Result<(), RadrootsSdkError> { + let query_result = self.query_order_projection(&plan.order_id).await?; + require_receipt_record_state(plan, &query_result.projection) + } + + async fn query_order_projection( + &self, + order_id: &RadrootsOrderId, + ) -> Result<RadrootsOrderProjectionQueryResult, RadrootsSdkError> { + order_projection_query_for_order_id( &self.sdk._event_store, - &plan.order_id, + order_id, ORDER_STATUS_MAX_LIMIT, ) .await - .map_err(projection_error)?; - require_decision_request_evidence(plan, &query_result.projection) + .map_err(projection_error) + } + + async fn prepared_order_event_exists( + &self, + expected_event_id: &RadrootsEventId, + ) -> Result<bool, RadrootsSdkError> { + self.sdk + ._event_store + .get_event(expected_event_id.as_str()) + .await + .map(|event| event.is_some()) + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + }) } } @@ -946,43 +2324,249 @@ impl OrderStatusReceipt { } #[cfg(feature = "runtime")] -fn order_submit_plan( +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 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 order_revision_proposal_plan( + actor: &RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + proposal: RadrootsOrderRevisionProposal, + created_at: RadrootsSdkTimestamp, +) -> Result<OrderRevisionProposalPlan, RadrootsSdkError> { + require_seller_actor(actor, "order.prepare_revision_proposal")?; + let root_event_id = order_reference_event_id(&root_event, "root")?; + let previous_event_id = order_reference_event_id(&previous_event, "previous")?; + if proposal.seller_pubkey.as_str() != actor.pubkey().as_str() { + return Err(RadrootsSdkError::UnauthorizedActor { + operation: "order.prepare_revision_proposal".to_owned(), + reason: "actor pubkey must match order seller_pubkey".to_owned(), + }); + } + require_payload_event_refs( + "order revision proposal", + &proposal.root_event_id, + &proposal.prev_event_id, + &root_event_id, + &previous_event_id, + )?; + let created_at_nostr = created_at.try_into_nostr_created_at()?; + let order_id = proposal.order_id.clone(); + let listing_addr = proposal.listing_addr.clone(); + let buyer_pubkey = proposal.buyer_pubkey.clone(); + let seller_pubkey = proposal.seller_pubkey.clone(); + let draft = + order::build_order_revision_proposal_draft(&root_event_id, &previous_event_id, &proposal) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order revision proposal draft encode failed: {error}"), + })?; + let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( + draft.into_wire_parts(), + ORDER_REVISION_PROPOSAL_CONTRACT_ID, + seller_pubkey.as_str(), + created_at_nostr, + "order revision proposal", + )?; + Ok(OrderRevisionProposalPlan { + order_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + root_event_id, + previous_event_id, + expected_event_id, + frozen_draft, + created_at, + }) +} + +#[cfg(feature = "runtime")] +fn order_revision_decision_plan( + actor: &RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + decision: RadrootsOrderRevisionDecision, + created_at: RadrootsSdkTimestamp, +) -> Result<OrderRevisionDecisionPlan, RadrootsSdkError> { + require_buyer_actor(actor, "order.prepare_revision_decision")?; + let root_event_id = order_reference_event_id(&root_event, "root")?; + let previous_event_id = order_reference_event_id(&previous_event, "previous")?; + if decision.buyer_pubkey.as_str() != actor.pubkey().as_str() { + return Err(RadrootsSdkError::UnauthorizedActor { + operation: "order.prepare_revision_decision".to_owned(), + reason: "actor pubkey must match order buyer_pubkey".to_owned(), + }); + } + require_payload_event_refs( + "order revision decision", + &decision.root_event_id, + &decision.prev_event_id, + &root_event_id, + &previous_event_id, + )?; + 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_revision_decision_draft(&root_event_id, &previous_event_id, &decision) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order revision decision draft encode failed: {error}"), + })?; + let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( + draft.into_wire_parts(), + ORDER_REVISION_DECISION_CONTRACT_ID, + buyer_pubkey.as_str(), + created_at_nostr, + "order revision decision", + )?; + Ok(OrderRevisionDecisionPlan { + order_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + root_event_id, + previous_event_id, + expected_event_id, + frozen_draft, + created_at, + }) +} + +#[cfg(feature = "runtime")] +fn order_cancellation_plan( actor: &RadrootsActorContext, - listing_event: RadrootsNostrEventPtr, - order_request: RadrootsOrderRequest, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + cancellation: RadrootsOrderCancellation, 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)?; +) -> Result<OrderCancellationPlan, RadrootsSdkError> { + require_buyer_actor(actor, "order.prepare_cancellation")?; + let root_event_id = order_reference_event_id(&root_event, "root")?; + let previous_event_id = order_reference_event_id(&previous_event, "previous")?; + if cancellation.buyer_pubkey.as_str() != actor.pubkey().as_str() { + return Err(RadrootsSdkError::UnauthorizedActor { + operation: "order.prepare_cancellation".to_owned(), + reason: "actor pubkey must match order buyer_pubkey".to_owned(), + }); + } 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 order_id = cancellation.order_id.clone(); + let listing_addr = cancellation.listing_addr.clone(); + let buyer_pubkey = cancellation.buyer_pubkey.clone(); + let seller_pubkey = cancellation.seller_pubkey.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( + order::build_order_cancellation_draft(&root_event_id, &previous_event_id, &cancellation) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order cancellation draft encode failed: {error}"), + })?; + let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( draft.into_wire_parts(), - ORDER_REQUEST_CONTRACT_ID, - order_request.buyer_pubkey.as_str(), + ORDER_CANCELLATION_CONTRACT_ID, + 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 cancellation", + )?; + Ok(OrderCancellationPlan { order_id, listing_addr, - listing_event_id, + buyer_pubkey, + seller_pubkey, + root_event_id, + previous_event_id, expected_event_id, frozen_draft, created_at, @@ -990,50 +2574,92 @@ fn order_submit_plan( } #[cfg(feature = "runtime")] -fn order_decision_plan( +fn order_fulfillment_update_plan( actor: &RadrootsActorContext, - request_event: RadrootsNostrEventPtr, - decision: RadrootsOrderDecision, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + fulfillment: RadrootsOrderFulfillmentUpdate, 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() { +) -> Result<OrderFulfillmentUpdatePlan, RadrootsSdkError> { + require_seller_actor(actor, "order.prepare_fulfillment_update")?; + let root_event_id = order_reference_event_id(&root_event, "root")?; + let previous_event_id = order_reference_event_id(&previous_event, "previous")?; + if fulfillment.seller_pubkey.as_str() != actor.pubkey().as_str() { return Err(RadrootsSdkError::UnauthorizedActor { - operation: "order.prepare_decision".to_owned(), + operation: "order.prepare_fulfillment_update".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( + let order_id = fulfillment.order_id.clone(); + let listing_addr = fulfillment.listing_addr.clone(); + let buyer_pubkey = fulfillment.buyer_pubkey.clone(); + let seller_pubkey = fulfillment.seller_pubkey.clone(); + let draft = + order::build_fulfillment_update_draft(&root_event_id, &previous_event_id, &fulfillment) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order fulfillment update draft encode failed: {error}"), + })?; + let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( draft.into_wire_parts(), - ORDER_DECISION_CONTRACT_ID, - decision.seller_pubkey.as_str(), + ORDER_FULFILLMENT_UPDATE_CONTRACT_ID, + 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()) + "order fulfillment update", + )?; + Ok(OrderFulfillmentUpdatePlan { + order_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + root_event_id, + previous_event_id, + expected_event_id, + frozen_draft, + created_at, + }) +} + +#[cfg(feature = "runtime")] +fn order_receipt_record_plan( + actor: &RadrootsActorContext, + root_event: RadrootsNostrEventPtr, + previous_event: RadrootsNostrEventPtr, + receipt: RadrootsOrderReceipt, + created_at: RadrootsSdkTimestamp, +) -> Result<OrderReceiptRecordPlan, RadrootsSdkError> { + require_buyer_actor(actor, "order.prepare_receipt_record")?; + let root_event_id = order_reference_event_id(&root_event, "root")?; + let previous_event_id = order_reference_event_id(&previous_event, "previous")?; + if receipt.buyer_pubkey.as_str() != actor.pubkey().as_str() { + return Err(RadrootsSdkError::UnauthorizedActor { + operation: "order.prepare_receipt_record".to_owned(), + reason: "actor pubkey must match order buyer_pubkey".to_owned(), + }); + } + let created_at_nostr = created_at.try_into_nostr_created_at()?; + let order_id = receipt.order_id.clone(); + let listing_addr = receipt.listing_addr.clone(); + let buyer_pubkey = receipt.buyer_pubkey.clone(); + let seller_pubkey = receipt.seller_pubkey.clone(); + let draft = order::build_buyer_receipt_draft(&root_event_id, &previous_event_id, &receipt) .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order decision draft produced invalid event id: {error}"), + message: format!("order receipt record draft encode failed: {error}"), })?; - Ok(OrderDecisionPlan { + let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( + draft.into_wire_parts(), + ORDER_RECEIPT_CONTRACT_ID, + buyer_pubkey.as_str(), + created_at_nostr, + "order receipt record", + )?; + Ok(OrderReceiptRecordPlan { order_id, listing_addr, buyer_pubkey, seller_pubkey, - request_event_id, + root_event_id, + previous_event_id, expected_event_id, frozen_draft, created_at, @@ -1041,6 +2667,27 @@ fn order_decision_plan( } #[cfg(feature = "runtime")] +fn freeze_order_workflow_draft( + parts: WireEventParts, + contract_id: &str, + expected_pubkey: &str, + created_at: u32, + operation: &'static str, +) -> Result<(RadrootsFrozenEventDraft, RadrootsEventId), RadrootsSdkError> { + let frozen_draft = + to_frozen_draft(parts, contract_id, expected_pubkey, created_at).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("{operation} draft freeze failed: {error}"), + } + })?; + let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("{operation} draft produced invalid event id: {error}"), + })?; + Ok((frozen_draft, expected_event_id)) +} + +#[cfg(feature = "runtime")] struct OrderRequestEvidence { order_id: RadrootsOrderId, listing_addr: RadrootsListingAddress, @@ -1140,18 +2787,21 @@ fn require_decision_request_evidence( }); } require_projection_match( + "order decision", "listing_addr", projection.listing_addr.as_ref(), &plan.listing_addr, &plan.order_id, )?; require_projection_match( + "order decision", "buyer_pubkey", projection.buyer_pubkey.as_ref(), &plan.buyer_pubkey, &plan.order_id, )?; require_projection_match( + "order decision", "seller_pubkey", projection.seller_pubkey.as_ref(), &plan.seller_pubkey, @@ -1161,7 +2811,366 @@ fn require_decision_request_evidence( } #[cfg(feature = "runtime")] +#[derive(Clone, Copy)] +struct OrderLifecycleReferences<'a> { + operation: &'static str, + order_id: &'a RadrootsOrderId, + listing_addr: &'a RadrootsListingAddress, + buyer_pubkey: &'a RadrootsPublicKey, + seller_pubkey: &'a RadrootsPublicKey, + root_event_id: &'a RadrootsEventId, + previous_event_id: &'a RadrootsEventId, +} + +#[cfg(feature = "runtime")] +fn require_revision_proposal_state( + plan: &OrderRevisionProposalPlan, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let refs = OrderLifecycleReferences { + operation: "order revision proposal", + order_id: &plan.order_id, + listing_addr: &plan.listing_addr, + buyer_pubkey: &plan.buyer_pubkey, + seller_pubkey: &plan.seller_pubkey, + root_event_id: &plan.root_event_id, + previous_event_id: &plan.previous_event_id, + }; + require_clean_lifecycle_projection(refs, projection)?; + require_lifecycle_status(&refs, projection, RadrootsOrderStatus::Accepted)?; + require_no_lifecycle_terminal(&refs, projection)?; + require_no_payment_for_revision(&refs, projection)?; + require_no_pending_revision(&refs, projection)?; + if projection.fulfillment_event_id.is_some() { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "revision proposal requires order before fulfillment", + )); + } + require_lifecycle_previous_is_current(&refs, projection) +} + +#[cfg(feature = "runtime")] +fn require_revision_decision_state( + plan: &OrderRevisionDecisionPlan, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let refs = OrderLifecycleReferences { + operation: "order revision decision", + order_id: &plan.order_id, + listing_addr: &plan.listing_addr, + buyer_pubkey: &plan.buyer_pubkey, + seller_pubkey: &plan.seller_pubkey, + root_event_id: &plan.root_event_id, + previous_event_id: &plan.previous_event_id, + }; + require_clean_lifecycle_projection(refs, projection)?; + require_lifecycle_status(&refs, projection, RadrootsOrderStatus::Accepted)?; + require_no_lifecycle_terminal(&refs, projection)?; + require_pending_revision(&refs, projection)?; + require_lifecycle_previous_is_current(&refs, projection) +} + +#[cfg(feature = "runtime")] +fn require_cancellation_state( + plan: &OrderCancellationPlan, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let refs = OrderLifecycleReferences { + operation: "order cancellation", + order_id: &plan.order_id, + listing_addr: &plan.listing_addr, + buyer_pubkey: &plan.buyer_pubkey, + seller_pubkey: &plan.seller_pubkey, + root_event_id: &plan.root_event_id, + previous_event_id: &plan.previous_event_id, + }; + require_clean_lifecycle_projection(refs, projection)?; + if !matches!( + projection.status, + RadrootsOrderStatus::Requested | RadrootsOrderStatus::Accepted + ) { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + format!( + "cancellation requires requested or accepted local state; current state is {:?}", + projection.status + ), + )); + } + require_no_lifecycle_terminal(&refs, projection)?; + require_no_pending_revision(&refs, projection)?; + if projection.fulfillment_event_id.is_some() { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "cancellation requires order before fulfillment", + )); + } + require_lifecycle_previous_is_current(&refs, projection) +} + +#[cfg(feature = "runtime")] +fn require_fulfillment_update_state( + plan: &OrderFulfillmentUpdatePlan, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let refs = OrderLifecycleReferences { + operation: "order fulfillment update", + order_id: &plan.order_id, + listing_addr: &plan.listing_addr, + buyer_pubkey: &plan.buyer_pubkey, + seller_pubkey: &plan.seller_pubkey, + root_event_id: &plan.root_event_id, + previous_event_id: &plan.previous_event_id, + }; + require_clean_lifecycle_projection(refs, projection)?; + require_lifecycle_status(&refs, projection, RadrootsOrderStatus::Accepted)?; + require_no_lifecycle_terminal(&refs, projection)?; + require_no_pending_revision(&refs, projection)?; + if matches!( + projection.fulfillment_status, + Some( + RadrootsOrderFulfillmentState::Delivered + | RadrootsOrderFulfillmentState::SellerCancelled + ) + ) { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "fulfillment update cannot follow terminal fulfillment status", + )); + } + require_lifecycle_previous_is_current(&refs, projection) +} + +#[cfg(feature = "runtime")] +fn require_receipt_record_state( + plan: &OrderReceiptRecordPlan, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let refs = OrderLifecycleReferences { + operation: "order receipt record", + order_id: &plan.order_id, + listing_addr: &plan.listing_addr, + buyer_pubkey: &plan.buyer_pubkey, + seller_pubkey: &plan.seller_pubkey, + root_event_id: &plan.root_event_id, + previous_event_id: &plan.previous_event_id, + }; + require_clean_lifecycle_projection(refs, projection)?; + require_lifecycle_status(&refs, projection, RadrootsOrderStatus::Accepted)?; + require_no_lifecycle_terminal(&refs, projection)?; + if projection.fulfillment_event_id.as_ref() != Some(refs.previous_event_id) { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "receipt record requires previous event to be the current fulfillment event", + )); + } + if !matches!( + projection.fulfillment_status, + Some( + RadrootsOrderFulfillmentState::ReadyForPickup + | RadrootsOrderFulfillmentState::Delivered + ) + ) { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "receipt record requires ready-for-pickup or delivered fulfillment state", + )); + } + require_lifecycle_previous_is_current(&refs, projection) +} + +#[cfg(feature = "runtime")] +fn require_clean_lifecycle_projection( + refs: OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + let Some(request_event_id) = &projection.request_event_id else { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "requires local order request evidence", + )); + }; + if request_event_id != refs.root_event_id { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + format!( + "root event {} does not match local request {}", + refs.root_event_id, request_event_id + ), + )); + } + if !projection.issues.is_empty() { + return Err(lifecycle_invalid( + refs.operation, + refs.order_id, + format!( + "local order evidence has {} reducer issue(s)", + projection.issues.len() + ), + )); + } + require_projection_match( + refs.operation, + "listing_addr", + projection.listing_addr.as_ref(), + refs.listing_addr, + refs.order_id, + )?; + require_projection_match( + refs.operation, + "buyer_pubkey", + projection.buyer_pubkey.as_ref(), + refs.buyer_pubkey, + refs.order_id, + )?; + require_projection_match( + refs.operation, + "seller_pubkey", + projection.seller_pubkey.as_ref(), + refs.seller_pubkey, + refs.order_id, + ) +} + +#[cfg(feature = "runtime")] +fn require_lifecycle_status( + refs: &OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, + expected: RadrootsOrderStatus, +) -> Result<(), RadrootsSdkError> { + if projection.status == expected { + Ok(()) + } else { + Err(lifecycle_invalid( + refs.operation, + refs.order_id, + format!( + "requires {:?} local state; current state is {:?}", + expected, projection.status + ), + )) + } +} + +#[cfg(feature = "runtime")] +fn require_no_lifecycle_terminal( + refs: &OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + if projection.lifecycle_terminal { + Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "requires non-terminal local order state", + )) + } else { + Ok(()) + } +} + +#[cfg(feature = "runtime")] +fn require_no_payment_for_revision( + refs: &OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + if projection.payment.state == RadrootsOrderPaymentState::NotRecorded { + Ok(()) + } else { + Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "revision proposal cannot follow recorded payment state", + )) + } +} + +#[cfg(feature = "runtime")] +fn require_pending_revision( + refs: &OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + if has_pending_revision(projection) { + Ok(()) + } else { + Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "requires pending revision proposal local state", + )) + } +} + +#[cfg(feature = "runtime")] +fn require_no_pending_revision( + refs: &OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + if has_pending_revision(projection) { + Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "cannot follow pending revision proposal local state", + )) + } else { + Ok(()) + } +} + +#[cfg(feature = "runtime")] +fn has_pending_revision(projection: &RadrootsOrderProjection) -> bool { + matches!(projection.status, RadrootsOrderStatus::Accepted) + && projection.fulfillment_event_id.is_none() + && projection.agreement_event_id.is_some() + && projection.last_event_id.is_some() + && projection.agreement_event_id != projection.last_event_id +} + +#[cfg(feature = "runtime")] +fn require_lifecycle_previous_is_current( + refs: &OrderLifecycleReferences<'_>, + projection: &RadrootsOrderProjection, +) -> Result<(), RadrootsSdkError> { + match projection.last_event_id.as_ref() { + Some(last_event_id) if last_event_id == refs.previous_event_id => Ok(()), + Some(last_event_id) => Err(lifecycle_invalid( + refs.operation, + refs.order_id, + format!( + "previous event {} does not match current lifecycle event {}", + refs.previous_event_id, last_event_id + ), + )), + None => Err(lifecycle_invalid( + refs.operation, + refs.order_id, + "requires current lifecycle event evidence", + )), + } +} + +#[cfg(feature = "runtime")] +fn lifecycle_invalid( + operation: &'static str, + order_id: &RadrootsOrderId, + reason: impl Into<String>, +) -> RadrootsSdkError { + RadrootsSdkError::InvalidRequest { + message: format!("{operation} for order {order_id} {}", reason.into()), + } +} + +#[cfg(feature = "runtime")] fn require_projection_match<T>( + operation: &'static str, field: &'static str, actual: Option<&T>, expected: &T, @@ -1174,12 +3183,12 @@ where 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}" + "{operation} {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}" + "{operation} request evidence is missing {field} for order {order_id}" ), }), } @@ -1238,6 +3247,43 @@ fn request_event_id( } #[cfg(feature = "runtime")] +fn order_reference_event_id( + event: &RadrootsNostrEventPtr, + label: &'static str, +) -> Result<RadrootsEventId, RadrootsSdkError> { + RadrootsEventId::parse(event.id.as_str()).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order {label} evidence event id is invalid: {error}"), + }) +} + +#[cfg(feature = "runtime")] +fn require_payload_event_refs( + operation: &'static str, + payload_root_event_id: &RadrootsEventId, + payload_previous_event_id: &RadrootsEventId, + root_event_id: &RadrootsEventId, + previous_event_id: &RadrootsEventId, +) -> Result<(), RadrootsSdkError> { + if payload_root_event_id != root_event_id { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "{operation} root_event_id {} does not match root evidence {}", + payload_root_event_id, root_event_id + ), + }); + } + if payload_previous_event_id != previous_event_id { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "{operation} prev_event_id {} does not match previous evidence {}", + payload_previous_event_id, previous_event_id + ), + }); + } + Ok(()) +} + +#[cfg(feature = "runtime")] fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> RadrootsSdkError { match error { RadrootsOrderCanonicalizationError::InvalidBuyerSigner => { diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -12,7 +12,11 @@ use radroots_events::{ contract::RadrootsActorRole, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, ids::{RadrootsEventId, RadrootsOrderId}, - kinds::{KIND_LISTING, KIND_ORDER_DECISION, KIND_ORDER_REQUEST}, + kinds::{ + KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE, + KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, + KIND_ORDER_REVISION_PROPOSAL, + }, }; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr, @@ -22,21 +26,29 @@ use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; use radroots_relay_transport::RadrootsMockRelayPublishAdapter; use radroots_sdk::protocol::events::RadrootsNostrEventPtr; use radroots_sdk::protocol::order::{ - RadrootsListingAddress, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, - RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, + RadrootsListingAddress, RadrootsOrderCancellation, RadrootsOrderDecision, + RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, + RadrootsOrderEconomics, RadrootsOrderFulfillmentState, RadrootsOrderFulfillmentUpdate, RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, - RadrootsOrderRequest, + RadrootsOrderReceipt, RadrootsOrderRequest, RadrootsOrderRevisionDecision, + RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, }; use radroots_sdk::protocol::wire::WireEventParts; use radroots_sdk::{ - ORDER_DECISION_OPERATION_KIND, ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, - ORDER_SUBMIT_OPERATION_KIND, OrderDecisionEnqueueRequest, OrderDecisionPrepareRequest, - OrderPaymentStateKind, OrderRequestEvidenceIngestRequest, OrderSettlementStateKind, - OrderStatusKind, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPrepareRequest, - PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk, - RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, - RadrootsSdkTimestamp, SdkMutationState, SdkOrderStatusIssue, SdkOrderStatusIssueKind, - SdkOrderStatusSource, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, + ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, + ORDER_FULFILLMENT_UPDATE_OPERATION_KIND, ORDER_RECEIPT_RECORD_OPERATION_KIND, + ORDER_REVISION_DECISION_OPERATION_KIND, ORDER_REVISION_PROPOSAL_OPERATION_KIND, + ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND, + OrderCancellationEnqueueRequest, OrderDecisionEnqueueRequest, OrderDecisionPrepareRequest, + OrderFulfillmentStatusKind, OrderFulfillmentUpdateEnqueueRequest, OrderPaymentStateKind, + OrderReceiptRecordEnqueueRequest, OrderRequestEvidenceIngestRequest, + OrderRevisionDecisionEnqueueRequest, OrderRevisionProposalEnqueueRequest, + OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, OrderSubmitEnqueueRequest, + OrderSubmitPrepareRequest, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, + RadrootsSdk, RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure, + RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkMutationState, SdkOrderStatusIssue, + SdkOrderStatusIssueKind, SdkOrderStatusSource, SdkRelayTargetPolicy, SdkRelayTargetSet, + SdkRelayUrlPolicy, }; const BUYER_SECRET_KEY_HEX: &str = @@ -780,6 +792,110 @@ fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision { } } +fn order_revision_proposal( + raw_order_id: &str, + root_event_id: &RadrootsEventId, + previous_event_id: &RadrootsEventId, +) -> RadrootsOrderRevisionProposal { + RadrootsOrderRevisionProposal { + revision_id: format!("revision-{raw_order_id}") + .parse() + .expect("revision id"), + order_id: order_id(raw_order_id), + listing_addr: listing_address(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + root_event_id: root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + }], + economics: revision_economics(), + reason: "increase quantity".to_owned(), + } +} + +fn order_revision_decision( + proposal: &RadrootsOrderRevisionProposal, + previous_event_id: &RadrootsEventId, + decision: RadrootsOrderRevisionOutcome, +) -> RadrootsOrderRevisionDecision { + RadrootsOrderRevisionDecision { + revision_id: proposal.revision_id.clone(), + order_id: proposal.order_id.clone(), + listing_addr: proposal.listing_addr.clone(), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + root_event_id: proposal.root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + decision, + } +} + +fn order_fulfillment_update( + raw_order_id: &str, + status: RadrootsOrderFulfillmentState, +) -> RadrootsOrderFulfillmentUpdate { + RadrootsOrderFulfillmentUpdate { + order_id: order_id(raw_order_id), + listing_addr: listing_address(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + status, + } +} + +fn order_cancellation(raw_order_id: &str) -> RadrootsOrderCancellation { + RadrootsOrderCancellation { + order_id: order_id(raw_order_id), + listing_addr: listing_address(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + reason: "buyer changed pickup plan".to_owned(), + } +} + +fn order_receipt_record(raw_order_id: &str, received: bool) -> RadrootsOrderReceipt { + RadrootsOrderReceipt { + order_id: order_id(raw_order_id), + listing_addr: listing_address(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + received, + issue: if received { + None + } else { + Some("missing one item".to_owned()) + }, + received_at: 1_785_000_000, + } +} + +fn revision_economics() -> RadrootsOrderEconomics { + RadrootsOrderEconomics { + quote_id: "revision-quote-1".parse().expect("revision quote id"), + quote_version: 2, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("15"), + }], + discounts: Vec::<RadrootsOrderEconomicLine>::new(), + adjustments: Vec::<RadrootsOrderEconomicLine>::new(), + subtotal: usd("15"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("15"), + } +} + fn signed_event( secret_key_hex: &str, created_at: u32, @@ -811,6 +927,26 @@ fn request_event_ptr(event: &RadrootsNostrEvent) -> RadrootsNostrEventPtr { } } +fn order_event_ptr(event_id: &RadrootsEventId) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event_id.as_str().to_owned(), + relays: Some(RELAY.to_owned()), + } +} + +async fn outbox_operation_kind(sdk: &RadrootsSdk, operation_id: i64) -> String { + let paths = sdk.storage_paths().expect("paths"); + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + outbox + .get_operation(operation_id) + .await + .expect("outbox operation") + .expect("outbox operation") + .operation_kind +} + fn signed_order_decision_event( raw_order_id: &str, root_event_id: &RadrootsEventId, @@ -1447,6 +1583,411 @@ async fn order_decision_enqueue_rejects_existing_decision_state_before_mutation( } #[tokio::test] +async fn order_revision_order_fulfillment_order_receipt_lifecycle_enqueue_updates_status() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-lifecycle-complete", 50); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 5_000)) + .await + .expect("ingest request"); + let decision_receipt = sdk + .orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-lifecycle-complete"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("decision target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + let proposal = order_revision_proposal( + "order-lifecycle-complete", + &request_event_id, + &decision_receipt.signed_event_id, + ); + let proposal_receipt = sdk + .orders() + .enqueue_revision_proposal( + OrderRevisionProposalEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + proposal.clone(), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("proposal target relays") + .try_with_idempotency_key("order-lifecycle-revision-proposal") + .expect("proposal idempotency"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision proposal"); + assert_eq!( + proposal_receipt.signed_event_id, + proposal_receipt.expected_event_id + ); + assert_eq!( + outbox_operation_kind(&sdk, proposal_receipt.outbox_operation_id).await, + ORDER_REVISION_PROPOSAL_OPERATION_KIND + ); + let stored_proposal = store + .get_event(proposal_receipt.signed_event_id.as_str()) + .await + .expect("proposal event lookup") + .expect("proposal event"); + assert_eq!(stored_proposal.kind, KIND_ORDER_REVISION_PROPOSAL); + assert_eq!( + stored_proposal.contract_id.as_deref(), + Some("radroots.order.revision_proposal.v1") + ); + + let revision_decision = order_revision_decision( + &proposal, + &proposal_receipt.signed_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ); + let revision_decision_receipt = sdk + .orders() + .enqueue_revision_decision( + OrderRevisionDecisionEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&proposal_receipt.signed_event_id), + revision_decision, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("revision decision target relays"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision decision"); + assert_eq!( + outbox_operation_kind(&sdk, revision_decision_receipt.outbox_operation_id).await, + ORDER_REVISION_DECISION_OPERATION_KIND + ); + assert_eq!( + store + .get_event(revision_decision_receipt.signed_event_id.as_str()) + .await + .expect("revision decision lookup") + .expect("revision decision") + .kind, + KIND_ORDER_REVISION_DECISION + ); + + let fulfillment_receipt = sdk + .orders() + .enqueue_fulfillment_update( + OrderFulfillmentUpdateEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&revision_decision_receipt.signed_event_id), + order_fulfillment_update( + "order-lifecycle-complete", + RadrootsOrderFulfillmentState::ReadyForPickup, + ), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("fulfillment target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue fulfillment"); + assert_eq!( + outbox_operation_kind(&sdk, fulfillment_receipt.outbox_operation_id).await, + ORDER_FULFILLMENT_UPDATE_OPERATION_KIND + ); + assert_eq!( + store + .get_event(fulfillment_receipt.signed_event_id.as_str()) + .await + .expect("fulfillment lookup") + .expect("fulfillment") + .kind, + KIND_ORDER_FULFILLMENT_UPDATE + ); + + let receipt = sdk + .orders() + .enqueue_receipt_record( + OrderReceiptRecordEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&fulfillment_receipt.signed_event_id), + order_receipt_record("order-lifecycle-complete", true), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("receipt target relays"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue receipt"); + assert_eq!( + outbox_operation_kind(&sdk, receipt.outbox_operation_id).await, + ORDER_RECEIPT_RECORD_OPERATION_KIND + ); + assert_eq!( + store + .get_event(receipt.signed_event_id.as_str()) + .await + .expect("receipt lookup") + .expect("receipt") + .kind, + KIND_ORDER_RECEIPT + ); + + let status = sdk + .orders() + .status(status_request("order-lifecycle-complete")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Completed); + assert_eq!(status.event_count, 6); + assert_eq!( + status + .fulfillment_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(fulfillment_receipt.signed_event_id.as_str()) + ); + assert_eq!( + status + .receipt_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(receipt.signed_event_id.as_str()) + ); + assert_eq!( + status.fulfillment_status, + Some(OrderFulfillmentStatusKind::ReadyForPickup) + ); + assert!(status.lifecycle_terminal); + assert!(status.issues.is_empty()); +} + +#[tokio::test] +async fn order_cancel_lifecycle_enqueue_updates_status() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-lifecycle-cancel", 60); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 6_000)) + .await + .expect("ingest request"); + let decision_receipt = sdk + .orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-lifecycle-cancel"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("decision target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + let cancellation = sdk + .orders() + .enqueue_cancellation( + OrderCancellationEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + order_cancellation("order-lifecycle-cancel"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("cancellation target relays") + .try_with_idempotency_key("order-lifecycle-cancel") + .expect("cancellation idempotency"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue cancellation"); + + assert_eq!(cancellation.root_event_id, request_event_id); + assert_eq!( + cancellation.previous_event_id, + decision_receipt.signed_event_id + ); + assert_eq!( + outbox_operation_kind(&sdk, cancellation.outbox_operation_id).await, + ORDER_CANCELLATION_OPERATION_KIND + ); + assert_eq!( + store + .get_event(cancellation.signed_event_id.as_str()) + .await + .expect("cancellation lookup") + .expect("cancellation") + .kind, + KIND_ORDER_CANCELLATION + ); + let replay = sdk + .orders() + .enqueue_cancellation( + OrderCancellationEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + order_cancellation("order-lifecycle-cancel"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("replay target relays") + .try_with_idempotency_key("order-lifecycle-cancel") + .expect("replay idempotency"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("replay cancellation"); + assert_eq!(replay.state, SdkMutationState::AlreadyQueued); + assert_eq!(replay.signed_event_id, cancellation.signed_event_id); + assert_eq!(replay.outbox_event_id, cancellation.outbox_event_id); + let status = sdk + .orders() + .status(status_request("order-lifecycle-cancel")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Cancelled); + assert_eq!( + status + .cancellation_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(cancellation.signed_event_id.as_str()) + ); + assert!(status.lifecycle_terminal); + assert!(status.issues.is_empty()); +} + +#[tokio::test] +async fn order_lifecycle_enqueue_rejects_invalid_state_before_mutation() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-lifecycle-invalid", 70); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + let missing = sdk + .orders() + .enqueue_fulfillment_update( + OrderFulfillmentUpdateEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&request_event_id), + order_fulfillment_update( + "order-lifecycle-invalid", + RadrootsOrderFulfillmentState::ReadyForPickup, + ), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("missing target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("missing local evidence"); + assert!(matches!(missing, RadrootsSdkError::InvalidRequest { .. })); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 7_000)) + .await + .expect("ingest request"); + let decision_receipt = sdk + .orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-lifecycle-invalid"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("decision target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + let revision_without_proposal = order_revision_decision( + &order_revision_proposal( + "order-lifecycle-invalid", + &request_event_id, + &decision_receipt.signed_event_id, + ), + &decision_receipt.signed_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ); + let revision_error = sdk + .orders() + .enqueue_revision_decision( + OrderRevisionDecisionEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + revision_without_proposal, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("revision decision target relays"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("revision decision without proposal"); + assert!(matches!( + revision_error, + RadrootsSdkError::InvalidRequest { .. } + )); + + let receipt_error = sdk + .orders() + .enqueue_receipt_record( + OrderReceiptRecordEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + order_receipt_record("order-lifecycle-invalid", true), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("receipt target relays"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("receipt without fulfillment"); + assert!(matches!( + receipt_error, + RadrootsSdkError::InvalidRequest { .. } + )); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 2 + ); +} + +#[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");