commit d819a9f25c67ecae4d3cd437c26c96a8103cbf64
parent 1a52b44a85b52eb65e5056ffc5dc565c887c34e2
Author: triesap <tyson@radroots.org>
Date: Wed, 1 Jul 2026 08:52:43 +0000
sdk: add trade validation receipt client
- add grouped trade validation receipt list, inspect, and verify APIs
- fetch receipt and worker evidence through configured relay targets
- expose typed validation receipt DTOs without root trade aliases
- cover the client with public surface, source-boundary, and relay evidence tests
Diffstat:
7 files changed, 1216 insertions(+), 13 deletions(-)
diff --git a/crates/sdk/README b/crates/sdk/README
@@ -43,7 +43,9 @@ adapter-level substrate checks. `radrootsd-proxy` adds daemon-resolved publishin
`sdk.trades()` exposes the local trade runtime surface. With `runtime`, callers can ingest local trade
evidence, ingest DVM validation receipts through `sdk.dvm()`, read `sdk.trades().status(...)`, resync
-through `sdk.trades().resync()`, and read seller inbox projections through `sdk.trades().seller()`.
+through `sdk.trades().resync()`, inspect validation receipts through
+`sdk.trades().validation_receipts()`, and read seller inbox projections through
+`sdk.trades().seller()`.
`sdk.trades().status(...)` accepts `TradeStatusRequest` and reads local projections; it returns typed
source, event count, limit state, event IDs, ambiguity candidates, eligibility, next-action, and
reducer issue data. Status is not a network fetch.
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -111,7 +111,13 @@ pub use crate::orders_runtime::{
TradeResyncRelayTransportOutcomeKind, TradeResyncRequest, TradeSellerInboxReceipt,
TradeSellerInboxRequest, TradeStatusAmbiguityCandidate, TradeStatusEligibility,
TradeStatusEvidenceSummary, TradeStatusKind, TradeStatusNextActionKind, TradeStatusReceipt,
- TradeStatusRequest,
+ TradeStatusRequest, TradeValidationReceiptEvent, TradeValidationReceiptInspectReceipt,
+ TradeValidationReceiptInspectRequest, TradeValidationReceiptInvalidCandidate,
+ TradeValidationReceiptListReceipt, TradeValidationReceiptListRequest,
+ TradeValidationReceiptRelayEvidenceReceipt, TradeValidationReceiptRelayOutcomeKind,
+ TradeValidationReceiptRelayOutcomeReceipt, TradeValidationReceiptRelayTransportOutcomeKind,
+ TradeValidationReceiptTags, TradeValidationReceiptVerifyRequest,
+ TradeValidationReceiptWorkerEvidence, TradeValidationReceiptWorkerEvidenceSelection,
};
#[cfg(all(feature = "runtime", feature = "signer-adapters"))]
pub use crate::orders_runtime::{
@@ -135,7 +141,7 @@ pub use crate::product_clients::TradeBuyerClient;
#[cfg(feature = "runtime")]
pub use crate::product_clients::{
DvmClient, FarmsClient, GeoNamesClient, ListingsClient, MarketClient, SyncClient,
- TradeResyncClient, TradeSellerClient, TradesClient,
+ TradeResyncClient, TradeSellerClient, TradeValidationReceiptsClient, TradesClient,
};
#[cfg(feature = "runtime")]
pub use crate::relay_targets::{
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -17,8 +17,8 @@ use crate::{
};
#[cfg(feature = "runtime")]
use crate::{
- RadrootsSdkError, RadrootsSdkTimestamp, TradeResyncClient, TradeSellerClient, TradesClient,
- order,
+ RadrootsSdkError, RadrootsSdkTimestamp, TradeResyncClient, TradeSellerClient,
+ TradeValidationReceiptsClient, TradesClient, order,
};
#[cfg(all(feature = "runtime", test))]
use crate::{SdkRelayUrlPolicy, workflow_runtime::enqueue_signed_workflow};
@@ -27,7 +27,7 @@ use radroots_authority::RadrootsActorContext;
#[cfg(all(feature = "runtime", test))]
use radroots_authority::RadrootsEventSigner;
#[cfg(feature = "runtime")]
-use radroots_event_store::RadrootsEventIngest;
+use radroots_event_store::{RadrootsEventIngest, RadrootsStoredEvent};
#[cfg(feature = "runtime")]
use radroots_events::{
RadrootsNostrEvent,
@@ -35,11 +35,11 @@ use radroots_events::{
ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey},
kinds::{
KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST,
- KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_TRADE_VALIDATION_RECEIPT,
- ORDER_EVENT_KINDS,
+ KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL,
+ KIND_TRADE_TRANSITION_PROOF_RESULT, KIND_TRADE_VALIDATION_RECEIPT, ORDER_EVENT_KINDS,
},
order::RadrootsOrderEconomics,
- tags::{TAG_D, TAG_P},
+ tags::{TAG_D, TAG_E, TAG_P},
};
#[cfg(any(feature = "signer-adapters", test))]
use radroots_events::{
@@ -60,7 +60,9 @@ use radroots_events_codec::order::{
#[cfg(any(feature = "signer-adapters", test))]
use radroots_events_codec::wire::{WireEventParts, to_frozen_draft};
#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
-use radroots_nostr::prelude::{RadrootsNostrFilter, RadrootsNostrKind, radroots_nostr_filter_tag};
+use radroots_nostr::prelude::{
+ RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrKind, radroots_nostr_filter_tag,
+};
#[cfg(feature = "runtime")]
use radroots_relay_transport::{
RadrootsNostrClientFetchAdapter, RadrootsRelayFetchAdapter, RadrootsRelayFetchEventReceipt,
@@ -83,10 +85,21 @@ use radroots_trade::order::{
order_projection_query_for_trade_locator,
};
#[cfg(feature = "runtime")]
+use radroots_trade::validation_receipt::{
+ RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority,
+ RadrootsTradeValidationReceipt, RadrootsValidationReceiptError,
+ RadrootsValidationReceiptExpectedBinding, RadrootsValidationReceiptProofSystem,
+ RadrootsValidationReceiptTags, verify_validation_receipt_event,
+};
+#[cfg(feature = "runtime")]
use radroots_trade::workflow::RadrootsTradeWorkflowState;
#[cfg(feature = "runtime")]
+use serde::Deserialize;
+#[cfg(feature = "runtime")]
use serde::ser::SerializeStruct;
#[cfg(feature = "runtime")]
+use std::collections::{BTreeMap, BTreeSet};
+#[cfg(feature = "runtime")]
pub const TRADE_STATUS_DEFAULT_LIMIT: u32 = 500;
#[cfg(feature = "runtime")]
pub const TRADE_STATUS_MAX_LIMIT: u32 = 1_000;
@@ -1509,6 +1522,266 @@ pub enum TradeResyncRelayTransportOutcomeKind {
#[cfg(feature = "runtime")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
+pub struct TradeValidationReceiptListRequest {
+ pub order_id: RadrootsOrderId,
+ pub limit: u32,
+ pub trusted_worker_pubkeys: Vec<RadrootsPublicKey>,
+}
+
+#[cfg(feature = "runtime")]
+impl TradeValidationReceiptListRequest {
+ pub fn new(order_id: RadrootsOrderId) -> Self {
+ Self {
+ order_id,
+ limit: TRADE_STATUS_DEFAULT_LIMIT,
+ trusted_worker_pubkeys: Vec::new(),
+ }
+ }
+
+ pub fn parse(order_id: &str) -> Result<Self, RadrootsSdkError> {
+ RadrootsOrderId::parse(order_id)
+ .map(Self::new)
+ .map_err(|error| RadrootsSdkError::invalid_trade_id(order_id, error.to_string()))
+ }
+
+ pub fn with_limit(mut self, limit: u32) -> Self {
+ self.limit = limit;
+ self
+ }
+
+ pub fn try_with_trusted_worker_pubkeys<I, S>(
+ mut self,
+ pubkeys: I,
+ ) -> Result<Self, RadrootsSdkError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ {
+ self.trusted_worker_pubkeys = parse_worker_pubkeys(pubkeys)?;
+ Ok(self)
+ }
+
+ fn validate(&self) -> Result<(), RadrootsSdkError> {
+ validate_validation_receipt_limit(self.limit)
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[non_exhaustive]
+pub struct TradeValidationReceiptInspectRequest {
+ pub receipt_event_id: RadrootsEventId,
+ pub trusted_worker_pubkeys: Vec<RadrootsPublicKey>,
+}
+
+#[cfg(feature = "runtime")]
+impl TradeValidationReceiptInspectRequest {
+ pub fn new(receipt_event_id: RadrootsEventId) -> Self {
+ Self {
+ receipt_event_id,
+ trusted_worker_pubkeys: Vec::new(),
+ }
+ }
+
+ pub fn parse(receipt_event_id: &str) -> Result<Self, RadrootsSdkError> {
+ RadrootsEventId::parse(receipt_event_id)
+ .map(Self::new)
+ .map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "invalid validation receipt event id `{receipt_event_id}`: {error}"
+ ),
+ })
+ }
+
+ pub fn try_with_trusted_worker_pubkeys<I, S>(
+ mut self,
+ pubkeys: I,
+ ) -> Result<Self, RadrootsSdkError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ {
+ self.trusted_worker_pubkeys = parse_worker_pubkeys(pubkeys)?;
+ Ok(self)
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[non_exhaustive]
+pub struct TradeValidationReceiptVerifyRequest {
+ pub receipt_event_id: RadrootsEventId,
+ pub trusted_worker_pubkeys: Vec<RadrootsPublicKey>,
+}
+
+#[cfg(feature = "runtime")]
+impl TradeValidationReceiptVerifyRequest {
+ pub fn new(receipt_event_id: RadrootsEventId) -> Self {
+ Self {
+ receipt_event_id,
+ trusted_worker_pubkeys: Vec::new(),
+ }
+ }
+
+ pub fn parse(receipt_event_id: &str) -> Result<Self, RadrootsSdkError> {
+ RadrootsEventId::parse(receipt_event_id)
+ .map(Self::new)
+ .map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "invalid validation receipt event id `{receipt_event_id}`: {error}"
+ ),
+ })
+ }
+
+ pub fn try_with_trusted_worker_pubkeys<I, S>(
+ mut self,
+ pubkeys: I,
+ ) -> Result<Self, RadrootsSdkError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ {
+ self.trusted_worker_pubkeys = parse_worker_pubkeys(pubkeys)?;
+ Ok(self)
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptListReceipt {
+ pub relay_targets: Vec<String>,
+ pub relay_evidence: TradeValidationReceiptRelayEvidenceReceipt,
+ pub order_id: RadrootsOrderId,
+ pub receipts: Vec<TradeValidationReceiptEvent>,
+ pub invalid_receipts: Vec<TradeValidationReceiptInvalidCandidate>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptInspectReceipt {
+ pub relay_targets: Vec<String>,
+ pub relay_evidence: TradeValidationReceiptRelayEvidenceReceipt,
+ pub receipt_event_id: RadrootsEventId,
+ pub receipt: Option<TradeValidationReceiptEvent>,
+ pub invalid_receipt: Option<TradeValidationReceiptInvalidCandidate>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptEvent {
+ pub event: RadrootsNostrEvent,
+ pub receipt: RadrootsTradeValidationReceipt,
+ pub tags: TradeValidationReceiptTags,
+ pub worker_evidence: TradeValidationReceiptWorkerEvidenceSelection,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptInvalidCandidate {
+ pub event: RadrootsNostrEvent,
+ pub reason_code: String,
+ pub reason: String,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptTags {
+ pub order_id: String,
+ pub event_set_root: String,
+ pub listing_event_id: String,
+ pub reducer_output_root: String,
+ pub public_values_hash: String,
+ pub proof_system: String,
+ pub receipt_type: String,
+ pub root_event_id: String,
+ pub target_event_id: String,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptWorkerEvidenceSelection {
+ pub trusted: Option<TradeValidationReceiptWorkerEvidence>,
+ pub untrusted: Option<TradeValidationReceiptWorkerEvidence>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptWorkerEvidence {
+ pub result_event_id: RadrootsEventId,
+ pub author: RadrootsPublicKey,
+ pub status: String,
+ pub prover_backend: String,
+ pub proof_mode: String,
+ pub proof_system: String,
+ pub proof_generated: bool,
+ pub sp1_execute_checked: bool,
+ pub sp1_execute_public_values_hash: Option<String>,
+ pub cryptographic_proof_verified: bool,
+ pub public_values_hash: String,
+ pub validation_authority: Option<RadrootsTradeValidationAuthority>,
+ pub commitment_confidence: Option<RadrootsTradeCommitmentConfidence>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptRelayEvidenceReceipt {
+ pub inserted_count: usize,
+ pub duplicate_count: usize,
+ pub malformed_count: usize,
+ pub unsupported_count: usize,
+ pub eose_count: usize,
+ pub closed_count: usize,
+ pub notice_count: usize,
+ pub events: Vec<TradeResyncEventImportReceipt>,
+ pub relays: Vec<TradeValidationReceiptRelayOutcomeReceipt>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct TradeValidationReceiptRelayOutcomeReceipt {
+ pub relay_url: String,
+ pub outcome_kind: TradeValidationReceiptRelayOutcomeKind,
+ pub transport_outcome_kind: Option<TradeValidationReceiptRelayTransportOutcomeKind>,
+ pub message: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum TradeValidationReceiptRelayOutcomeKind {
+ Eose,
+ Closed,
+ Notice,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum TradeValidationReceiptRelayTransportOutcomeKind {
+ Accepted,
+ DuplicateAccepted,
+ Blocked,
+ RateLimited,
+ Invalid,
+ PowRequired,
+ Restricted,
+ AuthRequired,
+ Muted,
+ Unsupported,
+ PaymentRequired,
+ Error,
+ Timeout,
+ ConnectionFailed,
+ RelayUrlRejected,
+ SkippedAlreadyAccepted,
+ Unknown,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[non_exhaustive]
pub struct TradeStatusRequest {
pub locator: RadrootsTradeLocator,
pub limit: u32,
@@ -2858,6 +3131,716 @@ impl From<RadrootsRelayOutcomeKind> for TradeResyncRelayTransportOutcomeKind {
}
}
+#[cfg(feature = "runtime")]
+impl<'sdk> TradeValidationReceiptsClient<'sdk> {
+ pub async fn list(
+ &self,
+ request: TradeValidationReceiptListRequest,
+ ) -> Result<TradeValidationReceiptListReceipt, RadrootsSdkError> {
+ #[cfg(feature = "relay-runtime")]
+ {
+ let adapter = RadrootsNostrClientFetchAdapter;
+ return self.list_with_fetch_adapter(request, &adapter).await;
+ }
+ #[cfg(not(feature = "relay-runtime"))]
+ {
+ let _ = request;
+ Err(RadrootsSdkError::ProductSyncUnsupported {
+ operation: "trade.validation_receipts.list",
+ required_feature: "relay-runtime",
+ })
+ }
+ }
+
+ #[cfg(feature = "relay-runtime")]
+ pub async fn list_with_fetch_adapter<A>(
+ &self,
+ request: TradeValidationReceiptListRequest,
+ adapter: &A,
+ ) -> Result<TradeValidationReceiptListReceipt, RadrootsSdkError>
+ where
+ A: RadrootsRelayFetchAdapter,
+ {
+ request.validate()?;
+ let relay_targets =
+ validation_receipt_relay_targets(self.sdk, "trade.validation_receipts.list")?;
+ let fetch_request =
+ validation_receipt_list_fetch_request(self.sdk, &request, &relay_targets)?;
+ let fetch_receipt =
+ fetch_and_ingest_relay_events(adapter, &self.sdk._event_store, fetch_request).await?;
+ let relay_evidence = TradeValidationReceiptRelayEvidenceReceipt::from_fetch(fetch_receipt);
+ let events = validation_receipt_events_from_fetch(
+ self.sdk,
+ &relay_evidence.events,
+ KIND_TRADE_VALIDATION_RECEIPT,
+ )
+ .await?;
+ let (mut receipts, mut invalid_receipts) =
+ classify_validation_receipts(events, Some(request.order_id.as_str()))?;
+ attach_worker_evidence(
+ self.sdk,
+ adapter,
+ &relay_targets,
+ &request.trusted_worker_pubkeys,
+ &mut receipts,
+ )
+ .await?;
+ receipts.sort_by(validation_receipt_event_order);
+ invalid_receipts.sort_by(validation_receipt_invalid_order);
+ Ok(TradeValidationReceiptListReceipt {
+ relay_targets,
+ relay_evidence,
+ order_id: request.order_id,
+ receipts,
+ invalid_receipts,
+ })
+ }
+
+ pub async fn inspect(
+ &self,
+ request: TradeValidationReceiptInspectRequest,
+ ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> {
+ #[cfg(feature = "relay-runtime")]
+ {
+ let adapter = RadrootsNostrClientFetchAdapter;
+ return self.inspect_with_fetch_adapter(request, &adapter).await;
+ }
+ #[cfg(not(feature = "relay-runtime"))]
+ {
+ let _ = request;
+ Err(RadrootsSdkError::ProductSyncUnsupported {
+ operation: "trade.validation_receipts.inspect",
+ required_feature: "relay-runtime",
+ })
+ }
+ }
+
+ #[cfg(feature = "relay-runtime")]
+ pub async fn inspect_with_fetch_adapter<A>(
+ &self,
+ request: TradeValidationReceiptInspectRequest,
+ adapter: &A,
+ ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError>
+ where
+ A: RadrootsRelayFetchAdapter,
+ {
+ validation_receipt_inspect(
+ self.sdk,
+ request.receipt_event_id,
+ request.trusted_worker_pubkeys,
+ adapter,
+ "trade.validation_receipts.inspect",
+ )
+ .await
+ }
+
+ pub async fn verify(
+ &self,
+ request: TradeValidationReceiptVerifyRequest,
+ ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> {
+ #[cfg(feature = "relay-runtime")]
+ {
+ let adapter = RadrootsNostrClientFetchAdapter;
+ return self.verify_with_fetch_adapter(request, &adapter).await;
+ }
+ #[cfg(not(feature = "relay-runtime"))]
+ {
+ let _ = request;
+ Err(RadrootsSdkError::ProductSyncUnsupported {
+ operation: "trade.validation_receipts.verify",
+ required_feature: "relay-runtime",
+ })
+ }
+ }
+
+ #[cfg(feature = "relay-runtime")]
+ pub async fn verify_with_fetch_adapter<A>(
+ &self,
+ request: TradeValidationReceiptVerifyRequest,
+ adapter: &A,
+ ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError>
+ where
+ A: RadrootsRelayFetchAdapter,
+ {
+ validation_receipt_inspect(
+ self.sdk,
+ request.receipt_event_id,
+ request.trusted_worker_pubkeys,
+ adapter,
+ "trade.validation_receipts.verify",
+ )
+ .await
+ }
+}
+
+#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
+async fn validation_receipt_inspect<A>(
+ sdk: &crate::RadrootsClient,
+ receipt_event_id: RadrootsEventId,
+ trusted_worker_pubkeys: Vec<RadrootsPublicKey>,
+ adapter: &A,
+ operation: &'static str,
+) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError>
+where
+ A: RadrootsRelayFetchAdapter,
+{
+ let relay_targets = validation_receipt_relay_targets(sdk, operation)?;
+ let fetch_request =
+ validation_receipt_inspect_fetch_request(sdk, &receipt_event_id, &relay_targets)?;
+ let fetch_receipt =
+ fetch_and_ingest_relay_events(adapter, &sdk._event_store, fetch_request).await?;
+ let relay_evidence = TradeValidationReceiptRelayEvidenceReceipt::from_fetch(fetch_receipt);
+ let events = validation_receipt_events_from_fetch(
+ sdk,
+ &relay_evidence.events,
+ KIND_TRADE_VALIDATION_RECEIPT,
+ )
+ .await?;
+ let (mut receipts, mut invalid_receipts) = classify_validation_receipts(events, None)?;
+ attach_worker_evidence(
+ sdk,
+ adapter,
+ &relay_targets,
+ &trusted_worker_pubkeys,
+ &mut receipts,
+ )
+ .await?;
+ receipts.sort_by(validation_receipt_event_order);
+ invalid_receipts.sort_by(validation_receipt_invalid_order);
+ Ok(TradeValidationReceiptInspectReceipt {
+ relay_targets,
+ relay_evidence,
+ receipt_event_id,
+ receipt: receipts.into_iter().next(),
+ invalid_receipt: invalid_receipts.into_iter().next(),
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn validation_receipt_relay_targets(
+ sdk: &crate::RadrootsClient,
+ operation: impl Into<String>,
+) -> Result<Vec<String>, RadrootsSdkError> {
+ let relay_targets = sdk.relay_urls().to_vec();
+ if relay_targets.is_empty() {
+ return Err(RadrootsSdkError::empty_target_relays(operation));
+ }
+ Ok(relay_targets)
+}
+
+#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
+fn validation_receipt_list_fetch_request(
+ sdk: &crate::RadrootsClient,
+ request: &TradeValidationReceiptListRequest,
+ relay_targets: &[String],
+) -> Result<RadrootsRelayFetchRequest, RadrootsSdkError> {
+ let filter = RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::Custom(
+ KIND_TRADE_VALIDATION_RECEIPT as u16,
+ ))
+ .limit(request.limit as usize);
+ let filter =
+ radroots_nostr_filter_tag(filter, TAG_D, vec![request.order_id.as_str().to_owned()])
+ .map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!("validation receipt list filter invalid: {error}"),
+ })?;
+ Ok(
+ RadrootsRelayFetchRequest::fetch(sdk_now_ms(sdk)?, request.limit as usize)
+ .with_relay_urls(relay_targets.iter().cloned())
+ .with_filters([filter]),
+ )
+}
+
+#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
+fn validation_receipt_inspect_fetch_request(
+ sdk: &crate::RadrootsClient,
+ receipt_event_id: &RadrootsEventId,
+ relay_targets: &[String],
+) -> Result<RadrootsRelayFetchRequest, RadrootsSdkError> {
+ let nostr_event_id =
+ RadrootsNostrEventId::parse(receipt_event_id.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "validation receipt event id `{}` cannot build relay filter: {error}",
+ receipt_event_id.as_str()
+ ),
+ }
+ })?;
+ let filter = RadrootsNostrFilter::new().id(nostr_event_id);
+ Ok(RadrootsRelayFetchRequest::fetch(sdk_now_ms(sdk)?, 1)
+ .with_relay_urls(relay_targets.iter().cloned())
+ .with_filters([filter]))
+}
+
+#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
+fn validation_receipt_worker_fetch_request(
+ sdk: &crate::RadrootsClient,
+ receipts: &[TradeValidationReceiptEvent],
+ relay_targets: &[String],
+) -> Result<Option<RadrootsRelayFetchRequest>, RadrootsSdkError> {
+ let root_event_ids = receipts
+ .iter()
+ .map(|receipt| receipt.tags.root_event_id.clone())
+ .collect::<BTreeSet<_>>()
+ .into_iter()
+ .collect::<Vec<_>>();
+ if root_event_ids.is_empty() {
+ return Ok(None);
+ }
+ let filter = RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::Custom(
+ KIND_TRADE_TRANSITION_PROOF_RESULT as u16,
+ ))
+ .limit(receipts.len().saturating_mul(4).max(1));
+ let filter = radroots_nostr_filter_tag(filter, TAG_E, root_event_ids).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("validation receipt worker evidence filter invalid: {error}"),
+ }
+ })?;
+ Ok(Some(
+ RadrootsRelayFetchRequest::fetch(sdk_now_ms(sdk)?, receipts.len().saturating_mul(4).max(1))
+ .with_relay_urls(relay_targets.iter().cloned())
+ .with_filters([filter]),
+ ))
+}
+
+#[cfg(feature = "runtime")]
+async fn validation_receipt_events_from_fetch(
+ sdk: &crate::RadrootsClient,
+ events: &[TradeResyncEventImportReceipt],
+ expected_kind: u32,
+) -> Result<Vec<RadrootsNostrEvent>, RadrootsSdkError> {
+ let mut event_ids = events
+ .iter()
+ .filter(|event| !event.malformed)
+ .filter_map(|event| event.event_id.as_deref())
+ .collect::<BTreeSet<_>>()
+ .into_iter()
+ .collect::<Vec<_>>();
+ event_ids.sort();
+ let mut fetched = Vec::new();
+ for event_id in event_ids {
+ let Some(stored_event) = sdk
+ ._event_store
+ .get_event(event_id)
+ .await
+ .map_err(|error| RadrootsSdkError::EventStore {
+ message: error.to_string(),
+ })?
+ else {
+ continue;
+ };
+ if stored_event.kind == expected_kind {
+ fetched.push(stored_event_to_nostr_event(&stored_event)?);
+ }
+ }
+ Ok(fetched)
+}
+
+#[cfg(feature = "runtime")]
+fn classify_validation_receipts(
+ events: Vec<RadrootsNostrEvent>,
+ expected_order_id: Option<&str>,
+) -> Result<
+ (
+ Vec<TradeValidationReceiptEvent>,
+ Vec<TradeValidationReceiptInvalidCandidate>,
+ ),
+ RadrootsSdkError,
+> {
+ let mut receipts = Vec::new();
+ let mut invalid_receipts = Vec::new();
+ for event in events {
+ let expected = RadrootsValidationReceiptExpectedBinding {
+ order_id: expected_order_id,
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ };
+ match verify_validation_receipt_event(&event, expected) {
+ Ok(verified) => receipts.push(TradeValidationReceiptEvent {
+ event,
+ receipt: verified.receipt,
+ tags: TradeValidationReceiptTags::from(verified.tags),
+ worker_evidence: TradeValidationReceiptWorkerEvidenceSelection::default(),
+ }),
+ Err(error) => invalid_receipts.push(TradeValidationReceiptInvalidCandidate {
+ event,
+ reason_code: validation_receipt_invalid_reason_code(&error).to_owned(),
+ reason: error.to_string(),
+ }),
+ }
+ }
+ Ok((receipts, invalid_receipts))
+}
+
+#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
+async fn attach_worker_evidence<A>(
+ sdk: &crate::RadrootsClient,
+ adapter: &A,
+ relay_targets: &[String],
+ trusted_worker_pubkeys: &[RadrootsPublicKey],
+ receipts: &mut [TradeValidationReceiptEvent],
+) -> Result<(), RadrootsSdkError>
+where
+ A: RadrootsRelayFetchAdapter,
+{
+ if receipts.is_empty() || trusted_worker_pubkeys.is_empty() {
+ return Ok(());
+ }
+ let Some(fetch_request) =
+ validation_receipt_worker_fetch_request(sdk, receipts, relay_targets)?
+ else {
+ return Ok(());
+ };
+ let fetch_receipt =
+ fetch_and_ingest_relay_events(adapter, &sdk._event_store, fetch_request).await?;
+ let relay_evidence = TradeValidationReceiptRelayEvidenceReceipt::from_fetch(fetch_receipt);
+ let events = validation_receipt_events_from_fetch(
+ sdk,
+ &relay_evidence.events,
+ KIND_TRADE_TRANSITION_PROOF_RESULT,
+ )
+ .await?;
+ let mut selections = worker_evidence_for_receipts(trusted_worker_pubkeys, receipts, events)?;
+ for receipt in receipts {
+ receipt.worker_evidence = selections
+ .remove(receipt.event.id.as_str())
+ .unwrap_or_default();
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn worker_evidence_for_receipts(
+ trusted_worker_pubkeys: &[RadrootsPublicKey],
+ receipts: &[TradeValidationReceiptEvent],
+ events: Vec<RadrootsNostrEvent>,
+) -> Result<BTreeMap<String, TradeValidationReceiptWorkerEvidenceSelection>, RadrootsSdkError> {
+ let trusted_pubkeys = trusted_worker_pubkeys
+ .iter()
+ .map(|pubkey| pubkey.as_str().to_ascii_lowercase())
+ .collect::<BTreeSet<_>>();
+ let binding_by_receipt_id = receipts
+ .iter()
+ .map(|receipt| (receipt.event.id.as_str(), receipt))
+ .collect::<BTreeMap<_, _>>();
+ let mut by_receipt =
+ BTreeMap::<String, Vec<(u32, String, bool, TradeValidationReceiptWorkerEvidence)>>::new();
+
+ for event in events {
+ let payload =
+ match serde_json::from_str::<RawTradeValidationReceiptWorkerResult>(&event.content) {
+ Ok(payload) => payload,
+ Err(_) => continue,
+ };
+ let Some(binding) = binding_by_receipt_id.get(payload.receipt_event_id.as_str()) else {
+ continue;
+ };
+ if !worker_payload_binds_receipt(&payload, binding) {
+ continue;
+ }
+ let author = RadrootsPublicKey::parse(event.author.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("validation receipt worker evidence author is invalid: {error}"),
+ }
+ })?;
+ let result_event_id = RadrootsEventId::parse(event.id.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("validation receipt worker evidence event id is invalid: {error}"),
+ }
+ })?;
+ let trusted = trusted_pubkeys.contains(author.as_str().to_ascii_lowercase().as_str());
+ let receipt_event_id = payload.receipt_event_id.clone();
+ let view = TradeValidationReceiptWorkerEvidence {
+ result_event_id,
+ author,
+ status: payload.status,
+ prover_backend: payload.prover_backend,
+ proof_mode: payload.proof_mode,
+ proof_system: payload.proof_system,
+ proof_generated: payload.proof_generated,
+ sp1_execute_checked: payload.sp1_execute_checked,
+ sp1_execute_public_values_hash: payload.sp1_execute_public_values_hash,
+ cryptographic_proof_verified: payload.cryptographic_proof_verified,
+ public_values_hash: payload.public_values_hash,
+ validation_authority: payload
+ .validation_authority
+ .as_deref()
+ .and_then(RadrootsTradeValidationAuthority::from_label),
+ commitment_confidence: payload
+ .confidence
+ .as_deref()
+ .and_then(RadrootsTradeCommitmentConfidence::from_label),
+ };
+ by_receipt.entry(receipt_event_id).or_default().push((
+ event.created_at,
+ event.id,
+ trusted,
+ view,
+ ));
+ }
+
+ Ok(by_receipt
+ .into_iter()
+ .map(|(receipt_event_id, mut candidates)| {
+ candidates.sort_by(|left, right| {
+ left.0
+ .cmp(&right.0)
+ .then_with(|| left.1.cmp(&right.1))
+ .then_with(|| left.2.cmp(&right.2))
+ });
+ let mut selection = TradeValidationReceiptWorkerEvidenceSelection::default();
+ for (_, _, trusted, view) in candidates.into_iter().rev() {
+ if trusted && selection.trusted.is_none() {
+ selection.trusted = Some(view);
+ } else if !trusted && selection.untrusted.is_none() {
+ selection.untrusted = Some(view);
+ }
+ if selection.trusted.is_some() && selection.untrusted.is_some() {
+ break;
+ }
+ }
+ (receipt_event_id, selection)
+ })
+ .collect())
+}
+
+#[cfg(feature = "runtime")]
+fn worker_payload_binds_receipt(
+ payload: &RawTradeValidationReceiptWorkerResult,
+ receipt: &TradeValidationReceiptEvent,
+) -> bool {
+ payload.status == "succeeded"
+ && payload.worker_role.as_deref() == Some("non_authoritative_prover")
+ && payload.receipt_kind == Some(KIND_TRADE_VALIDATION_RECEIPT)
+ && payload.receipt_event_id == receipt.event.id
+ && payload.order_id.as_deref() == Some(receipt.tags.order_id.as_str())
+ && payload.listing_event_id.as_deref() == Some(receipt.tags.listing_event_id.as_str())
+ && payload.event_set_root.as_deref() == Some(receipt.tags.event_set_root.as_str())
+ && payload.reducer_output_root.as_deref() == Some(receipt.tags.reducer_output_root.as_str())
+ && payload.request_event_id.as_deref() == Some(receipt.tags.root_event_id.as_str())
+ && payload.decision_event_id.as_deref() == Some(receipt.tags.target_event_id.as_str())
+ && payload.public_values_hash == receipt.receipt.public_values_hash
+ && payload.proof_system == receipt.receipt.proof.system.as_str()
+ && payload.proof_mode == receipt.receipt.proof.mode.as_deref().unwrap_or("none")
+ && payload.proof_generated
+ == (receipt.receipt.proof.system != RadrootsValidationReceiptProofSystem::None)
+ && payload.cryptographic_proof_verified == payload.proof_generated
+ && payload.sp1_execute_checked
+ && payload.sp1_execute_public_values_hash.as_deref()
+ == Some(receipt.receipt.public_values_hash.as_str())
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Debug, Deserialize)]
+struct RawTradeValidationReceiptWorkerResult {
+ cryptographic_proof_verified: bool,
+ decision_event_id: Option<String>,
+ event_set_root: Option<String>,
+ listing_event_id: Option<String>,
+ order_id: Option<String>,
+ proof_generated: bool,
+ proof_mode: String,
+ proof_system: String,
+ public_values_hash: String,
+ prover_backend: String,
+ receipt_event_id: String,
+ receipt_kind: Option<u32>,
+ reducer_output_root: Option<String>,
+ request_event_id: Option<String>,
+ sp1_execute_checked: bool,
+ sp1_execute_public_values_hash: Option<String>,
+ status: String,
+ validation_authority: Option<String>,
+ confidence: Option<String>,
+ worker_role: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+impl TradeValidationReceiptRelayEvidenceReceipt {
+ fn from_fetch(receipt: RadrootsRelayFetchReceipt) -> Self {
+ Self {
+ inserted_count: receipt.inserted_count,
+ duplicate_count: receipt.duplicate_count,
+ malformed_count: receipt.malformed_count,
+ unsupported_count: receipt.unsupported_count,
+ eose_count: receipt.eose_count,
+ closed_count: receipt.closed_count,
+ notice_count: receipt.notice_count,
+ events: receipt.events.into_iter().map(Into::into).collect(),
+ relays: receipt.relay_outcomes.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsRelayFetchRelayOutcome> for TradeValidationReceiptRelayOutcomeReceipt {
+ fn from(receipt: RadrootsRelayFetchRelayOutcome) -> Self {
+ Self {
+ relay_url: receipt.relay_url,
+ outcome_kind: receipt.kind.into(),
+ transport_outcome_kind: receipt.relay_outcome.map(|outcome| outcome.kind.into()),
+ message: receipt.message,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsRelayFetchOutcomeKind> for TradeValidationReceiptRelayOutcomeKind {
+ fn from(kind: RadrootsRelayFetchOutcomeKind) -> Self {
+ match kind {
+ RadrootsRelayFetchOutcomeKind::Eose => Self::Eose,
+ RadrootsRelayFetchOutcomeKind::Closed => Self::Closed,
+ RadrootsRelayFetchOutcomeKind::Notice => Self::Notice,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsRelayOutcomeKind> for TradeValidationReceiptRelayTransportOutcomeKind {
+ fn from(kind: RadrootsRelayOutcomeKind) -> Self {
+ match kind {
+ RadrootsRelayOutcomeKind::Accepted => Self::Accepted,
+ RadrootsRelayOutcomeKind::DuplicateAccepted => Self::DuplicateAccepted,
+ RadrootsRelayOutcomeKind::Blocked => Self::Blocked,
+ RadrootsRelayOutcomeKind::RateLimited => Self::RateLimited,
+ RadrootsRelayOutcomeKind::Invalid => Self::Invalid,
+ RadrootsRelayOutcomeKind::PowRequired => Self::PowRequired,
+ RadrootsRelayOutcomeKind::Restricted => Self::Restricted,
+ RadrootsRelayOutcomeKind::AuthRequired => Self::AuthRequired,
+ RadrootsRelayOutcomeKind::Muted => Self::Muted,
+ RadrootsRelayOutcomeKind::Unsupported => Self::Unsupported,
+ RadrootsRelayOutcomeKind::PaymentRequired => Self::PaymentRequired,
+ RadrootsRelayOutcomeKind::Error => Self::Error,
+ RadrootsRelayOutcomeKind::Timeout => Self::Timeout,
+ RadrootsRelayOutcomeKind::ConnectionFailed => Self::ConnectionFailed,
+ RadrootsRelayOutcomeKind::RelayUrlRejected => Self::RelayUrlRejected,
+ RadrootsRelayOutcomeKind::SkippedAlreadyAccepted => Self::SkippedAlreadyAccepted,
+ RadrootsRelayOutcomeKind::Unknown => Self::Unknown,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsValidationReceiptTags> for TradeValidationReceiptTags {
+ fn from(tags: RadrootsValidationReceiptTags) -> Self {
+ Self {
+ order_id: tags.order_id,
+ event_set_root: tags.event_set_root,
+ listing_event_id: tags.listing_event_id,
+ reducer_output_root: tags.reducer_output_root,
+ public_values_hash: tags.public_values_hash,
+ proof_system: tags.proof_system.as_str().to_owned(),
+ receipt_type: tags.receipt_type.as_str().to_owned(),
+ root_event_id: tags.root_event_id,
+ target_event_id: tags.target_event_id,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn stored_event_to_nostr_event(
+ stored_event: &RadrootsStoredEvent,
+) -> Result<RadrootsNostrEvent, RadrootsSdkError> {
+ let tags = serde_json::from_str(&stored_event.tags_json).map_err(|error| {
+ RadrootsSdkError::EventStore {
+ message: format!(
+ "stored event {} contains invalid tags_json: {error}",
+ stored_event.event_id
+ ),
+ }
+ })?;
+ Ok(RadrootsNostrEvent {
+ id: stored_event.event_id.clone(),
+ author: stored_event.pubkey.clone(),
+ created_at: stored_event.created_at,
+ kind: stored_event.kind,
+ tags,
+ content: stored_event.content.clone(),
+ sig: stored_event.sig.clone(),
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn validation_receipt_event_order(
+ left: &TradeValidationReceiptEvent,
+ right: &TradeValidationReceiptEvent,
+) -> core::cmp::Ordering {
+ left.event
+ .created_at
+ .cmp(&right.event.created_at)
+ .then_with(|| left.event.id.cmp(&right.event.id))
+}
+
+#[cfg(feature = "runtime")]
+fn validation_receipt_invalid_order(
+ left: &TradeValidationReceiptInvalidCandidate,
+ right: &TradeValidationReceiptInvalidCandidate,
+) -> core::cmp::Ordering {
+ left.event
+ .created_at
+ .cmp(&right.event.created_at)
+ .then_with(|| left.event.id.cmp(&right.event.id))
+}
+
+#[cfg(feature = "runtime")]
+fn validate_validation_receipt_limit(limit: u32) -> Result<(), RadrootsSdkError> {
+ if limit == 0 || limit > TRADE_STATUS_MAX_LIMIT {
+ return Err(RadrootsSdkError::trade_status_limit_invalid(
+ limit,
+ 1,
+ TRADE_STATUS_MAX_LIMIT,
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn parse_worker_pubkeys<I, S>(pubkeys: I) -> Result<Vec<RadrootsPublicKey>, RadrootsSdkError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ pubkeys
+ .into_iter()
+ .map(|pubkey| {
+ let value = pubkey.as_ref();
+ RadrootsPublicKey::parse(value).map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!("invalid trusted worker pubkey `{value}`: {error}"),
+ })
+ })
+ .collect()
+}
+
+#[cfg(feature = "runtime")]
+fn validation_receipt_invalid_reason_code(error: &RadrootsValidationReceiptError) -> &'static str {
+ match error {
+ RadrootsValidationReceiptError::InvalidProofMetadata("proof.material")
+ | RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_missing") => {
+ "sp1_proof_material_missing"
+ }
+ RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_conflict") => {
+ "sp1_proof_material_conflict"
+ }
+ RadrootsValidationReceiptError::InvalidProofMetadata("proof.inline_proof_base64") => {
+ "sp1_inline_proof_invalid"
+ }
+ RadrootsValidationReceiptError::InvalidProofMetadata("proof.proof_reference") => {
+ "sp1_proof_reference_invalid"
+ }
+ RadrootsValidationReceiptError::TagMismatch("public_values_hash")
+ | RadrootsValidationReceiptError::ExpectedBindingMismatch("public_values_hash") => {
+ "public_values_hash_mismatch"
+ }
+ RadrootsValidationReceiptError::ExpectedBindingMismatch("program_hash") => {
+ "sp1_program_hash_mismatch"
+ }
+ RadrootsValidationReceiptError::ExpectedBindingMismatch("verifying_key_hash") => {
+ "sp1_verifying_key_hash_mismatch"
+ }
+ _ => "validation_receipt_invalid",
+ }
+}
+
#[cfg(all(feature = "runtime", feature = "signer-adapters"))]
impl<'sdk> TradeBuyerClient<'sdk> {
pub async fn propose_trade(
diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs
@@ -77,6 +77,10 @@ impl<'client> TradesClient<'client> {
pub fn resync(&self) -> TradeResyncClient<'client> {
TradeResyncClient { sdk: self.sdk }
}
+
+ pub fn validation_receipts(&self) -> TradeValidationReceiptsClient<'client> {
+ TradeValidationReceiptsClient { sdk: self.sdk }
+ }
}
#[cfg(all(feature = "runtime", feature = "signer-adapters"))]
@@ -99,6 +103,12 @@ pub struct TradeResyncClient<'client> {
#[cfg(feature = "runtime")]
#[derive(Clone, Copy)]
+pub struct TradeValidationReceiptsClient<'client> {
+ pub(crate) sdk: &'client RadrootsClient,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy)]
pub struct DvmClient<'client> {
pub(crate) sdk: &'client RadrootsClient,
}
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -17,7 +17,10 @@ use radroots_events::{
RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderRevisionId,
RadrootsPublicKey,
},
- kinds::{KIND_LISTING, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_POST},
+ kinds::{
+ KIND_LISTING, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_POST,
+ KIND_TRADE_TRANSITION_PROOF_RESULT, KIND_TRADE_VALIDATION_RECEIPT,
+ },
order::{
RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem,
RadrootsOrderEconomicLine, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment,
@@ -42,7 +45,8 @@ use radroots_sdk::{
TradeRequestEvidenceIngestRequest, TradeResyncRelayOutcomeKind,
TradeResyncRelayTransportOutcomeKind, TradeResyncRequest, TradeRevisionDecisionRequest,
TradeRevisionProposalRequest, TradeSellerInboxRequest, TradeStatusKind,
- TradeStatusNextActionKind, TradeStatusRequest,
+ TradeStatusNextActionKind, TradeStatusRequest, TradeValidationReceiptInspectRequest,
+ TradeValidationReceiptListRequest,
};
use radroots_sdk::{PrivacyPreflightConfirmation, PrivacyPreflightStatus, ProductSensitivityField};
#[cfg(all(feature = "signer-adapters", feature = "local-signer"))]
@@ -1191,6 +1195,102 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() {
#[cfg(feature = "relay-runtime")]
#[tokio::test]
+async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY]).await;
+ let order_id = "trade-validation-receipts-sdk";
+ let listing_event_id = deterministic_event_id("validation-receipt-listing");
+ let root_event_id = deterministic_event_id("validation-receipt-request");
+ let target_event_id = deterministic_event_id("validation-receipt-decision");
+ let receipt_raw_event = signed_raw_validation_receipt_event(
+ order_id,
+ &listing_event_id,
+ &root_event_id,
+ &target_event_id,
+ 33,
+ );
+ let receipt_event_id =
+ RadrootsEventId::parse(receipt_raw_event.id.to_hex()).expect("receipt event id");
+ let worker_raw_event = signed_raw_worker_result_event(
+ order_id,
+ &receipt_event_id,
+ &listing_event_id,
+ &root_event_id,
+ &target_event_id,
+ 34,
+ );
+ let worker_event_id = worker_raw_event.id.to_hex();
+ let service_pubkey = public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX);
+ let adapter = RadrootsMockRelayFetchAdapter::new(vec![
+ relay_raw_event_item(&receipt_raw_event, RELAY, 4_000),
+ relay_raw_event_item(&worker_raw_event, RELAY, 4_001),
+ relay_eose(RELAY),
+ ]);
+
+ let list = sdk
+ .trades()
+ .validation_receipts()
+ .list_with_fetch_adapter(
+ TradeValidationReceiptListRequest::parse(order_id)
+ .expect("list request")
+ .try_with_trusted_worker_pubkeys([service_pubkey.as_str()])
+ .expect("trusted worker"),
+ &adapter,
+ )
+ .await
+ .expect("validation receipt list");
+
+ assert_eq!(list.relay_targets, vec![RELAY.to_owned()]);
+ assert_eq!(list.receipts.len(), 1);
+ assert!(list.invalid_receipts.is_empty());
+ assert_eq!(
+ list.receipts[0].event.id.as_str(),
+ receipt_event_id.as_str()
+ );
+ let trusted = list.receipts[0]
+ .worker_evidence
+ .trusted
+ .as_ref()
+ .expect("trusted evidence");
+ assert_eq!(trusted.author.as_str(), service_pubkey);
+ assert_eq!(
+ trusted
+ .validation_authority
+ .map(|authority| authority.as_str()),
+ Some("trusted_rhi_service_key")
+ );
+ assert_eq!(
+ trusted
+ .commitment_confidence
+ .map(|confidence| confidence.as_str()),
+ Some("committed_by_trusted_service")
+ );
+
+ let inspect = sdk
+ .trades()
+ .validation_receipts()
+ .inspect_with_fetch_adapter(
+ TradeValidationReceiptInspectRequest::new(receipt_event_id.clone())
+ .try_with_trusted_worker_pubkeys([service_pubkey.as_str()])
+ .expect("trusted worker"),
+ &adapter,
+ )
+ .await
+ .expect("validation receipt inspect");
+
+ assert_eq!(inspect.receipt_event_id, receipt_event_id);
+ assert!(inspect.invalid_receipt.is_none());
+ assert_eq!(
+ inspect
+ .receipt
+ .as_ref()
+ .and_then(|receipt| receipt.worker_evidence.trusted.as_ref())
+ .map(|evidence| evidence.result_event_id.as_str()),
+ Some(worker_event_id.as_str())
+ );
+}
+
+#[cfg(feature = "relay-runtime")]
+#[tokio::test]
async fn trade_resync_imports_relay_evidence_into_empty_local_store() {
let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await;
let request_event = signed_raw_order_request_event("resync-empty-local-import", 41);
@@ -2467,6 +2567,62 @@ fn validation_receipt_wire_parts(
validation_receipt_event_build(raw_order_id, &receipt).expect("receipt event")
}
+fn signed_raw_worker_result_event(
+ raw_order_id: &str,
+ receipt_event_id: &RadrootsEventId,
+ listing_event_id: &RadrootsEventId,
+ root_event_id: &RadrootsEventId,
+ target_event_id: &RadrootsEventId,
+ created_at: u32,
+) -> nostr::Event {
+ let content = serde_json::json!({
+ "confidence": "committed_by_trusted_service",
+ "cryptographic_proof_verified": false,
+ "customer_pubkey": BUYER_PUBLIC_KEY_HEX,
+ "decision_event_id": target_event_id.as_str(),
+ "event_set_root": hash32('c'),
+ "listing_event_id": listing_event_id.as_str(),
+ "order_id": raw_order_id,
+ "proof_generated": false,
+ "proof_mode": "none",
+ "proof_system": "none",
+ "public_values_hash": validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#),
+ "prover_backend": "local_execute",
+ "receipt_event_id": receipt_event_id.as_str(),
+ "receipt_kind": KIND_TRADE_VALIDATION_RECEIPT,
+ "reducer_output_root": hash32('4'),
+ "request_event_id": root_event_id.as_str(),
+ "request_hash": hash32('9'),
+ "sp1_execute_checked": true,
+ "sp1_execute_public_values_hash": validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#),
+ "status": "succeeded",
+ "validation_authority": "trusted_rhi_service_key",
+ "worker_pubkey": public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX),
+ "worker_role": "non_authoritative_prover"
+ })
+ .to_string();
+ signed_raw_event(
+ SERVICE_SECRET_KEY_HEX,
+ created_at,
+ WireEventParts {
+ kind: KIND_TRADE_TRANSITION_PROOF_RESULT,
+ content,
+ tags: vec![
+ vec!["e".to_owned(), root_event_id.as_str().to_owned()],
+ vec![
+ "radroots:validation_receipt".to_owned(),
+ receipt_event_id.as_str().to_owned(),
+ ],
+ ],
+ },
+ )
+}
+
+fn public_key_hex_for_secret(secret_key_hex: &str) -> String {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ RadrootsNostrKeys::new(secret_key).public_key().to_hex()
+}
+
async fn insert_perf_non_trade_events(store: &RadrootsEventStore, base: i64, count: i64) {
let mut inserted = 0;
while inserted < count {
diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs
@@ -94,6 +94,7 @@ const REQUIRED_SDK_README_CONCEPTS: &[&str] = &[
"sdk.trades().seller()",
"sdk.trades().status(...)",
"sdk.trades().resync()",
+ "sdk.trades().validation_receipts()",
"sdk.dvm()",
];
@@ -115,6 +116,20 @@ const REQUIRED_TRADE_RUNTIME_EXPORTS: &[&str] = &[
"TradeStatusNextActionKind",
"TradeStatusReceipt",
"TradeStatusRequest",
+ "TradeValidationReceiptEvent",
+ "TradeValidationReceiptInspectReceipt",
+ "TradeValidationReceiptInspectRequest",
+ "TradeValidationReceiptInvalidCandidate",
+ "TradeValidationReceiptListReceipt",
+ "TradeValidationReceiptListRequest",
+ "TradeValidationReceiptRelayEvidenceReceipt",
+ "TradeValidationReceiptRelayOutcomeKind",
+ "TradeValidationReceiptRelayOutcomeReceipt",
+ "TradeValidationReceiptRelayTransportOutcomeKind",
+ "TradeValidationReceiptTags",
+ "TradeValidationReceiptVerifyRequest",
+ "TradeValidationReceiptWorkerEvidence",
+ "TradeValidationReceiptWorkerEvidenceSelection",
"SdkTradeStatusIssue",
"SdkTradeStatusIssueKind",
"SdkTradeStatusSource",
@@ -269,6 +284,12 @@ const REQUIRED_TRADE_SELLER_CLIENT_METHODS: &[&str] = &[
const REQUIRED_TRADE_RESYNC_CLIENT_METHODS: &[&str] = &["pub async fn resync("];
+const REQUIRED_TRADE_VALIDATION_RECEIPTS_CLIENT_METHODS: &[&str] = &[
+ "pub async fn list(",
+ "pub async fn inspect(",
+ "pub async fn verify(",
+];
+
const FORBIDDEN_PRODUCT_CLIENT_HANDLES: &[&str] = &[
"TradeStatusClient",
"TradeValidationClient",
@@ -849,6 +870,13 @@ fn trade_product_facade_methods_are_inventory_guarded() {
"TradeResyncClient must expose product workflow method `{method}`"
);
}
+
+ for method in REQUIRED_TRADE_VALIDATION_RECEIPTS_CLIENT_METHODS {
+ assert!(
+ source.contains(method),
+ "TradeValidationReceiptsClient must expose product workflow method `{method}`"
+ );
+ }
}
#[test]
@@ -872,6 +900,12 @@ fn trade_product_facade_feature_gates_are_explicit() {
);
assert!(
product_clients_source.contains(
+ "pub fn validation_receipts(&self) -> TradeValidationReceiptsClient<'client>"
+ ),
+ "TradesClient must expose validation receipts through the grouped trade product facade"
+ );
+ assert!(
+ product_clients_source.contains(
"#[cfg(all(feature = \"runtime\", feature = \"signer-adapters\"))]\n#[derive(Clone, Copy)]\npub struct TradeBuyerClient<'client>"
),
"TradeBuyerClient must be gated by runtime plus signer-adapters"
@@ -974,6 +1008,7 @@ fn product_clients_remain_thin_sdk_handles() {
"SyncClient",
"TradeResyncClient",
"TradeSellerClient",
+ "TradeValidationReceiptsClient",
"TradesClient",
] {
assert!(
diff --git a/crates/sdk/tests/trade_public_api.rs b/crates/sdk/tests/trade_public_api.rs
@@ -4,7 +4,7 @@ use radroots_authority::RadrootsActorContext;
use radroots_events::contract::RadrootsActorRole;
use radroots_sdk::{
RadrootsClient, TradeResyncRequest, TradeSellerInboxRequest, TradeStatusKind,
- TradeStatusRequest,
+ TradeStatusRequest, TradeValidationReceiptListRequest,
};
const SELLER_PUBLIC_KEY_HEX: &str =
@@ -17,6 +17,7 @@ async fn grouped_trade_surface_is_the_public_product_entrypoint() {
let _seller = trades.seller();
let _resync = trades.resync();
+ let validation_receipts = trades.validation_receipts();
let status = trades
.status(TradeStatusRequest::parse("trade-public-api-order").expect("status request"))
@@ -33,6 +34,16 @@ async fn grouped_trade_surface_is_the_public_product_entrypoint() {
assert_eq!(resync_error.code(), "empty_target_relays");
+ let validation_receipt_error = validation_receipts
+ .list(
+ TradeValidationReceiptListRequest::parse("trade-public-api-order")
+ .expect("validation receipt list request"),
+ )
+ .await
+ .expect_err("validation receipt list requires configured relays");
+
+ assert_eq!(validation_receipt_error.code(), "empty_target_relays");
+
let seller_actor =
RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller])
.expect("seller actor");