commit 2a2d79afd5a9766e5e954f55c5b31d8f391dbfa9
parent 425c2f721c6cefa4b90c0f4833e2fb080c45c4ec
Author: triesap <tyson@radroots.org>
Date: Fri, 26 Jun 2026 10:11:54 +0000
sdk: add dvm validation runtime
Diffstat:
11 files changed, 2103 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2073,6 +2073,7 @@ dependencies = [
"radroots_replica_db_schema",
"radroots_replica_sync",
"radroots_runtime_paths",
+ "radroots_sp1_guest_trade",
"radroots_sql_core",
"radroots_trade",
"reqwest",
@@ -2120,6 +2121,16 @@ name = "radroots_secret_vault"
version = "0.1.0-alpha.2"
[[package]]
+name = "radroots_sp1_guest_trade"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "radroots_sql_core"
version = "0.1.0-alpha.2"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -49,6 +49,7 @@ radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema", version
radroots_replica_sync = { path = "../lib/crates/replica_sync", version = "0.1.0-alpha.2", default-features = false }
radroots_runtime_paths = { path = "../lib/crates/runtime_paths", version = "0.1.0-alpha.2", default-features = false }
radroots_sdk_sql_wasm_runtime = { path = "crates/sql_wasm_runtime", version = "0.1.0-alpha.2" }
+radroots_sp1_guest_trade = { path = "../lib/crates/sp1_guest_trade", version = "0.1.0-alpha.2", default-features = false }
radroots_sql_core = { path = "../lib/crates/sql_core", version = "0.1.0-alpha.2", default-features = false }
radroots_trade = { path = "../lib/crates/trade", version = "0.1.0-alpha.2", default-features = false, features = [
"serde_json",
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -62,6 +62,7 @@ runtime = [
"dep:radroots_outbox",
"dep:radroots_relay_transport",
"dep:radroots_runtime_paths",
+ "dep:radroots_sp1_guest_trade",
"dep:sha2",
"dep:sqlx",
"dep:uuid",
@@ -111,6 +112,7 @@ radroots_outbox = { workspace = true, optional = true, default-features = false
radroots_publish_proxy_protocol = { workspace = true, optional = true, default-features = false }
radroots_relay_transport = { workspace = true, optional = true, default-features = false }
radroots_runtime_paths = { workspace = true, optional = true, default-features = false }
+radroots_sp1_guest_trade = { workspace = true, optional = true }
radroots_trade = { workspace = true, default-features = false }
radroots_identity = { workspace = true, optional = true, default-features = false }
radroots_nostr = { workspace = true, optional = true, default-features = false }
diff --git a/crates/sdk/src/dvm_runtime.rs b/crates/sdk/src/dvm_runtime.rs
@@ -0,0 +1,732 @@
+#[cfg(feature = "signer-adapters")]
+use crate::workflow_runtime::enqueue_configured_signed_workflow;
+#[cfg(feature = "runtime")]
+use crate::{
+ DvmClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState,
+ SdkRelayTargetPolicy, SdkRelayUrlPolicy, SyncProjectionRefreshReceipt,
+ SyncProjectionRefreshRequest,
+ runtime::sdk_now_ms,
+ sync_runtime::refresh_product_projections_for_sdk,
+ workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow},
+};
+#[cfg(feature = "runtime")]
+use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, authorize_actor_for_draft};
+#[cfg(feature = "runtime")]
+use radroots_event_store::RadrootsEventIngest;
+#[cfg(feature = "runtime")]
+use radroots_events::{
+ RadrootsNostrEvent,
+ draft::RadrootsFrozenEventDraft,
+ ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey},
+ kinds::KIND_TRADE_TRANSITION_PROOF_REQUEST,
+};
+#[cfg(feature = "runtime")]
+use radroots_events_codec::wire::{WireEventParts, canonicalize_tags, to_frozen_draft};
+#[cfg(feature = "runtime")]
+use radroots_sp1_guest_trade::{
+ RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET, RADROOTS_SP1_TRADE_PROTOCOL_VERSION,
+ RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH, RADROOTS_SP1_TRADE_WITNESS_VERSION,
+ RadrootsSp1TradeInventoryBinWitness,
+};
+#[cfg(feature = "runtime")]
+use radroots_trade::validation_receipt::{
+ RadrootsValidationReceiptExpectedBinding, RadrootsValidationReceiptProofSystem,
+ RadrootsValidationReceiptResult, RadrootsValidationReceiptType,
+ verify_validation_receipt_event,
+};
+
+#[cfg(feature = "runtime")]
+pub const DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID: &str =
+ "radroots.trade.transition_proof.request.v1";
+#[cfg(feature = "runtime")]
+pub const DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND: &str =
+ "dvm.trade_transition_proof.request.v1";
+
+#[cfg(feature = "runtime")]
+pub type SdkDvmInventoryBinWitness = RadrootsSp1TradeInventoryBinWitness;
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum DvmProofMode {
+ #[default]
+ None,
+ Core,
+ Compressed,
+ Groth16,
+ Plonk,
+}
+
+#[cfg(feature = "runtime")]
+impl DvmProofMode {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::None => "none",
+ Self::Core => "core",
+ Self::Compressed => "compressed",
+ Self::Groth16 => "groth16",
+ Self::Plonk => "plonk",
+ }
+ }
+
+ pub const fn proof_system(self) -> RadrootsValidationReceiptProofSystem {
+ match self {
+ Self::None => RadrootsValidationReceiptProofSystem::None,
+ Self::Core => RadrootsValidationReceiptProofSystem::Sp1Core,
+ Self::Compressed => RadrootsValidationReceiptProofSystem::Sp1Compressed,
+ Self::Groth16 => RadrootsValidationReceiptProofSystem::Sp1Groth16,
+ Self::Plonk => RadrootsValidationReceiptProofSystem::Sp1Plonk,
+ }
+ }
+
+ const fn requires_sp1_identity(self) -> bool {
+ !matches!(self, Self::None)
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, serde::Serialize)]
+#[non_exhaustive]
+pub struct DvmTradeTransitionProofPrepareRequest {
+ #[serde(serialize_with = "crate::actor_json::serialize_actor_context")]
+ pub actor: RadrootsActorContext,
+ pub worker_pubkey: RadrootsPublicKey,
+ pub listing_addr: RadrootsListingAddress,
+ pub listing_event_id: RadrootsEventId,
+ pub request_event_id: RadrootsEventId,
+ pub decision_event_id: RadrootsEventId,
+ pub inventory_bins: Vec<SdkDvmInventoryBinWitness>,
+ pub inventory_sequence: u128,
+ pub previous_state_root: Option<String>,
+ pub proof_mode: DvmProofMode,
+ pub sp1_program_hash: Option<String>,
+ pub sp1_verifying_key_hash: Option<String>,
+ pub created_at: Option<RadrootsSdkTimestamp>,
+}
+
+#[cfg(feature = "runtime")]
+impl DvmTradeTransitionProofPrepareRequest {
+ pub fn new(
+ actor: RadrootsActorContext,
+ worker_pubkey: RadrootsPublicKey,
+ listing_addr: RadrootsListingAddress,
+ listing_event_id: RadrootsEventId,
+ request_event_id: RadrootsEventId,
+ decision_event_id: RadrootsEventId,
+ inventory_bins: Vec<SdkDvmInventoryBinWitness>,
+ ) -> Self {
+ Self {
+ actor,
+ worker_pubkey,
+ listing_addr,
+ listing_event_id,
+ request_event_id,
+ decision_event_id,
+ inventory_bins,
+ inventory_sequence: 0,
+ previous_state_root: None,
+ proof_mode: DvmProofMode::None,
+ sp1_program_hash: None,
+ sp1_verifying_key_hash: None,
+ created_at: None,
+ }
+ }
+
+ pub fn with_inventory_sequence(mut self, inventory_sequence: u128) -> Self {
+ self.inventory_sequence = inventory_sequence;
+ self
+ }
+
+ pub fn with_previous_state_root(mut self, previous_state_root: impl Into<String>) -> Self {
+ self.previous_state_root = Some(previous_state_root.into());
+ self
+ }
+
+ pub fn with_proof_mode(mut self, proof_mode: DvmProofMode) -> Self {
+ self.proof_mode = proof_mode;
+ self
+ }
+
+ pub fn with_sp1_identity(
+ mut self,
+ program_hash: impl Into<String>,
+ verifying_key_hash: impl Into<String>,
+ ) -> Self {
+ self.sp1_program_hash = Some(program_hash.into());
+ self.sp1_verifying_key_hash = Some(verifying_key_hash.into());
+ self
+ }
+
+ pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self {
+ self.created_at = Some(created_at);
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, serde::Serialize)]
+#[non_exhaustive]
+pub struct DvmTradeTransitionProofEnqueueRequest {
+ #[serde(flatten)]
+ pub prepare: DvmTradeTransitionProofPrepareRequest,
+ pub target_relays: SdkRelayTargetPolicy,
+ pub idempotency_key: Option<SdkIdempotencyKey>,
+}
+
+#[cfg(feature = "runtime")]
+impl DvmTradeTransitionProofEnqueueRequest {
+ pub fn new(
+ actor: RadrootsActorContext,
+ worker_pubkey: RadrootsPublicKey,
+ listing_addr: RadrootsListingAddress,
+ listing_event_id: RadrootsEventId,
+ request_event_id: RadrootsEventId,
+ decision_event_id: RadrootsEventId,
+ inventory_bins: Vec<SdkDvmInventoryBinWitness>,
+ target_relays: SdkRelayTargetPolicy,
+ ) -> Self {
+ Self::from_prepare(
+ DvmTradeTransitionProofPrepareRequest::new(
+ actor,
+ worker_pubkey,
+ listing_addr,
+ listing_event_id,
+ request_event_id,
+ decision_event_id,
+ inventory_bins,
+ ),
+ target_relays,
+ )
+ }
+
+ pub fn from_prepare(
+ prepare: DvmTradeTransitionProofPrepareRequest,
+ target_relays: SdkRelayTargetPolicy,
+ ) -> Self {
+ Self {
+ prepare,
+ target_relays,
+ idempotency_key: None,
+ }
+ }
+
+ pub fn try_with_target_relays<I, S>(
+ mut self,
+ target_relays: I,
+ policy: SdkRelayUrlPolicy,
+ ) -> Result<Self, RadrootsSdkError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ {
+ self.target_relays = SdkRelayTargetPolicy::try_explicit(target_relays, policy)?;
+ Ok(self)
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self {
+ self.idempotency_key = Some(idempotency_key);
+ self
+ }
+
+ pub fn try_with_idempotency_key(
+ mut self,
+ idempotency_key: impl AsRef<str>,
+ ) -> Result<Self, RadrootsSdkError> {
+ self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?);
+ Ok(self)
+ }
+
+ pub fn with_inventory_sequence(mut self, inventory_sequence: u128) -> Self {
+ self.prepare.inventory_sequence = inventory_sequence;
+ self
+ }
+
+ pub fn with_previous_state_root(mut self, previous_state_root: impl Into<String>) -> Self {
+ self.prepare.previous_state_root = Some(previous_state_root.into());
+ self
+ }
+
+ pub fn with_proof_mode(mut self, proof_mode: DvmProofMode) -> Self {
+ self.prepare.proof_mode = proof_mode;
+ self
+ }
+
+ pub fn with_sp1_identity(
+ mut self,
+ program_hash: impl Into<String>,
+ verifying_key_hash: impl Into<String>,
+ ) -> Self {
+ self.prepare.sp1_program_hash = Some(program_hash.into());
+ self.prepare.sp1_verifying_key_hash = Some(verifying_key_hash.into());
+ self
+ }
+
+ pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self {
+ self.prepare.created_at = Some(created_at);
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct DvmTradeTransitionProofRequestPayload {
+ pub witness_version: u32,
+ pub proof_target: String,
+ pub listing_event_id: String,
+ pub request_event_id: String,
+ pub decision_event_id: String,
+ pub inventory_bins: Vec<SdkDvmInventoryBinWitness>,
+ pub inventory_sequence: u128,
+ pub previous_state_root: Option<String>,
+ pub proof_mode: DvmProofMode,
+ pub reducer_program_hash: String,
+ pub radroots_protocol_version: String,
+ pub sp1_program_hash: Option<String>,
+ pub sp1_verifying_key_hash: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct DvmTradeTransitionProofPlan {
+ pub worker_pubkey: RadrootsPublicKey,
+ pub listing_addr: RadrootsListingAddress,
+ pub listing_event_id: RadrootsEventId,
+ pub request_event_id: RadrootsEventId,
+ pub decision_event_id: RadrootsEventId,
+ pub proof_mode: DvmProofMode,
+ pub expected_receipt_proof_system: RadrootsValidationReceiptProofSystem,
+ pub expected_event_id: RadrootsEventId,
+ pub frozen_draft: RadrootsFrozenEventDraft,
+ pub payload: DvmTradeTransitionProofRequestPayload,
+ pub created_at: RadrootsSdkTimestamp,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct DvmTradeTransitionProofReceipt {
+ pub worker_pubkey: RadrootsPublicKey,
+ pub listing_addr: RadrootsListingAddress,
+ pub listing_event_id: RadrootsEventId,
+ pub request_event_id: RadrootsEventId,
+ pub decision_event_id: RadrootsEventId,
+ pub expected_event_id: RadrootsEventId,
+ pub signed_event_id: RadrootsEventId,
+ pub proof_mode: DvmProofMode,
+ pub expected_receipt_proof_system: RadrootsValidationReceiptProofSystem,
+ pub local_event_seq: i64,
+ pub outbox_operation_id: i64,
+ pub outbox_event_id: i64,
+ pub state: SdkMutationState,
+ pub idempotency_digest_prefix: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, serde::Serialize)]
+#[non_exhaustive]
+pub struct DvmValidationReceiptIngestRequest {
+ pub event: RadrootsNostrEvent,
+ pub observed_at: Option<RadrootsSdkTimestamp>,
+ pub expected_order_id: Option<RadrootsOrderId>,
+ pub expected_listing_event_id: Option<RadrootsEventId>,
+ pub expected_root_event_id: Option<RadrootsEventId>,
+ pub expected_target_event_id: Option<RadrootsEventId>,
+ pub projection_refresh: SyncProjectionRefreshRequest,
+}
+
+#[cfg(feature = "runtime")]
+impl DvmValidationReceiptIngestRequest {
+ pub fn new(event: RadrootsNostrEvent) -> Self {
+ Self {
+ event,
+ observed_at: None,
+ expected_order_id: None,
+ expected_listing_event_id: None,
+ expected_root_event_id: None,
+ expected_target_event_id: None,
+ projection_refresh: SyncProjectionRefreshRequest::new(),
+ }
+ }
+
+ pub fn with_observed_at(mut self, observed_at: RadrootsSdkTimestamp) -> Self {
+ self.observed_at = Some(observed_at);
+ self
+ }
+
+ pub fn with_expected_order_id(mut self, order_id: RadrootsOrderId) -> Self {
+ self.expected_order_id = Some(order_id);
+ self
+ }
+
+ pub fn with_expected_listing_event_id(mut self, listing_event_id: RadrootsEventId) -> Self {
+ self.expected_listing_event_id = Some(listing_event_id);
+ self
+ }
+
+ pub fn with_expected_root_event_id(mut self, root_event_id: RadrootsEventId) -> Self {
+ self.expected_root_event_id = Some(root_event_id);
+ self
+ }
+
+ pub fn with_expected_target_event_id(mut self, target_event_id: RadrootsEventId) -> Self {
+ self.expected_target_event_id = Some(target_event_id);
+ self
+ }
+
+ pub fn with_projection_refresh(mut self, refresh: SyncProjectionRefreshRequest) -> Self {
+ self.projection_refresh = refresh;
+ self
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct DvmValidationReceiptIngestReceipt {
+ pub receipt_event_id: RadrootsEventId,
+ pub order_id: RadrootsOrderId,
+ pub listing_event_id: RadrootsEventId,
+ pub root_event_id: RadrootsEventId,
+ pub target_event_id: RadrootsEventId,
+ pub receipt_type: RadrootsValidationReceiptType,
+ pub result: RadrootsValidationReceiptResult,
+ pub proof_system: RadrootsValidationReceiptProofSystem,
+ pub local_event_seq: i64,
+ pub inserted: bool,
+ pub refresh: SyncProjectionRefreshReceipt,
+}
+
+#[cfg(feature = "runtime")]
+impl<'sdk> DvmClient<'sdk> {
+ pub fn prepare_trade_transition_proof_request(
+ &self,
+ request: DvmTradeTransitionProofPrepareRequest,
+ ) -> Result<DvmTradeTransitionProofPlan, RadrootsSdkError> {
+ let created_at = match request.created_at {
+ Some(created_at) => created_at,
+ None => self.sdk.now()?,
+ };
+ dvm_trade_transition_proof_plan(request, created_at)
+ }
+
+ #[cfg(feature = "signer-adapters")]
+ pub async fn enqueue_trade_transition_proof_request(
+ &self,
+ request: DvmTradeTransitionProofEnqueueRequest,
+ ) -> Result<DvmTradeTransitionProofReceipt, RadrootsSdkError> {
+ let actor = request.prepare.actor.clone();
+ let target_relays = request.target_relays.clone();
+ let idempotency_key = request.idempotency_key.clone();
+ let plan = self.prepare_trade_transition_proof_request(request.prepare)?;
+ let enqueue = enqueue_configured_signed_workflow(
+ self.sdk,
+ SdkWorkflowEnqueueRequest {
+ operation_kind: DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND,
+ actor: &actor,
+ frozen_draft: &plan.frozen_draft,
+ target_relays,
+ idempotency_key,
+ },
+ )
+ .await?;
+ Ok(dvm_trade_transition_proof_receipt(plan, enqueue))
+ }
+
+ pub async fn enqueue_trade_transition_proof_request_with_explicit_signer(
+ &self,
+ request: DvmTradeTransitionProofEnqueueRequest,
+ signer: &dyn RadrootsEventSigner,
+ ) -> Result<DvmTradeTransitionProofReceipt, RadrootsSdkError> {
+ let actor = request.prepare.actor.clone();
+ let target_relays = request.target_relays.clone();
+ let idempotency_key = request.idempotency_key.clone();
+ let plan = self.prepare_trade_transition_proof_request(request.prepare)?;
+ let enqueue = enqueue_signed_workflow(
+ self.sdk,
+ SdkWorkflowEnqueueRequest {
+ operation_kind: DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND,
+ actor: &actor,
+ frozen_draft: &plan.frozen_draft,
+ target_relays,
+ idempotency_key,
+ },
+ signer,
+ )
+ .await?;
+ Ok(dvm_trade_transition_proof_receipt(plan, enqueue))
+ }
+
+ pub async fn ingest_validation_receipt(
+ &self,
+ request: DvmValidationReceiptIngestRequest,
+ ) -> Result<DvmValidationReceiptIngestReceipt, RadrootsSdkError> {
+ let verified = verify_validation_receipt_event(
+ &request.event,
+ RadrootsValidationReceiptExpectedBinding {
+ order_id: request
+ .expected_order_id
+ .as_ref()
+ .map(RadrootsOrderId::as_str),
+ listing_event_id: request
+ .expected_listing_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ root_event_id: request
+ .expected_root_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ target_event_id: request
+ .expected_target_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ )
+ .map_err(validation_receipt_sdk_error)?;
+ let receipt_event_id = parse_event_id(request.event.id.as_str(), "receipt event id")?;
+ let order_id =
+ RadrootsOrderId::parse(verified.tags.order_id.as_str()).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("validation receipt order id is invalid: {error}"),
+ }
+ })?;
+ let listing_event_id = RadrootsEventId::parse(verified.tags.listing_event_id.as_str())
+ .expect("verified validation receipt listing event id is valid");
+ let root_event_id = RadrootsEventId::parse(verified.tags.root_event_id.as_str())
+ .expect("verified validation receipt root event id is valid");
+ let target_event_id = RadrootsEventId::parse(verified.tags.target_event_id.as_str())
+ .expect("verified validation receipt target event id is valid");
+ let observed_at_ms = match request.observed_at {
+ Some(observed_at) => sdk_timestamp_ms(observed_at)?,
+ None => sdk_now_ms(self.sdk)?,
+ };
+ let ingest = self
+ .sdk
+ ._event_store
+ .ingest_event(RadrootsEventIngest::new(request.event, observed_at_ms))
+ .await?;
+ let refresh =
+ refresh_product_projections_for_sdk(self.sdk, request.projection_refresh).await?;
+ Ok(DvmValidationReceiptIngestReceipt {
+ receipt_event_id,
+ order_id,
+ listing_event_id,
+ root_event_id,
+ target_event_id,
+ receipt_type: verified.receipt.receipt_type,
+ result: verified.receipt.result,
+ proof_system: verified.receipt.proof.system,
+ local_event_seq: ingest.seq,
+ inserted: ingest.inserted,
+ refresh,
+ })
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn dvm_trade_transition_proof_plan(
+ request: DvmTradeTransitionProofPrepareRequest,
+ created_at: RadrootsSdkTimestamp,
+) -> Result<DvmTradeTransitionProofPlan, RadrootsSdkError> {
+ validate_inventory_bins(&request.inventory_bins)?;
+ validate_sp1_identity(&request)?;
+ let payload = DvmTradeTransitionProofRequestPayload {
+ witness_version: RADROOTS_SP1_TRADE_WITNESS_VERSION,
+ proof_target: RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET.to_owned(),
+ listing_event_id: request.listing_event_id.as_str().to_owned(),
+ request_event_id: request.request_event_id.as_str().to_owned(),
+ decision_event_id: request.decision_event_id.as_str().to_owned(),
+ inventory_bins: request.inventory_bins.clone(),
+ inventory_sequence: request.inventory_sequence,
+ previous_state_root: request.previous_state_root.clone(),
+ proof_mode: request.proof_mode,
+ reducer_program_hash: RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH.to_owned(),
+ radroots_protocol_version: RADROOTS_SP1_TRADE_PROTOCOL_VERSION.to_owned(),
+ sp1_program_hash: request.sp1_program_hash.clone(),
+ sp1_verifying_key_hash: request.sp1_verifying_key_hash.clone(),
+ };
+ let content = serde_json::to_string(&payload).expect("DVM proof request payload serializes");
+ let mut tags = vec![
+ vec!["a".to_owned(), request.listing_addr.as_str().to_owned()],
+ vec!["p".to_owned(), request.worker_pubkey.as_str().to_owned()],
+ vec![
+ "i".to_owned(),
+ request.listing_event_id.as_str().to_owned(),
+ "event".to_owned(),
+ "listing".to_owned(),
+ ],
+ vec![
+ "i".to_owned(),
+ request.request_event_id.as_str().to_owned(),
+ "event".to_owned(),
+ "order_request".to_owned(),
+ ],
+ vec![
+ "i".to_owned(),
+ request.decision_event_id.as_str().to_owned(),
+ "event".to_owned(),
+ "order_decision".to_owned(),
+ ],
+ ];
+ canonicalize_tags(&mut tags);
+ let frozen_draft = to_frozen_draft(
+ WireEventParts {
+ kind: KIND_TRADE_TRANSITION_PROOF_REQUEST,
+ content,
+ tags,
+ },
+ DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID,
+ request.actor.pubkey().as_str(),
+ created_at.try_into_nostr_created_at()?,
+ )
+ .expect("DVM proof request draft is valid");
+ authorize_actor_for_draft(&request.actor, &frozen_draft)?;
+ let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str())
+ .expect("frozen DVM proof request draft produces a valid event id");
+ Ok(DvmTradeTransitionProofPlan {
+ worker_pubkey: request.worker_pubkey,
+ listing_addr: request.listing_addr,
+ listing_event_id: request.listing_event_id,
+ request_event_id: request.request_event_id,
+ decision_event_id: request.decision_event_id,
+ proof_mode: request.proof_mode,
+ expected_receipt_proof_system: request.proof_mode.proof_system(),
+ expected_event_id,
+ frozen_draft,
+ payload,
+ created_at,
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn validate_inventory_bins(
+ inventory_bins: &[SdkDvmInventoryBinWitness],
+) -> Result<(), RadrootsSdkError> {
+ if inventory_bins.is_empty() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "DVM proof request inventory bins cannot be empty".to_owned(),
+ });
+ }
+ for bin in inventory_bins {
+ if bin.bin_id.trim().is_empty() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "DVM proof request inventory bin id cannot be empty".to_owned(),
+ });
+ }
+ if bin.previous_reserved > bin.listing_capacity {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "DVM proof request inventory bin `{}` previous_reserved exceeds listing_capacity",
+ bin.bin_id
+ ),
+ });
+ }
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn validate_sp1_identity(
+ request: &DvmTradeTransitionProofPrepareRequest,
+) -> Result<(), RadrootsSdkError> {
+ validate_optional_hash32(&request.previous_state_root, "previous_state_root")?;
+ validate_optional_hash32(&request.sp1_program_hash, "sp1_program_hash")?;
+ validate_optional_hash32(&request.sp1_verifying_key_hash, "sp1_verifying_key_hash")?;
+ let has_sp1_identity =
+ request.sp1_program_hash.is_some() || request.sp1_verifying_key_hash.is_some();
+ if request.proof_mode == DvmProofMode::None && has_sp1_identity {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "DVM proof mode none cannot include SP1 identity hashes".to_owned(),
+ });
+ }
+ if request.proof_mode.requires_sp1_identity()
+ && (request.sp1_program_hash.is_none() || request.sp1_verifying_key_hash.is_none())
+ {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "DVM proof mode {} requires SP1 program and verifying key hashes",
+ request.proof_mode.as_str()
+ ),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn validate_optional_hash32(
+ value: &Option<String>,
+ field: &'static str,
+) -> Result<(), RadrootsSdkError> {
+ if let Some(value) = value {
+ let hash = value.as_str();
+ if hash.len() != 66 || !hash.starts_with("0x") || !is_lower_hex(&hash[2..]) {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("DVM proof request {field} must be 0x-prefixed lowercase hex32"),
+ });
+ }
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn is_lower_hex(value: &str) -> bool {
+ value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+#[cfg(feature = "runtime")]
+fn dvm_trade_transition_proof_receipt(
+ plan: DvmTradeTransitionProofPlan,
+ enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt,
+) -> DvmTradeTransitionProofReceipt {
+ DvmTradeTransitionProofReceipt {
+ worker_pubkey: plan.worker_pubkey,
+ listing_addr: plan.listing_addr,
+ listing_event_id: plan.listing_event_id,
+ request_event_id: plan.request_event_id,
+ decision_event_id: plan.decision_event_id,
+ expected_event_id: plan.expected_event_id,
+ signed_event_id: enqueue.signed_event_id,
+ proof_mode: plan.proof_mode,
+ expected_receipt_proof_system: plan.expected_receipt_proof_system,
+ local_event_seq: enqueue.local_event_seq,
+ outbox_operation_id: enqueue.outbox_operation_id,
+ outbox_event_id: enqueue.outbox_event_id,
+ state: enqueue.state.into(),
+ idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn parse_event_id(value: &str, field: &'static str) -> Result<RadrootsEventId, RadrootsSdkError> {
+ RadrootsEventId::parse(value).map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!("{field} is invalid: {error}"),
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn validation_receipt_sdk_error(
+ error: radroots_trade::validation_receipt::RadrootsValidationReceiptError,
+) -> RadrootsSdkError {
+ RadrootsSdkError::InvalidRequest {
+ message: format!("validation receipt event is invalid: {error}"),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkError> {
+ let seconds = i64::try_from(timestamp.unix_seconds()).map_err(|_| {
+ RadrootsSdkError::TimestampOutOfRange {
+ value: timestamp.unix_seconds(),
+ }
+ })?;
+ seconds
+ .checked_mul(1_000)
+ .ok_or(RadrootsSdkError::TimestampOutOfRange {
+ value: timestamp.unix_seconds(),
+ })
+}
+
+#[cfg(all(test, feature = "runtime"))]
+#[path = "../tests/unit/dvm_runtime_tests.rs"]
+mod tests;
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -14,6 +14,8 @@ mod actor_json;
))]
pub mod adapters;
#[cfg(feature = "runtime")]
+mod dvm_runtime;
+#[cfg(feature = "runtime")]
mod error;
mod farm;
#[cfg(feature = "runtime")]
@@ -50,6 +52,15 @@ mod sync_runtime;
mod workflow_runtime;
#[cfg(feature = "runtime")]
+pub use crate::dvm_runtime::{
+ DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID,
+ DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND, DvmProofMode,
+ DvmTradeTransitionProofEnqueueRequest, DvmTradeTransitionProofPlan,
+ DvmTradeTransitionProofPrepareRequest, DvmTradeTransitionProofReceipt,
+ DvmTradeTransitionProofRequestPayload, DvmValidationReceiptIngestReceipt,
+ DvmValidationReceiptIngestRequest, SdkDvmInventoryBinWitness,
+};
+#[cfg(feature = "runtime")]
pub use crate::error::{
RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind,
RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure,
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -931,6 +931,7 @@ pub struct OrderStatusReceipt {
pub request_event_id: Option<RadrootsEventId>,
pub decision_event_id: Option<RadrootsEventId>,
pub agreement_event_id: Option<RadrootsEventId>,
+ pub rhi_receipt_event_id: Option<RadrootsEventId>,
pub pending_revision_event_id: Option<RadrootsEventId>,
pub cancellation_event_id: Option<RadrootsEventId>,
pub last_event_id: Option<RadrootsEventId>,
@@ -1974,6 +1975,7 @@ impl OrderStatusReceipt {
request_event_id: projection.request_event_id,
decision_event_id: projection.decision_event_id,
agreement_event_id: projection.agreement_event_id,
+ rhi_receipt_event_id: projection.validation_receipt_event_id,
pending_revision_event_id: projection.pending_revision_event_id,
cancellation_event_id: projection.cancellation_event_id,
last_event_id: projection.last_event_id,
diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs
@@ -69,13 +69,13 @@ impl<'client> TradesClient<'client> {
#[cfg(feature = "runtime")]
#[derive(Clone, Copy)]
pub struct DvmClient<'client> {
- pub(crate) _sdk: &'client RadrootsClient,
+ pub(crate) sdk: &'client RadrootsClient,
}
#[cfg(feature = "runtime")]
impl<'client> DvmClient<'client> {
pub(crate) fn new(sdk: &'client RadrootsClient) -> Self {
- Self { _sdk: sdk }
+ Self { sdk }
}
}
diff --git a/crates/sdk/tests/dvm_runtime.rs b/crates/sdk/tests/dvm_runtime.rs
@@ -0,0 +1,856 @@
+#![cfg(feature = "runtime")]
+
+use radroots_authority::{
+ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity,
+};
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
+};
+use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore};
+use radroots_events::{
+ RadrootsNostrEvent,
+ contract::RadrootsActorRole,
+ draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent},
+ ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey},
+ kinds::{KIND_LISTING, KIND_TRADE_TRANSITION_PROOF_REQUEST},
+ order::{
+ RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem,
+ RadrootsOrderEconomicLine, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment,
+ RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest,
+ },
+};
+use radroots_nostr::prelude::{
+ RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr,
+ radroots_nostr_build_event, radroots_nostr_sign_frozen_draft,
+};
+use radroots_outbox::RadrootsOutbox;
+use radroots_sdk::protocol::events::RadrootsNostrEventPtr;
+use radroots_sdk::{
+ DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND, DvmTradeTransitionProofEnqueueRequest,
+ DvmTradeTransitionProofPrepareRequest, DvmValidationReceiptIngestRequest, OrderStatusKind,
+ OrderStatusNextActionKind, OrderStatusRequest, RadrootsClient, RadrootsSdkError,
+ RadrootsSdkStorageConfig, RadrootsSdkTimestamp, SdkDvmInventoryBinWitness, SdkMutationState,
+ SdkRelayTargetPolicy, SdkRelayUrlPolicy,
+};
+#[cfg(feature = "signer-adapters")]
+use radroots_sdk::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider};
+use radroots_trade::validation_receipt::{
+ RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof,
+ RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult,
+ RadrootsValidationReceiptStatement, RadrootsValidationReceiptType,
+ validation_receipt_event_build, validation_receipt_public_values_hash_hex,
+};
+
+const BUYER_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+const BUYER_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+const SELLER_SECRET_KEY_HEX: &str =
+ "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
+const SELLER_PUBLIC_KEY_HEX: &str =
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+const SERVICE_SECRET_KEY_HEX: &str =
+ "48314941f2c9c01ef99f531df7b1d59a8de23dbeb45a498e5aa5f671e921931f";
+const RELAY: &str = "wss://relay.radroots.test";
+
+#[derive(Clone)]
+struct FixtureSigner {
+ identity: RadrootsSignerIdentity,
+ keys: RadrootsNostrKeys,
+}
+
+impl FixtureSigner {
+ fn new(secret_key_hex: &str) -> Self {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let pubkey = keys.public_key().to_hex();
+ Self {
+ identity: RadrootsSignerIdentity::new(pubkey).expect("identity"),
+ keys,
+ }
+ }
+}
+
+impl RadrootsEventSigner for FixtureSigner {
+ fn pubkey(&self) -> &RadrootsPublicKey {
+ self.identity.pubkey()
+ }
+
+ fn sign_frozen_draft(
+ &self,
+ draft: &RadrootsFrozenEventDraft,
+ ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> {
+ radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| {
+ RadrootsSignerError::SigningFailed {
+ message: error.to_string(),
+ }
+ })
+ }
+}
+
+#[tokio::test]
+async fn dvm_trade_transition_proof_request_enqueues_signed_job() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let signer = FixtureSigner::new(SERVICE_SECRET_KEY_HEX);
+ let actor = service_actor(&signer);
+ let listing_event_id = deterministic_event_id("listing-event");
+ let request_event_id = deterministic_event_id("request-event");
+ let decision_event_id = deterministic_event_id("decision-event");
+ let request = DvmTradeTransitionProofEnqueueRequest::new(
+ actor,
+ signer.pubkey().clone(),
+ listing_address(),
+ listing_event_id.clone(),
+ request_event_id.clone(),
+ decision_event_id.clone(),
+ inventory_bins(),
+ explicit_relays(),
+ )
+ .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_050))
+ .with_inventory_sequence(7);
+
+ let receipt = sdk
+ .dvm()
+ .enqueue_trade_transition_proof_request_with_explicit_signer(request, &signer)
+ .await
+ .expect("enqueue DVM proof request");
+
+ assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
+ assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
+ assert_eq!(receipt.local_event_seq, 1);
+ assert_eq!(receipt.listing_event_id, listing_event_id);
+ assert_eq!(receipt.request_event_id, request_event_id);
+ assert_eq!(receipt.decision_event_id, decision_event_id);
+ assert_eq!(
+ outbox_operation_kind(&sdk, receipt.outbox_operation_id).await,
+ DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND
+ );
+
+ let stored = store
+ .get_event(receipt.signed_event_id.as_str())
+ .await
+ .expect("get event")
+ .expect("stored DVM request");
+ let content: serde_json::Value =
+ serde_json::from_str(&stored.content).expect("proof request content");
+ let tags: Vec<Vec<String>> = serde_json::from_str(&stored.tags_json).expect("stored tags");
+ assert_eq!(stored.kind, KIND_TRADE_TRANSITION_PROOF_REQUEST);
+ assert_eq!(content["proof_mode"], "none");
+ assert_eq!(content["inventory_sequence"], 7);
+ assert!(
+ tags.iter()
+ .any(|tag| tag == &vec!["p".to_owned(), signer.pubkey().as_str().to_owned()])
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(String::as_str) == Some("a"))
+ );
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 1
+ );
+}
+
+#[cfg(feature = "signer-adapters")]
+#[tokio::test]
+async fn dvm_trade_transition_proof_request_uses_configured_local_signer() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let signer_keys = keys_from_secret(SERVICE_SECRET_KEY_HEX);
+ let signer_pubkey = signer_keys.public_key().to_hex();
+ let sdk = RadrootsClient::builder()
+ .storage(RadrootsSdkStorageConfig::Directory(
+ tempdir.path().join("sdk"),
+ ))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .signer_provider(RadrootsSdkSignerProvider::LocalKey(
+ RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"),
+ ))
+ .build()
+ .await
+ .expect("sdk");
+ let request = DvmTradeTransitionProofEnqueueRequest::new(
+ RadrootsActorContext::test(signer_pubkey.as_str(), [RadrootsActorRole::Service])
+ .expect("service actor"),
+ signer_pubkey.parse().expect("worker pubkey"),
+ listing_address(),
+ deterministic_event_id("listing-event"),
+ deterministic_event_id("request-event"),
+ deterministic_event_id("decision-event"),
+ inventory_bins(),
+ explicit_relays(),
+ )
+ .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_050));
+
+ let receipt = sdk
+ .dvm()
+ .enqueue_trade_transition_proof_request(request)
+ .await
+ .expect("configured signer enqueue");
+
+ assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
+ assert_eq!(
+ outbox_operation_kind(&sdk, receipt.outbox_operation_id).await,
+ DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND
+ );
+}
+
+#[cfg(feature = "signer-adapters")]
+#[tokio::test]
+async fn dvm_configured_enqueue_reports_prepare_and_target_errors_without_mutation() {
+ let (tempdir, sdk, store) = directory_sdk_and_store_with_signer().await;
+ let signer_keys = keys_from_secret(SERVICE_SECRET_KEY_HEX);
+ let signer_pubkey = signer_keys.public_key().to_hex();
+ let invalid = DvmTradeTransitionProofEnqueueRequest::new(
+ RadrootsActorContext::test(signer_pubkey.as_str(), [RadrootsActorRole::Service])
+ .expect("service actor"),
+ signer_pubkey.parse().expect("worker pubkey"),
+ listing_address(),
+ deterministic_event_id("listing-event"),
+ deterministic_event_id("request-event"),
+ deterministic_event_id("decision-event"),
+ Vec::new(),
+ explicit_relays(),
+ );
+ let error = sdk
+ .dvm()
+ .enqueue_trade_transition_proof_request(invalid)
+ .await
+ .expect_err("prepare failure");
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+
+ let missing_relays = DvmTradeTransitionProofEnqueueRequest::new(
+ RadrootsActorContext::test(signer_pubkey.as_str(), [RadrootsActorRole::Service])
+ .expect("service actor"),
+ signer_pubkey.parse().expect("worker pubkey"),
+ listing_address(),
+ deterministic_event_id("listing-event"),
+ deterministic_event_id("request-event"),
+ deterministic_event_id("decision-event"),
+ inventory_bins(),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ );
+ let error = sdk
+ .dvm()
+ .enqueue_trade_transition_proof_request(missing_relays)
+ .await
+ .expect_err("missing configured relays");
+ assert!(matches!(error, RadrootsSdkError::EmptyTargetRelays { .. }));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+ drop(tempdir);
+}
+
+#[tokio::test]
+async fn dvm_validation_receipt_ingest_commits_pending_trade_status() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request_event = signed_order_request_event("order-dvm-ingest", 10);
+ let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id");
+ let decision_event = signed_order_decision_event("order-dvm-ingest", &request_event_id, 11);
+ let decision_event_id =
+ RadrootsEventId::parse(decision_event.id.as_str()).expect("decision id");
+ for (event, observed_at_ms) in [
+ (request_event.clone(), 1_000),
+ (decision_event.clone(), 1_100),
+ ] {
+ store
+ .ingest_event(RadrootsEventIngest::new(event, observed_at_ms))
+ .await
+ .expect("ingest order event");
+ }
+ let pending = sdk
+ .trades()
+ .status(status_request("order-dvm-ingest"))
+ .await
+ .expect("pending status");
+ assert_eq!(pending.status, OrderStatusKind::AgreedPendingRhi);
+ assert!(pending.rhi_receipt_event_id.is_none());
+
+ let listing_event_id = deterministic_event_id("listing-event");
+ let receipt_event = signed_validation_receipt_event(
+ "order-dvm-ingest",
+ &listing_event_id,
+ &request_event_id,
+ &decision_event_id,
+ 12,
+ );
+ let receipt_event_id = RadrootsEventId::parse(receipt_event.id.as_str()).expect("receipt id");
+ let ingest = sdk
+ .dvm()
+ .ingest_validation_receipt(
+ DvmValidationReceiptIngestRequest::new(receipt_event)
+ .with_expected_order_id(order_id("order-dvm-ingest"))
+ .with_expected_listing_event_id(listing_event_id.clone())
+ .with_expected_root_event_id(request_event_id.clone())
+ .with_expected_target_event_id(decision_event_id.clone())
+ .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_060)),
+ )
+ .await
+ .expect("ingest validation receipt");
+
+ assert_eq!(ingest.receipt_event_id, receipt_event_id);
+ assert_eq!(ingest.order_id.as_str(), "order-dvm-ingest");
+ assert_eq!(ingest.listing_event_id, listing_event_id);
+ assert_eq!(ingest.root_event_id, request_event_id);
+ assert_eq!(ingest.target_event_id, decision_event_id);
+ assert_eq!(
+ ingest.receipt_type,
+ RadrootsValidationReceiptType::TradeTransition
+ );
+ assert_eq!(ingest.result, RadrootsValidationReceiptResult::Valid);
+ assert_eq!(
+ ingest.proof_system,
+ RadrootsValidationReceiptProofSystem::None
+ );
+ assert_eq!(ingest.local_event_seq, 3);
+ assert!(ingest.inserted);
+ assert_eq!(ingest.refresh.validation_receipts, 1);
+ assert_eq!(ingest.refresh.trade_upserts, 1);
+
+ let committed = sdk
+ .trades()
+ .status(status_request("order-dvm-ingest"))
+ .await
+ .expect("committed status");
+ assert_eq!(committed.status, OrderStatusKind::Committed);
+ assert!(committed.lifecycle_terminal);
+ assert_eq!(committed.next_action, OrderStatusNextActionKind::Terminal);
+ assert_eq!(
+ committed.rhi_receipt_event_id,
+ Some(receipt_event_id.clone())
+ );
+ assert_eq!(committed.last_event_id, Some(receipt_event_id));
+}
+
+#[tokio::test]
+async fn dvm_validation_receipt_ingest_rejects_binding_mismatch_without_mutation() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let receipt_event = signed_validation_receipt_event(
+ "order-dvm-mismatch",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+
+ let error = sdk
+ .dvm()
+ .ingest_validation_receipt(
+ DvmValidationReceiptIngestRequest::new(receipt_event)
+ .with_expected_order_id(order_id("other-order")),
+ )
+ .await
+ .expect_err("binding mismatch");
+
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+}
+
+#[tokio::test]
+async fn dvm_trade_transition_proof_request_reports_prepare_and_target_errors_without_mutation() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let signer = FixtureSigner::new(SERVICE_SECRET_KEY_HEX);
+ let actor = service_actor(&signer);
+ let invalid = DvmTradeTransitionProofEnqueueRequest::new(
+ actor.clone(),
+ signer.pubkey().clone(),
+ listing_address(),
+ deterministic_event_id("listing-event"),
+ deterministic_event_id("request-event"),
+ deterministic_event_id("decision-event"),
+ Vec::new(),
+ explicit_relays(),
+ );
+ let error = sdk
+ .dvm()
+ .enqueue_trade_transition_proof_request_with_explicit_signer(invalid, &signer)
+ .await
+ .expect_err("prepare failure");
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+
+ let missing_relays = DvmTradeTransitionProofEnqueueRequest::new(
+ actor,
+ signer.pubkey().clone(),
+ listing_address(),
+ deterministic_event_id("listing-event"),
+ deterministic_event_id("request-event"),
+ deterministic_event_id("decision-event"),
+ inventory_bins(),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ );
+ let error = sdk
+ .dvm()
+ .enqueue_trade_transition_proof_request_with_explicit_signer(missing_relays, &signer)
+ .await
+ .expect_err("missing configured relays");
+ assert!(matches!(error, RadrootsSdkError::EmptyTargetRelays { .. }));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+}
+
+#[tokio::test]
+async fn dvm_validation_receipt_ingest_reports_timestamp_and_refresh_errors() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let overflow_observed = signed_validation_receipt_event(
+ "order-dvm-observed-overflow",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+ let error = sdk
+ .dvm()
+ .ingest_validation_receipt(
+ DvmValidationReceiptIngestRequest::new(overflow_observed)
+ .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)),
+ )
+ .await
+ .expect_err("observed overflow");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX
+ ));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+
+ let (_tempdir, sdk, store) =
+ directory_sdk_and_store_with_clock(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)).await;
+ let default_observed = signed_validation_receipt_event(
+ "order-dvm-default-observed-overflow",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+ let error = sdk
+ .dvm()
+ .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new(default_observed))
+ .await
+ .expect_err("default observed overflow");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX
+ ));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+
+ let (_tempdir, sdk, store) =
+ directory_sdk_and_store_with_clock(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)).await;
+ let refresh_overflow = signed_validation_receipt_event(
+ "order-dvm-refresh-overflow",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+ let error = sdk
+ .dvm()
+ .ingest_validation_receipt(
+ DvmValidationReceiptIngestRequest::new(refresh_overflow)
+ .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)),
+ )
+ .await
+ .expect_err("refresh overflow");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX
+ ));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 1
+ );
+}
+
+#[tokio::test]
+async fn dvm_prepare_and_ingest_use_sdk_clock_defaults() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let signer = FixtureSigner::new(SERVICE_SECRET_KEY_HEX);
+ let plan = sdk
+ .dvm()
+ .prepare_trade_transition_proof_request(DvmTradeTransitionProofPrepareRequest::new(
+ service_actor(&signer),
+ signer.pubkey().clone(),
+ listing_address(),
+ deterministic_event_id("listing-event"),
+ deterministic_event_id("request-event"),
+ deterministic_event_id("decision-event"),
+ inventory_bins(),
+ ))
+ .expect("default timestamp plan");
+ assert_eq!(
+ plan.created_at,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)
+ );
+
+ let receipt_event = signed_validation_receipt_event(
+ "order-dvm-clock-default",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+ let ingest = sdk
+ .dvm()
+ .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new(receipt_event))
+ .await
+ .expect("default observed timestamp ingest");
+
+ assert_eq!(ingest.local_event_seq, 1);
+}
+
+#[tokio::test]
+async fn dvm_validation_receipt_ingest_rejects_invalid_verified_order_id() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let receipt_event = signed_validation_receipt_event(
+ "bad order id",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+
+ let error = sdk
+ .dvm()
+ .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new(receipt_event))
+ .await
+ .expect_err("invalid order id");
+
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+}
+
+#[tokio::test]
+async fn dvm_validation_receipt_ingest_rejects_invalid_receipt_event_id() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let mut receipt_event = signed_validation_receipt_event(
+ "order-dvm-bad-receipt-id",
+ &deterministic_event_id("listing-event"),
+ &deterministic_event_id("request-event"),
+ &deterministic_event_id("decision-event"),
+ 12,
+ );
+ receipt_event.id = "bad id".to_owned();
+
+ let error = sdk
+ .dvm()
+ .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new(receipt_event))
+ .await
+ .expect_err("invalid receipt event id");
+
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 0
+ );
+}
+
+async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) {
+ directory_sdk_and_store_with_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)).await
+}
+
+async fn directory_sdk_and_store_with_clock(
+ timestamp: RadrootsSdkTimestamp,
+) -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let sdk = RadrootsClient::builder()
+ .storage(RadrootsSdkStorageConfig::Directory(
+ tempdir.path().join("sdk"),
+ ))
+ .fixed_clock(timestamp)
+ .build()
+ .await
+ .expect("sdk");
+ let store =
+ RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path)
+ .await
+ .expect("event store");
+ (tempdir, sdk, store)
+}
+
+#[cfg(feature = "signer-adapters")]
+async fn directory_sdk_and_store_with_signer()
+-> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let signer_keys = keys_from_secret(SERVICE_SECRET_KEY_HEX);
+ let sdk = RadrootsClient::builder()
+ .storage(RadrootsSdkStorageConfig::Directory(
+ tempdir.path().join("sdk"),
+ ))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .signer_provider(RadrootsSdkSignerProvider::LocalKey(
+ RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"),
+ ))
+ .build()
+ .await
+ .expect("sdk");
+ let store =
+ RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path)
+ .await
+ .expect("event store");
+ (tempdir, sdk, store)
+}
+
+fn service_actor(signer: &FixtureSigner) -> RadrootsActorContext {
+ RadrootsActorContext::test(signer.pubkey().as_str(), [RadrootsActorRole::Service])
+ .expect("service actor")
+}
+
+fn explicit_relays() -> SdkRelayTargetPolicy {
+ SdkRelayTargetPolicy::try_explicit([RELAY], SdkRelayUrlPolicy::Public).expect("relay targets")
+}
+
+fn inventory_bins() -> Vec<SdkDvmInventoryBinWitness> {
+ vec![SdkDvmInventoryBinWitness {
+ bin_id: "bin-1".to_owned(),
+ listing_capacity: 5,
+ previous_reserved: 1,
+ }]
+}
+
+fn order_id(raw: &str) -> RadrootsOrderId {
+ RadrootsOrderId::parse(raw).expect("order id")
+}
+
+fn status_request(raw: &str) -> OrderStatusRequest {
+ OrderStatusRequest::parse(raw).expect("status request")
+}
+
+fn listing_address() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!(
+ "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address")
+}
+
+fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: deterministic_event_id("listing-event").into_string(),
+ relays: Some(RELAY.to_owned()),
+ }
+}
+
+fn deterministic_event_id(raw: &str) -> RadrootsEventId {
+ let mut bytes = [0u8; 32];
+ for (index, byte) in raw.bytes().enumerate() {
+ let primary = index % bytes.len();
+ let secondary = (index * 7 + 13) % bytes.len();
+ bytes[primary] = bytes[primary]
+ .wrapping_add(byte)
+ .wrapping_add((index as u8).wrapping_mul(31));
+ bytes[secondary] ^= byte.rotate_left((index % 8) as u32);
+ }
+ let mut hex = String::with_capacity(64);
+ for byte in bytes {
+ use core::fmt::Write as _;
+ write!(&mut hex, "{byte:02x}").expect("write hex");
+ }
+ RadrootsEventId::parse(hex).expect("event id")
+}
+
+fn decimal(raw: &str) -> RadrootsCoreDecimal {
+ raw.parse().expect("decimal")
+}
+
+fn usd(raw: &str) -> RadrootsCoreMoney {
+ RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD)
+}
+
+fn economics() -> RadrootsOrderEconomics {
+ RadrootsOrderEconomics {
+ quote_id: "quote-1".parse().expect("quote id"),
+ quote_version: 1,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ quantity_amount: decimal("1"),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: decimal("5"),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: usd("10"),
+ }],
+ discounts: Vec::<RadrootsOrderEconomicLine>::new(),
+ adjustments: Vec::<RadrootsOrderEconomicLine>::new(),
+ subtotal: usd("10"),
+ discount_total: usd("0"),
+ adjustment_total: usd("0"),
+ total: usd("10"),
+ }
+}
+
+fn order_request(raw_order_id: &str) -> RadrootsOrderRequest {
+ RadrootsOrderRequest {
+ order_id: order_id(raw_order_id),
+ listing_addr: listing_address(),
+ buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"),
+ seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"),
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ economics: economics(),
+ }
+}
+
+fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision {
+ RadrootsOrderDecision {
+ order_id: order_id(raw_order_id),
+ listing_addr: listing_address(),
+ buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"),
+ seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"),
+ decision: RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: vec![RadrootsOrderInventoryCommitment {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ },
+ }
+}
+
+fn signed_order_request_event(raw_order_id: &str, created_at: u32) -> RadrootsNostrEvent {
+ let draft = radroots_sdk::protocol::order::build_order_request_draft(
+ &listing_event_ptr(),
+ &order_request(raw_order_id),
+ )
+ .expect("request draft");
+ signed_event(BUYER_SECRET_KEY_HEX, created_at, draft.into_wire_parts())
+}
+
+fn signed_order_decision_event(
+ raw_order_id: &str,
+ root_event_id: &RadrootsEventId,
+ created_at: u32,
+) -> RadrootsNostrEvent {
+ let draft = radroots_sdk::protocol::order::build_order_decision_draft(
+ root_event_id,
+ root_event_id,
+ &order_decision(raw_order_id),
+ )
+ .expect("decision draft");
+ signed_event(SELLER_SECRET_KEY_HEX, created_at, draft.into_wire_parts())
+}
+
+fn signed_validation_receipt_event(
+ raw_order_id: &str,
+ listing_event_id: &RadrootsEventId,
+ root_event_id: &RadrootsEventId,
+ target_event_id: &RadrootsEventId,
+ created_at: u32,
+) -> RadrootsNostrEvent {
+ let receipt = RadrootsTradeValidationReceipt {
+ changed_records_root: hash32('6'),
+ domain: "radroots.receipt".to_owned(),
+ error_bitmap: "0x00000000000000000000000000000000".to_owned(),
+ event_set_root: hash32('c'),
+ new_state_root: hash32('4'),
+ previous_state_root: hash32('3'),
+ proof: RadrootsValidationReceiptProof {
+ inline_proof_base64: None,
+ mode: None,
+ program_hash: None,
+ proof_reference: None,
+ system: RadrootsValidationReceiptProofSystem::None,
+ verifying_key_hash: None,
+ },
+ public_values_hash: validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#),
+ receipt_type: RadrootsValidationReceiptType::TradeTransition,
+ result: RadrootsValidationReceiptResult::Valid,
+ statement: RadrootsValidationReceiptStatement {
+ listing_event_id: listing_event_id.as_str().to_owned(),
+ root_event_id: root_event_id.as_str().to_owned(),
+ target_event_id: target_event_id.as_str().to_owned(),
+ statement_type: RadrootsValidationReceiptType::TradeTransition,
+ },
+ version: 1,
+ };
+ let parts = validation_receipt_event_build(raw_order_id, &receipt).expect("receipt event");
+ signed_event(SERVICE_SECRET_KEY_HEX, created_at, parts)
+}
+
+fn signed_event(
+ secret_key_hex: &str,
+ created_at: u32,
+ parts: radroots_sdk::protocol::wire::WireEventParts,
+) -> RadrootsNostrEvent {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("event builder")
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at)))
+ .sign_with_keys(&keys)
+ .expect("signed event");
+ radroots_event_from_nostr(&event)
+}
+
+#[cfg(feature = "signer-adapters")]
+fn keys_from_secret(secret_key_hex: &str) -> RadrootsNostrKeys {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ RadrootsNostrKeys::new(secret_key)
+}
+
+fn hash32(ch: char) -> String {
+ format!("0x{}", ch.to_string().repeat(64))
+}
+
+async fn outbox_operation_kind(sdk: &RadrootsClient, operation_id: i64) -> String {
+ let paths = sdk.storage_paths().expect("paths");
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ outbox
+ .get_operation(operation_id)
+ .await
+ .expect("outbox operation")
+ .expect("outbox operation")
+ .operation_kind
+}
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -3038,6 +3038,10 @@ async fn order_status_contract_dtos_serialize_deterministically() {
assert_eq!(receipt_json["listing_addr"], serde_json::Value::Null);
assert_eq!(receipt_json["buyer_pubkey"], serde_json::Value::Null);
assert_eq!(receipt_json["seller_pubkey"], serde_json::Value::Null);
+ assert_eq!(
+ receipt_json["rhi_receipt_event_id"],
+ serde_json::Value::Null
+ );
assert_eq!(receipt_json["economics"], serde_json::Value::Null);
assert_eq!(receipt_json["next_action"], "no_local_order");
assert_eq!(receipt_json["evidence"]["event_count"], 0);
diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs
@@ -87,6 +87,20 @@ const REQUIRED_ORDER_RUNTIME_EXPORTS: &[&str] = &[
"SdkOrderStatusSource",
];
+const REQUIRED_DVM_RUNTIME_EXPORTS: &[&str] = &[
+ "DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID",
+ "DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND",
+ "DvmProofMode",
+ "DvmTradeTransitionProofEnqueueRequest",
+ "DvmTradeTransitionProofPlan",
+ "DvmTradeTransitionProofPrepareRequest",
+ "DvmTradeTransitionProofReceipt",
+ "DvmTradeTransitionProofRequestPayload",
+ "DvmValidationReceiptIngestReceipt",
+ "DvmValidationReceiptIngestRequest",
+ "SdkDvmInventoryBinWitness",
+];
+
const REQUIRED_ORDERS_CLIENT_METHODS: &[&str] = &[
"pub async fn ingest_evidence(",
"pub async fn ingest_request_evidence(",
@@ -108,6 +122,15 @@ const REQUIRED_ORDERS_CLIENT_METHODS: &[&str] = &[
"pub async fn status(",
];
+const REQUIRED_DVM_CLIENT_METHODS: &[&str] = &[
+ "pub fn prepare_trade_transition_proof_request(",
+ "pub async fn enqueue_trade_transition_proof_request_with_explicit_signer(",
+ "pub async fn ingest_validation_receipt(",
+];
+
+const REQUIRED_DVM_CLIENT_CONFIGURED_SIGNER_METHODS: &[&str] =
+ &["pub async fn enqueue_trade_transition_proof_request("];
+
const REQUIRED_ORDERS_CLIENT_ADVANCED_SIGNER_METHODS: &[&str] = &[
"pub async fn enqueue_submit_with_explicit_signer(",
"pub async fn enqueue_prepared_submit_with_explicit_signer(",
@@ -284,6 +307,39 @@ fn order_runtime_public_exports_are_explicit() {
}
#[test]
+fn dvm_runtime_public_exports_are_explicit() {
+ let source = read_source(
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("src/lib.rs")
+ .as_path(),
+ );
+
+ assert!(
+ source.contains("mod dvm_runtime;"),
+ "src/lib.rs must keep dvm_runtime as an internal implementation module"
+ );
+ assert!(
+ source.contains("pub use crate::dvm_runtime::{"),
+ "src/lib.rs must explicitly re-export approved DVM runtime types"
+ );
+ assert!(
+ !source.contains("pub mod dvm_runtime;"),
+ "src/lib.rs must not expose the dvm_runtime module path"
+ );
+ assert!(
+ !source.contains("pub use crate::dvm_runtime::*;"),
+ "src/lib.rs must not wildcard-export the DVM runtime"
+ );
+
+ for export in REQUIRED_DVM_RUNTIME_EXPORTS {
+ assert!(
+ source.contains(export),
+ "src/lib.rs must explicitly expose DVM SDK runtime export `{export}`"
+ );
+ }
+}
+
+#[test]
fn orders_client_surface_is_inventory_guarded() {
let source = read_source(
Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -312,6 +368,34 @@ fn orders_client_surface_is_inventory_guarded() {
}
#[test]
+fn dvm_client_surface_is_inventory_guarded() {
+ let source = read_source(
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("src/dvm_runtime.rs")
+ .as_path(),
+ );
+
+ assert!(
+ source.contains("impl<'sdk> DvmClient<'sdk> {"),
+ "src/dvm_runtime.rs must own DvmClient runtime methods"
+ );
+
+ for method in REQUIRED_DVM_CLIENT_METHODS {
+ assert!(
+ source.contains(method),
+ "DvmClient must expose inventory-guarded method `{method}`"
+ );
+ }
+
+ for method in REQUIRED_DVM_CLIENT_CONFIGURED_SIGNER_METHODS {
+ assert!(
+ source.contains(method),
+ "DvmClient must expose configured-signer method `{method}`"
+ );
+ }
+}
+
+#[test]
fn product_clients_remain_thin_sdk_handles() {
let lib_source = read_source(
Path::new(env!("CARGO_MANIFEST_DIR"))
diff --git a/crates/sdk/tests/unit/dvm_runtime_tests.rs b/crates/sdk/tests/unit/dvm_runtime_tests.rs
@@ -0,0 +1,398 @@
+use super::{
+ DvmProofMode, DvmTradeTransitionProofEnqueueRequest, DvmTradeTransitionProofPrepareRequest,
+ DvmValidationReceiptIngestRequest, SdkDvmInventoryBinWitness, dvm_trade_transition_proof_plan,
+ sdk_timestamp_ms,
+};
+use crate::{
+ RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkRelayTargetPolicy,
+ SdkRelayUrlPolicy, SyncProjectionRefreshRequest,
+};
+use radroots_authority::RadrootsActorContext;
+use radroots_events::{
+ RadrootsNostrEvent,
+ contract::RadrootsActorRole,
+ ids::{RadrootsEventId, RadrootsListingAddress, RadrootsPublicKey},
+ kinds::KIND_TRADE_TRANSITION_PROOF_REQUEST,
+};
+use radroots_trade::validation_receipt::RadrootsValidationReceiptProofSystem;
+
+const SERVICE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const SELLER: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
+const WORKER: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
+const RELAY: &str = "wss://relay.radroots.test";
+
+#[test]
+fn trade_transition_proof_plan_builds_microstandard_wire_payload() {
+ let request = proof_request(service_actor())
+ .with_inventory_sequence(7)
+ .with_previous_state_root(hash32('3'));
+ let plan = dvm_trade_transition_proof_plan(
+ request,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ )
+ .expect("plan");
+ let payload: serde_json::Value =
+ serde_json::from_str(&plan.frozen_draft.content).expect("payload json");
+
+ assert_eq!(
+ plan.frozen_draft.contract_id,
+ super::DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID
+ );
+ assert_eq!(plan.frozen_draft.kind, KIND_TRADE_TRANSITION_PROOF_REQUEST);
+ assert_eq!(plan.frozen_draft.expected_pubkey, SERVICE);
+ assert_eq!(plan.worker_pubkey.as_str(), WORKER);
+ assert_eq!(plan.proof_mode, DvmProofMode::None);
+ assert_eq!(
+ plan.expected_receipt_proof_system,
+ RadrootsValidationReceiptProofSystem::None
+ );
+ assert_eq!(payload["proof_mode"], "none");
+ assert_eq!(payload["witness_version"], 1);
+ assert_eq!(payload["proof_target"], "trade.order_acceptance.v1");
+ assert_eq!(payload["radroots_protocol_version"], "radroots.trade.v1");
+ assert_eq!(payload["inventory_sequence"], 7);
+ assert_eq!(payload["previous_state_root"], hash32('3'));
+ assert_eq!(payload["listing_event_id"], event_id('1').as_str());
+ assert_eq!(
+ plan.frozen_draft.tags[0],
+ vec!["a", listing_addr().as_str()]
+ );
+ assert_eq!(
+ plan.frozen_draft.tags[1],
+ vec!["i", event_id('1').as_str(), "event", "listing"]
+ );
+ assert_eq!(
+ plan.frozen_draft.tags[2],
+ vec!["i", event_id('2').as_str(), "event", "order_request"]
+ );
+ assert_eq!(
+ plan.frozen_draft.tags[3],
+ vec!["i", event_id('3').as_str(), "event", "order_decision"]
+ );
+ assert_eq!(plan.frozen_draft.tags[4], vec!["p", WORKER]);
+}
+
+#[test]
+fn trade_transition_proof_plan_rejects_non_service_actor_before_mutation() {
+ let error = dvm_trade_transition_proof_plan(
+ proof_request(buyer_actor()),
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ )
+ .expect_err("role error");
+
+ assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. }));
+}
+
+#[test]
+fn trade_transition_proof_plan_rejects_inventory_and_sp1_identity_edges() {
+ let empty_bins = DvmTradeTransitionProofPrepareRequest::new(
+ service_actor(),
+ worker_pubkey(),
+ listing_addr(),
+ event_id('1'),
+ event_id('2'),
+ event_id('3'),
+ Vec::new(),
+ );
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ empty_bins,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let over_reserved = DvmTradeTransitionProofPrepareRequest::new(
+ service_actor(),
+ worker_pubkey(),
+ listing_addr(),
+ event_id('1'),
+ event_id('2'),
+ event_id('3'),
+ vec![SdkDvmInventoryBinWitness {
+ bin_id: "bin-1".to_owned(),
+ listing_capacity: 2,
+ previous_reserved: 3,
+ }],
+ );
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ over_reserved,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let empty_bin_id = DvmTradeTransitionProofPrepareRequest::new(
+ service_actor(),
+ worker_pubkey(),
+ listing_addr(),
+ event_id('1'),
+ event_id('2'),
+ event_id('3'),
+ vec![SdkDvmInventoryBinWitness {
+ bin_id: " ".to_owned(),
+ listing_capacity: 2,
+ previous_reserved: 1,
+ }],
+ );
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ empty_bin_id,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let missing_sp1_identity = proof_request(service_actor()).with_proof_mode(DvmProofMode::Core);
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ missing_sp1_identity,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let none_with_sp1_identity =
+ proof_request(service_actor()).with_sp1_identity(hash32('a'), hash32('b'));
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ none_with_sp1_identity,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let invalid_hash = proof_request(service_actor())
+ .with_proof_mode(DvmProofMode::Core)
+ .with_sp1_identity("0xABC", hash32('b'));
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ invalid_hash,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let invalid_previous_state = proof_request(service_actor()).with_previous_state_root("0xABC");
+ assert!(matches!(
+ dvm_trade_transition_proof_plan(
+ invalid_previous_state,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ ),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+}
+
+#[test]
+fn proof_modes_map_to_expected_receipt_proof_systems_and_labels() {
+ let cases = [
+ (
+ DvmProofMode::None,
+ "none",
+ RadrootsValidationReceiptProofSystem::None,
+ ),
+ (
+ DvmProofMode::Core,
+ "core",
+ RadrootsValidationReceiptProofSystem::Sp1Core,
+ ),
+ (
+ DvmProofMode::Compressed,
+ "compressed",
+ RadrootsValidationReceiptProofSystem::Sp1Compressed,
+ ),
+ (
+ DvmProofMode::Groth16,
+ "groth16",
+ RadrootsValidationReceiptProofSystem::Sp1Groth16,
+ ),
+ (
+ DvmProofMode::Plonk,
+ "plonk",
+ RadrootsValidationReceiptProofSystem::Sp1Plonk,
+ ),
+ ];
+
+ for (mode, label, proof_system) in cases {
+ assert_eq!(mode.as_str(), label);
+ assert_eq!(mode.proof_system(), proof_system);
+ }
+
+ let request = proof_request(service_actor())
+ .with_proof_mode(DvmProofMode::Compressed)
+ .with_sp1_identity(hash32('a'), hash32('b'));
+ let plan = dvm_trade_transition_proof_plan(
+ request,
+ RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000),
+ )
+ .expect("sp1 plan");
+
+ assert_eq!(plan.payload.proof_mode, DvmProofMode::Compressed);
+ assert_eq!(
+ plan.expected_receipt_proof_system,
+ RadrootsValidationReceiptProofSystem::Sp1Compressed
+ );
+}
+
+#[test]
+fn enqueue_request_builders_and_ingest_request_builders_are_deterministic() {
+ let prepare = proof_request(service_actor())
+ .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_010));
+ let idempotency = SdkIdempotencyKey::new("dvm-proof-request").expect("idempotency");
+ let idempotency_len = idempotency.as_str().len();
+ let request = DvmTradeTransitionProofEnqueueRequest::from_prepare(
+ prepare.clone(),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("relays")
+ .with_idempotency_key(idempotency)
+ .with_inventory_sequence(11)
+ .with_previous_state_root(hash32('1'));
+ let serialized = serde_json::to_value(&request).expect("request json");
+
+ assert_eq!(request.prepare.inventory_sequence, 11);
+ assert_eq!(request.prepare.previous_state_root, Some(hash32('1')));
+ assert_eq!(serialized["proof_mode"], "none");
+ assert_eq!(serialized["target_relays"]["kind"], "explicit");
+ assert_eq!(serialized["idempotency_key"]["value"], "<redacted>");
+ assert_eq!(serialized["idempotency_key"]["len"], idempotency_len);
+
+ let request = DvmTradeTransitionProofEnqueueRequest::new(
+ service_actor(),
+ worker_pubkey(),
+ listing_addr(),
+ event_id('1'),
+ event_id('2'),
+ event_id('3'),
+ inventory_bins(),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_idempotency_key("dvm-proof-request-2")
+ .expect("idempotency")
+ .with_proof_mode(DvmProofMode::Core)
+ .with_sp1_identity(hash32('a'), hash32('b'))
+ .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_011));
+ assert_eq!(request.prepare.proof_mode, DvmProofMode::Core);
+ assert_eq!(request.prepare.sp1_program_hash, Some(hash32('a')));
+ assert_eq!(request.prepare.sp1_verifying_key_hash, Some(hash32('b')));
+ assert!(matches!(
+ DvmTradeTransitionProofEnqueueRequest::from_prepare(
+ prepare,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays(["ws://relay.example.com"], SdkRelayUrlPolicy::Public),
+ Err(RadrootsSdkError::InvalidRelayUrl { .. })
+ ));
+ assert!(matches!(
+ DvmTradeTransitionProofEnqueueRequest::from_prepare(
+ proof_request(service_actor()),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_idempotency_key(""),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let ingest = DvmValidationReceiptIngestRequest::new(dummy_event())
+ .with_projection_refresh(SyncProjectionRefreshRequest::new().with_limit(5));
+ assert_eq!(ingest.projection_refresh.limit, 5);
+}
+
+#[test]
+fn dvm_timestamp_edges_return_structured_errors() {
+ let error = dvm_trade_transition_proof_plan(
+ proof_request(service_actor()),
+ RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX) + 1),
+ )
+ .expect_err("nostr timestamp range");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::TimestampOutOfRange { value } if value == u64::from(u32::MAX) + 1
+ ));
+
+ let error = sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX))
+ .expect_err("millisecond timestamp range");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX
+ ));
+}
+
+#[tokio::test]
+async fn dvm_client_prepare_reports_default_clock_errors() {
+ let sdk = crate::RadrootsClient::builder()
+ .clock(crate::RadrootsSdkClock::BeforeUnixEpoch)
+ .build()
+ .await
+ .expect("sdk");
+
+ let error = sdk
+ .dvm()
+ .prepare_trade_transition_proof_request(proof_request(service_actor()))
+ .expect_err("clock error");
+
+ assert!(matches!(error, RadrootsSdkError::ClockBeforeUnixEpoch));
+}
+
+fn proof_request(actor: RadrootsActorContext) -> DvmTradeTransitionProofPrepareRequest {
+ DvmTradeTransitionProofPrepareRequest::new(
+ actor,
+ worker_pubkey(),
+ listing_addr(),
+ event_id('1'),
+ event_id('2'),
+ event_id('3'),
+ vec![SdkDvmInventoryBinWitness {
+ bin_id: "bin-1".to_owned(),
+ listing_capacity: 5,
+ previous_reserved: 1,
+ }],
+ )
+}
+
+fn inventory_bins() -> Vec<SdkDvmInventoryBinWitness> {
+ vec![SdkDvmInventoryBinWitness {
+ bin_id: "bin-1".to_owned(),
+ listing_capacity: 5,
+ previous_reserved: 1,
+ }]
+}
+
+fn service_actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(SERVICE, [RadrootsActorRole::Service]).expect("service actor")
+}
+
+fn buyer_actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(BUYER, [RadrootsActorRole::Buyer]).expect("buyer actor")
+}
+
+fn worker_pubkey() -> RadrootsPublicKey {
+ RadrootsPublicKey::parse(WORKER).expect("worker pubkey")
+}
+
+fn listing_addr() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!("30402:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
+ .expect("listing addr")
+}
+
+fn event_id(ch: char) -> RadrootsEventId {
+ RadrootsEventId::parse(ch.to_string().repeat(64)).expect("event id")
+}
+
+fn hash32(ch: char) -> String {
+ format!("0x{}", ch.to_string().repeat(64))
+}
+
+fn dummy_event() -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: event_id('9').into_string(),
+ author: event_id('a').into_string(),
+ created_at: 1,
+ kind: 3440,
+ tags: Vec::new(),
+ content: "{}".to_owned(),
+ sig: event_id('b').into_string(),
+ }
+}