sdk

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

commit c146bca18a56a55d6364a008e4914e07ae4665dc
parent 698ee92807ac1365058ed96ed19f78cdb7978e73
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 22:58:11 -0700

sdk: add order request evidence ingest

- add a typed SDK request evidence ingest API for order workflows
- validate signed order requests before storing local evidence
- expose ingest receipts for downstream decision runtimes
- cover decision-enabled ingest and non-request rejection tests

Diffstat:
Mcrates/sdk/src/lib.rs | 7++++---
Mcrates/sdk/src/orders_runtime.rs | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/orders_runtime.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 227 insertions(+), 10 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -88,9 +88,10 @@ pub use crate::orders_runtime::{ ORDER_DECISION_OPERATION_KIND, ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND, OrderDecisionEnqueueRequest, OrderDecisionPlan, OrderDecisionPrepareRequest, OrderDecisionReceipt, OrderFulfillmentStatusKind, - OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt, - OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPlan, OrderSubmitPrepareRequest, - OrderSubmitReceipt, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, + OrderPaymentStateKind, OrderRequestEvidenceIngestReceipt, OrderRequestEvidenceIngestRequest, + 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 @@ -9,8 +9,10 @@ use crate::{ #[cfg(feature = "runtime")] use radroots_authority::{RadrootsActorContext, RadrootsEventSigner}; #[cfg(feature = "runtime")] +use radroots_event_store::RadrootsEventIngest; +#[cfg(feature = "runtime")] use radroots_events::{ - RadrootsNostrEventPtr, + RadrootsNostrEvent, RadrootsNostrEventPtr, contract::RadrootsActorRole, draft::RadrootsFrozenEventDraft, ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey}, @@ -196,6 +198,54 @@ pub struct OrderSubmitReceipt { #[cfg(feature = "runtime")] #[derive(Clone, Debug)] #[non_exhaustive] +pub struct OrderRequestEvidenceIngestRequest { + pub event: RadrootsNostrEvent, + pub observed_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for OrderRequestEvidenceIngestRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("OrderRequestEvidenceIngestRequest", 2)?; + state.serialize_field("event", &self.event)?; + state.serialize_field("observed_at", &self.observed_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +impl OrderRequestEvidenceIngestRequest { + 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 OrderRequestEvidenceIngestReceipt { + pub order_id: RadrootsOrderId, + pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub request_event_id: RadrootsEventId, + 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, @@ -644,6 +694,32 @@ impl SdkOrderStatusIssueKind { #[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, @@ -965,6 +1041,67 @@ fn order_decision_plan( } #[cfg(feature = "runtime")] +struct OrderRequestEvidence { + order_id: RadrootsOrderId, + listing_addr: RadrootsListingAddress, + buyer_pubkey: RadrootsPublicKey, + seller_pubkey: RadrootsPublicKey, + request_event_id: RadrootsEventId, +} + +#[cfg(feature = "runtime")] +fn parse_order_request_evidence( + event: &RadrootsNostrEvent, +) -> Result<OrderRequestEvidence, RadrootsSdkError> { + let request_event_id = RadrootsEventId::parse(event.id.as_str()).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("order request evidence event id is invalid: {error}"), + } + })?; + let author_pubkey = RadrootsPublicKey::parse(event.author.as_str()).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("order request evidence author is invalid: {error}"), + } + })?; + let envelope = + order::parse_order_request(event).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order request evidence decode failed: {error}"), + })?; + let payload = envelope.payload; + if payload.buyer_pubkey != author_pubkey { + return Err(RadrootsSdkError::InvalidRequest { + message: "order request evidence author must match buyer_pubkey".to_owned(), + }); + } + if envelope.order_id != payload.order_id.as_str() { + return Err(RadrootsSdkError::InvalidRequest { + message: "order request evidence order_id envelope mismatch".to_owned(), + }); + } + if envelope.listing_addr != payload.listing_addr.as_str() { + return Err(RadrootsSdkError::InvalidRequest { + message: "order request evidence listing_addr envelope mismatch".to_owned(), + }); + } + Ok(OrderRequestEvidence { + order_id: payload.order_id, + listing_addr: payload.listing_addr, + buyer_pubkey: payload.buyer_pubkey, + seller_pubkey: payload.seller_pubkey, + request_event_id, + }) +} + +#[cfg(feature = "runtime")] +fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkError> { + let seconds = timestamp.unix_seconds(); + let millis = seconds + .checked_mul(1_000) + .ok_or(RadrootsSdkError::TimestampOutOfRange { value: seconds })?; + i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds }) +} + +#[cfg(feature = "runtime")] fn require_decision_request_evidence( plan: &OrderDecisionPlan, projection: &RadrootsOrderProjection, diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -31,12 +31,12 @@ 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, OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, - OrderSubmitEnqueueRequest, OrderSubmitPrepareRequest, PushOutboxEventState, - PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk, RadrootsSdkError, - RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, - SdkMutationState, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, - SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, + OrderPaymentStateKind, OrderRequestEvidenceIngestRequest, 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 = @@ -826,6 +826,85 @@ fn signed_order_decision_event( } #[tokio::test] +async fn order_request_evidence_ingest_stores_request_and_enables_decision_enqueue() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-decision-ingested", 39); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + let ingest_request = OrderRequestEvidenceIngestRequest::new(request_event.clone()) + .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_039)); + + let ingest_receipt = sdk + .orders() + .ingest_request_evidence(ingest_request) + .await + .expect("ingest request evidence"); + + assert_eq!(ingest_receipt.order_id.as_str(), "order-decision-ingested"); + assert_eq!(ingest_receipt.listing_addr, listing_address()); + assert_eq!(ingest_receipt.buyer_pubkey.as_str(), BUYER_PUBLIC_KEY_HEX); + assert_eq!(ingest_receipt.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); + assert_eq!(ingest_receipt.request_event_id, request_event_id); + assert_eq!(ingest_receipt.local_event_seq, 1); + assert!(ingest_receipt.inserted); + + let request = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-decision-ingested"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"); + let receipt = sdk + .orders() + .enqueue_decision(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .await + .expect("enqueue decision"); + + assert_eq!(receipt.local_event_seq, 2); + let duplicate_receipt = sdk + .orders() + .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new( + request_event.clone(), + )) + .await + .expect("duplicate request evidence"); + assert_eq!(duplicate_receipt.local_event_seq, 1); + assert!(!duplicate_receipt.inserted); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 2 + ); +} + +#[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"); + let decision_event = signed_order_decision_event("non-request-root", &root_event_id, 40); + + let error = sdk + .orders() + .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(decision_event)) + .await + .expect_err("non request event"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); +} + +#[tokio::test] async fn order_decision_prepare_accept_and_decline_are_side_effect_free() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; let request_event_id = deterministic_event_id("order-decision-prepare-request");