sdk

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

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:
Mcrates/sdk/src/lib.rs | 15++++++++-------
Mcrates/sdk/src/orders_runtime.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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");