lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit f0d412234b339d76e26970f9798d87784a133583
parent 7af64268eda3a7f9aa062b1a144c65beffbb27bd
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:
Mcrates/sdk/README | 4+++-
Mcrates/sdk/src/lib.rs | 10++++++++--
Mcrates/sdk/src/orders_runtime.rs | 997++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/product_clients.rs | 10++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/source_boundary.rs | 35+++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/trade_public_api.rs | 13++++++++++++-
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");