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:
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");