lib

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

commit 8f8435669750f272889501b0fef0c173c32d3860
parent 6cf47f4a0daa32ea93580a848f97ed08578d9957
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 10:11:54 +0000

sdk: add dvm validation runtime

Diffstat:
Mcrates/sdk/Cargo.toml | 2++
Acrates/sdk/src/dvm_runtime.rs | 732+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 11+++++++++++
Mcrates/sdk/src/orders_runtime.rs | 2++
Mcrates/sdk/src/product_clients.rs | 4++--
Acrates/sdk/tests/dvm_runtime.rs | 856+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 4++++
Mcrates/sdk/tests/source_boundary.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/dvm_runtime_tests.rs | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 2091 insertions(+), 2 deletions(-)

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(), + } +}