commit 873a2daee1a1895ffa8e7c60bb3d9fd4b0dfb25a
parent d8ad5e3bbf77649df87e7f9edbf497613ed7f271
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 14:20:49 -0700
sdk: add order status runtime
Add the local order status API over the rr-rs trade projection contract with SDK-owned product receipts. Cover missing orders, accepted lifecycle projection, invalid limits, and sanitized malformed-store errors.
Diffstat:
7 files changed, 543 insertions(+), 5 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1996,6 +1996,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
+ "sqlx",
"tempfile",
"tokio",
"tokio-tungstenite",
@@ -2065,6 +2066,7 @@ dependencies = [
"hex",
"radroots_authority",
"radroots_core",
+ "radroots_event_store",
"radroots_events",
"radroots_events_codec",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
@@ -65,6 +65,7 @@ serde = { version = "1", default-features = false, features = [
] }
serde_json = { version = "1", default-features = false, features = ["alloc"] }
serde-wasm-bindgen = { version = "0.6" }
+sqlx = { version = "0.8.6", default-features = false }
tempfile = { version = "3" }
tokio = { version = "1" }
tokio-tungstenite = "0.26.2"
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -55,6 +55,7 @@ runtime = [
"radroots_relay_transport/std",
"radroots_relay_transport/storage",
"radroots_relay_transport/runtime-tokio",
+ "radroots_trade/event_store",
]
local-signer = ["runtime", "radroots_authority/local_signer"]
relay-runtime = ["runtime", "radroots_relay_transport/client"]
@@ -95,6 +96,14 @@ radroots_replica_db = { workspace = true, default-features = false, features = [
radroots_replica_db_schema = { workspace = true }
radroots_replica_sync = { workspace = true, features = ["std"] }
radroots_sql_core = { workspace = true, features = ["native"] }
+radroots_nostr = { workspace = true, default-features = false, features = [
+ "std",
+ "events",
+] }
+sqlx = { workspace = true, default-features = false, features = [
+ "runtime-tokio",
+ "sqlite",
+] }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tokio-tungstenite = "0.26.2"
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -28,6 +28,8 @@ pub mod listing;
mod listings_runtime;
pub mod order;
#[cfg(feature = "runtime")]
+mod orders_runtime;
+#[cfg(feature = "runtime")]
mod product_clients;
pub mod profile;
#[cfg(feature = "runtime")]
@@ -76,6 +78,12 @@ pub use crate::listings_runtime::{
ListingEnqueueReceipt, ListingPublishRequest, PreparedListingPublish,
};
#[cfg(feature = "runtime")]
+pub use crate::orders_runtime::{
+ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderFulfillmentStatusKind,
+ OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt,
+ OrderStatusRequest,
+};
+#[cfg(feature = "runtime")]
pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient};
#[cfg(feature = "runtime")]
pub use crate::receipt::{RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt};
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -0,0 +1,224 @@
+#[cfg(feature = "runtime")]
+use crate::{OrdersClient, RadrootsSdkError};
+#[cfg(feature = "runtime")]
+use radroots_events::{ids::RadrootsOrderId, order::RadrootsOrderFulfillmentState};
+#[cfg(feature = "runtime")]
+use radroots_trade::order::{
+ RadrootsOrderPaymentState, RadrootsOrderProjection, RadrootsOrderSettlementState,
+ RadrootsOrderStatus, RadrootsOrderStoreQueryError, order_projection_for_order_id,
+};
+
+#[cfg(feature = "runtime")]
+pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500;
+#[cfg(feature = "runtime")]
+pub const ORDER_STATUS_MAX_LIMIT: u32 = 1_000;
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct OrderStatusRequest {
+ pub order_id: String,
+ pub limit: u32,
+}
+
+#[cfg(feature = "runtime")]
+impl OrderStatusRequest {
+ pub fn new(order_id: impl Into<String>) -> Self {
+ Self {
+ order_id: order_id.into(),
+ limit: ORDER_STATUS_DEFAULT_LIMIT,
+ }
+ }
+
+ pub fn with_limit(mut self, limit: u32) -> Self {
+ self.limit = limit;
+ self
+ }
+
+ fn validate(&self) -> Result<RadrootsOrderId, RadrootsSdkError> {
+ if self.limit == 0 || self.limit > ORDER_STATUS_MAX_LIMIT {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "order status limit must be between 1 and {ORDER_STATUS_MAX_LIMIT}"
+ ),
+ });
+ }
+ RadrootsOrderId::parse(self.order_id.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("order_id is invalid: {error}"),
+ }
+ })
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct OrderStatusReceipt {
+ pub order_id: String,
+ pub found: bool,
+ pub status: OrderStatusKind,
+ pub fulfillment_status: Option<OrderFulfillmentStatusKind>,
+ pub payment_state: OrderPaymentStateKind,
+ pub settlement_state: OrderSettlementStateKind,
+ pub lifecycle_terminal: bool,
+ pub request_event_id: Option<String>,
+ pub decision_event_id: Option<String>,
+ pub fulfillment_event_id: Option<String>,
+ pub cancellation_event_id: Option<String>,
+ pub receipt_event_id: Option<String>,
+ pub last_event_id: Option<String>,
+ pub issue_count: usize,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OrderStatusKind {
+ Missing,
+ Requested,
+ Accepted,
+ Declined,
+ Cancelled,
+ Completed,
+ Disputed,
+ Invalid,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OrderFulfillmentStatusKind {
+ AcceptedNotFulfilled,
+ Preparing,
+ ReadyForPickup,
+ OutForDelivery,
+ Delivered,
+ SellerCancelled,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OrderPaymentStateKind {
+ NotRecorded,
+ Recorded,
+ Settled,
+ Rejected,
+ Invalid,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OrderSettlementStateKind {
+ NotRequired,
+ Pending,
+ Accepted,
+ Rejected,
+ Invalid,
+}
+
+#[cfg(feature = "runtime")]
+impl<'sdk> OrdersClient<'sdk> {
+ pub async fn status(
+ &self,
+ request: OrderStatusRequest,
+ ) -> Result<OrderStatusReceipt, RadrootsSdkError> {
+ let order_id = request.validate()?;
+ let projection =
+ order_projection_for_order_id(&self.sdk._event_store, &order_id, request.limit)
+ .await
+ .map_err(projection_error)?;
+ Ok(OrderStatusReceipt::from_projection(projection))
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl OrderStatusReceipt {
+ fn from_projection(projection: RadrootsOrderProjection) -> Self {
+ let found = projection.status != RadrootsOrderStatus::Missing;
+ Self {
+ order_id: projection.order_id.into_string(),
+ found,
+ status: projection.status.into(),
+ fulfillment_status: projection.fulfillment_status.map(Into::into),
+ payment_state: projection.payment.state.into(),
+ settlement_state: projection.payment.settlement_state.into(),
+ lifecycle_terminal: projection.lifecycle_terminal,
+ request_event_id: projection.request_event_id.map(Into::into),
+ decision_event_id: projection.decision_event_id.map(Into::into),
+ fulfillment_event_id: projection.fulfillment_event_id.map(Into::into),
+ cancellation_event_id: projection.cancellation_event_id.map(Into::into),
+ receipt_event_id: projection.receipt_event_id.map(Into::into),
+ last_event_id: projection.last_event_id.map(Into::into),
+ issue_count: projection.issues.len(),
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsOrderStatus> for OrderStatusKind {
+ fn from(status: RadrootsOrderStatus) -> Self {
+ match status {
+ RadrootsOrderStatus::Missing => Self::Missing,
+ RadrootsOrderStatus::Requested => Self::Requested,
+ RadrootsOrderStatus::Accepted => Self::Accepted,
+ RadrootsOrderStatus::Declined => Self::Declined,
+ RadrootsOrderStatus::Cancelled => Self::Cancelled,
+ RadrootsOrderStatus::Completed => Self::Completed,
+ RadrootsOrderStatus::Disputed => Self::Disputed,
+ RadrootsOrderStatus::Invalid => Self::Invalid,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsOrderFulfillmentState> for OrderFulfillmentStatusKind {
+ fn from(status: RadrootsOrderFulfillmentState) -> Self {
+ match status {
+ RadrootsOrderFulfillmentState::AcceptedNotFulfilled => Self::AcceptedNotFulfilled,
+ RadrootsOrderFulfillmentState::Preparing => Self::Preparing,
+ RadrootsOrderFulfillmentState::ReadyForPickup => Self::ReadyForPickup,
+ RadrootsOrderFulfillmentState::OutForDelivery => Self::OutForDelivery,
+ RadrootsOrderFulfillmentState::Delivered => Self::Delivered,
+ RadrootsOrderFulfillmentState::SellerCancelled => Self::SellerCancelled,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsOrderPaymentState> for OrderPaymentStateKind {
+ fn from(state: RadrootsOrderPaymentState) -> Self {
+ match state {
+ RadrootsOrderPaymentState::NotRecorded => Self::NotRecorded,
+ RadrootsOrderPaymentState::Recorded => Self::Recorded,
+ RadrootsOrderPaymentState::Settled => Self::Settled,
+ RadrootsOrderPaymentState::Rejected => Self::Rejected,
+ RadrootsOrderPaymentState::Invalid => Self::Invalid,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsOrderSettlementState> for OrderSettlementStateKind {
+ fn from(state: RadrootsOrderSettlementState) -> Self {
+ match state {
+ RadrootsOrderSettlementState::NotRequired => Self::NotRequired,
+ RadrootsOrderSettlementState::Pending => Self::Pending,
+ RadrootsOrderSettlementState::Accepted => Self::Accepted,
+ RadrootsOrderSettlementState::Rejected => Self::Rejected,
+ RadrootsOrderSettlementState::Invalid => Self::Invalid,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn projection_error(error: RadrootsOrderStoreQueryError) -> RadrootsSdkError {
+ let message = match error {
+ RadrootsOrderStoreQueryError::Store(_) => "order status store query failed",
+ RadrootsOrderStoreQueryError::InvalidStoredTagsJson { .. } => {
+ "stored order event tags could not be decoded"
+ }
+ RadrootsOrderStoreQueryError::Decode { .. } => {
+ "stored order event could not decode as order record"
+ }
+ };
+ RadrootsSdkError::Projection {
+ message: message.to_owned(),
+ }
+}
diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs
@@ -1,7 +1,5 @@
#[cfg(feature = "runtime")]
use crate::RadrootsSdk;
-#[cfg(feature = "runtime")]
-use core::marker::PhantomData;
#[cfg(feature = "runtime")]
#[derive(Clone, Copy)]
@@ -19,13 +17,13 @@ impl<'sdk> ListingsClient<'sdk> {
#[cfg(feature = "runtime")]
#[derive(Clone, Copy)]
pub struct OrdersClient<'sdk> {
- _sdk: PhantomData<&'sdk RadrootsSdk>,
+ pub(crate) sdk: &'sdk RadrootsSdk,
}
#[cfg(feature = "runtime")]
impl<'sdk> OrdersClient<'sdk> {
- pub(crate) fn new(_sdk: &'sdk RadrootsSdk) -> Self {
- Self { _sdk: PhantomData }
+ pub(crate) fn new(sdk: &'sdk RadrootsSdk) -> Self {
+ Self { sdk }
}
}
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -0,0 +1,296 @@
+#![cfg(feature = "runtime")]
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
+};
+use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore};
+use radroots_events::kinds::KIND_LISTING;
+use radroots_events::{
+ RadrootsNostrEvent,
+ ids::{RadrootsEventId, RadrootsOrderId},
+};
+use radroots_nostr::prelude::{
+ RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr,
+ radroots_nostr_build_event,
+};
+use radroots_sdk::WireEventParts;
+use radroots_sdk::order::{
+ RadrootsListingAddress, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
+ RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics,
+ RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis,
+ RadrootsOrderRequest,
+};
+use radroots_sdk::{
+ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderPaymentStateKind,
+ OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, RadrootsNostrEventPtr,
+ RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp,
+};
+
+const BUYER_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+const BUYER_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+const SELLER_SECRET_KEY_HEX: &str =
+ "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
+const SELLER_PUBLIC_KEY_HEX: &str =
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+
+async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsSdk, RadrootsEventStore) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let sdk = RadrootsSdk::builder()
+ .directory_storage(tempdir.path().join("sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .build()
+ .await
+ .expect("sdk");
+ let store =
+ RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path)
+ .await
+ .expect("event store");
+ (tempdir, sdk, store)
+}
+
+fn order_id(raw: &str) -> RadrootsOrderId {
+ RadrootsOrderId::parse(raw).expect("order id")
+}
+
+fn listing_address() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!(
+ "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address")
+}
+
+fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: deterministic_event_id("listing-event").into_string(),
+ relays: Some("wss://relay.radroots.test".to_owned()),
+ }
+}
+
+fn deterministic_event_id(raw: &str) -> RadrootsEventId {
+ let mut bytes = [0u8; 32];
+ for (index, byte) in raw.bytes().enumerate() {
+ let primary = index % bytes.len();
+ let secondary = (index * 7 + 13) % bytes.len();
+ bytes[primary] = bytes[primary]
+ .wrapping_add(byte)
+ .wrapping_add((index as u8).wrapping_mul(31));
+ bytes[secondary] ^= byte.rotate_left((index % 8) as u32);
+ }
+ let mut hex = String::with_capacity(64);
+ for byte in bytes {
+ use core::fmt::Write as _;
+ write!(&mut hex, "{byte:02x}").expect("write hex");
+ }
+ RadrootsEventId::parse(hex).expect("event id")
+}
+
+fn decimal(raw: &str) -> RadrootsCoreDecimal {
+ raw.parse().expect("decimal")
+}
+
+fn usd(raw: &str) -> RadrootsCoreMoney {
+ RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD)
+}
+
+fn economics() -> RadrootsOrderEconomics {
+ RadrootsOrderEconomics {
+ quote_id: "quote-1".parse().expect("quote id"),
+ quote_version: 1,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ quantity_amount: decimal("1"),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: decimal("5"),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: usd("10"),
+ }],
+ discounts: Vec::<RadrootsOrderEconomicLine>::new(),
+ adjustments: Vec::<RadrootsOrderEconomicLine>::new(),
+ subtotal: usd("10"),
+ discount_total: usd("0"),
+ adjustment_total: usd("0"),
+ total: usd("10"),
+ }
+}
+
+fn order_request(raw_order_id: &str) -> RadrootsOrderRequest {
+ RadrootsOrderRequest {
+ 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"),
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ economics: economics(),
+ }
+}
+
+fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision {
+ RadrootsOrderDecision {
+ 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"),
+ decision: RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: vec![RadrootsOrderInventoryCommitment {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ },
+ }
+}
+
+fn signed_event(
+ secret_key_hex: &str,
+ created_at: u32,
+ parts: WireEventParts,
+) -> RadrootsNostrEvent {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("event builder")
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at)))
+ .sign_with_keys(&keys)
+ .expect("signed event");
+ radroots_event_from_nostr(&event)
+}
+
+fn signed_order_request_event(raw_order_id: &str, created_at: u32) -> RadrootsNostrEvent {
+ let draft = radroots_sdk::order::build_order_request_draft(
+ &listing_event_ptr(),
+ &order_request(raw_order_id),
+ )
+ .expect("request draft");
+ signed_event(BUYER_SECRET_KEY_HEX, created_at, draft.into_wire_parts())
+}
+
+fn signed_order_decision_event(
+ raw_order_id: &str,
+ root_event_id: &RadrootsEventId,
+ created_at: u32,
+) -> RadrootsNostrEvent {
+ let draft = radroots_sdk::order::build_order_decision_draft(
+ root_event_id,
+ root_event_id,
+ &order_decision(raw_order_id),
+ )
+ .expect("decision draft");
+ signed_event(SELLER_SECRET_KEY_HEX, created_at, draft.into_wire_parts())
+}
+
+#[tokio::test]
+async fn order_status_returns_not_found_for_missing_local_order() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let request = OrderStatusRequest::new("order-1");
+
+ assert_eq!(request.limit, ORDER_STATUS_DEFAULT_LIMIT);
+
+ let receipt = sdk.orders().status(request).await.expect("status");
+
+ assert!(!receipt.found);
+ assert_eq!(receipt.order_id, "order-1");
+ assert_eq!(receipt.status, OrderStatusKind::Missing);
+ assert_eq!(receipt.payment_state, OrderPaymentStateKind::NotRecorded);
+ assert_eq!(
+ receipt.settlement_state,
+ OrderSettlementStateKind::NotRequired
+ );
+ assert_eq!(receipt.issue_count, 0);
+}
+
+#[tokio::test]
+async fn order_status_rejects_invalid_limits_before_querying() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+
+ let zero = sdk
+ .orders()
+ .status(OrderStatusRequest::new("order-1").with_limit(0))
+ .await
+ .expect_err("zero limit");
+ let too_large = sdk
+ .orders()
+ .status(OrderStatusRequest::new("order-1").with_limit(ORDER_STATUS_MAX_LIMIT + 1))
+ .await
+ .expect_err("too large");
+
+ assert!(matches!(zero, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(matches!(too_large, RadrootsSdkError::InvalidRequest { .. }));
+}
+
+#[tokio::test]
+async fn order_status_projects_local_request_and_decision_events() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request_event = signed_order_request_event("order-1", 20);
+ let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id");
+ let decision_event = signed_order_decision_event("order-1", &request_event_id, 21);
+
+ for (event, observed_at_ms) in [
+ (request_event.clone(), 2_000),
+ (decision_event.clone(), 2_100),
+ ] {
+ store
+ .ingest_event(RadrootsEventIngest::new(event, observed_at_ms))
+ .await
+ .expect("ingest");
+ }
+
+ let receipt = sdk
+ .orders()
+ .status(OrderStatusRequest::new("order-1").with_limit(1_000))
+ .await
+ .expect("status");
+
+ assert!(receipt.found);
+ assert_eq!(receipt.status, OrderStatusKind::Accepted);
+ assert_eq!(
+ receipt.request_event_id.as_deref(),
+ Some(request_event.id.as_str())
+ );
+ assert_eq!(
+ receipt.decision_event_id.as_deref(),
+ Some(decision_event.id.as_str())
+ );
+ assert_eq!(
+ receipt.last_event_id.as_deref(),
+ Some(decision_event.id.as_str())
+ );
+ assert_eq!(receipt.issue_count, 0);
+ assert!(!receipt.lifecycle_terminal);
+}
+
+#[tokio::test]
+async fn order_status_maps_malformed_local_data_to_sanitized_error() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request_event = signed_order_request_event("order-1", 30);
+ let raw_event_json = serde_json::to_string(&request_event).expect("raw event json");
+ store
+ .ingest_event(RadrootsEventIngest::new(request_event.clone(), 3_000))
+ .await
+ .expect("ingest");
+ sqlx::query("UPDATE nostr_event SET tags_json = '[' WHERE event_id = ?")
+ .bind(request_event.id.as_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt tags");
+
+ let error = sdk
+ .orders()
+ .status(OrderStatusRequest::new("order-1"))
+ .await
+ .expect_err("projection error");
+ let message = error.to_string();
+
+ assert!(matches!(error, RadrootsSdkError::Projection { .. }));
+ assert!(message.contains("stored order event tags could not be decoded"));
+ assert!(!message.contains(raw_event_json.as_str()));
+ assert!(!message.contains(request_event.sig.as_str()));
+ assert!(!message.contains("\"tags\""));
+ assert!(!message.contains("\"content\""));
+}