commit e60c2aaa76955bdee99983001e8b7a9fec3eef58
parent 40f83f485fb5af7c4763ad4e8662ae601e1277ab
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 23:58:19 -0700
sdk: add order lifecycle evidence ingest
- add an SDK order evidence ingest request and receipt for lifecycle projection inputs
- parse order request, decision, revision, cancellation, fulfillment, receipt, payment, and settlement events through one runtime path
- expose the ingest API from the SDK public surface
- cover lifecycle ingest storage and non-order rejection with local-runtime tests
Diffstat:
3 files changed, 282 insertions(+), 9 deletions(-)
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -91,13 +91,14 @@ pub use crate::orders_runtime::{
ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND,
OrderCancellationEnqueueRequest, OrderCancellationPlan, OrderCancellationPrepareRequest,
OrderCancellationReceipt, OrderDecisionEnqueueRequest, OrderDecisionPlan,
- OrderDecisionPrepareRequest, OrderDecisionReceipt, OrderFulfillmentStatusKind,
- OrderFulfillmentUpdateEnqueueRequest, OrderFulfillmentUpdatePlan,
- OrderFulfillmentUpdatePrepareRequest, OrderFulfillmentUpdateReceipt, OrderPaymentStateKind,
- OrderReceiptRecordEnqueueRequest, OrderReceiptRecordPlan, OrderReceiptRecordPrepareRequest,
- OrderReceiptRecordReceipt, OrderRequestEvidenceIngestReceipt,
- OrderRequestEvidenceIngestRequest, OrderRevisionDecisionEnqueueRequest,
- OrderRevisionDecisionPlan, OrderRevisionDecisionPrepareRequest, OrderRevisionDecisionReceipt,
+ OrderDecisionPrepareRequest, OrderDecisionReceipt, OrderEvidenceIngestReceipt,
+ OrderEvidenceIngestRequest, OrderFulfillmentStatusKind, OrderFulfillmentUpdateEnqueueRequest,
+ OrderFulfillmentUpdatePlan, OrderFulfillmentUpdatePrepareRequest,
+ OrderFulfillmentUpdateReceipt, OrderPaymentStateKind, OrderReceiptRecordEnqueueRequest,
+ OrderReceiptRecordPlan, OrderReceiptRecordPrepareRequest, OrderReceiptRecordReceipt,
+ OrderRequestEvidenceIngestReceipt, OrderRequestEvidenceIngestRequest,
+ OrderRevisionDecisionEnqueueRequest, OrderRevisionDecisionPlan,
+ OrderRevisionDecisionPrepareRequest, OrderRevisionDecisionReceipt,
OrderRevisionProposalEnqueueRequest, OrderRevisionProposalPlan,
OrderRevisionProposalPrepareRequest, OrderRevisionProposalReceipt, OrderSettlementStateKind,
OrderStatusKind, OrderStatusReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest,
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -16,6 +16,11 @@ use radroots_events::{
contract::RadrootsActorRole,
draft::RadrootsFrozenEventDraft,
ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey},
+ kinds::{
+ KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE,
+ KIND_ORDER_PAYMENT_RECORD, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST,
+ KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION,
+ },
order::{
RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderFulfillmentState,
RadrootsOrderFulfillmentUpdate, RadrootsOrderReceipt, RadrootsOrderRequest,
@@ -23,6 +28,13 @@ use radroots_events::{
},
};
#[cfg(feature = "runtime")]
+use radroots_events_codec::order::{
+ order_cancellation_from_event, order_decision_from_event, order_fulfillment_update_from_event,
+ order_payment_record_from_event, order_receipt_from_event, order_request_from_event,
+ order_revision_decision_from_event, order_revision_proposal_from_event,
+ order_settlement_decision_from_event,
+};
+#[cfg(feature = "runtime")]
use radroots_events_codec::wire::{WireEventParts, to_frozen_draft};
#[cfg(feature = "runtime")]
use radroots_trade::order::{
@@ -270,6 +282,53 @@ pub struct OrderRequestEvidenceIngestReceipt {
#[cfg(feature = "runtime")]
#[derive(Clone, Debug)]
#[non_exhaustive]
+pub struct OrderEvidenceIngestRequest {
+ pub event: RadrootsNostrEvent,
+ pub observed_at: Option<RadrootsSdkTimestamp>,
+}
+
+#[cfg(feature = "runtime")]
+impl serde::Serialize for OrderEvidenceIngestRequest {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("OrderEvidenceIngestRequest", 2)?;
+ state.serialize_field("event", &self.event)?;
+ state.serialize_field("observed_at", &self.observed_at)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl OrderEvidenceIngestRequest {
+ pub fn new(event: RadrootsNostrEvent) -> Self {
+ Self {
+ event,
+ observed_at: None,
+ }
+ }
+
+ pub fn with_observed_at(mut self, observed_at: RadrootsSdkTimestamp) -> Self {
+ self.observed_at = Some(observed_at);
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct OrderEvidenceIngestReceipt {
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub event_id: RadrootsEventId,
+ pub event_kind: u32,
+ pub local_event_seq: i64,
+ pub inserted: bool,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug)]
+#[non_exhaustive]
pub struct OrderDecisionPrepareRequest {
pub actor: RadrootsActorContext,
pub request_event: RadrootsNostrEventPtr,
@@ -1543,6 +1602,31 @@ impl SdkOrderStatusIssueKind {
#[cfg(feature = "runtime")]
impl<'sdk> OrdersClient<'sdk> {
+ pub async fn ingest_evidence(
+ &self,
+ request: OrderEvidenceIngestRequest,
+ ) -> Result<OrderEvidenceIngestReceipt, RadrootsSdkError> {
+ let evidence = parse_order_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(OrderEvidenceIngestReceipt {
+ order_id: evidence.order_id,
+ listing_addr: evidence.listing_addr,
+ event_id: evidence.event_id,
+ event_kind: evidence.event_kind,
+ local_event_seq: receipt.seq,
+ inserted: receipt.inserted,
+ })
+ }
+
pub async fn ingest_request_evidence(
&self,
request: OrderRequestEvidenceIngestRequest,
@@ -2296,6 +2380,102 @@ impl<'sdk> OrdersClient<'sdk> {
}
#[cfg(feature = "runtime")]
+struct ParsedOrderEvidence {
+ order_id: RadrootsOrderId,
+ listing_addr: RadrootsListingAddress,
+ event_id: RadrootsEventId,
+ event_kind: u32,
+}
+
+#[cfg(feature = "runtime")]
+fn parse_order_evidence(
+ event: &RadrootsNostrEvent,
+) -> Result<ParsedOrderEvidence, RadrootsSdkError> {
+ let event_id = RadrootsEventId::parse(event.id.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("order evidence event id is invalid: {error}"),
+ }
+ })?;
+ let (order_id, listing_addr) = match event.kind {
+ KIND_ORDER_REQUEST => {
+ let payload = order_request_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_DECISION => {
+ let payload = order_decision_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_REVISION_PROPOSAL => {
+ let payload = order_revision_proposal_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_REVISION_DECISION => {
+ let payload = order_revision_decision_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_CANCELLATION => {
+ let payload = order_cancellation_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_FULFILLMENT_UPDATE => {
+ let payload = order_fulfillment_update_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_RECEIPT => {
+ let payload = order_receipt_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_PAYMENT_RECORD => {
+ let payload = order_payment_record_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ KIND_ORDER_SETTLEMENT_DECISION => {
+ let payload = order_settlement_decision_from_event(event)
+ .map_err(order_evidence_parse_error)?
+ .payload;
+ (payload.order_id, payload.listing_addr)
+ }
+ other => {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("order evidence event kind {other} is not supported"),
+ });
+ }
+ };
+
+ Ok(ParsedOrderEvidence {
+ order_id,
+ listing_addr,
+ event_id,
+ event_kind: event.kind,
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn order_evidence_parse_error(
+ error: radroots_events_codec::order::RadrootsOrderEnvelopeParseError,
+) -> RadrootsSdkError {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("order evidence event is invalid: {error}"),
+ }
+}
+
+#[cfg(feature = "runtime")]
impl OrderStatusReceipt {
fn from_query_result(query_result: RadrootsOrderProjectionQueryResult) -> Self {
let projection = query_result.projection;
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -40,8 +40,8 @@ use radroots_sdk::{
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,
+ OrderEvidenceIngestRequest, OrderFulfillmentStatusKind, OrderFulfillmentUpdateEnqueueRequest,
+ OrderPaymentStateKind, OrderReceiptRecordEnqueueRequest, OrderRequestEvidenceIngestRequest,
OrderRevisionDecisionEnqueueRequest, OrderRevisionProposalEnqueueRequest,
OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, OrderSubmitEnqueueRequest,
OrderSubmitPrepareRequest, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest,
@@ -961,6 +961,18 @@ fn signed_order_decision_event(
signed_event(SELLER_SECRET_KEY_HEX, created_at, draft.into_wire_parts())
}
+fn signed_non_order_event(created_at: u32) -> RadrootsNostrEvent {
+ signed_event(
+ SELLER_SECRET_KEY_HEX,
+ created_at,
+ WireEventParts {
+ kind: KIND_LISTING,
+ content: "{}".to_owned(),
+ tags: vec![vec!["d".to_owned(), "not-an-order".to_owned()]],
+ },
+ )
+}
+
#[tokio::test]
async fn order_request_evidence_ingest_stores_request_and_enables_decision_enqueue() {
let (_tempdir, sdk, store) = directory_sdk_and_store().await;
@@ -1018,6 +1030,86 @@ async fn order_request_evidence_ingest_stores_request_and_enables_decision_enque
}
#[tokio::test]
+async fn order_evidence_ingest_stores_lifecycle_evidence_for_projection() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request_event = signed_order_request_event("order-evidence-ingest", 39);
+ let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id");
+ let decision_event =
+ signed_order_decision_event("order-evidence-ingest", &request_event_id, 40);
+
+ let request_receipt = sdk
+ .orders()
+ .ingest_evidence(OrderEvidenceIngestRequest::new(request_event.clone()))
+ .await
+ .expect("request evidence");
+ assert_eq!(request_receipt.order_id.as_str(), "order-evidence-ingest");
+ assert_eq!(request_receipt.event_kind, KIND_ORDER_REQUEST);
+ assert_eq!(request_receipt.local_event_seq, 1);
+ assert!(request_receipt.inserted);
+
+ let decision_receipt = sdk
+ .orders()
+ .ingest_evidence(OrderEvidenceIngestRequest::new(decision_event.clone()))
+ .await
+ .expect("decision evidence");
+ assert_eq!(decision_receipt.order_id.as_str(), "order-evidence-ingest");
+ assert_eq!(decision_receipt.event_kind, KIND_ORDER_DECISION);
+ assert_eq!(decision_receipt.local_event_seq, 2);
+ assert!(decision_receipt.inserted);
+
+ let duplicate_receipt = sdk
+ .orders()
+ .ingest_evidence(OrderEvidenceIngestRequest::new(decision_event))
+ .await
+ .expect("duplicate decision evidence");
+ assert_eq!(duplicate_receipt.local_event_seq, 2);
+ assert!(!duplicate_receipt.inserted);
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 2
+ );
+
+ let status = sdk
+ .orders()
+ .status(status_request("order-evidence-ingest"))
+ .await
+ .expect("status");
+ assert_eq!(status.status, OrderStatusKind::Accepted);
+ assert_eq!(status.event_count, 2);
+ assert_eq!(
+ status
+ .decision_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(decision_receipt.event_id.as_str())
+ );
+}
+
+#[tokio::test]
+async fn order_evidence_ingest_rejects_non_order_events() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let error = sdk
+ .orders()
+ .ingest_evidence(OrderEvidenceIngestRequest::new(signed_non_order_event(41)))
+ .await
+ .expect_err("non order event");
+
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+}
+
+#[tokio::test]
async fn order_request_evidence_ingest_rejects_non_request_events() {
let (_tempdir, sdk, store) = directory_sdk_and_store().await;
let root_event_id = deterministic_event_id("non-request-root");