rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 1038fc9b5b564fbf54bab3bb7c810231b6e8a53e
parent 5de817d46c6d6a3dfa02a1066d3bb8d6bf91de26
Author: triesap <tyson@radroots.org>
Date:   Wed, 15 Jul 2026 17:34:50 +0000

validation: publish validator-set receipts

Replace listing validation and proof DVM handling with validator-set-bound V1 validation receipt publication from accepted order decisions.\n\nMake deterministic validation production-capable by default, enforce validator-set/proof policy fail-closed, persist receipt completion in processed jobs, and update RHI source guards.

Diffstat:
Mconfig.toml | 4+++-
Msrc/config.rs | 130+++++++++++++++++++++++++++++++-------------------------------------------------
Dsrc/features/trade_listing/handlers/dvm.rs | 1270-------------------------------------------------------------------------------
Asrc/features/trade_listing/handlers/events.rs | 947+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/features/trade_listing/handlers/mod.rs | 2+-
Msrc/features/trade_listing/processed_jobs.rs | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/features/trade_listing/subscriber.rs | 56++++++++++++++++++++++++++++----------------------------
Msrc/features/trade_validation_receipt.rs | 7465++++++-------------------------------------------------------------------------
Msrc/lib.rs | 6++----
Msrc/proof_smoke.rs | 23++++++++++++++---------
Mtests/source_guards.rs | 109++++++++++++++++++++++++++++++++++++-------------------------------------------
11 files changed, 1685 insertions(+), 8369 deletions(-)

diff --git a/config.toml b/config.toml @@ -25,5 +25,7 @@ replay_window_secs = 86400 replay_overlap_secs = 300 [trade_validation_receipt] -backend = "disabled" +backend = "local_execute" proof_mode = "none" +validator_set_addr = "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" +validator_set_event_id = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" diff --git a/src/config.rs b/src/config.rs @@ -4,10 +4,7 @@ use radroots_runtime::{BackoffConfig, RadrootsNostrServiceConfig}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use crate::features::trade_validation_receipt::{ - TradeValidationReceiptProverBackend, TradeValidationReceiptProverPolicy, - TradeValidationReceiptRuntimePolicy, -}; +use crate::features::trade_validation_receipt::TradeValidationReceiptProverPolicy; use crate::paths::{ RhiRuntimePaths, default_subscriber_state_path_for_process, resolve_runtime_paths_with_resolver, }; @@ -222,34 +219,6 @@ impl RawSettings { } } -fn validate_trade_validation_receipt_runtime_profile( - policy: &TradeValidationReceiptProverPolicy, - profile: radroots_runtime_paths::RadrootsPathProfile, -) -> Result<()> { - if policy.runtime_policy == TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment - && policy.backend != TradeValidationReceiptProverBackend::DeterministicNone - { - bail!( - "trade_validation_receipt.runtime_policy repo_local_development requires backend deterministic_none" - ); - } - if policy.backend == TradeValidationReceiptProverBackend::DeterministicNone - && policy.runtime_policy != TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment - { - bail!( - "trade_validation_receipt.backend deterministic_none requires runtime_policy repo_local_development" - ); - } - if policy.backend == TradeValidationReceiptProverBackend::DeterministicNone - && profile != radroots_runtime_paths::RadrootsPathProfile::RepoLocal - { - bail!( - "trade_validation_receipt.backend deterministic_none requires runtime profile repo_local" - ); - } - Ok(()) -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub metadata: RadrootsNostrMetadata, @@ -268,10 +237,7 @@ fn load_settings_from_path_with_resolver( let settings: RawSettings = toml::from_str(&raw).with_context(|| format!("parse configuration {}", path.display()))?; let settings = settings.into_settings(&paths)?; - validate_trade_validation_receipt_runtime_profile( - &settings.config.trade_validation_receipt, - profile, - )?; + settings.config.trade_validation_receipt.validate()?; Ok(settings) } @@ -288,9 +254,7 @@ pub fn load_settings_from_path(path: &Path) -> Result<Settings> { #[cfg(test)] mod tests { use super::load_settings_from_path_with_resolver; - use crate::features::trade_validation_receipt::{ - TradeValidationReceiptProverBackend, TradeValidationReceiptRuntimePolicy, - }; + use crate::features::trade_validation_receipt::TradeValidationReceiptProverBackend; use crate::paths::{ default_subscriber_state_path_for_process, resolve_runtime_paths_with_resolver, runtime_contract_with_resolver, @@ -476,7 +440,7 @@ replay_overlap_secs = 45 assert_eq!(settings.config.subscriber.state.replay_overlap_secs, 45); assert_eq!( settings.config.trade_validation_receipt.backend, - TradeValidationReceiptProverBackend::Disabled + TradeValidationReceiptProverBackend::LocalExecute ); assert_eq!( settings.config.trade_validation_receipt.proof_mode, @@ -516,19 +480,19 @@ jitter_ms = 5 path = "state/trade-listing.json" [trade_validation_receipt] -backend = "deterministic_none" +backend = "local_execute" proof_mode = "none" -runtime_policy = "repo_local_development" +validator_set_addr = "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" +validator_set_event_id = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "#, ) .expect("write config"); - let repo_local_root = temp.path().join("repo-local-runtime"); let settings = load_settings_from_path_with_resolver( &config_path, &linux_resolver(), - RadrootsPathProfile::RepoLocal, - Some(repo_local_root.as_path()), + RadrootsPathProfile::InteractiveUser, + None, ) .expect("load settings"); @@ -561,20 +525,34 @@ runtime_policy = "repo_local_development" ); assert_eq!( settings.config.trade_validation_receipt.backend, - TradeValidationReceiptProverBackend::DeterministicNone + TradeValidationReceiptProverBackend::LocalExecute ); assert_eq!( settings.config.trade_validation_receipt.proof_mode, RadrootsSp1TradeProofMode::None ); assert_eq!( - settings.config.trade_validation_receipt.runtime_policy, - TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment + settings + .config + .trade_validation_receipt + .validator_set_addr + .as_deref(), + Some( + "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" + ) + ); + assert_eq!( + settings + .config + .trade_validation_receipt + .validator_set_event_id + .as_deref(), + Some("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") ); } #[test] - fn load_settings_rejects_deterministic_policy_without_repo_local_runtime_policy() { + fn load_settings_rejects_sp1_identity_hash_without_sp1_mode() { let temp = tempfile::tempdir().expect("tempdir"); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -584,31 +562,29 @@ runtime_policy = "repo_local_development" name = "rhi-test" [trade_validation_receipt] -backend = "deterministic_none" +backend = "local_execute" proof_mode = "none" +expected_sp1_program_hash = "0x1111111111111111111111111111111111111111111111111111111111111111" "#, ) .expect("write config"); - let repo_local_root = temp.path().join("repo-local-runtime"); let error = load_settings_from_path_with_resolver( &config_path, &linux_resolver(), - RadrootsPathProfile::RepoLocal, - Some(repo_local_root.as_path()), + RadrootsPathProfile::InteractiveUser, + None, ) - .expect_err("deterministic_none without runtime policy must fail"); + .expect_err("sp1 identity without sp1 proof mode must fail"); let message = format!("{error:#}"); assert!( - message.contains( - "trade_validation_receipt.backend deterministic_none requires runtime_policy repo_local_development" - ), + message.contains("SP1 identity constraints require an SP1 proof mode"), "{message}" ); } #[test] - fn load_settings_rejects_deterministic_policy_outside_repo_local_profile() { + fn load_settings_rejects_sp1_backend_without_identity_hashes() { let temp = tempfile::tempdir().expect("tempdir"); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -618,32 +594,26 @@ proof_mode = "none" name = "rhi-test" [trade_validation_receipt] -backend = "deterministic_none" -proof_mode = "none" -runtime_policy = "repo_local_development" +backend = "local_cpu_prove" +proof_mode = "core" "#, ) .expect("write config"); - for profile in [ + let error = load_settings_from_path_with_resolver( + &config_path, + &linux_resolver(), RadrootsPathProfile::InteractiveUser, - RadrootsPathProfile::ServiceHost, - ] { - let error = load_settings_from_path_with_resolver( - &config_path, - &linux_resolver(), - profile, - None, - ) - .expect_err("production-capable profiles must reject deterministic_none"); - let message = format!("{error:#}"); - assert!( - message.contains( - "trade_validation_receipt.backend deterministic_none requires runtime profile repo_local" - ), - "{message}" - ); - } + None, + ) + .expect_err("sp1 backend without identity hashes must fail"); + let message = format!("{error:#}"); + assert!( + message.contains( + "trade validation receipt policy requires SP1 program and verifying-key hashes" + ), + "{message}" + ); } #[test] @@ -690,7 +660,7 @@ replay_window_secs = 10 name = "rhi-test" [config.trade_validation_receipt] -backend = "deterministic_none" +backend = "local_execute" proof_mode = "none" "#, "unknown field `config`", diff --git a/src/features/trade_listing/handlers/dvm.rs b/src/features/trade_listing/handlers/dvm.rs @@ -1,1270 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr(coverage_nightly, coverage(off))] - -use std::{sync::Arc, time::Duration}; - -use radroots_event::farm::RadrootsFarmRef; -use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; -use radroots_event::kinds::{ - KIND_FARM, KIND_JOB_FEEDBACK, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, - KIND_TRADE_LISTING_VALIDATION_REQUEST, KIND_TRADE_LISTING_VALIDATION_RESULT, - KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT, - KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, is_order_event_kind, - is_trade_validation_service_event_kind, -}; -use radroots_event::trade_validation::{ - RadrootsTradeValidationListingError as TradeListingValidationError, - RadrootsTradeValidationListingRequest as TradeListingValidateRequest, - RadrootsTradeValidationListingResult as TradeListingValidateResult, -}; -use radroots_event_codec::order::{RadrootsOrderEnvelopeParseError, parse_order_listing_event_tag}; -use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, - RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrTag, radroots_event_from_nostr, - radroots_nostr_build_event, radroots_nostr_fetch_event_by_id, radroots_nostr_parse_pubkey, - radroots_nostr_send_event, -}; -use radroots_trade::dvm::{RadrootsTradeDvmFeedbackStatus, build_job_feedback_tags}; -use radroots_trade::listing::{ - parse_listing_address, parse_public_listing_address, validation::validate_listing_event, -}; -use radroots_trade::order::{ - RadrootsOrderEventRecord, RadrootsOrderProjection, order_event_record_from_event, - reduce_order_event_records, -}; -use radroots_trade::workflow::RadrootsTradeWorkflowState; -use thiserror::Error; - -use crate::features::trade_listing::state::{ - TradeListingRuntime, TradeListingState, TradeListingStateError, TradeOrderState, -}; -use crate::features::trade_validation_receipt::{ - TradeValidationReceiptJobError, TradeValidationReceiptProverPolicy, - handle_trade_validation_receipt_job_request, -}; - -#[derive(Debug, Error)] -pub enum TradeListingDvmError { - #[error("event kind not supported")] - UnsupportedKind, - #[error("missing recipient tag")] - MissingRecipient, - #[error("missing required tag: {0}")] - MissingTag(&'static str), - #[error("tag mismatch: {0}")] - TagMismatch(&'static str), - #[error("invalid envelope: {0}")] - InvalidEnvelope(String), - #[error("invalid envelope payload: {0}")] - InvalidPayload(String), - #[error("invalid listing address")] - InvalidListingAddr, - #[error("invalid order request payload")] - InvalidOrder, - #[error("shared workflow rejected trade transition: {0}")] - Workflow(String), - #[error("state error: {0}")] - State(#[from] TradeListingStateError), - #[error("nostr error: {0}")] - Nostr(#[from] radroots_nostr::error::RadrootsNostrError), - #[error("serde error: {0}")] - Serde(#[from] serde_json::Error), - #[error("unauthorized sender")] - Unauthorized, -} - -#[cfg(test)] -#[derive(Default)] -struct DvmTestHooks { - fetch_event_by_id_results: - std::collections::VecDeque<Result<RadrootsNostrEvent, TradeListingDvmError>>, - fetch_events_results: - std::collections::VecDeque<Result<Vec<RadrootsNostrEvent>, TradeListingDvmError>>, - send_event_results: std::collections::VecDeque<Result<(), TradeListingDvmError>>, - validate_listing_results: - std::collections::VecDeque<Result<(String, RadrootsFarmRef), TradeListingValidationError>>, - farm_validation_results: - std::collections::VecDeque<Result<Vec<TradeListingValidationError>, TradeListingDvmError>>, -} - -#[cfg(test)] -static DVM_TEST_HOOKS: std::sync::OnceLock<std::sync::Mutex<DvmTestHooks>> = - std::sync::OnceLock::new(); - -#[cfg(test)] -fn dvm_test_hooks() -> &'static std::sync::Mutex<DvmTestHooks> { - DVM_TEST_HOOKS.get_or_init(|| std::sync::Mutex::new(DvmTestHooks::default())) -} - -#[cfg(test)] -fn pop_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingDvmError>> { - dvm_test_hooks() - .lock() - .expect("dvm test hooks lock") - .fetch_event_by_id_results - .pop_front() -} - -#[cfg(test)] -fn pop_fetch_events_hook() -> Option<Result<Vec<RadrootsNostrEvent>, TradeListingDvmError>> { - dvm_test_hooks() - .lock() - .expect("dvm test hooks lock") - .fetch_events_results - .pop_front() -} - -#[cfg(test)] -fn pop_send_event_hook() -> Option<Result<(), TradeListingDvmError>> { - dvm_test_hooks() - .lock() - .expect("dvm test hooks lock") - .send_event_results - .pop_front() -} - -#[cfg(test)] -fn pop_validate_listing_hook() --> Option<Result<(String, RadrootsFarmRef), TradeListingValidationError>> { - dvm_test_hooks() - .lock() - .expect("dvm test hooks lock") - .validate_listing_results - .pop_front() -} - -#[cfg(test)] -fn pop_farm_validation_hook() --> Option<Result<Vec<TradeListingValidationError>, TradeListingDvmError>> { - dvm_test_hooks() - .lock() - .expect("dvm test hooks lock") - .farm_validation_results - .pop_front() -} - -#[cfg(test)] -fn take_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingDvmError>> { - pop_fetch_event_by_id_hook() -} - -#[cfg(not(test))] -#[cfg_attr(coverage_nightly, coverage(off))] -fn take_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingDvmError>> { - None -} - -#[cfg(test)] -fn take_fetch_events_hook() -> Option<Result<Vec<RadrootsNostrEvent>, TradeListingDvmError>> { - pop_fetch_events_hook() -} - -#[cfg(not(test))] -#[cfg_attr(coverage_nightly, coverage(off))] -fn take_fetch_events_hook() -> Option<Result<Vec<RadrootsNostrEvent>, TradeListingDvmError>> { - None -} - -#[cfg(test)] -fn take_send_event_hook() -> Option<Result<(), TradeListingDvmError>> { - pop_send_event_hook() -} - -#[cfg(not(test))] -#[cfg_attr(coverage_nightly, coverage(off))] -fn take_send_event_hook() -> Option<Result<(), TradeListingDvmError>> { - None -} - -#[cfg(test)] -fn take_validate_listing_hook() --> Option<Result<(String, RadrootsFarmRef), TradeListingValidationError>> { - pop_validate_listing_hook() -} - -#[cfg(not(test))] -#[cfg_attr(coverage_nightly, coverage(off))] -fn take_validate_listing_hook() --> Option<Result<(String, RadrootsFarmRef), TradeListingValidationError>> { - None -} - -async fn fetch_event_by_id_io( - client: &RadrootsNostrClient, - id: &str, -) -> Result<RadrootsNostrEvent, TradeListingDvmError> { - match take_fetch_event_by_id_hook() { - Some(result) => result, - None => radroots_nostr_fetch_event_by_id(client, id) - .await - .map_err(TradeListingDvmError::from), - } -} - -async fn fetch_events_io( - client: &RadrootsNostrClient, - filter: RadrootsNostrFilter, - timeout: Duration, -) -> Result<Vec<RadrootsNostrEvent>, TradeListingDvmError> { - match take_fetch_events_hook() { - Some(result) => result, - None => client - .fetch_events(filter, timeout) - .await - .map_err(TradeListingDvmError::from), - } -} - -#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] -async fn send_event_io( - client: &RadrootsNostrClient, - builder: RadrootsNostrEventBuilder, -) -> Result<(), TradeListingDvmError> { - match take_send_event_hook() { - Some(result) => result, - None => radroots_nostr_send_event(client, builder) - .await - .map(|_| ()) - .map_err(TradeListingDvmError::from), - } -} - -#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] -fn validate_listing_event_io( - event: &RadrootsNostrEvent, -) -> Result<(String, RadrootsFarmRef), TradeListingValidationError> { - match take_validate_listing_hook() { - Some(result) => result, - None => validate_listing_event(&radroots_event_from_nostr(event)) - .map(|listing| (listing.listing_addr, listing.listing.farm)), - } -} - -pub async fn handle_event_with_policy( - event: RadrootsNostrEvent, - _tags: Vec<RadrootsNostrTag>, - keys: RadrootsNostrKeys, - client: RadrootsNostrClient, - runtime: TradeListingRuntime, - proof_policy: &TradeValidationReceiptProverPolicy, -) -> Result<(), TradeListingDvmError> { - let kind = event_kind_u32(&event)?; - let state = runtime.state(); - if is_listing_kind(kind) { - return handle_listing_event(&event, &state).await; - } - if event.pubkey == keys.public_key() { - return Ok(()); - } - if kind == KIND_TRADE_TRANSITION_PROOF_REQUEST { - return handle_trade_validation_receipt_job_request( - &event, - &keys, - &client, - &runtime, - proof_policy, - ) - .await - .map_err(map_trade_validation_receipt_job_error); - } - if kind == KIND_TRADE_LISTING_VALIDATION_REQUEST { - ensure_service_recipient(&event, &keys)?; - return handle_listing_validate_request(&event, &client, &state).await; - } - if kind == KIND_TRADE_LISTING_VALIDATION_RESULT - || kind == KIND_TRADE_TRANSITION_PROOF_RESULT - || kind == KIND_TRADE_VALIDATION_RECEIPT - { - state - .lock() - .await - .mark_non_order_event_seen(&event.id.to_string()); - return Ok(()); - } - if is_order_event_kind(kind) { - return handle_order_event(&event, kind, &client, &state).await; - } - if is_trade_validation_service_event_kind(kind) { - return Err(TradeListingDvmError::UnsupportedKind); - } - Err(TradeListingDvmError::UnsupportedKind) -} - -#[cfg(test)] -pub async fn handle_event( - event: RadrootsNostrEvent, - tags: Vec<RadrootsNostrTag>, - keys: RadrootsNostrKeys, - client: RadrootsNostrClient, - runtime: TradeListingRuntime, -) -> Result<(), TradeListingDvmError> { - handle_event_with_policy( - event, - tags, - keys, - client, - runtime, - &TradeValidationReceiptProverPolicy::default(), - ) - .await -} - -fn event_kind_u32(event: &RadrootsNostrEvent) -> Result<u32, TradeListingDvmError> { - match event.kind { - RadrootsNostrKind::Custom(value) => Ok(u32::from(value)), - _ => Err(TradeListingDvmError::UnsupportedKind), - } -} - -fn map_trade_validation_receipt_job_error( - error: TradeValidationReceiptJobError, -) -> TradeListingDvmError { - match error { - TradeValidationReceiptJobError::UnsupportedKind => TradeListingDvmError::UnsupportedKind, - TradeValidationReceiptJobError::MissingRecipient => TradeListingDvmError::MissingRecipient, - TradeValidationReceiptJobError::Nostr(error) => TradeListingDvmError::Nostr(error), - other => TradeListingDvmError::InvalidPayload(other.to_string()), - } -} - -fn map_order_parse_error(error: RadrootsOrderEnvelopeParseError) -> TradeListingDvmError { - match error { - RadrootsOrderEnvelopeParseError::InvalidKind(_) => TradeListingDvmError::UnsupportedKind, - RadrootsOrderEnvelopeParseError::MissingTag(tag) => TradeListingDvmError::MissingTag(tag), - RadrootsOrderEnvelopeParseError::ListingAddrTagMismatch => { - TradeListingDvmError::TagMismatch("a") - } - RadrootsOrderEnvelopeParseError::OrderIdTagMismatch => { - TradeListingDvmError::TagMismatch("d") - } - RadrootsOrderEnvelopeParseError::InvalidListingAddr(_) => { - TradeListingDvmError::InvalidListingAddr - } - RadrootsOrderEnvelopeParseError::InvalidEnvelope(error) => { - TradeListingDvmError::InvalidEnvelope(error.to_string()) - } - other => TradeListingDvmError::InvalidPayload(other.to_string()), - } -} - -fn ensure_service_recipient( - event: &RadrootsNostrEvent, - keys: &RadrootsNostrKeys, -) -> Result<(), TradeListingDvmError> { - let tags = radroots_event_from_nostr(event).tags_as_vec(); - if tag_has_value(&tags, "p", &keys.public_key().to_string()) { - Ok(()) - } else { - Err(TradeListingDvmError::MissingRecipient) - } -} - -async fn handle_listing_event( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let event_id = event.id.to_string(); - { - let state = state.lock().await; - if state.is_non_order_event_seen(&event_id) { - return Ok(()); - } - } - let validated = validate_listing_event(&radroots_event_from_nostr(event)) - .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))?; - let kind = event_kind_u32(event)?; - let mut state = state.lock().await; - state.upsert_listing_event(&validated.listing_addr, &event_id, kind); - state.mark_non_order_event_seen(&event_id); - Ok(()) -} - -#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] -async fn handle_listing_validate_request( - event: &RadrootsNostrEvent, - client: &RadrootsNostrClient, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let event_id = event.id.to_string(); - { - let state = state.lock().await; - if state.is_non_order_event_seen(&event_id) { - return Ok(()); - } - } - let rr_event = radroots_event_from_nostr(event); - let rr_tags = rr_event.tags_as_vec(); - let listing_addr = required_tag_value(&rr_tags, "a")?; - parse_listing_address(&listing_addr).map_err(|_| TradeListingDvmError::InvalidListingAddr)?; - let payload: TradeListingValidateRequest = serde_json::from_str(&event.content)?; - let listing_event = resolve_listing_event(client, &listing_addr, payload.listing_event).await; - let (validated_event_id, errors) = match listing_event { - Ok(Some(listing_event)) => match validate_listing_event_io(&listing_event) { - Ok((validated_listing_addr, farm)) if validated_listing_addr == listing_addr => { - let errors = validate_farm_dependencies(client, &farm).await?; - if errors.is_empty() { - (Some(listing_event.id.to_string()), errors) - } else { - (None, errors) - } - } - Ok(_) => ( - None, - vec![TradeListingValidationError::ListingEventNotFound { - listing_addr: listing_addr.clone(), - }], - ), - Err(error) => (None, vec![error]), - }, - Ok(None) => ( - None, - vec![TradeListingValidationError::ListingEventNotFound { - listing_addr: listing_addr.clone(), - }], - ), - Err(_) => ( - None, - vec![TradeListingValidationError::ListingEventFetchFailed { - listing_addr: listing_addr.clone(), - }], - ), - }; - { - let mut state = state.lock().await; - match validated_event_id { - Some(validated_event_id) => { - state.mark_listing_validated(&listing_addr, &validated_event_id); - } - None => state.clear_listing_validation(&listing_addr), - } - state.mark_non_order_event_seen(&event_id); - } - send_validate_result(event, client, &listing_addr, errors).await -} - -async fn resolve_listing_event( - client: &RadrootsNostrClient, - listing_addr: &str, - listing_event: Option<radroots_event::RadrootsEventPtr>, -) -> Result<Option<RadrootsNostrEvent>, TradeListingDvmError> { - match listing_event { - Some(ptr) => fetch_event_by_id_io(client, &ptr.id).await.map(Some), - None => fetch_listing_by_addr(client, listing_addr).await, - } -} - -async fn send_validate_result( - event: &RadrootsNostrEvent, - client: &RadrootsNostrClient, - listing_addr: &str, - errors: Vec<TradeListingValidationError>, -) -> Result<(), TradeListingDvmError> { - let payload = TradeListingValidateResult { - valid: errors.is_empty(), - errors, - }; - let content = serde_json::to_string(&payload)?; - let tags = vec![ - vec!["p".to_string(), event.pubkey.to_string()], - vec!["a".to_string(), listing_addr.to_string()], - vec!["e".to_string(), event.id.to_string()], - ]; - let builder = radroots_nostr_build_event(KIND_TRADE_LISTING_VALIDATION_RESULT, content, tags)?; - send_event_io(client, builder).await -} - -async fn handle_order_event( - event: &RadrootsNostrEvent, - kind: u32, - client: &RadrootsNostrClient, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - match kind { - KIND_ORDER_REQUEST => handle_order_request(event, client, state).await, - KIND_ORDER_DECISION - | KIND_ORDER_REVISION_PROPOSAL - | KIND_ORDER_REVISION_DECISION - | KIND_ORDER_CANCELLATION => handle_order_workflow_event(event, client, state).await, - _ => Err(TradeListingDvmError::UnsupportedKind), - } -} - -async fn handle_order_request( - event: &RadrootsNostrEvent, - client: &RadrootsNostrClient, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let record = order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; - let order_id = record.order_id().clone(); - let projection = reduce_order_event_records(&order_id, [record.clone()]); - if projection.status != RadrootsTradeWorkflowState::Requested || !projection.issues.is_empty() { - return Err(TradeListingDvmError::Workflow(workflow_rejection_message( - &projection, - ))); - } - let RadrootsOrderEventRecord::Request(request) = record else { - return Err(TradeListingDvmError::UnsupportedKind); - }; - let listing_addr = parse_public_listing_address(&request.payload.listing_addr) - .map_err(|_| TradeListingDvmError::InvalidListingAddr)?; - if request.payload.seller_pubkey != listing_addr.seller_pubkey { - return Err(TradeListingDvmError::InvalidListingAddr); - } - let rr_tags = rr_event.tags_as_vec(); - let listing_event = parse_order_listing_event_tag(&rr_tags) - .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))? - .ok_or(TradeListingDvmError::MissingTag("listing_event"))?; - let listing_snapshot_event_id = - ensure_listing_snapshot(&request.payload.listing_addr, &listing_event, client, state) - .await?; - let event_id = event.id.to_string(); - let mut state = state.lock().await; - if state.order_exists(order_id.as_str()) { - return Ok(()); - } - let mut seen = std::collections::HashSet::new(); - seen.insert(event_id.clone()); - state.insert_order(TradeOrderState { - order_id: order_id.to_string(), - listing_addr: request.payload.listing_addr.to_string(), - buyer_pubkey: request.payload.buyer_pubkey.to_string(), - seller_pubkey: request.payload.seller_pubkey.to_string(), - status: projection.status, - listing_snapshot_event_id: Some(listing_snapshot_event_id), - root_event_id: Some(event_id.clone()), - last_event_id: Some(event_id), - seen_event_ids: seen, - }); - Ok(()) -} - -async fn ensure_listing_snapshot( - listing_addr: &str, - listing_event: &radroots_event::RadrootsEventPtr, - client: &RadrootsNostrClient, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<String, TradeListingDvmError> { - { - let state = state.lock().await; - if state.listing_event_id(listing_addr) == Some(listing_event.id.as_str()) { - return Ok(listing_event.id.clone()); - } - } - let event = fetch_event_by_id_io(client, &listing_event.id).await?; - let (validated_listing_addr, _) = validate_listing_event_io(&event) - .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))?; - if validated_listing_addr != listing_addr { - return Err(TradeListingDvmError::InvalidOrder); - } - let kind = event_kind_u32(&event)?; - let mut state = state.lock().await; - state.upsert_listing_event(listing_addr, &listing_event.id, kind); - Ok(listing_event.id.clone()) -} - -async fn handle_order_workflow_event( - event: &RadrootsNostrEvent, - client: &RadrootsNostrClient, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let current_record = - order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; - let order_id = current_record.order_id().clone(); - let event_id = event.id.to_string(); - let order_snapshot = { - let state = state.lock().await; - if state.is_event_seen(order_id.as_str(), &event_id) { - return Ok(()); - } - state - .get_order(order_id.as_str()) - .cloned() - .ok_or(TradeListingStateError::MissingOrder)? - }; - let mut records = - fetch_seen_order_records(client, &order_snapshot, event, current_record).await?; - let projection = reduce_order_event_records(&order_id, records.drain(..)); - if projection.status != RadrootsTradeWorkflowState::Invalid && !projection.issues.is_empty() { - return Err(TradeListingDvmError::Workflow(workflow_rejection_message( - &projection, - ))); - } - let mut state = state.lock().await; - if state.is_event_seen(order_id.as_str(), &event_id) { - return Ok(()); - } - let order = state - .get_order_mut(order_id.as_str()) - .ok_or(TradeListingStateError::MissingOrder)?; - ensure_projection_binding(order, &projection)?; - order.status = projection.status; - order.last_event_id = projection - .last_event_id - .map(|last_event_id| last_event_id.to_string()) - .or_else(|| Some(event_id.clone())); - order.seen_event_ids.insert(event_id); - Ok(()) -} - -async fn fetch_seen_order_records( - client: &RadrootsNostrClient, - order: &TradeOrderState, - current_event: &RadrootsNostrEvent, - current_record: RadrootsOrderEventRecord, -) -> Result<Vec<RadrootsOrderEventRecord>, TradeListingDvmError> { - let current_event_id = current_event.id.to_string(); - let mut event_ids = order - .seen_event_ids - .iter() - .filter(|event_id| event_id.as_str() != current_event_id) - .cloned() - .collect::<Vec<_>>(); - event_ids.sort(); - let mut records = Vec::with_capacity(event_ids.len() + 1); - for event_id in event_ids { - let event = fetch_event_by_id_io(client, &event_id).await?; - let rr_event = radroots_event_from_nostr(&event); - let record = order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; - if record.order_id().as_str() != order.order_id { - return Err(TradeListingDvmError::InvalidOrder); - } - records.push(record); - } - records.push(current_record); - Ok(records) -} - -fn ensure_projection_binding( - order: &TradeOrderState, - projection: &RadrootsOrderProjection, -) -> Result<(), TradeListingDvmError> { - if projection - .listing_addr - .as_ref() - .is_some_and(|listing_addr| listing_addr.to_string() != order.listing_addr) - || projection - .buyer_pubkey - .as_ref() - .is_some_and(|buyer_pubkey| buyer_pubkey.to_string() != order.buyer_pubkey) - || projection - .seller_pubkey - .as_ref() - .is_some_and(|seller_pubkey| seller_pubkey.to_string() != order.seller_pubkey) - { - return Err(TradeListingDvmError::InvalidOrder); - } - Ok(()) -} - -fn workflow_rejection_message(projection: &RadrootsOrderProjection) -> String { - format!("{:?}:{:?}", projection.status, projection.issues) -} - -fn map_order_decode_error( - error: radroots_trade::order::RadrootsOrderEventDecodeError, -) -> TradeListingDvmError { - match error { - radroots_trade::order::RadrootsOrderEventDecodeError::Envelope(error) => { - map_order_parse_error(error) - } - radroots_trade::order::RadrootsOrderEventDecodeError::UnsupportedKind { .. } => { - TradeListingDvmError::UnsupportedKind - } - other => TradeListingDvmError::InvalidPayload(other.to_string()), - } -} - -#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] -async fn fetch_listing_by_addr( - client: &RadrootsNostrClient, - listing_addr: &str, -) -> Result<Option<RadrootsNostrEvent>, TradeListingDvmError> { - let addr = parse_listing_address(listing_addr) - .map_err(|_| TradeListingDvmError::InvalidListingAddr)?; - let author = radroots_nostr_parse_pubkey(addr.seller_pubkey.as_str()) - .map_err(|_| TradeListingDvmError::InvalidListingAddr)?; - let kind = u16::try_from(addr.kind).map_err(|_| TradeListingDvmError::InvalidListingAddr)?; - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(kind)) - .author(author) - .identifier(addr.listing_id.into_string()); - let event = fetch_events_io(client, filter, Duration::from_secs(10)).await?; - Ok(event - .into_iter() - .filter(|event| event.kind == RadrootsNostrKind::Custom(kind)) - .max_by_key(|event| event.created_at)) -} - -#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] -async fn fetch_latest_event_by_kind( - client: &RadrootsNostrClient, - filter: RadrootsNostrFilter, - kind: RadrootsNostrKind, -) -> Result<Option<RadrootsNostrEvent>, TradeListingDvmError> { - let event = fetch_events_io(client, filter, Duration::from_secs(10)).await?; - Ok(event - .into_iter() - .filter(|event| event.kind == kind) - .max_by_key(|event| event.created_at)) -} - -async fn validate_farm_dependencies( - client: &RadrootsNostrClient, - farm: &RadrootsFarmRef, -) -> Result<Vec<TradeListingValidationError>, TradeListingDvmError> { - #[cfg(test)] - if let Some(result) = pop_farm_validation_hook() { - return result; - } - let mut errors = Vec::new(); - let farm_pubkey = farm.pubkey.trim(); - let farm_d_tag = farm.d_tag.trim(); - let author = match radroots_nostr_parse_pubkey(farm_pubkey) { - Ok(author) => author, - Err(_) => { - errors.push(TradeListingValidationError::MissingFarmProfile); - errors.push(TradeListingValidationError::MissingFarmRecord); - return Ok(errors); - } - }; - let profile_filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Metadata) - .author(author); - let profile_event = - fetch_latest_event_by_kind(client, profile_filter, RadrootsNostrKind::Metadata).await?; - let has_profile = profile_event - .map(|event| { - let rr_event = radroots_event_from_nostr(&event); - tag_has_value(&rr_event.tags_as_vec(), "t", "radroots:type:farm") - }) - .unwrap_or(false); - if !has_profile { - errors.push(TradeListingValidationError::MissingFarmProfile); - } - if farm_d_tag.is_empty() { - errors.push(TradeListingValidationError::MissingFarmRecord); - return Ok(errors); - } - let author = radroots_nostr_parse_pubkey(farm_pubkey) - .map_err(|_| TradeListingDvmError::InvalidPayload("invalid farm pubkey".to_string()))?; - let record_filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(KIND_FARM as u16)) - .author(author) - .identifier(farm_d_tag.to_string()); - let record_event = fetch_latest_event_by_kind( - client, - record_filter, - RadrootsNostrKind::Custom(KIND_FARM as u16), - ) - .await?; - if record_event.is_none() { - errors.push(TradeListingValidationError::MissingFarmRecord); - } - Ok(errors) -} - -fn required_tag_value( - tags: &[Vec<String>], - key: &'static str, -) -> Result<String, TradeListingDvmError> { - tags.iter() - .find_map(|tag| { - if tag.first().map(String::as_str) == Some(key) { - tag.get(1).cloned() - } else { - None - } - }) - .filter(|value| !value.trim().is_empty()) - .ok_or(TradeListingDvmError::MissingTag(key)) -} - -fn tag_has_value(tags: &[Vec<String>], key: &str, value: &str) -> bool { - tags.iter().any(|tag| { - tag.first().map(String::as_str) == Some(key) - && tag.get(1).map(String::as_str) == Some(value) - }) -} - -pub async fn handle_error( - error: TradeListingDvmError, - event: &RadrootsNostrEvent, - client: &RadrootsNostrClient, -) -> Result<(), TradeListingDvmError> { - let request_event_id = RadrootsEventId::parse(event.id.to_hex()) - .map_err(|err| TradeListingDvmError::InvalidPayload(err.to_string()))?; - let customer_pubkey = RadrootsPublicKey::parse(event.pubkey.to_hex()) - .map_err(|err| TradeListingDvmError::InvalidPayload(err.to_string()))?; - let tags = build_job_feedback_tags( - RadrootsTradeDvmFeedbackStatus::Error, - &request_event_id, - &customer_pubkey, - ); - let builder = radroots_nostr_build_event(KIND_JOB_FEEDBACK, error.to_string(), tags)?; - send_event_io(client, builder).await -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - DvmTestHooks, TradeListingDvmError, dvm_test_hooks, handle_error, handle_event, - tag_has_value, - }; - use crate::features::trade_listing::state::TradeListingRuntime; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, - }; - use radroots_event::RadrootsEventPtr; - use radroots_event::farm::RadrootsFarmRef; - use radroots_event::ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, - }; - use radroots_event::kinds::{ - KIND_LISTING, KIND_ORDER_REQUEST, KIND_TRADE_LISTING_VALIDATION_REQUEST, - }; - use radroots_event::order::{ - RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, - RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, - RadrootsOrderRequest, - }; - use radroots_event::trade_validation::RadrootsTradeValidationListingRequest; - use radroots_event_codec::order::{ - order_cancellation_event_build, order_decision_event_build, order_request_event_build, - }; - use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, - RadrootsNostrKind, radroots_nostr_build_event, - }; - use radroots_trade::workflow::RadrootsTradeWorkflowState; - use tokio::sync::{Mutex, MutexGuard}; - - static TEST_LOCK: Mutex<()> = Mutex::const_new(()); - - async fn test_guard() -> MutexGuard<'static, ()> { - let guard = TEST_LOCK.lock().await; - *dvm_test_hooks().lock().expect("hooks") = DvmTestHooks::default(); - guard - } - - fn listing_id() -> &'static str { - "AAAAAAAAAAAAAAAAAAAAAg" - } - - fn listing_addr(seller: &RadrootsNostrKeys) -> String { - format!("{}:{}:{}", KIND_LISTING, seller.public_key(), listing_id()) - } - - fn listing_event_id() -> &'static str { - "0000000000000000000000000000000000000000000000000000000000000001" - } - - fn typed_listing_addr(seller: &RadrootsNostrKeys) -> RadrootsListingAddress { - RadrootsListingAddress::parse(listing_addr(seller)).expect("listing address") - } - - fn typed_order_id(order_id: &str) -> RadrootsOrderId { - RadrootsOrderId::parse(order_id).expect("order id") - } - - fn typed_quote_id(order_id: &str) -> RadrootsOrderQuoteId { - RadrootsOrderQuoteId::parse(format!("{order_id}-quote")).expect("quote id") - } - - fn typed_bin_id() -> RadrootsInventoryBinId { - RadrootsInventoryBinId::parse("bin-1").expect("bin id") - } - - fn typed_pubkey(keys: &RadrootsNostrKeys) -> RadrootsPublicKey { - RadrootsPublicKey::parse(keys.public_key().to_string()).expect("public key") - } - - fn typed_event_id(event: &RadrootsNostrEvent) -> RadrootsEventId { - RadrootsEventId::parse(event.id.to_string()).expect("event id") - } - - fn listing_event_ptr() -> RadrootsEventPtr { - RadrootsEventPtr { - id: listing_event_id().to_string(), - relays: None, - } - } - - fn order_economics(order_id: &str) -> RadrootsOrderEconomics { - RadrootsOrderEconomics { - quote_id: typed_quote_id(order_id), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: typed_bin_id(), - bin_count: 2, - quantity_amount: RadrootsCoreDecimal::from(1u32), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: RadrootsCoreDecimal::from(5u32), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(10u32), - RadrootsCoreCurrency::USD, - ), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(10u32), - RadrootsCoreCurrency::USD, - ), - discount_total: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(0u32), - RadrootsCoreCurrency::USD, - ), - adjustment_total: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(0u32), - RadrootsCoreCurrency::USD, - ), - total: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(10u32), - RadrootsCoreCurrency::USD, - ), - } - } - - fn order_request( - order_id: &str, - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsOrderRequest { - RadrootsOrderRequest { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(seller), - buyer_pubkey: typed_pubkey(buyer), - seller_pubkey: typed_pubkey(seller), - items: vec![RadrootsOrderItem { - bin_id: typed_bin_id(), - bin_count: 2, - }], - economics: order_economics(order_id), - } - } - - fn order_decision( - order_id: &str, - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(seller), - buyer_pubkey: typed_pubkey(buyer), - seller_pubkey: typed_pubkey(seller), - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: typed_bin_id(), - bin_count: 2, - }], - }, - } - } - - fn order_cancellation( - order_id: &str, - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsOrderCancellation { - RadrootsOrderCancellation { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(seller), - buyer_pubkey: typed_pubkey(buyer), - seller_pubkey: typed_pubkey(seller), - reason: "cancel after agreement".to_string(), - } - } - - fn signed_order_request_event( - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsNostrEvent { - let payload = order_request("order-1", buyer, seller); - let wire = order_request_event_build(&listing_event_ptr(), &payload).expect("wire"); - radroots_nostr_build_event(wire.kind, wire.content, wire.tags) - .expect("builder") - .sign_with_keys(buyer) - .expect("event") - } - - fn signed_order_decision_event( - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - request_event: &RadrootsNostrEvent, - ) -> RadrootsNostrEvent { - let payload = order_decision("order-1", buyer, seller); - let root_event_id = typed_event_id(request_event); - let wire = - order_decision_event_build(&root_event_id, &root_event_id, &payload).expect("wire"); - radroots_nostr_build_event(wire.kind, wire.content, wire.tags) - .expect("builder") - .sign_with_keys(seller) - .expect("event") - } - - fn signed_order_cancellation_event( - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - request_event: &RadrootsNostrEvent, - decision_event: &RadrootsNostrEvent, - ) -> RadrootsNostrEvent { - let payload = order_cancellation("order-1", buyer, seller); - let root_event_id = typed_event_id(request_event); - let prev_event_id = typed_event_id(decision_event); - let wire = - order_cancellation_event_build(&root_event_id, &prev_event_id, &payload).expect("wire"); - radroots_nostr_build_event(wire.kind, wire.content, wire.tags) - .expect("builder") - .sign_with_keys(buyer) - .expect("event") - } - - fn listing_event(seller: &RadrootsNostrKeys) -> RadrootsNostrEvent { - RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(KIND_LISTING as u16), "{}") - .tags(vec![radroots_nostr::prelude::RadrootsNostrTag::identifier( - listing_id(), - )]) - .sign_with_keys(seller) - .expect("listing event") - } - - #[tokio::test] - async fn order_request_inserts_canonical_order_state() { - let _guard = test_guard().await; - let worker = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let client = RadrootsNostrClient::new(worker.clone()); - let runtime = TradeListingRuntime::new(); - let state = runtime.state(); - state.lock().await.upsert_listing_event( - &listing_addr(&seller), - listing_event_id(), - KIND_LISTING, - ); - - let request_event = signed_order_request_event(&buyer, &seller); - handle_event(request_event, Vec::new(), worker, client, runtime.clone()) - .await - .expect("order request"); - - let mut state = state.lock().await; - let order = state.get_order_mut("order-1").expect("order"); - assert_eq!(order.status, RadrootsTradeWorkflowState::Requested); - assert_eq!(order.buyer_pubkey, buyer.public_key().to_string()); - assert_eq!(order.seller_pubkey, seller.public_key().to_string()); - } - - #[tokio::test] - async fn order_decision_uses_shared_workflow_pending_rhi_state() { - let _guard = test_guard().await; - let worker = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let client = RadrootsNostrClient::new(worker.clone()); - let runtime = TradeListingRuntime::new(); - let state = runtime.state(); - state.lock().await.upsert_listing_event( - &listing_addr(&seller), - listing_event_id(), - KIND_LISTING, - ); - let request_event = signed_order_request_event(&buyer, &seller); - handle_event( - request_event.clone(), - Vec::new(), - worker.clone(), - client.clone(), - runtime.clone(), - ) - .await - .expect("order request"); - let decision_event = signed_order_decision_event(&buyer, &seller, &request_event); - dvm_test_hooks() - .lock() - .expect("hooks") - .fetch_event_by_id_results - .push_back(Ok(request_event)); - - handle_event(decision_event, Vec::new(), worker, client, runtime.clone()) - .await - .expect("order decision"); - - let mut state = state.lock().await; - let order = state.get_order_mut("order-1").expect("order"); - assert_eq!(order.status, RadrootsTradeWorkflowState::AgreedPendingRhi); - } - - #[tokio::test] - async fn cancellation_after_pending_agreement_uses_shared_workflow_invalid_state() { - let _guard = test_guard().await; - let worker = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let client = RadrootsNostrClient::new(worker.clone()); - let runtime = TradeListingRuntime::new(); - let state = runtime.state(); - state.lock().await.upsert_listing_event( - &listing_addr(&seller), - listing_event_id(), - KIND_LISTING, - ); - let request_event = signed_order_request_event(&buyer, &seller); - handle_event( - request_event.clone(), - Vec::new(), - worker.clone(), - client.clone(), - runtime.clone(), - ) - .await - .expect("order request"); - let decision_event = signed_order_decision_event(&buyer, &seller, &request_event); - dvm_test_hooks() - .lock() - .expect("hooks") - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - handle_event( - decision_event.clone(), - Vec::new(), - worker.clone(), - client.clone(), - runtime.clone(), - ) - .await - .expect("order decision"); - let cancellation_event = - signed_order_cancellation_event(&buyer, &seller, &request_event, &decision_event); - { - let mut hooks = dvm_test_hooks().lock().expect("hooks"); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - handle_event( - cancellation_event, - Vec::new(), - worker, - client, - runtime.clone(), - ) - .await - .expect("order cancellation"); - - let mut state = state.lock().await; - let order = state.get_order_mut("order-1").expect("order"); - assert_eq!(order.status, RadrootsTradeWorkflowState::Invalid); - } - - #[tokio::test] - async fn listing_validation_request_sends_result_and_marks_listing_validated() { - let _guard = test_guard().await; - let worker = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let client = RadrootsNostrClient::new(worker.clone()); - let runtime = TradeListingRuntime::new(); - let state = runtime.state(); - let listing_addr = listing_addr(&seller); - { - let mut hooks = dvm_test_hooks().lock().expect("hooks"); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event(&seller))); - hooks.validate_listing_results.push_back(Ok(( - listing_addr.clone(), - RadrootsFarmRef { - pubkey: seller.public_key().to_string(), - d_tag: "farm-1".to_string(), - }, - ))); - hooks.farm_validation_results.push_back(Ok(Vec::new())); - hooks.send_event_results.push_back(Ok(())); - } - let payload = RadrootsTradeValidationListingRequest { - listing_event: Some(listing_event_ptr()), - }; - let event = radroots_nostr_build_event( - KIND_TRADE_LISTING_VALIDATION_REQUEST, - serde_json::to_string(&payload).expect("payload"), - vec![ - vec!["p".to_string(), worker.public_key().to_string()], - vec!["a".to_string(), listing_addr.clone()], - ], - ) - .expect("builder") - .sign_with_keys(&requester) - .expect("event"); - - handle_event(event, Vec::new(), worker, client, runtime.clone()) - .await - .expect("validation request"); - - assert!(state.lock().await.is_listing_validated(&listing_addr)); - } - - #[tokio::test] - async fn unsupported_kind_is_rejected() { - let _guard = test_guard().await; - let worker = RadrootsNostrKeys::generate(); - let client = RadrootsNostrClient::new(worker.clone()); - let runtime = TradeListingRuntime::new(); - let event = RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(4999), "test") - .sign_with_keys(&RadrootsNostrKeys::generate()) - .expect("event"); - assert!(matches!( - handle_event(event, Vec::new(), worker, client, runtime).await, - Err(TradeListingDvmError::UnsupportedKind) - )); - } - - #[test] - fn tag_helpers_cover_core_paths() { - assert!(tag_has_value( - &[vec!["p".to_string(), "pubkey".to_string()]], - "p", - "pubkey" - )); - assert!(!tag_has_value( - &[vec!["p".to_string(), "pubkey".to_string()]], - "p", - "other" - )); - } - - #[tokio::test] - async fn handle_error_uses_send_hook() { - let _guard = test_guard().await; - dvm_test_hooks() - .lock() - .expect("hooks") - .send_event_results - .push_back(Ok(())); - let keys = RadrootsNostrKeys::generate(); - let client = RadrootsNostrClient::new(keys.clone()); - let event = RadrootsNostrEventBuilder::new( - RadrootsNostrKind::Custom(KIND_ORDER_REQUEST as u16), - "bad", - ) - .sign_with_keys(&keys) - .expect("event"); - assert!( - handle_error(TradeListingDvmError::InvalidOrder, &event, &client) - .await - .is_ok() - ); - } -} diff --git a/src/features/trade_listing/handlers/events.rs b/src/features/trade_listing/handlers/events.rs @@ -0,0 +1,947 @@ +#![forbid(unsafe_code)] +#![cfg_attr(coverage_nightly, coverage(off))] + +use std::sync::Arc; + +use radroots_event::farm::RadrootsFarmRef; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event::kinds::{ + KIND_JOB_FEEDBACK, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, + KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, is_order_event_kind, + is_trade_validation_service_event_kind, +}; +use radroots_event::trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError; +use radroots_event_codec::order::{RadrootsOrderEnvelopeParseError, parse_order_listing_event_tag}; +use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, + RadrootsNostrKind, RadrootsNostrTag, radroots_event_from_nostr, radroots_nostr_build_event, + radroots_nostr_fetch_event_by_id, radroots_nostr_send_event, +}; +use radroots_trade::listing::{parse_public_listing_address, validation::validate_listing_event}; +use radroots_trade::order::{ + RadrootsOrderEventRecord, RadrootsOrderProjection, order_event_record_from_event, + reduce_order_event_records, +}; +use radroots_trade::workflow::RadrootsTradeWorkflowState; +use thiserror::Error; + +use crate::features::trade_listing::state::{ + TradeListingRuntime, TradeListingState, TradeListingStateError, TradeOrderState, +}; +use crate::features::trade_validation_receipt::{ + TradeValidationReceiptJobError, TradeValidationReceiptProverPolicy, publish_validation_receipt, +}; + +#[derive(Debug, Error)] +pub enum TradeListingEventError { + #[error("event kind not supported")] + UnsupportedKind, + #[error("missing recipient tag")] + MissingRecipient, + #[error("missing required tag: {0}")] + MissingTag(&'static str), + #[error("tag mismatch: {0}")] + TagMismatch(&'static str), + #[error("invalid envelope: {0}")] + InvalidEnvelope(String), + #[error("invalid envelope payload: {0}")] + InvalidPayload(String), + #[error("invalid listing address")] + InvalidListingAddr, + #[error("invalid order request payload")] + InvalidOrder, + #[error("shared workflow rejected trade transition: {0}")] + Workflow(String), + #[error("state error: {0}")] + State(#[from] TradeListingStateError), + #[error("nostr error: {0}")] + Nostr(#[from] radroots_nostr::error::RadrootsNostrError), + #[error("serde error: {0}")] + Serde(#[from] serde_json::Error), + #[error("unauthorized sender")] + Unauthorized, +} + +#[cfg(test)] +#[derive(Default)] +struct TradeListingEventTestHooks { + fetch_event_by_id_results: + std::collections::VecDeque<Result<RadrootsNostrEvent, TradeListingEventError>>, + send_event_results: std::collections::VecDeque<Result<(), TradeListingEventError>>, + validate_listing_results: + std::collections::VecDeque<Result<(String, RadrootsFarmRef), TradeListingValidationError>>, +} + +#[cfg(test)] +static TRADE_LISTING_EVENT_TEST_HOOKS: std::sync::OnceLock< + std::sync::Mutex<TradeListingEventTestHooks>, +> = std::sync::OnceLock::new(); + +#[cfg(test)] +fn trade_listing_event_test_hooks() -> &'static std::sync::Mutex<TradeListingEventTestHooks> { + TRADE_LISTING_EVENT_TEST_HOOKS + .get_or_init(|| std::sync::Mutex::new(TradeListingEventTestHooks::default())) +} + +#[cfg(test)] +fn pop_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingEventError>> { + trade_listing_event_test_hooks() + .lock() + .expect("trade listing event test hooks lock") + .fetch_event_by_id_results + .pop_front() +} + +#[cfg(test)] +fn pop_send_event_hook() -> Option<Result<(), TradeListingEventError>> { + trade_listing_event_test_hooks() + .lock() + .expect("trade listing event test hooks lock") + .send_event_results + .pop_front() +} + +#[cfg(test)] +fn pop_validate_listing_hook() +-> Option<Result<(String, RadrootsFarmRef), TradeListingValidationError>> { + trade_listing_event_test_hooks() + .lock() + .expect("trade listing event test hooks lock") + .validate_listing_results + .pop_front() +} + +#[cfg(test)] +fn take_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingEventError>> { + pop_fetch_event_by_id_hook() +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] +fn take_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingEventError>> { + None +} + +#[cfg(test)] +fn take_send_event_hook() -> Option<Result<(), TradeListingEventError>> { + pop_send_event_hook() +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] +fn take_send_event_hook() -> Option<Result<(), TradeListingEventError>> { + None +} + +#[cfg(test)] +fn take_validate_listing_hook() +-> Option<Result<(String, RadrootsFarmRef), TradeListingValidationError>> { + pop_validate_listing_hook() +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] +fn take_validate_listing_hook() +-> Option<Result<(String, RadrootsFarmRef), TradeListingValidationError>> { + None +} + +async fn fetch_event_by_id_io( + client: &RadrootsNostrClient, + id: &str, +) -> Result<RadrootsNostrEvent, TradeListingEventError> { + match take_fetch_event_by_id_hook() { + Some(result) => result, + None => radroots_nostr_fetch_event_by_id(client, id) + .await + .map_err(TradeListingEventError::from), + } +} + +#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] +async fn send_event_io( + client: &RadrootsNostrClient, + builder: RadrootsNostrEventBuilder, +) -> Result<(), TradeListingEventError> { + match take_send_event_hook() { + Some(result) => result, + None => radroots_nostr_send_event(client, builder) + .await + .map(|_| ()) + .map_err(TradeListingEventError::from), + } +} + +#[cfg_attr(all(not(test), coverage_nightly), coverage(off))] +fn validate_listing_event_io( + event: &RadrootsNostrEvent, +) -> Result<(String, RadrootsFarmRef), TradeListingValidationError> { + match take_validate_listing_hook() { + Some(result) => result, + None => validate_listing_event(&radroots_event_from_nostr(event)) + .map(|listing| (listing.listing_addr, listing.listing.farm)), + } +} + +pub async fn handle_event_with_policy( + event: RadrootsNostrEvent, + _tags: Vec<RadrootsNostrTag>, + keys: RadrootsNostrKeys, + client: RadrootsNostrClient, + runtime: TradeListingRuntime, + proof_policy: &TradeValidationReceiptProverPolicy, +) -> Result<(), TradeListingEventError> { + let kind = event_kind_u32(&event)?; + let state = runtime.state(); + if is_listing_kind(kind) { + return handle_listing_event(&event, &state).await; + } + if event.pubkey == keys.public_key() { + return Ok(()); + } + if kind == KIND_TRADE_VALIDATION_RECEIPT { + state + .lock() + .await + .mark_non_order_event_seen(&event.id.to_string()); + return Ok(()); + } + if is_order_event_kind(kind) { + return handle_order_event(&event, kind, &keys, &client, &runtime, proof_policy).await; + } + if is_trade_validation_service_event_kind(kind) { + return Err(TradeListingEventError::UnsupportedKind); + } + Err(TradeListingEventError::UnsupportedKind) +} + +#[cfg(test)] +pub async fn handle_event( + event: RadrootsNostrEvent, + tags: Vec<RadrootsNostrTag>, + keys: RadrootsNostrKeys, + client: RadrootsNostrClient, + runtime: TradeListingRuntime, +) -> Result<(), TradeListingEventError> { + handle_event_with_policy( + event, + tags, + keys, + client, + runtime, + &TradeValidationReceiptProverPolicy::default(), + ) + .await +} + +fn event_kind_u32(event: &RadrootsNostrEvent) -> Result<u32, TradeListingEventError> { + match event.kind { + RadrootsNostrKind::Custom(value) => Ok(u32::from(value)), + _ => Err(TradeListingEventError::UnsupportedKind), + } +} + +fn map_trade_validation_receipt_job_error( + error: TradeValidationReceiptJobError, +) -> TradeListingEventError { + match error { + TradeValidationReceiptJobError::UnsupportedKind => TradeListingEventError::UnsupportedKind, + TradeValidationReceiptJobError::MissingRecipient => { + TradeListingEventError::MissingRecipient + } + TradeValidationReceiptJobError::Nostr(error) => TradeListingEventError::Nostr(error), + other => TradeListingEventError::InvalidPayload(other.to_string()), + } +} + +fn map_order_parse_error(error: RadrootsOrderEnvelopeParseError) -> TradeListingEventError { + match error { + RadrootsOrderEnvelopeParseError::InvalidKind(_) => TradeListingEventError::UnsupportedKind, + RadrootsOrderEnvelopeParseError::MissingTag(tag) => TradeListingEventError::MissingTag(tag), + RadrootsOrderEnvelopeParseError::ListingAddrTagMismatch => { + TradeListingEventError::TagMismatch("a") + } + RadrootsOrderEnvelopeParseError::OrderIdTagMismatch => { + TradeListingEventError::TagMismatch("d") + } + RadrootsOrderEnvelopeParseError::InvalidListingAddr(_) => { + TradeListingEventError::InvalidListingAddr + } + RadrootsOrderEnvelopeParseError::InvalidEnvelope(error) => { + TradeListingEventError::InvalidEnvelope(error.to_string()) + } + other => TradeListingEventError::InvalidPayload(other.to_string()), + } +} + +async fn handle_listing_event( + event: &RadrootsNostrEvent, + state: &Arc<tokio::sync::Mutex<TradeListingState>>, +) -> Result<(), TradeListingEventError> { + let event_id = event.id.to_string(); + { + let state = state.lock().await; + if state.is_non_order_event_seen(&event_id) { + return Ok(()); + } + } + let validated = validate_listing_event(&radroots_event_from_nostr(event)) + .map_err(|error| TradeListingEventError::InvalidPayload(error.to_string()))?; + let kind = event_kind_u32(event)?; + let mut state = state.lock().await; + state.upsert_listing_event(&validated.listing_addr, &event_id, kind); + state.mark_non_order_event_seen(&event_id); + Ok(()) +} + +async fn handle_order_event( + event: &RadrootsNostrEvent, + kind: u32, + keys: &RadrootsNostrKeys, + client: &RadrootsNostrClient, + runtime: &TradeListingRuntime, + proof_policy: &TradeValidationReceiptProverPolicy, +) -> Result<(), TradeListingEventError> { + let state = runtime.state(); + match kind { + KIND_ORDER_REQUEST => handle_order_request(event, client, &state).await, + KIND_ORDER_DECISION | KIND_ORDER_CANCELLATION => { + handle_order_workflow_event(event, client, runtime, keys, proof_policy).await + } + _ => Err(TradeListingEventError::UnsupportedKind), + } +} + +async fn handle_order_request( + event: &RadrootsNostrEvent, + client: &RadrootsNostrClient, + state: &Arc<tokio::sync::Mutex<TradeListingState>>, +) -> Result<(), TradeListingEventError> { + let rr_event = radroots_event_from_nostr(event); + let record = order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; + let order_id = record.order_id().clone(); + let projection = reduce_order_event_records(&order_id, [record.clone()]); + if projection.status != RadrootsTradeWorkflowState::Requested || !projection.issues.is_empty() { + return Err(TradeListingEventError::Workflow( + workflow_rejection_message(&projection), + )); + } + let RadrootsOrderEventRecord::Request(request) = record else { + return Err(TradeListingEventError::UnsupportedKind); + }; + let listing_addr = parse_public_listing_address(&request.payload.listing_addr) + .map_err(|_| TradeListingEventError::InvalidListingAddr)?; + if request.payload.seller_pubkey != listing_addr.seller_pubkey { + return Err(TradeListingEventError::InvalidListingAddr); + } + let rr_tags = rr_event.tags_as_vec(); + let listing_event = parse_order_listing_event_tag(&rr_tags) + .map_err(|error| TradeListingEventError::InvalidPayload(error.to_string()))? + .ok_or(TradeListingEventError::MissingTag("listing_event"))?; + let listing_snapshot_event_id = + ensure_listing_snapshot(&request.payload.listing_addr, &listing_event, client, state) + .await?; + let event_id = event.id.to_string(); + let mut state = state.lock().await; + if state.order_exists(order_id.as_str()) { + return Ok(()); + } + let mut seen = std::collections::HashSet::new(); + seen.insert(event_id.clone()); + state.insert_order(TradeOrderState { + order_id: order_id.to_string(), + listing_addr: request.payload.listing_addr.to_string(), + buyer_pubkey: request.payload.buyer_pubkey.to_string(), + seller_pubkey: request.payload.seller_pubkey.to_string(), + status: projection.status, + listing_snapshot_event_id: Some(listing_snapshot_event_id), + root_event_id: Some(event_id.clone()), + last_event_id: Some(event_id), + seen_event_ids: seen, + }); + Ok(()) +} + +async fn ensure_listing_snapshot( + listing_addr: &str, + listing_event: &radroots_event::RadrootsEventPtr, + client: &RadrootsNostrClient, + state: &Arc<tokio::sync::Mutex<TradeListingState>>, +) -> Result<String, TradeListingEventError> { + { + let state = state.lock().await; + if state.listing_event_id(listing_addr) == Some(listing_event.id.as_str()) { + return Ok(listing_event.id.clone()); + } + } + let event = fetch_event_by_id_io(client, &listing_event.id).await?; + let (validated_listing_addr, _) = validate_listing_event_io(&event) + .map_err(|error| TradeListingEventError::InvalidPayload(error.to_string()))?; + if validated_listing_addr != listing_addr { + return Err(TradeListingEventError::InvalidOrder); + } + let kind = event_kind_u32(&event)?; + let mut state = state.lock().await; + state.upsert_listing_event(listing_addr, &listing_event.id, kind); + Ok(listing_event.id.clone()) +} + +async fn handle_order_workflow_event( + event: &RadrootsNostrEvent, + client: &RadrootsNostrClient, + runtime: &TradeListingRuntime, + keys: &RadrootsNostrKeys, + proof_policy: &TradeValidationReceiptProverPolicy, +) -> Result<(), TradeListingEventError> { + let state = runtime.state(); + let rr_event = radroots_event_from_nostr(event); + let current_record = + order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; + let order_id = current_record.order_id().clone(); + let event_id = event.id.to_string(); + let order_snapshot = { + let state = state.lock().await; + if state.is_event_seen(order_id.as_str(), &event_id) { + return Ok(()); + } + state + .get_order(order_id.as_str()) + .cloned() + .ok_or(TradeListingStateError::MissingOrder)? + }; + let mut records = + fetch_seen_order_records(client, &order_snapshot, event, current_record).await?; + let projection = reduce_order_event_records(&order_id, records.drain(..)); + if projection.status != RadrootsTradeWorkflowState::Invalid && !projection.issues.is_empty() { + return Err(TradeListingEventError::Workflow( + workflow_rejection_message(&projection), + )); + } + let mut state = state.lock().await; + if state.is_event_seen(order_id.as_str(), &event_id) { + return Ok(()); + } + let order = state + .get_order_mut(order_id.as_str()) + .ok_or(TradeListingStateError::MissingOrder)?; + ensure_projection_binding(order, &projection)?; + let projected_status = projection.status.clone(); + order.status = projected_status.clone(); + order.last_event_id = projection + .last_event_id + .map(|last_event_id| last_event_id.to_string()) + .or_else(|| Some(event_id.clone())); + order.seen_event_ids.insert(event_id); + drop(state); + if projected_status == RadrootsTradeWorkflowState::AgreedPendingValidation { + publish_validation_receipt(event, client, runtime, keys, proof_policy) + .await + .map_err(map_trade_validation_receipt_job_error)?; + } + Ok(()) +} + +async fn fetch_seen_order_records( + client: &RadrootsNostrClient, + order: &TradeOrderState, + current_event: &RadrootsNostrEvent, + current_record: RadrootsOrderEventRecord, +) -> Result<Vec<RadrootsOrderEventRecord>, TradeListingEventError> { + let current_event_id = current_event.id.to_string(); + let mut event_ids = order + .seen_event_ids + .iter() + .filter(|event_id| event_id.as_str() != current_event_id) + .cloned() + .collect::<Vec<_>>(); + event_ids.sort(); + let mut records = Vec::with_capacity(event_ids.len() + 1); + for event_id in event_ids { + let event = fetch_event_by_id_io(client, &event_id).await?; + let rr_event = radroots_event_from_nostr(&event); + let record = order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; + if record.order_id().as_str() != order.order_id { + return Err(TradeListingEventError::InvalidOrder); + } + records.push(record); + } + records.push(current_record); + Ok(records) +} + +fn ensure_projection_binding( + order: &TradeOrderState, + projection: &RadrootsOrderProjection, +) -> Result<(), TradeListingEventError> { + if projection + .listing_addr + .as_ref() + .is_some_and(|listing_addr| listing_addr.to_string() != order.listing_addr) + || projection + .buyer_pubkey + .as_ref() + .is_some_and(|buyer_pubkey| buyer_pubkey.to_string() != order.buyer_pubkey) + || projection + .seller_pubkey + .as_ref() + .is_some_and(|seller_pubkey| seller_pubkey.to_string() != order.seller_pubkey) + { + return Err(TradeListingEventError::InvalidOrder); + } + Ok(()) +} + +fn workflow_rejection_message(projection: &RadrootsOrderProjection) -> String { + format!("{:?}:{:?}", projection.status, projection.issues) +} + +fn map_order_decode_error( + error: radroots_trade::order::RadrootsOrderEventDecodeError, +) -> TradeListingEventError { + match error { + radroots_trade::order::RadrootsOrderEventDecodeError::Envelope(error) => { + map_order_parse_error(error) + } + radroots_trade::order::RadrootsOrderEventDecodeError::UnsupportedKind { .. } => { + TradeListingEventError::UnsupportedKind + } + other => TradeListingEventError::InvalidPayload(other.to_string()), + } +} + +#[cfg(test)] +fn tag_has_value(tags: &[Vec<String>], key: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.first().map(String::as_str) == Some(key) + && tag.get(1).map(String::as_str) == Some(value) + }) +} + +pub async fn handle_error( + error: TradeListingEventError, + event: &RadrootsNostrEvent, + client: &RadrootsNostrClient, +) -> Result<(), TradeListingEventError> { + let request_event_id = RadrootsEventId::parse(event.id.to_hex()) + .map_err(|err| TradeListingEventError::InvalidPayload(err.to_string()))?; + let customer_pubkey = RadrootsPublicKey::parse(event.pubkey.to_hex()) + .map_err(|err| TradeListingEventError::InvalidPayload(err.to_string()))?; + let tags = vec![ + vec!["e".to_string(), request_event_id.into_string()], + vec!["p".to_string(), customer_pubkey.into_string()], + vec!["status".to_string(), "error".to_string()], + ]; + let builder = radroots_nostr_build_event(KIND_JOB_FEEDBACK, error.to_string(), tags)?; + send_event_io(client, builder).await +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{ + TradeListingEventError, TradeListingEventTestHooks, handle_error, handle_event, + tag_has_value, trade_listing_event_test_hooks, + }; + use crate::features::trade_listing::state::TradeListingRuntime; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, + }; + use radroots_event::RadrootsEventPtr; + use radroots_event::ids::{ + RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, + RadrootsOrderQuoteId, RadrootsPublicKey, + }; + use radroots_event::kinds::{KIND_LISTING, KIND_ORDER_REQUEST}; + use radroots_event::order::{ + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, + RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, + RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, + RadrootsOrderRequest, + }; + use radroots_event_codec::order::{ + order_cancellation_event_build, order_decision_event_build, order_request_event_build, + }; + use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, + RadrootsNostrKind, radroots_nostr_build_event, + }; + use radroots_trade::workflow::RadrootsTradeWorkflowState; + use tokio::sync::{Mutex, MutexGuard}; + + static TEST_LOCK: Mutex<()> = Mutex::const_new(()); + + async fn test_guard() -> MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().await; + *trade_listing_event_test_hooks().lock().expect("hooks") = + TradeListingEventTestHooks::default(); + guard + } + + fn listing_id() -> &'static str { + "AAAAAAAAAAAAAAAAAAAAAg" + } + + fn listing_addr(seller: &RadrootsNostrKeys) -> String { + format!("{}:{}:{}", KIND_LISTING, seller.public_key(), listing_id()) + } + + fn listing_event_id() -> &'static str { + "0000000000000000000000000000000000000000000000000000000000000001" + } + + fn typed_listing_addr(seller: &RadrootsNostrKeys) -> RadrootsListingAddress { + RadrootsListingAddress::parse(listing_addr(seller)).expect("listing address") + } + + fn typed_order_id(order_id: &str) -> RadrootsOrderId { + RadrootsOrderId::parse(order_id).expect("order id") + } + + fn typed_quote_id(order_id: &str) -> RadrootsOrderQuoteId { + RadrootsOrderQuoteId::parse(format!("{order_id}-quote")).expect("quote id") + } + + fn typed_bin_id() -> RadrootsInventoryBinId { + RadrootsInventoryBinId::parse("bin-1").expect("bin id") + } + + fn typed_pubkey(keys: &RadrootsNostrKeys) -> RadrootsPublicKey { + RadrootsPublicKey::parse(keys.public_key().to_string()).expect("public key") + } + + fn typed_event_id(event: &RadrootsNostrEvent) -> RadrootsEventId { + RadrootsEventId::parse(event.id.to_string()).expect("event id") + } + + fn listing_event_ptr() -> RadrootsEventPtr { + RadrootsEventPtr { + id: listing_event_id().to_string(), + relays: None, + } + } + + fn order_economics(order_id: &str) -> RadrootsOrderEconomics { + RadrootsOrderEconomics { + quote_id: typed_quote_id(order_id), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: typed_bin_id(), + bin_count: 2, + quantity_amount: RadrootsCoreDecimal::from(1u32), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: RadrootsCoreDecimal::from(5u32), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + }], + discounts: Vec::<RadrootsOrderEconomicLine>::new(), + adjustments: Vec::<RadrootsOrderEconomicLine>::new(), + subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + discount_total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(0u32), + RadrootsCoreCurrency::USD, + ), + adjustment_total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(0u32), + RadrootsCoreCurrency::USD, + ), + total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + } + } + + fn order_request( + order_id: &str, + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsOrderRequest { + RadrootsOrderRequest { + order_id: typed_order_id(order_id), + listing_addr: typed_listing_addr(seller), + buyer_pubkey: typed_pubkey(buyer), + seller_pubkey: typed_pubkey(seller), + items: vec![RadrootsOrderItem { + bin_id: typed_bin_id(), + bin_count: 2, + }], + economics: order_economics(order_id), + } + } + + fn order_decision( + order_id: &str, + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsOrderDecision { + RadrootsOrderDecision { + order_id: typed_order_id(order_id), + listing_addr: typed_listing_addr(seller), + buyer_pubkey: typed_pubkey(buyer), + seller_pubkey: typed_pubkey(seller), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: typed_bin_id(), + bin_count: 2, + }], + }, + } + } + + fn order_cancellation( + order_id: &str, + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsOrderCancellation { + RadrootsOrderCancellation { + order_id: typed_order_id(order_id), + listing_addr: typed_listing_addr(seller), + buyer_pubkey: typed_pubkey(buyer), + seller_pubkey: typed_pubkey(seller), + reason: "cancel after agreement".to_string(), + } + } + + fn signed_order_request_event( + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsNostrEvent { + let payload = order_request("order-1", buyer, seller); + let wire = order_request_event_build(&listing_event_ptr(), &payload).expect("wire"); + radroots_nostr_build_event(wire.kind, wire.content, wire.tags) + .expect("builder") + .sign_with_keys(buyer) + .expect("event") + } + + fn signed_order_decision_event( + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + request_event: &RadrootsNostrEvent, + ) -> RadrootsNostrEvent { + let payload = order_decision("order-1", buyer, seller); + let root_event_id = typed_event_id(request_event); + let wire = + order_decision_event_build(&root_event_id, &root_event_id, &payload).expect("wire"); + radroots_nostr_build_event(wire.kind, wire.content, wire.tags) + .expect("builder") + .sign_with_keys(seller) + .expect("event") + } + + fn signed_order_cancellation_event( + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + request_event: &RadrootsNostrEvent, + decision_event: &RadrootsNostrEvent, + ) -> RadrootsNostrEvent { + let payload = order_cancellation("order-1", buyer, seller); + let root_event_id = typed_event_id(request_event); + let prev_event_id = typed_event_id(decision_event); + let wire = + order_cancellation_event_build(&root_event_id, &prev_event_id, &payload).expect("wire"); + radroots_nostr_build_event(wire.kind, wire.content, wire.tags) + .expect("builder") + .sign_with_keys(buyer) + .expect("event") + } + + #[tokio::test] + async fn order_request_inserts_canonical_order_state() { + let _guard = test_guard().await; + let worker = RadrootsNostrKeys::generate(); + let buyer = RadrootsNostrKeys::generate(); + let seller = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(worker.clone()); + let runtime = TradeListingRuntime::new(); + let state = runtime.state(); + state.lock().await.upsert_listing_event( + &listing_addr(&seller), + listing_event_id(), + KIND_LISTING, + ); + + let request_event = signed_order_request_event(&buyer, &seller); + handle_event(request_event, Vec::new(), worker, client, runtime.clone()) + .await + .expect("order request"); + + let mut state = state.lock().await; + let order = state.get_order_mut("order-1").expect("order"); + assert_eq!(order.status, RadrootsTradeWorkflowState::Requested); + assert_eq!(order.buyer_pubkey, buyer.public_key().to_string()); + assert_eq!(order.seller_pubkey, seller.public_key().to_string()); + } + + #[tokio::test] + async fn order_decision_uses_shared_workflow_pending_validation_state() { + let _guard = test_guard().await; + let worker = RadrootsNostrKeys::generate(); + let buyer = RadrootsNostrKeys::generate(); + let seller = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(worker.clone()); + let runtime = TradeListingRuntime::new(); + let state = runtime.state(); + state.lock().await.upsert_listing_event( + &listing_addr(&seller), + listing_event_id(), + KIND_LISTING, + ); + let request_event = signed_order_request_event(&buyer, &seller); + handle_event( + request_event.clone(), + Vec::new(), + worker.clone(), + client.clone(), + runtime.clone(), + ) + .await + .expect("order request"); + let decision_event = signed_order_decision_event(&buyer, &seller, &request_event); + trade_listing_event_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(request_event)); + + let error = handle_event(decision_event, Vec::new(), worker, client, runtime.clone()) + .await + .expect_err("missing validator-set policy must fail closed"); + assert!(matches!(error, TradeListingEventError::InvalidPayload(_))); + + let mut state = state.lock().await; + let order = state.get_order_mut("order-1").expect("order"); + assert_eq!( + order.status, + RadrootsTradeWorkflowState::AgreedPendingValidation + ); + } + + #[tokio::test] + async fn cancellation_after_pending_agreement_uses_shared_workflow_invalid_state() { + let _guard = test_guard().await; + let worker = RadrootsNostrKeys::generate(); + let buyer = RadrootsNostrKeys::generate(); + let seller = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(worker.clone()); + let runtime = TradeListingRuntime::new(); + let state = runtime.state(); + state.lock().await.upsert_listing_event( + &listing_addr(&seller), + listing_event_id(), + KIND_LISTING, + ); + let request_event = signed_order_request_event(&buyer, &seller); + handle_event( + request_event.clone(), + Vec::new(), + worker.clone(), + client.clone(), + runtime.clone(), + ) + .await + .expect("order request"); + let decision_event = signed_order_decision_event(&buyer, &seller, &request_event); + trade_listing_event_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(request_event.clone())); + let decision_error = handle_event( + decision_event.clone(), + Vec::new(), + worker.clone(), + client.clone(), + runtime.clone(), + ) + .await + .expect_err("missing validator-set policy must fail closed"); + assert!(matches!( + decision_error, + TradeListingEventError::InvalidPayload(_) + )); + let cancellation_event = + signed_order_cancellation_event(&buyer, &seller, &request_event, &decision_event); + { + let mut hooks = trade_listing_event_test_hooks().lock().expect("hooks"); + hooks.fetch_event_by_id_results.push_back(Ok(request_event)); + hooks + .fetch_event_by_id_results + .push_back(Ok(decision_event)); + } + + handle_event( + cancellation_event, + Vec::new(), + worker, + client, + runtime.clone(), + ) + .await + .expect("order cancellation"); + + let mut state = state.lock().await; + let order = state.get_order_mut("order-1").expect("order"); + assert_eq!(order.status, RadrootsTradeWorkflowState::Invalid); + } + + #[tokio::test] + async fn unsupported_kind_is_rejected() { + let _guard = test_guard().await; + let worker = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(worker.clone()); + let runtime = TradeListingRuntime::new(); + let event = RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(4999), "test") + .sign_with_keys(&RadrootsNostrKeys::generate()) + .expect("event"); + assert!(matches!( + handle_event(event, Vec::new(), worker, client, runtime).await, + Err(TradeListingEventError::UnsupportedKind) + )); + } + + #[test] + fn tag_helpers_cover_core_paths() { + assert!(tag_has_value( + &[vec!["p".to_string(), "pubkey".to_string()]], + "p", + "pubkey" + )); + assert!(!tag_has_value( + &[vec!["p".to_string(), "pubkey".to_string()]], + "p", + "other" + )); + } + + #[tokio::test] + async fn handle_error_uses_send_hook() { + let _guard = test_guard().await; + trade_listing_event_test_hooks() + .lock() + .expect("hooks") + .send_event_results + .push_back(Ok(())); + let keys = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(keys.clone()); + let event = RadrootsNostrEventBuilder::new( + RadrootsNostrKind::Custom(KIND_ORDER_REQUEST as u16), + "bad", + ) + .sign_with_keys(&keys) + .expect("event"); + assert!( + handle_error(TradeListingEventError::InvalidOrder, &event, &client) + .await + .is_ok() + ); + } +} diff --git a/src/features/trade_listing/handlers/mod.rs b/src/features/trade_listing/handlers/mod.rs @@ -1 +1 @@ -pub mod dvm; +pub mod events; diff --git a/src/features/trade_listing/processed_jobs.rs b/src/features/trade_listing/processed_jobs.rs @@ -234,6 +234,48 @@ impl RhiProcessedJobStore { Ok(existing) } + pub async fn mark_receipt_completed( + &self, + job: &RhiProcessedJobState, + receipt_event_id: &str, + receipt_event_json: &str, + completed_timestamp: u32, + now_ms: i64, + ) -> Result<RhiProcessedJobState, RhiProcessedJobStoreError> { + self.ensure_schema().await?; + let mut tx = self.pool.begin().await?; + let Some(mut existing) = select_job(&mut tx, job.request_id.as_str()).await? else { + return Err(RhiProcessedJobStoreError::MissingProcessedJobClaim( + job.request_id.clone(), + )); + }; + ensure_processed_job_matches(&existing, job)?; + ensure_receipt_matches(&existing, receipt_event_id)?; + ensure_receipt_event_json_matches(&existing, receipt_event_json)?; + ensure_result_matches(&existing, receipt_event_id)?; + ensure_result_event_json_matches(&existing, receipt_event_json)?; + if existing.status == RhiProcessedJobStatus::Completed { + tx.commit().await?; + return Ok(existing); + } + if !matches!( + existing.status, + RhiProcessedJobStatus::ReceiptPublished | RhiProcessedJobStatus::ResultPublishing + ) || existing.receipt_event_id.is_none() + { + return Err(RhiProcessedJobStoreError::ReceiptPublicationNotClaimed); + } + existing.status = RhiProcessedJobStatus::Completed; + existing.receipt_event_id = Some(receipt_event_id.to_owned()); + existing.receipt_event_json = Some(receipt_event_json.to_owned()); + existing.result_event_id = Some(receipt_event_id.to_owned()); + existing.result_event_json = Some(receipt_event_json.to_owned()); + existing.completed_timestamp = Some(completed_timestamp); + update_job(&mut tx, &existing, now_ms, None).await?; + tx.commit().await?; + Ok(existing) + } + pub async fn mark_completed( &self, job: &RhiProcessedJobState, diff --git a/src/features/trade_listing/subscriber.rs b/src/features/trade_listing/subscriber.rs @@ -6,7 +6,7 @@ use std::time::Duration; use anyhow::{Result, anyhow}; use radroots_event::kinds::{ - KIND_LISTING, KIND_LISTING_DRAFT, ORDER_EVENT_KINDS, TRADE_VALIDATION_EVENT_KINDS, + KIND_LISTING, ORDER_EVENT_KINDS, TRADE_VALIDATION_EVENT_KINDS, is_trade_validation_service_event_kind, }; use radroots_nostr::prelude::{ @@ -19,7 +19,7 @@ use tokio::time::sleep; use tracing::{info, warn}; use crate::features::trade_listing::{ - handlers::dvm::{TradeListingDvmError, handle_error, handle_event_with_policy}, + handlers::events::{TradeListingEventError, handle_error, handle_event_with_policy}, state::TradeListingRuntime, }; use crate::features::trade_validation_receipt::TradeValidationReceiptProverPolicy; @@ -34,8 +34,8 @@ struct SubscriberTestHooks { resolve_tags_results: std::collections::VecDeque< Result<Vec<RadrootsNostrTag>, radroots_nostr::error::RadrootsNostrTagsResolveError>, >, - handle_event_results: std::collections::VecDeque<Result<(), TradeListingDvmError>>, - handle_error_results: std::collections::VecDeque<Result<(), TradeListingDvmError>>, + handle_event_results: std::collections::VecDeque<Result<(), TradeListingEventError>>, + handle_error_results: std::collections::VecDeque<Result<(), TradeListingEventError>>, } #[cfg(test)] @@ -118,7 +118,7 @@ fn take_resolve_tags_hook() } #[cfg(test)] -fn pop_handle_event_hook() -> Option<Result<(), TradeListingDvmError>> { +fn pop_handle_event_hook() -> Option<Result<(), TradeListingEventError>> { subscriber_test_hooks() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -127,18 +127,18 @@ fn pop_handle_event_hook() -> Option<Result<(), TradeListingDvmError>> { } #[cfg(test)] -fn take_handle_event_hook() -> Option<Result<(), TradeListingDvmError>> { +fn take_handle_event_hook() -> Option<Result<(), TradeListingEventError>> { pop_handle_event_hook() } #[cfg(not(test))] #[cfg_attr(coverage_nightly, coverage(off))] -fn take_handle_event_hook() -> Option<Result<(), TradeListingDvmError>> { +fn take_handle_event_hook() -> Option<Result<(), TradeListingEventError>> { None } #[cfg(test)] -fn pop_handle_error_hook() -> Option<Result<(), TradeListingDvmError>> { +fn pop_handle_error_hook() -> Option<Result<(), TradeListingEventError>> { subscriber_test_hooks() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -147,13 +147,13 @@ fn pop_handle_error_hook() -> Option<Result<(), TradeListingDvmError>> { } #[cfg(test)] -fn take_handle_error_hook() -> Option<Result<(), TradeListingDvmError>> { +fn take_handle_error_hook() -> Option<Result<(), TradeListingEventError>> { pop_handle_error_hook() } #[cfg(not(test))] #[cfg_attr(coverage_nightly, coverage(off))] -fn take_handle_error_hook() -> Option<Result<(), TradeListingDvmError>> { +fn take_handle_error_hook() -> Option<Result<(), TradeListingEventError>> { None } @@ -204,7 +204,7 @@ async fn handle_event_io( client: RadrootsNostrClient, runtime: TradeListingRuntime, proof_policy: TradeValidationReceiptProverPolicy, -) -> Result<(), TradeListingDvmError> { +) -> Result<(), TradeListingEventError> { let result = match take_handle_event_hook() { Some(result) => result, None => { @@ -217,10 +217,10 @@ async fn handle_event_io( } async fn handle_error_io( - err: TradeListingDvmError, + err: TradeListingEventError, event: &RadrootsNostrEvent, client: &RadrootsNostrClient, -) -> Result<(), TradeListingDvmError> { +) -> Result<(), TradeListingEventError> { let result = match take_handle_error_hook() { Some(result) => result, None => handle_error(err, event, client).await, @@ -272,7 +272,7 @@ async fn process_event_notification( .await { match err { - TradeListingDvmError::MissingRecipient | TradeListingDvmError::UnsupportedKind => {} + TradeListingEventError::MissingRecipient | TradeListingEventError::UnsupportedKind => {} other => { if event_kind.is_some_and(is_trade_validation_service_event_kind) { if let Err(err) = handle_error_io(other, &event, &client).await { @@ -308,7 +308,7 @@ pub async fn subscriber( proof_policy: TradeValidationReceiptProverPolicy, mut stop_rx: watch::Receiver<bool>, ) -> Result<()> { - let subscribed_kinds = [KIND_LISTING, KIND_LISTING_DRAFT] + let subscribed_kinds = [KIND_LISTING] .into_iter() .chain(ORDER_EVENT_KINDS) .chain(TRADE_VALIDATION_EVENT_KINDS) @@ -380,7 +380,7 @@ mod tests { SubscriberTestHooks, handle_error_io, handle_event_io, map_notification_recv_result, process_event_notification, resolve_tags_io, subscriber, subscriber_test_hooks, }; - use crate::features::trade_listing::handlers::dvm::TradeListingDvmError; + use crate::features::trade_listing::handlers::events::TradeListingEventError; use crate::features::trade_listing::state::TradeListingRuntime; use crate::features::trade_validation_receipt::TradeValidationReceiptProverPolicy; use radroots_nostr::error::RadrootsNostrTagsResolveError; @@ -472,7 +472,7 @@ mod tests { proof_policy() ) .await, - Err(TradeListingDvmError::UnsupportedKind) + Err(TradeListingEventError::UnsupportedKind) )); subscriber_test_hooks() .lock() @@ -492,14 +492,14 @@ mod tests { .is_ok() ); - let _ = handle_error_io(TradeListingDvmError::InvalidOrder, &event, &client).await; + let _ = handle_error_io(TradeListingEventError::InvalidOrder, &event, &client).await; subscriber_test_hooks() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .handle_error_results .push_back(Ok(())); assert!( - handle_error_io(TradeListingDvmError::InvalidOrder, &event, &client) + handle_error_io(TradeListingEventError::InvalidOrder, &event, &client) .await .is_ok() ); @@ -682,13 +682,13 @@ mod tests { hooks.resolve_tags_results.push_back(Ok(Vec::new())); hooks .handle_event_results - .push_back(Err(TradeListingDvmError::MissingRecipient)); + .push_back(Err(TradeListingEventError::MissingRecipient)); hooks .handle_event_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); hooks .handle_error_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); drop(hooks); let (tx, rx) = watch::channel(false); @@ -722,10 +722,10 @@ mod tests { hooks.resolve_tags_results.push_back(Ok(Vec::new())); hooks .handle_event_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); hooks .handle_error_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); drop(hooks); let (_tx, rx) = watch::channel(false); @@ -753,7 +753,7 @@ mod tests { hooks.resolve_tags_results.push_back(Ok(Vec::new())); hooks .handle_event_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); hooks.handle_error_results.push_back(Ok(())); drop(hooks); @@ -783,10 +783,10 @@ mod tests { hooks.resolve_tags_results.push_back(Ok(Vec::new())); hooks .handle_event_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); hooks .handle_error_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); drop(hooks); process_event_notification(event, keys, client, runtime, proof_policy()) @@ -815,7 +815,7 @@ mod tests { hooks.resolve_tags_results.push_back(Ok(Vec::new())); hooks .handle_event_results - .push_back(Err(TradeListingDvmError::InvalidOrder)); + .push_back(Err(TradeListingEventError::InvalidOrder)); hooks.handle_error_results.push_back(Ok(())); drop(hooks); diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs @@ -1,88 +1,44 @@ #![forbid(unsafe_code)] #![cfg_attr(coverage_nightly, coverage(off))] -use radroots_event::ids::RadrootsEventId; -use radroots_event::kinds::{ - KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT, - KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, -}; -use radroots_event::order::{ - RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderRequest, -}; -use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts}; -use radroots_event_codec::order::{ - order_decision_from_event, order_request_from_event, parse_order_listing_event_tag, - parse_order_prev_tag, parse_order_root_tag, -}; +use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsEventId}; +use radroots_event::kinds::KIND_ORDER_DECISION; use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKeys, - RadrootsNostrKind, RadrootsNostrTimestamp, radroots_event_from_nostr, - radroots_nostr_build_event, radroots_nostr_fetch_event_by_id, radroots_nostr_filter_tag, -}; -use radroots_trade::dvm::{ - RadrootsOrderDecisionEventWitnessDto, RadrootsOrderDecisionWitnessDto, - RadrootsOrderItemWitnessDto, RadrootsOrderRequestWitnessDto, - RadrootsTradeCanonicalEventEvidenceDto, RadrootsTradeCanonicalEventEvidenceRole, - RadrootsTradeCanonicalEventWorkflowPosition, RadrootsTradeDvmError, - RadrootsTradeInventoryBinWitnessDto, RadrootsTradeInventoryCommitmentWitnessDto, - RadrootsTradeProofMode, RadrootsTradeTransitionProofRequestEnvelope, - RadrootsTradeTransitionProofRequestV1, RadrootsTradeTransitionProofResultBinding, - build_transition_proof_request_tags, build_transition_proof_result_tags, - parse_transition_proof_request_event, + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrKind, + RadrootsNostrTimestamp, radroots_event_from_nostr, radroots_nostr_build_event, + radroots_nostr_fetch_event_by_id, }; +use radroots_trade::order::{RadrootsOrderEventRecord, order_event_record_from_event}; use radroots_trade::validation_receipt::{ - RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority, - RadrootsValidationReceiptError, RadrootsValidationReceiptExpectedBinding, + RadrootsTradeValidationReceipt, RadrootsValidationReceiptError, + RadrootsValidationReceiptExpectedBinding, RadrootsValidationReceiptProof, RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, - RadrootsVerifiedValidationReceipt, validation_receipt_event_build, + RadrootsValidationReceiptStatement, RadrootsValidationReceiptType, VALIDATION_RECEIPT_DOMAIN, + VALIDATION_RECEIPT_VERSION, validation_receipt_event_build, + validation_receipt_public_values_hash_hex, validator_set_address_from_str, verify_validation_receipt_event, }; -use radroots_trade::{ - order::{ - RadrootsGroupedOrderEventRecords, RadrootsOrderEventRecord, order_event_record_from_event, - }, - workflow::{ - RadrootsTradeWorkflowRecords, RadrootsTradeWorkflowState, reduce_trade_workflow_records, - }, -}; -use radroots_trade_sp1_guest::{ - RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET, RADROOTS_SP1_TRADE_PROTOCOL_VERSION, - RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH, RADROOTS_SP1_TRADE_WITNESS_VERSION, - RadrootsSp1TradeCanonicalEventEvidence, RadrootsSp1TradeEventEvidenceRole, - RadrootsSp1TradeEventWorkflowPosition, RadrootsSp1TradeInventoryBinWitness, - RadrootsSp1TradeInventoryCommitmentWitness, RadrootsSp1TradeOrderAcceptanceWitness, - RadrootsSp1TradeOrderDecisionEventWitness, RadrootsSp1TradeOrderDecisionWitness, - RadrootsSp1TradeOrderItemWitness, RadrootsSp1TradeOrderRequestWitness, -}; -use radroots_trade_sp1_host::{ - RadrootsSp1TradeHostError, RadrootsSp1TradeProofBundle, RadrootsSp1TradeProofMode, - generate_order_acceptance_proof, validation_receipt_for_order_acceptance_proof, - verify_order_acceptance_proof_artifact_structure, -}; +use radroots_trade::workflow::RadrootsTradeWorkflowState; +use radroots_trade_sp1_host::RadrootsSp1TradeProofMode; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::{collections::BTreeMap, time::Duration}; +use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; use crate::features::trade_listing::processed_jobs::{ RhiProcessedJobClaim, RhiProcessedJobState, RhiProcessedJobStatus, RhiProcessedJobStoreError, }; -use crate::features::trade_listing::state::{TradeListingRuntime, TradeListingRuntimeError}; - -#[cfg(feature = "sp1_verify")] -use radroots_trade_sp1_host::{ - RADROOTS_SP1_TRADE_REMOTE_PROVER_SCHEMA_VERSION, RADROOTS_SP1_TRADE_SP1_VERSION_LINE, - RadrootsSp1TradeRemoteProverRequest, RadrootsSp1TradeRemoteProverResponse, - RadrootsSp1TradeRemoteProverStatus, RadrootsSp1TradeResolvedProofArtifact, +use crate::features::trade_listing::state::{ + TradeListingRuntime, TradeListingRuntimeError, TradeOrderState, }; const RHI_PROCESSED_JOB_CLAIM_LEASE_MS: i64 = 10 * 60 * 1000; +const ZERO_ERROR_BITMAP: &str = "0x00000000000000000000000000000000"; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TradeValidationReceiptProverBackend { - Disabled, - DeterministicNone, + #[default] LocalExecute, LocalCpuProve, LocalCudaProve, @@ -92,8 +48,6 @@ pub enum TradeValidationReceiptProverBackend { impl TradeValidationReceiptProverBackend { pub const fn as_str(self) -> &'static str { match self { - Self::Disabled => "disabled", - Self::DeterministicNone => "deterministic_none", Self::LocalExecute => "local_execute", Self::LocalCpuProve => "local_cpu_prove", Self::LocalCudaProve => "local_cuda_prove", @@ -102,30 +56,17 @@ impl TradeValidationReceiptProverBackend { } } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TradeValidationReceiptRuntimePolicy { - #[default] - Production, - RepoLocalDevelopment, -} - -impl TradeValidationReceiptRuntimePolicy { - pub const fn as_str(self) -> &'static str { - match self { - Self::Production => "production", - Self::RepoLocalDevelopment => "repo_local_development", - } - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TradeValidationReceiptProverPolicy { + #[serde(default)] pub backend: TradeValidationReceiptProverBackend, + #[serde(default = "default_proof_mode")] pub proof_mode: RadrootsSp1TradeProofMode, #[serde(default)] - pub runtime_policy: TradeValidationReceiptRuntimePolicy, + pub validator_set_addr: Option<String>, + #[serde(default)] + pub validator_set_event_id: Option<String>, #[serde(default)] pub expected_sp1_program_hash: Option<String>, #[serde(default)] @@ -134,103 +75,30 @@ pub struct TradeValidationReceiptProverPolicy { pub remote_http: Option<TradeValidationReceiptRemoteHttpProverConfig>, } -impl Default for TradeValidationReceiptProverPolicy { - fn default() -> Self { - Self::disabled() - } +fn default_proof_mode() -> RadrootsSp1TradeProofMode { + RadrootsSp1TradeProofMode::None } -impl TradeValidationReceiptProverPolicy { - pub fn disabled() -> Self { - Self { - backend: TradeValidationReceiptProverBackend::Disabled, - proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: None, - remote_http: None, - } - } - - pub fn repo_local_deterministic_none() -> Self { +impl Default for TradeValidationReceiptProverPolicy { + fn default() -> Self { Self { - backend: TradeValidationReceiptProverBackend::DeterministicNone, + backend: TradeValidationReceiptProverBackend::LocalExecute, proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment, + validator_set_addr: None, + validator_set_event_id: None, expected_sp1_program_hash: None, expected_sp1_verifying_key_hash: None, remote_http: None, } } +} +impl TradeValidationReceiptProverPolicy { pub fn validate(&self) -> Result<(), TradeValidationReceiptJobError> { - self.validate_shape()?; - if self.backend == TradeValidationReceiptProverBackend::RemoteHttpProve { - if let Some(remote_http) = self.remote_http.as_ref() { - remote_http_auth_token(remote_http)?; - } - } - self.validate_build_availability() - } - - pub fn validate_request( - &self, - request: &RadrootsTradeTransitionProofRequestV1, - ) -> Result<(), TradeValidationReceiptJobError> { - if sp1_proof_mode_from_dvm(request.proof_mode) != self.proof_mode { - return Err(TradeValidationReceiptJobError::ProverBackendPolicyMismatch); - } - if self.proof_mode == RadrootsSp1TradeProofMode::None { - if request.sp1_program_hash.is_some() || request.sp1_verifying_key_hash.is_some() { - return Err(TradeValidationReceiptJobError::Sp1IdentityConstraintsRequireSp1Proof); - } - return Ok(()); - } - if request.sp1_program_hash.as_deref() != self.expected_sp1_program_hash.as_deref() { - return Err(TradeValidationReceiptJobError::ExpectedSp1ProgramHashMismatch); - } - if request.sp1_verifying_key_hash.as_deref() - != self.expected_sp1_verifying_key_hash.as_deref() - { - return Err(TradeValidationReceiptJobError::ExpectedSp1VerifyingKeyHashMismatch); - } - Ok(()) - } - - fn validate_shape(&self) -> Result<(), TradeValidationReceiptJobError> { validate_optional_hash32(&self.expected_sp1_program_hash)?; validate_optional_hash32(&self.expected_sp1_verifying_key_hash)?; - if self.backend != TradeValidationReceiptProverBackend::DeterministicNone - && self.runtime_policy == TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment - { - return Err( - TradeValidationReceiptJobError::RepoLocalDevelopmentPolicyRequiresDeterministicNone, - ); - } match self.backend { - TradeValidationReceiptProverBackend::Disabled => { - if self.proof_mode != RadrootsSp1TradeProofMode::None { - return Err(TradeValidationReceiptJobError::ProverBackendDisabled); - } - if self.expected_sp1_program_hash.is_some() - || self.expected_sp1_verifying_key_hash.is_some() - { - return Err( - TradeValidationReceiptJobError::Sp1IdentityConstraintsRequireSp1Proof, - ); - } - Ok(()) - } - TradeValidationReceiptProverBackend::DeterministicNone - | TradeValidationReceiptProverBackend::LocalExecute => { - if self.backend == TradeValidationReceiptProverBackend::DeterministicNone - && self.runtime_policy - != TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment - { - return Err( - TradeValidationReceiptJobError::DeterministicNoneRequiresRepoLocalDevelopment, - ); - } + TradeValidationReceiptProverBackend::LocalExecute => { if self.proof_mode != RadrootsSp1TradeProofMode::None { return Err(TradeValidationReceiptJobError::ProverBackendRequiresNone); } @@ -244,7 +112,8 @@ impl TradeValidationReceiptProverPolicy { Ok(()) } TradeValidationReceiptProverBackend::LocalCpuProve - | TradeValidationReceiptProverBackend::LocalCudaProve => { + | TradeValidationReceiptProverBackend::LocalCudaProve + | TradeValidationReceiptProverBackend::RemoteHttpProve => { if self.proof_mode == RadrootsSp1TradeProofMode::None { return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof); } @@ -253,56 +122,51 @@ impl TradeValidationReceiptProverPolicy { { return Err(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired); } - Ok(()) - } - TradeValidationReceiptProverBackend::RemoteHttpProve => { - if self.proof_mode == RadrootsSp1TradeProofMode::None { - return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof); + match self.backend { + TradeValidationReceiptProverBackend::LocalCpuProve + | TradeValidationReceiptProverBackend::RemoteHttpProve => { + if !cfg!(feature = "sp1_verify") { + return Err(TradeValidationReceiptJobError::ProverBackendUnavailable( + self.backend.as_str(), + )); + } + } + TradeValidationReceiptProverBackend::LocalCudaProve => { + return Err(TradeValidationReceiptJobError::ProverBackendUnavailable( + self.backend.as_str(), + )); + } + TradeValidationReceiptProverBackend::LocalExecute => {} } - if self.expected_sp1_program_hash.is_none() - || self.expected_sp1_verifying_key_hash.is_none() + if self.backend == TradeValidationReceiptProverBackend::RemoteHttpProve + && self.remote_http.is_none() { - return Err(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired); + return Err(TradeValidationReceiptJobError::RemoteHttpConfigRequired); } - let remote_http = self - .remote_http - .as_ref() - .ok_or(TradeValidationReceiptJobError::RemoteHttpConfigRequired)?; - remote_http.validate() + Ok(()) } } } - fn validate_build_availability(&self) -> Result<(), TradeValidationReceiptJobError> { - match self.backend { - TradeValidationReceiptProverBackend::Disabled => Ok(()), - TradeValidationReceiptProverBackend::LocalExecute if !cfg!(feature = "sp1_proving") => { - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - self.backend.as_str(), - )) - } - TradeValidationReceiptProverBackend::LocalCpuProve - if !cfg!(feature = "sp1_proving") => - { - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - self.backend.as_str(), - )) - } - TradeValidationReceiptProverBackend::LocalCudaProve => Err( - TradeValidationReceiptJobError::ProverBackendUnavailable(self.backend.as_str()), - ), - TradeValidationReceiptProverBackend::RemoteHttpProve - if !cfg!(feature = "sp1_verify") => - { - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - self.backend.as_str(), - )) - } - TradeValidationReceiptProverBackend::DeterministicNone - | TradeValidationReceiptProverBackend::LocalExecute - | TradeValidationReceiptProverBackend::LocalCpuProve - | TradeValidationReceiptProverBackend::RemoteHttpProve => Ok(()), - } + pub fn validator_binding( + &self, + ) -> Result<(RadrootsAddressableCoordinate, String), TradeValidationReceiptJobError> { + let validator_set_addr = self.validator_set_addr.as_deref().ok_or( + TradeValidationReceiptJobError::MissingValidatorSetBinding("validator_set_addr"), + )?; + let validator_set_event_id = self.validator_set_event_id.as_deref().ok_or( + TradeValidationReceiptJobError::MissingValidatorSetBinding("validator_set_event_id"), + )?; + let validator_set_addr = + validator_set_address_from_str(validator_set_addr).map_err(|_| { + TradeValidationReceiptJobError::InvalidValidatorSetBinding("validator_set_addr") + })?; + let validator_set_event_id = RadrootsEventId::parse(validator_set_event_id) + .map_err(|_| { + TradeValidationReceiptJobError::InvalidValidatorSetBinding("validator_set_event_id") + })? + .into_string(); + Ok((validator_set_addr, validator_set_event_id)) } } @@ -310,203 +174,16 @@ impl TradeValidationReceiptProverPolicy { #[serde(deny_unknown_fields)] pub struct TradeValidationReceiptRemoteHttpProverConfig { pub endpoint_url: String, - pub auth: TradeValidationReceiptRemoteHttpAuth, - pub request_timeout_ms: u64, - pub poll_interval_ms: u64, - pub max_poll_attempts: u32, - pub max_response_bytes: usize, -} - -impl TradeValidationReceiptRemoteHttpProverConfig { - pub fn validate(&self) -> Result<(), TradeValidationReceiptJobError> { - let url = remote_http_endpoint_url(self)?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "endpoint_url", - )); - } - if matches!( - self.auth, - TradeValidationReceiptRemoteHttpAuth::BearerTokenEnv { .. } - ) && url.scheme() != "https" - { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "auth.endpoint_url_scheme", - )); - } - if self.request_timeout_ms == 0 { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "request_timeout_ms", - )); - } - if self.poll_interval_ms == 0 { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "poll_interval_ms", - )); - } - if self.max_response_bytes == 0 { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "max_response_bytes", - )); - } - self.auth.validate() - } -} - -fn remote_http_endpoint_url( - config: &TradeValidationReceiptRemoteHttpProverConfig, -) -> Result<reqwest::Url, TradeValidationReceiptJobError> { - if config.endpoint_url.trim().is_empty() { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "endpoint_url", - )); - } - reqwest::Url::parse(config.endpoint_url.as_str()) - .map_err(|_| TradeValidationReceiptJobError::RemoteHttpInvalidConfig("endpoint_url")) + #[serde(default)] + pub auth: Option<TradeValidationReceiptRemoteHttpAuth>, + #[serde(default)] + pub max_response_bytes: Option<usize>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "mode", rename_all = "snake_case")] +#[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum TradeValidationReceiptRemoteHttpAuth { - NoAuth, - BearerTokenEnv { env_var: String }, -} - -impl TradeValidationReceiptRemoteHttpAuth { - fn validate(&self) -> Result<(), TradeValidationReceiptJobError> { - match self { - Self::NoAuth => Ok(()), - Self::BearerTokenEnv { env_var } => { - validate_rhi_secret_env_var_name(env_var)?; - Ok(()) - } - } - } -} - -fn validate_rhi_secret_env_var_name(env_var: &str) -> Result<&str, TradeValidationReceiptJobError> { - let env_var = env_var.trim(); - if env_var.is_empty() || !env_var.starts_with("RHI_") { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "auth.env_var", - )); - } - Ok(env_var) -} - -fn remote_http_auth_token( - config: &TradeValidationReceiptRemoteHttpProverConfig, -) -> Result<Option<String>, TradeValidationReceiptJobError> { - match &config.auth { - TradeValidationReceiptRemoteHttpAuth::NoAuth => Ok(None), - TradeValidationReceiptRemoteHttpAuth::BearerTokenEnv { env_var } => { - let env_var = validate_rhi_secret_env_var_name(env_var)?; - let value = remote_http_auth_env_var_value(env_var)?; - let token = value.trim(); - if token.is_empty() { - return Err(TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing( - env_var.to_owned(), - )); - } - Ok(Some(token.to_owned())) - } - } -} - -fn remote_http_auth_env_var_value(env_var: &str) -> Result<String, TradeValidationReceiptJobError> { - #[cfg(test)] - if let Some(value) = remote_http_auth_env_var_value_test_hook(env_var) { - return value; - } - std::env::var(env_var) - .map_err(|_| TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing(env_var.to_owned())) -} - -fn validate_job_execution_policy( - prover_policy: &TradeValidationReceiptProverPolicy, - request: &RadrootsTradeTransitionProofRequestV1, -) -> Result<(), TradeValidationReceiptJobError> { - validate_current_job_execution_readiness(prover_policy)?; - prover_policy.validate_request(request) -} - -fn validate_current_job_execution_readiness( - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result<(), TradeValidationReceiptJobError> { - prover_policy.validate_shape()?; - if prover_policy.backend == TradeValidationReceiptProverBackend::Disabled { - return Err(TradeValidationReceiptJobError::ProverBackendDisabled); - } - if prover_policy.backend == TradeValidationReceiptProverBackend::RemoteHttpProve { - if let Some(remote_http) = prover_policy.remote_http.as_ref() { - remote_http_auth_token(remote_http)?; - } - } - prover_policy.validate_build_availability() -} - -fn validate_recovered_replay_execution_policy( - prover_policy: &TradeValidationReceiptProverPolicy, - request: &RadrootsTradeTransitionProofRequestV1, -) -> Result<(), TradeValidationReceiptJobError> { - validate_current_job_execution_readiness(prover_policy)?; - prover_policy - .validate_request(request) - .map_err(recovered_replay_request_policy_error) -} - -fn recovered_replay_request_policy_error( - error: TradeValidationReceiptJobError, -) -> TradeValidationReceiptJobError { - match error { - TradeValidationReceiptJobError::ProverBackendPolicyMismatch - | TradeValidationReceiptJobError::Sp1IdentityConstraintsRequireSp1Proof - | TradeValidationReceiptJobError::ExpectedSp1ProgramHashMismatch - | TradeValidationReceiptJobError::ExpectedSp1VerifyingKeyHashMismatch => { - TradeValidationReceiptJobError::RecoveredResultPolicyMismatch - } - error => error, - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TradeValidationReceiptJobResult { - pub cryptographic_proof_verified: bool, - pub customer_pubkey: String, - pub decision_event_id: String, - pub event_set_root: String, - pub listing_event_id: String, - pub order_id: String, - pub proof_generated: bool, - pub proof_mode: RadrootsSp1TradeProofMode, - pub proof_system: String, - pub public_values_hash: String, - pub prover_backend: TradeValidationReceiptProverBackend, - pub receipt_event_id: String, - pub receipt_kind: u32, - pub reducer_output_root: String, - pub request_event_id: String, - pub request_hash: String, - pub sp1_execute_checked: bool, - pub sp1_execute_public_values_hash: Option<String>, - pub status: TradeValidationReceiptJobStatus, - pub validation_authority: RadrootsTradeValidationAuthority, - pub confidence: RadrootsTradeCommitmentConfidence, - pub worker_pubkey: String, - pub worker_role: TradeValidationReceiptWorkerRole, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TradeValidationReceiptJobStatus { - Succeeded, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TradeValidationReceiptWorkerRole { - NonAuthoritativeProver, + BearerEnv { env_var: String }, } #[derive(Debug, Error)] @@ -515,585 +192,365 @@ pub enum TradeValidationReceiptJobError { UnsupportedKind, #[error("missing recipient tag")] MissingRecipient, - #[error("invalid job request")] - InvalidJobRequest, - #[error("unsupported proof target")] - UnsupportedProofTarget, - #[error("unsupported witness version")] - UnsupportedWitnessVersion, - #[error("expected reducer program hash does not match canonical reducer")] - ExpectedReducerProgramHashMismatch, - #[error("expected protocol version does not match canonical protocol")] - ExpectedProtocolVersionMismatch, - #[error("unsupported proof mode")] - UnsupportedProofMode, + #[error("trade validation receipt policy is missing {0}")] + MissingValidatorSetBinding(&'static str), + #[error("trade validation receipt policy has invalid {0}")] + InvalidValidatorSetBinding(&'static str), + #[error("trade validation receipt policy backend requires proof_mode none")] + ProverBackendRequiresNone, + #[error("trade validation receipt policy backend requires SP1 proof mode")] + ProverBackendRequiresSp1Proof, + #[error("trade validation receipt policy backend is unavailable: {0}")] + ProverBackendUnavailable(&'static str), + #[error("trade validation receipt policy requires SP1 program and verifying-key hashes")] + Sp1IdentityPolicyRequired, #[error("SP1 identity constraints require an SP1 proof mode")] Sp1IdentityConstraintsRequireSp1Proof, - #[error("invalid listing event")] - InvalidListingEvent, - #[error("invalid signed event evidence")] + #[error("remote HTTP prover config is required")] + RemoteHttpConfigRequired, + #[error("invalid configured hash field")] + InvalidHashField, + #[error("invalid active trade event: {0}")] + InvalidActiveTradeEvent(String), + #[error("invalid signed event")] InvalidSignedEvent, - #[error("job request does not match fetched event set")] - EventSetMismatch, - #[error("duplicate DVM job request conflicts with processed job state")] + #[error("duplicate conflicting processed job")] DuplicateConflictingJob, - #[error("duplicate validation receipt conflicts with processed job state")] + #[error("duplicate conflicting receipt")] DuplicateConflictingReceipt, - #[error("duplicate validation result conflicts with processed job state")] + #[error("duplicate conflicting result")] DuplicateConflictingResult, - #[error("missing recovered proof execution metadata")] - MissingRecoveredProofMetadata, - #[error("recovered proof execution metadata does not match validation receipt")] - RecoveredProofMetadataMismatch, - #[error("recovered validation result does not match current prover policy")] - RecoveredResultPolicyMismatch, - #[error("recovered validation result does not match validation receipt")] - RecoveredResultMismatch, - #[error("invalid active trade event: {0}")] - InvalidActiveTradeEvent(String), - #[error("rhi prover backend is disabled")] - ProverBackendDisabled, - #[error("rhi prover backend requires proof_mode none")] - ProverBackendRequiresNone, - #[error("rhi deterministic_none prover backend requires repo_local_development runtime policy")] - DeterministicNoneRequiresRepoLocalDevelopment, - #[error("rhi repo_local_development runtime policy requires deterministic_none prover backend")] - RepoLocalDevelopmentPolicyRequiresDeterministicNone, - #[error("rhi prover backend requires an SP1 proof mode")] - ProverBackendRequiresSp1Proof, - #[error("rhi prover backend does not match configured policy")] - ProverBackendPolicyMismatch, - #[error("rhi prover backend {0} is unavailable in this build")] - ProverBackendUnavailable(&'static str), - #[error("configured SP1 identity policy is required for this prover backend")] - Sp1IdentityPolicyRequired, - #[error("remote_http prover config is required")] - RemoteHttpConfigRequired, - #[error("remote_http prover config field {0} is invalid")] - RemoteHttpInvalidConfig(&'static str), - #[error("remote_http bearer token environment variable {0} is missing or empty")] - RemoteHttpAuthTokenMissing(String), - #[error("remote_http transport error: {0}")] - RemoteHttpTransport(String), - #[error("remote_http response exceeded configured byte limit")] - RemoteHttpResponseTooLarge, - #[error("remote_http response field {0} is invalid")] - RemoteHttpInvalidResponse(&'static str), - #[error("remote_http terminal {status}: {reason_code}: {message}")] - RemoteHttpTerminal { - status: &'static str, - reason_code: String, - message: String, - }, - #[error("remote_http polling timed out")] - RemoteHttpTimeout, - #[error("remote_http response identity field {0} did not match")] - RemoteHttpIdentityMismatch(&'static str), - #[error("expected SP1 program hash does not match configured policy")] - ExpectedSp1ProgramHashMismatch, - #[error("expected SP1 verifying key hash does not match configured policy")] - ExpectedSp1VerifyingKeyHashMismatch, + #[error("stored processed job failed: {0}")] + StoredProcessedJobFailed(String), #[error("nostr error: {0}")] Nostr(#[from] radroots_nostr::error::RadrootsNostrError), #[error("serde error: {0}")] Serde(#[from] serde_json::Error), - #[error("proof error: {0}")] - Proof(#[from] RadrootsSp1TradeHostError), #[error("validation receipt error: {0}")] ValidationReceipt(#[from] RadrootsValidationReceiptError), - #[error("DVM contract error: {0}")] - Dvm(#[from] RadrootsTradeDvmError), - #[error("trade listing runtime error: {0}")] + #[error("order decode error: {0}")] + OrderDecode(#[from] radroots_trade::order::RadrootsOrderEventDecodeError), + #[error("runtime state error: {0}")] Runtime(#[from] TradeListingRuntimeError), -} - -#[derive(Clone, Debug)] -pub struct TradeValidationReceiptLocalWorkerRequest { - pub job_request_event: RadrootsNostrEvent, - pub listing_event: RadrootsNostrEvent, - pub order_request_event: RadrootsNostrEvent, - pub order_decision_event: RadrootsNostrEvent, - pub existing_receipt_events: Vec<RadrootsNostrEvent>, - pub publish_created_at_secs: Option<u64>, -} - -impl TradeValidationReceiptLocalWorkerRequest { - pub fn new( - job_request_event: RadrootsNostrEvent, - listing_event: RadrootsNostrEvent, - order_request_event: RadrootsNostrEvent, - order_decision_event: RadrootsNostrEvent, - ) -> Self { - Self { - job_request_event, - listing_event, - order_request_event, - order_decision_event, - existing_receipt_events: Vec::new(), - publish_created_at_secs: None, - } + #[error("processed-job store error: {0}")] + ProcessedJobStore(#[from] RhiProcessedJobStoreError), +} + +#[derive(Clone, Debug, Serialize)] +struct DeterministicReceiptPublicValues { + changed_records_root: String, + event_set_root: String, + listing_event_id: String, + new_state_root: String, + order_id: String, + previous_state_root: String, + proof_system: String, + result: String, + schema_version: u32, + target_event_id: String, + validator_set_addr: String, + validator_set_event_id: String, +} + +pub async fn publish_validation_receipt( + accepted_event: &RadrootsNostrEvent, + client: &RadrootsNostrClient, + runtime: &TradeListingRuntime, + keys: &RadrootsNostrKeys, + policy: &TradeValidationReceiptProverPolicy, +) -> Result<(), TradeValidationReceiptJobError> { + policy.validate()?; + if policy.backend != TradeValidationReceiptProverBackend::LocalExecute + || policy.proof_mode != RadrootsSp1TradeProofMode::None + { + return Err(TradeValidationReceiptJobError::ProverBackendUnavailable( + policy.backend.as_str(), + )); } - - pub fn with_existing_receipt_event(mut self, event: RadrootsNostrEvent) -> Self { - self.existing_receipt_events.push(event); - self + let (validator_set_addr, validator_set_event_id) = policy.validator_binding()?; + let kind = event_kind_u32(accepted_event)?; + if kind != KIND_ORDER_DECISION { + return Err(TradeValidationReceiptJobError::UnsupportedKind); } - - pub fn with_publish_created_at_secs(mut self, created_at_secs: u64) -> Self { - self.publish_created_at_secs = Some(created_at_secs); - self + let rr_event = radroots_event_from_nostr(accepted_event); + let record = order_event_record_from_event(&rr_event)?; + let RadrootsOrderEventRecord::Decision(decision) = record else { + return Err(TradeValidationReceiptJobError::UnsupportedKind); + }; + if !matches!( + decision.payload.decision, + radroots_event::order::RadrootsOrderDecisionOutcome::Accepted { .. } + ) { + return Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "order decision is not accepted".to_string(), + )); + } + let order = { + let state = runtime.state(); + let state = state.lock().await; + state + .get_order(decision.payload.order_id.as_str()) + .cloned() + .ok_or_else(|| { + TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "order is missing from RHI state".to_string(), + ) + })? + }; + if order.status != RadrootsTradeWorkflowState::AgreedPendingValidation { + return Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "order is not awaiting validation".to_string(), + )); } -} - -#[derive(Clone, Debug)] -pub struct TradeValidationReceiptLocalWorkerOutput { - pub published_events: Vec<RadrootsNostrEvent>, - pub processed_job: Option<RhiProcessedJobState>, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: bool, - proof_generated: bool, - sp1_execute_checked: bool, - sp1_execute_public_values_hash: Option<String>, -} - -pub fn build_trade_validation_receipt_job_request_event( - requester_keys: &RadrootsNostrKeys, - worker_keys: &RadrootsNostrKeys, - listing_event: &RadrootsNostrEvent, - order_request_event: &RadrootsNostrEvent, - order_decision_event: &RadrootsNostrEvent, - inventory_bins: Vec<RadrootsTradeInventoryBinWitnessDto>, - inventory_sequence: u128, - previous_state_root: Option<String>, - prover_policy: &TradeValidationReceiptProverPolicy, - created_at_secs: u64, -) -> Result<RadrootsNostrEvent, TradeValidationReceiptJobError> { - prover_policy.validate()?; - let request_rr = radroots_event_from_nostr(order_request_event); - let decision_rr = radroots_event_from_nostr(order_decision_event); - let request_envelope = order_request_from_event(&request_rr).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) + let root_event_id = order.root_event_id.as_deref().ok_or_else(|| { + TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "order is missing root event id".to_string(), + ) })?; - let decision_envelope = order_decision_from_event(&decision_rr).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) + let listing_event_id = order.listing_snapshot_event_id.as_deref().ok_or_else(|| { + TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "order is missing listing snapshot event id".to_string(), + ) })?; - let request = RadrootsTradeTransitionProofRequestV1 { - witness_version: RADROOTS_SP1_TRADE_WITNESS_VERSION, - proof_target: RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET.to_string(), - listing_event_id: RadrootsEventId::parse(listing_event.id.to_hex()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?, - request_event_id: RadrootsEventId::parse(order_request_event.id.to_hex()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?, - decision_event_id: RadrootsEventId::parse(order_decision_event.id.to_hex()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?, - event_evidence: canonical_dvm_event_evidence_from_event( - listing_event, - order_request_event, - order_decision_event, - )?, - request: dvm_order_request_witness_from_payload(&request_envelope.payload), - decision: dvm_order_decision_witness_from_payload(&decision_envelope.payload), - inventory_bins, - inventory_sequence, - previous_state_root, - proof_mode: dvm_proof_mode_from_sp1(prover_policy.proof_mode), - reducer_program_hash: RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH.to_string(), - radroots_protocol_version: RADROOTS_SP1_TRADE_PROTOCOL_VERSION.to_string(), - sp1_program_hash: prover_policy.expected_sp1_program_hash.clone(), - sp1_verifying_key_hash: prover_policy.expected_sp1_verifying_key_hash.clone(), - }; - let worker_pubkey = - radroots_event::ids::RadrootsPublicKey::parse(worker_keys.public_key().to_hex()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?; - let builder = radroots_nostr_build_event( - KIND_TRADE_TRANSITION_PROOF_REQUEST, - serde_json::to_string(&request)?, - build_transition_proof_request_tags(&worker_pubkey, &request), - )? - .custom_created_at(RadrootsNostrTimestamp::from_secs(created_at_secs)); - builder - .sign_with_keys(requester_keys) - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent) -} - -pub async fn handle_trade_validation_receipt_local_worker_request( - request: TradeValidationReceiptLocalWorkerRequest, - keys: &RadrootsNostrKeys, - runtime: &TradeListingRuntime, - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result<TradeValidationReceiptLocalWorkerOutput, TradeValidationReceiptJobError> { - let mut io = TradeValidationReceiptJobIo::local(keys, &request); - process_trade_validation_receipt_job_request( - &request.job_request_event, + let target_event_id = accepted_event.id.to_hex(); + if order.last_event_id.as_deref() != Some(target_event_id.as_str()) { + return Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "accepted event is not the current order head".to_string(), + )); + } + let root_event = fetch_event_by_id_io(client, root_event_id).await?; + let listing_event = fetch_event_by_id_io(client, listing_event_id).await?; + verify_root_event(&root_event, &order)?; + let receipt = deterministic_receipt_for_acceptance( + &order, + listing_event_id, + root_event_id, + target_event_id.as_str(), + validator_set_addr, + validator_set_event_id.as_str(), + [&listing_event, &root_event, accepted_event], + )?; + let receipt_parts = validation_receipt_event_build(&order.order_id, &receipt)?; + let receipt_event = signed_event_from_parts( keys, - runtime, - prover_policy, - &mut io, - ) - .await?; - let processed_job = { - runtime - .processed_jobs() - .get_job(&request.job_request_event.id.to_hex()) - .await - .map_err(processed_job_store_error)? - }; - Ok(TradeValidationReceiptLocalWorkerOutput { - published_events: io.into_published_events(), - processed_job, - }) -} - -pub async fn handle_trade_validation_receipt_job_request( - event: &RadrootsNostrEvent, - keys: &RadrootsNostrKeys, - client: &RadrootsNostrClient, - runtime: &TradeListingRuntime, - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result<(), TradeValidationReceiptJobError> { - let mut io = TradeValidationReceiptJobIo::Nostr { client }; - process_trade_validation_receipt_job_request(event, keys, runtime, prover_policy, &mut io).await + receipt_parts.kind, + receipt_parts.content, + receipt_parts.tags, + Some(accepted_event.created_at.as_secs()), + )?; + verify_validation_receipt_event( + &radroots_event_from_nostr(&receipt_event), + RadrootsValidationReceiptExpectedBinding { + order_id: Some(order.order_id.as_str()), + listing_event_id: Some(listing_event_id), + root_event_id: Some(root_event_id), + target_event_id: Some(target_event_id.as_str()), + validator_set_addr: policy.validator_set_addr.as_deref(), + validator_set_event_id: Some(validator_set_event_id.as_str()), + proof_system: Some(RadrootsValidationReceiptProofSystem::None), + public_values_hash: Some(receipt.public_values_hash.as_str()), + ..RadrootsValidationReceiptExpectedBinding::default() + }, + )?; + publish_receipt_with_processed_job(runtime, client, accepted_event, &order, receipt_event).await } -async fn process_trade_validation_receipt_job_request( - event: &RadrootsNostrEvent, - keys: &RadrootsNostrKeys, +async fn publish_receipt_with_processed_job( runtime: &TradeListingRuntime, - prover_policy: &TradeValidationReceiptProverPolicy, - io: &mut TradeValidationReceiptJobIo<'_>, + client: &RadrootsNostrClient, + accepted_event: &RadrootsNostrEvent, + order: &TradeOrderState, + receipt_event: RadrootsNostrEvent, ) -> Result<(), TradeValidationReceiptJobError> { - let kind = event_kind_u32(event)?; - if kind != KIND_TRADE_TRANSITION_PROOF_REQUEST { - return Err(TradeValidationReceiptJobError::UnsupportedKind); - } - - event - .verify() - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent)?; - let request_event = radroots_event_from_nostr(event); - let envelope = parse_transition_proof_request_event(&request_event)?; - if envelope.tags.worker_pubkey.as_str() != keys.public_key().to_string() { - return Err(TradeValidationReceiptJobError::MissingRecipient); - } - - let request = &envelope.content; - validate_job_request_shape(&request)?; - prover_policy.validate_shape()?; - - let job = processed_job_for_request(event, kind, &request_event)?; - match processed_job_action(runtime, &job).await? { - ProcessedJobAction::Completed => return Ok(()), - ProcessedJobAction::InProgress => return Ok(()), - ProcessedJobAction::RecoverResult { - receipt_event_id, - receipt_event_json, - result_event_id, - result_event_json, - proof_metadata, - } => { - validate_recovered_replay_execution_policy(prover_policy, request)?; - let receipt_event = signed_event_from_json(receipt_event_json.as_str())?; - if receipt_event.id.to_hex() != receipt_event_id { - return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); - } - let verified_receipt = - verify_existing_receipt_event(&receipt_event, request, prover_policy)?; - publish_result_and_complete( - event, - keys, - io, + let receipt_event_id = receipt_event.id.to_hex(); + let receipt_event_json = serde_json::to_string(&receipt_event)?; + let job = processed_job_for_receipt(accepted_event, order)?; + match runtime + .processed_jobs() + .claim_job(&job, now_unix_ms(), RHI_PROCESSED_JOB_CLAIM_LEASE_MS) + .await + .map_err(processed_job_store_error)? + { + RhiProcessedJobClaim::Execute => { + mark_receipt_publishing( runtime, &job, - &envelope, - receipt_event_id, - verified_receipt, - prover_policy, - proof_metadata.as_ref(), - result_event_id, - result_event_json, + receipt_event_id.as_str(), + receipt_event_json.as_str(), ) .await?; - return Ok(()); - } - ProcessedJobAction::RecoverReceipt { - receipt_event_id, - receipt_event_json, - } => { - validate_recovered_replay_execution_policy(prover_policy, request)?; - let receipt_event = signed_event_from_json(receipt_event_json.as_str())?; - if receipt_event.id.to_hex() != receipt_event_id { - return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); - } - let verified_receipt = - verify_existing_receipt_event(&receipt_event, request, prover_policy)?; - let published_receipt_event_id = io.publish_signed_event(receipt_event).await?; - if published_receipt_event_id != receipt_event_id { + let published_event_id = publish_signed_event_io(client, receipt_event).await?; + if published_event_id != receipt_event_id { return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); } - mark_job_receipt_published(runtime, &job, &receipt_event_id).await?; - let result_action = - match claim_job_result_publication(runtime, &job, &receipt_event_id).await? { - ResultPublicationAction::Publish(action) => action, - ResultPublicationAction::Skip => return Ok(()), - }; - publish_result_and_complete( - event, - keys, - io, + mark_receipt_published(runtime, &job, receipt_event_id.as_str()).await?; + mark_receipt_completed( runtime, &job, - &envelope, - receipt_event_id, - verified_receipt, - prover_policy, - result_action.proof_metadata.as_ref(), - result_action.result_event_id, - result_action.result_event_json, + receipt_event_id.as_str(), + receipt_event_json.as_str(), + accepted_event_created_at(accepted_event), ) - .await?; - return Ok(()); + .await } - ProcessedJobAction::Execute => {} - } - - validate_job_execution_policy(prover_policy, request)?; - - if let Some((receipt_event, verified_receipt)) = - find_existing_receipt_event(io, keys, request, prover_policy).await? - { - if verified_receipt.receipt.proof.system != RadrootsValidationReceiptProofSystem::None { - return Err(TradeValidationReceiptJobError::MissingRecoveredProofMetadata); + RhiProcessedJobClaim::InProgress | RhiProcessedJobClaim::Completed => Ok(()), + RhiProcessedJobClaim::Failed { error_code } => Err( + TradeValidationReceiptJobError::StoredProcessedJobFailed(error_code), + ), + RhiProcessedJobClaim::RecoverReceipt { + receipt_event_id, + receipt_event_json, } - let receipt_event_id = receipt_event.id.to_hex(); - let receipt_event_json = serde_json::to_string(&receipt_event)?; - mark_job_receipt_publishing( - runtime, - &job, - &receipt_event_id, - receipt_event_json.as_str(), - None, - ) - .await?; - mark_job_receipt_published(runtime, &job, &receipt_event_id).await?; - let result_action = - match claim_job_result_publication(runtime, &job, &receipt_event_id).await? { - ResultPublicationAction::Publish(action) => action, - ResultPublicationAction::Skip => return Ok(()), - }; - publish_result_and_complete( - event, - keys, - io, - runtime, - &job, - &envelope, + | RhiProcessedJobClaim::RecoverResult { receipt_event_id, - verified_receipt, - prover_policy, - result_action.proof_metadata.as_ref(), - result_action.result_event_id, - result_action.result_event_json, - ) - .await?; - return Ok(()); - } - - let listing_event = io - .fetch_event_by_id(request.listing_event_id.as_str()) - .await?; - let order_request_event = io - .fetch_event_by_id(request.request_event_id.as_str()) - .await?; - let order_decision_event = io - .fetch_event_by_id(request.decision_event_id.as_str()) - .await?; - validate_fetched_event(&listing_event, request.listing_event_id.as_str())?; - validate_fetched_event(&order_request_event, request.request_event_id.as_str())?; - validate_fetched_event(&order_decision_event, request.decision_event_id.as_str())?; - - let listing_kind = event_kind_u32(&listing_event) - .map_err(|_| TradeValidationReceiptJobError::InvalidListingEvent)?; - if !is_listing_kind(listing_kind) { - return Err(TradeValidationReceiptJobError::InvalidListingEvent); - } - - let request_rr = radroots_event_from_nostr(&order_request_event); - let decision_rr = radroots_event_from_nostr(&order_decision_event); - - let request_envelope = order_request_from_event(&request_rr).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })?; - let decision_envelope = order_decision_from_event(&decision_rr).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })?; - - let request_tags = request_rr.tags_as_vec(); - let decision_tags = decision_rr.tags_as_vec(); - - let listing_event_ptr = parse_order_listing_event_tag(&request_tags) - .map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })? - .ok_or(TradeValidationReceiptJobError::EventSetMismatch)?; - if listing_event_ptr.id != request.listing_event_id.as_str() { - return Err(TradeValidationReceiptJobError::EventSetMismatch); - } - - let root_event_id = parse_order_root_tag(&decision_tags).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })?; - let prev_event_id = parse_order_prev_tag(&decision_tags).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })?; - if root_event_id.as_deref() != Some(request.request_event_id.as_str()) - || prev_event_id.as_deref() != Some(request.request_event_id.as_str()) - { - return Err(TradeValidationReceiptJobError::EventSetMismatch); + receipt_event_json, + .. + } => { + let receipt_event = signed_event_from_json(receipt_event_json.as_str())?; + let published_event_id = publish_signed_event_io(client, receipt_event).await?; + if published_event_id != receipt_event_id { + return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); + } + mark_receipt_published(runtime, &job, receipt_event_id.as_str()).await?; + mark_receipt_completed( + runtime, + &job, + receipt_event_id.as_str(), + receipt_event_json.as_str(), + accepted_event_created_at(accepted_event), + ) + .await + } } +} - if dvm_order_request_witness_from_payload(&request_envelope.payload) != request.request { - return Err(TradeValidationReceiptJobError::EventSetMismatch); - } - if dvm_order_decision_witness_from_payload(&decision_envelope.payload) != request.decision { - return Err(TradeValidationReceiptJobError::EventSetMismatch); - } +fn deterministic_receipt_for_acceptance<'a>( + order: &TradeOrderState, + listing_event_id: &str, + root_event_id: &str, + target_event_id: &str, + validator_set_addr: RadrootsAddressableCoordinate, + validator_set_event_id: &str, + events: impl IntoIterator<Item = &'a RadrootsNostrEvent>, +) -> Result<RadrootsTradeValidationReceipt, TradeValidationReceiptJobError> { + let canonical_event_json = events + .into_iter() + .map(serde_json::to_string) + .collect::<Result<Vec<_>, _>>()?; + let event_set_root = hash32_for_parts( + "radroots:rhi-validation-event-set:v1", + canonical_event_json.iter().map(String::as_str), + ); + let previous_state_root = hash32_for_parts( + "radroots:rhi-validation-previous-state:v1", + [ + order.order_id.as_str(), + root_event_id, + workflow_state_label(RadrootsTradeWorkflowState::Requested), + ], + ); + let new_state_root = hash32_for_parts( + "radroots:rhi-validation-new-state:v1", + [ + order.order_id.as_str(), + target_event_id, + workflow_state_label(RadrootsTradeWorkflowState::AgreedPendingValidation), + ], + ); + let changed_records_root = hash32_for_parts( + "radroots:rhi-validation-changed-records:v1", + [order.order_id.as_str(), target_event_id], + ); + let public_values = DeterministicReceiptPublicValues { + changed_records_root: changed_records_root.clone(), + event_set_root: event_set_root.clone(), + listing_event_id: listing_event_id.to_string(), + new_state_root: new_state_root.clone(), + order_id: order.order_id.clone(), + previous_state_root: previous_state_root.clone(), + proof_system: RadrootsValidationReceiptProofSystem::None + .as_str() + .to_string(), + result: "valid".to_string(), + schema_version: 1, + target_event_id: target_event_id.to_string(), + validator_set_addr: validator_set_addr.as_str().to_string(), + validator_set_event_id: validator_set_event_id.to_string(), + }; + let public_values_json = serde_json::to_string(&public_values)?; + Ok(RadrootsTradeValidationReceipt { + changed_records_root, + domain: VALIDATION_RECEIPT_DOMAIN.to_string(), + error_bitmap: ZERO_ERROR_BITMAP.to_string(), + event_set_root, + new_state_root, + previous_state_root, + 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( + public_values_json.as_bytes(), + ), + receipt_type: RadrootsValidationReceiptType::TradeTransition, + result: RadrootsValidationReceiptResult::Valid, + statement: RadrootsValidationReceiptStatement { + listing_event_id: listing_event_id.to_string(), + root_event_id: root_event_id.to_string(), + target_event_id: target_event_id.to_string(), + validator_set_addr, + validator_set_event_id: validator_set_event_id.to_string(), + statement_type: RadrootsValidationReceiptType::TradeTransition, + }, + version: VALIDATION_RECEIPT_VERSION, + }) +} - let expected_evidence = canonical_dvm_event_evidence_from_event( - &listing_event, - &order_request_event, - &order_decision_event, - )?; - if expected_evidence != request.event_evidence { - return Err(TradeValidationReceiptJobError::EventSetMismatch); +fn verify_root_event( + root_event: &RadrootsNostrEvent, + order: &TradeOrderState, +) -> Result<(), TradeValidationReceiptJobError> { + let rr_event = radroots_event_from_nostr(root_event); + let record = order_event_record_from_event(&rr_event)?; + if record.order_id().as_str() != order.order_id { + return Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "root event order id mismatch".to_string(), + )); } - - validate_shared_workflow_pending_agreement( - request.listing_event_id.as_str(), - &request_rr, - &decision_rr, - )?; - - let witness = sp1_witness_from_dvm_request(request)?; - let proof_outcome = proof_bundle_for_policy(&witness, prover_policy).await?; - verify_order_acceptance_proof_artifact_structure( - &proof_outcome.bundle.execution, - &proof_outcome.bundle.proof, - )?; - let receipt = validation_receipt_for_order_acceptance_proof(&proof_outcome.bundle)?; - let receipt_parts = validation_receipt_event_build(&witness.request.order_id, &receipt)?; - let verified_receipt = verify_validation_receipt_event( - &RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: zero_event_id(), - author: keys.public_key().to_string(), - created_at: 0, - kind: receipt_parts.kind, - tags: receipt_parts.tags.clone(), - content: receipt_parts.content.clone(), - sig: zero_signature(), - }) - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent)?, - expected_receipt_binding( - request, - prover_policy, - Some(&receipt.event_set_root), - Some(&receipt.public_values_hash), - Some(&receipt.new_state_root), - Some(receipt.proof.system), - ), - )?; - let receipt_event = io.sign_event_parts( - keys, - receipt_parts.kind, - receipt_parts.content, - receipt_parts.tags, - None, - )?; - let receipt_event_id = receipt_event.id.to_hex(); - let receipt_event_json = serde_json::to_string(&receipt_event)?; - let proof_metadata = TradeValidationReceiptResultProofMetadata::from(&proof_outcome); - let proof_metadata_json = serde_json::to_string(&proof_metadata)?; - mark_job_receipt_publishing( - runtime, - &job, - &receipt_event_id, - receipt_event_json.as_str(), - Some(proof_metadata_json.as_str()), - ) - .await?; - let published_receipt_event_id = io.publish_signed_event(receipt_event).await?; - if published_receipt_event_id != receipt_event_id { - return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); + if !matches!(record, RadrootsOrderEventRecord::Request(_)) { + return Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( + "root event is not an order request".to_string(), + )); } - mark_job_receipt_published(runtime, &job, &receipt_event_id).await?; - let result_action = match claim_job_result_publication(runtime, &job, &receipt_event_id).await? - { - ResultPublicationAction::Publish(action) => action, - ResultPublicationAction::Skip => return Ok(()), - }; - - publish_result_and_complete( - event, - keys, - io, - runtime, - &job, - &envelope, - receipt_event_id, - verified_receipt, - prover_policy, - Some(&proof_metadata), - result_action.result_event_id, - result_action.result_event_json, - ) - .await?; - Ok(()) } -enum ProcessedJobAction { - Execute, - InProgress, - RecoverResult { - receipt_event_id: String, - receipt_event_json: String, - result_event_id: Option<String>, - result_event_json: Option<String>, - proof_metadata: Option<TradeValidationReceiptResultProofMetadata>, - }, - RecoverReceipt { - receipt_event_id: String, - receipt_event_json: String, - }, - Completed, -} - -struct ResultPublicationIntent { - result_event_id: Option<String>, - result_event_json: Option<String>, - proof_metadata: Option<TradeValidationReceiptResultProofMetadata>, -} - -enum ResultPublicationAction { - Publish(ResultPublicationIntent), - Skip, +fn workflow_state_label(state: RadrootsTradeWorkflowState) -> &'static str { + match state { + RadrootsTradeWorkflowState::Missing => "missing", + RadrootsTradeWorkflowState::Requested => "requested", + RadrootsTradeWorkflowState::AgreedPendingValidation => "agreed_pending_validation", + RadrootsTradeWorkflowState::Committed => "committed", + RadrootsTradeWorkflowState::Declined => "declined", + RadrootsTradeWorkflowState::Cancelled => "cancelled", + RadrootsTradeWorkflowState::ValidationExpired => "validation_expired", + RadrootsTradeWorkflowState::Invalid => "invalid", + } } -fn processed_job_for_request( - event: &RadrootsNostrEvent, - request_kind: u32, - request_event: &radroots_event::RadrootsEventEnvelope, +fn processed_job_for_receipt( + accepted_event: &RadrootsNostrEvent, + order: &TradeOrderState, ) -> Result<RhiProcessedJobState, TradeValidationReceiptJobError> { - let request_id = event.id.to_hex(); - let customer_pubkey = event.pubkey.to_hex(); Ok(RhiProcessedJobState { - request_id, - request_kind, - request_hash: request_event_hash(request_event)?, - customer_pubkey, + request_id: accepted_event.id.to_hex(), + request_kind: event_kind_u32(accepted_event)?, + request_hash: event_hash_hex(accepted_event)?, + customer_pubkey: order.buyer_pubkey.clone(), status: RhiProcessedJobStatus::Processing, receipt_event_id: None, receipt_event_json: None, @@ -1101,90 +558,16 @@ fn processed_job_for_request( result_event_json: None, proof_metadata_json: None, error_code: None, - created_timestamp: nostr_timestamp_u32(event.created_at.as_secs()), + created_timestamp: accepted_event_created_at(accepted_event), completed_timestamp: None, }) } -async fn processed_job_action( - runtime: &TradeListingRuntime, - job: &RhiProcessedJobState, -) -> Result<ProcessedJobAction, TradeValidationReceiptJobError> { - match runtime - .processed_jobs() - .claim_job(job, now_unix_ms(), RHI_PROCESSED_JOB_CLAIM_LEASE_MS) - .await - .map_err(processed_job_store_error)? - { - RhiProcessedJobClaim::Execute => Ok(ProcessedJobAction::Execute), - RhiProcessedJobClaim::InProgress => Ok(ProcessedJobAction::InProgress), - RhiProcessedJobClaim::Failed { .. } => Ok(ProcessedJobAction::Completed), - RhiProcessedJobClaim::RecoverResult { - receipt_event_id, - receipt_event_json, - result_event_id, - result_event_json, - proof_metadata_json, - } => Ok(ProcessedJobAction::RecoverResult { - receipt_event_id, - receipt_event_json, - result_event_id, - result_event_json, - proof_metadata: proof_metadata_from_json(proof_metadata_json.as_deref())?, - }), - RhiProcessedJobClaim::RecoverReceipt { - receipt_event_id, - receipt_event_json, - } => Ok(ProcessedJobAction::RecoverReceipt { - receipt_event_id, - receipt_event_json, - }), - RhiProcessedJobClaim::Completed => Ok(ProcessedJobAction::Completed), - } -} - -async fn claim_job_result_publication( - runtime: &TradeListingRuntime, - job: &RhiProcessedJobState, - receipt_event_id: &str, -) -> Result<ResultPublicationAction, TradeValidationReceiptJobError> { - match runtime - .processed_jobs() - .claim_job(job, now_unix_ms(), RHI_PROCESSED_JOB_CLAIM_LEASE_MS) - .await - .map_err(processed_job_store_error)? - { - RhiProcessedJobClaim::RecoverResult { - receipt_event_id: claimed_receipt_event_id, - receipt_event_json: _, - result_event_id, - result_event_json, - proof_metadata_json, - } => { - if claimed_receipt_event_id != receipt_event_id { - return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); - } - Ok(ResultPublicationAction::Publish(ResultPublicationIntent { - result_event_id, - result_event_json, - proof_metadata: proof_metadata_from_json(proof_metadata_json.as_deref())?, - })) - } - RhiProcessedJobClaim::InProgress - | RhiProcessedJobClaim::Completed - | RhiProcessedJobClaim::Failed { .. } => Ok(ResultPublicationAction::Skip), - RhiProcessedJobClaim::Execute | RhiProcessedJobClaim::RecoverReceipt { .. } => { - Err(TradeValidationReceiptJobError::InvalidJobRequest) - } - } -} - -async fn mark_job_receipt_publishing( +async fn mark_receipt_publishing( runtime: &TradeListingRuntime, job: &RhiProcessedJobState, receipt_event_id: &str, receipt_event_json: &str, - proof_metadata_json: Option<&str>, ) -> Result<(), TradeValidationReceiptJobError> { runtime .processed_jobs() @@ -1192,7 +575,7 @@ async fn mark_job_receipt_publishing( job, receipt_event_id, receipt_event_json, - proof_metadata_json, + None, now_unix_ms(), ) .await @@ -1200,7 +583,7 @@ async fn mark_job_receipt_publishing( Ok(()) } -async fn mark_job_receipt_published( +async fn mark_receipt_published( runtime: &TradeListingRuntime, job: &RhiProcessedJobState, receipt_event_id: &str, @@ -1213,19 +596,20 @@ async fn mark_job_receipt_published( Ok(()) } -async fn mark_job_completed( +async fn mark_receipt_completed( runtime: &TradeListingRuntime, job: &RhiProcessedJobState, receipt_event_id: &str, - result_event_id: &str, + receipt_event_json: &str, + completed_timestamp: u32, ) -> Result<(), TradeValidationReceiptJobError> { runtime .processed_jobs() - .mark_completed( + .mark_receipt_completed( job, receipt_event_id, - result_event_id, - now_unix_u32(), + receipt_event_json, + completed_timestamp, now_unix_ms(), ) .await @@ -1233,283 +617,28 @@ async fn mark_job_completed( Ok(()) } -enum TradeValidationReceiptJobIo<'a> { - Nostr { - client: &'a RadrootsNostrClient, - }, - Local { - keys: &'a RadrootsNostrKeys, - event_by_id: BTreeMap<String, RadrootsNostrEvent>, - published_events: Vec<RadrootsNostrEvent>, - publish_created_at_secs: Option<u64>, - }, -} - -impl<'a> TradeValidationReceiptJobIo<'a> { - fn local( - keys: &'a RadrootsNostrKeys, - request: &TradeValidationReceiptLocalWorkerRequest, - ) -> Self { - let mut event_by_id = BTreeMap::new(); - for event in [ - request.listing_event.clone(), - request.order_request_event.clone(), - request.order_decision_event.clone(), - ] { - event_by_id.insert(event.id.to_hex(), event); - } - for event in request.existing_receipt_events.iter().cloned() { - event_by_id.insert(event.id.to_hex(), event); - } - Self::Local { - keys, - event_by_id, - published_events: Vec::new(), - publish_created_at_secs: request.publish_created_at_secs, - } - } - - async fn fetch_event_by_id( - &mut self, - event_id: &str, - ) -> Result<RadrootsNostrEvent, TradeValidationReceiptJobError> { - match self { - Self::Nostr { client } => fetch_event_by_id_io(client, event_id).await, - Self::Local { event_by_id, .. } => event_by_id - .get(event_id) - .cloned() - .ok_or(TradeValidationReceiptJobError::EventSetMismatch), - } - } - - async fn fetch_candidate_receipts( - &mut self, - keys: &RadrootsNostrKeys, - request: &RadrootsTradeTransitionProofRequestV1, - ) -> Result<Vec<RadrootsNostrEvent>, TradeValidationReceiptJobError> { - match self { - Self::Nostr { client } => { - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom( - KIND_TRADE_VALIDATION_RECEIPT as u16, - )) - .author(keys.public_key()); - let filter = radroots_nostr_filter_tag( - filter, - "e", - vec![request.request_event_id.as_str().to_string()], - )?; - fetch_events_io(client, filter, Duration::from_secs(10)).await - } - Self::Local { event_by_id, .. } => Ok(event_by_id - .values() - .filter(|event| { - event.pubkey == keys.public_key() - && event_kind_u32(event) - .is_ok_and(|kind| kind == KIND_TRADE_VALIDATION_RECEIPT) - }) - .cloned() - .collect()), - } - } - - fn sign_event_parts( - &mut self, - keys: &RadrootsNostrKeys, - kind: u32, - content: String, - tags: Vec<Vec<String>>, - created_at_secs: Option<u64>, - ) -> Result<RadrootsNostrEvent, TradeValidationReceiptJobError> { - match self { - Self::Nostr { .. } => { - signed_event_from_parts(keys, kind, content, tags, created_at_secs) - } - Self::Local { - keys: local_keys, - publish_created_at_secs, - .. - } => { - if local_keys.public_key() != keys.public_key() { - return Err(TradeValidationReceiptJobError::MissingRecipient); - } - signed_event_from_parts( - keys, - kind, - content, - tags, - (*publish_created_at_secs).or(created_at_secs), - ) - } - } - } - - async fn publish_signed_event( - &mut self, - event: RadrootsNostrEvent, - ) -> Result<String, TradeValidationReceiptJobError> { - match self { - Self::Nostr { client } => publish_signed_event_io(client, event).await, - Self::Local { - event_by_id, - published_events, - .. - } => { - let event_id = event.id.to_hex(); - event_by_id.insert(event_id.clone(), event.clone()); - published_events.push(event); - Ok(event_id) - } - } - } - - fn into_published_events(self) -> Vec<RadrootsNostrEvent> { - match self { - Self::Nostr { .. } => Vec::new(), - Self::Local { - published_events, .. - } => published_events, - } - } -} - -async fn find_existing_receipt_event( - io: &mut TradeValidationReceiptJobIo<'_>, - keys: &RadrootsNostrKeys, - request: &RadrootsTradeTransitionProofRequestV1, - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result< - Option<(RadrootsNostrEvent, RadrootsVerifiedValidationReceipt)>, - TradeValidationReceiptJobError, -> { - let event = io.fetch_candidate_receipts(keys, request).await?; - let mut matches = Vec::new(); - for event in event { - if let Ok(verified) = verify_existing_receipt_event(&event, request, prover_policy) { - matches.push((event, verified)); - } - } - if matches.len() > 1 { - return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt); - } - Ok(matches.pop()) -} - -fn verify_existing_receipt_event( - event: &RadrootsNostrEvent, - request: &RadrootsTradeTransitionProofRequestV1, - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result<RadrootsVerifiedValidationReceipt, TradeValidationReceiptJobError> { - event - .verify() - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent)?; - verify_validation_receipt_event( - &radroots_event_from_nostr(event), - expected_receipt_binding(request, prover_policy, None, None, None, None), - ) - .map_err(receipt_verification_error) -} - -fn receipt_verification_error( - error: RadrootsValidationReceiptError, -) -> TradeValidationReceiptJobError { - match error { - RadrootsValidationReceiptError::ExpectedBindingMismatch( - "proof_system" | "program_hash" | "verifying_key_hash", - ) => TradeValidationReceiptJobError::RecoveredResultPolicyMismatch, - error => TradeValidationReceiptJobError::from(error), +async fn fetch_event_by_id_io( + client: &RadrootsNostrClient, + event_id: &str, +) -> Result<RadrootsNostrEvent, TradeValidationReceiptJobError> { + #[cfg(test)] + if let Some(event) = pop_fetch_event_by_id_hook() { + return event; } + Ok(radroots_nostr_fetch_event_by_id(client, event_id).await?) } -async fn publish_result_and_complete( - request_event: &RadrootsNostrEvent, - keys: &RadrootsNostrKeys, - io: &mut TradeValidationReceiptJobIo<'_>, - runtime: &TradeListingRuntime, - job: &RhiProcessedJobState, - envelope: &RadrootsTradeTransitionProofRequestEnvelope, - receipt_event_id: String, - verified_receipt: RadrootsVerifiedValidationReceipt, - prover_policy: &TradeValidationReceiptProverPolicy, - proof_metadata: Option<&TradeValidationReceiptResultProofMetadata>, - claimed_result_event_id: Option<String>, - claimed_result_event_json: Option<String>, -) -> Result<(), TradeValidationReceiptJobError> { - if let Some(result_event_json) = claimed_result_event_json { - let result_event: RadrootsNostrEvent = serde_json::from_str(result_event_json.as_str())?; - result_event - .verify() - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent)?; - validate_recovered_result_event( - &result_event, - request_event, - job, - envelope, - &receipt_event_id, - &verified_receipt, - prover_policy, - )?; - validate_job_execution_policy(prover_policy, &envelope.content)?; - let result_event_id = result_event.id.to_hex(); - if claimed_result_event_id - .as_ref() - .is_some_and(|claimed| claimed != &result_event_id) - { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); - } - let published_result_event_id = io.publish_signed_event(result_event).await?; - if published_result_event_id != result_event_id { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); - } - return mark_job_completed(runtime, job, &receipt_event_id, &result_event_id).await; - } - validate_job_execution_policy(prover_policy, &envelope.content)?; - let result = result_payload( - request_event, - job, - envelope, - &receipt_event_id, - verified_receipt, - prover_policy, - proof_metadata, - )?; - let result_content = serde_json::to_string(&result)?; - let result_tags = - result_tags_from_dvm(request_event, &envelope.tags.inputs, &receipt_event_id)?; - let result_event = signed_event_from_parts( - keys, - KIND_TRADE_TRANSITION_PROOF_RESULT, - result_content, - result_tags, - Some(request_event.created_at.as_secs()), - )?; - let result_event_id = result_event.id.to_hex(); - if claimed_result_event_id - .as_ref() - .is_some_and(|claimed| claimed != &result_event_id) - { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); - } - let result_event_json = serde_json::to_string(&result_event)?; - let intent = runtime - .processed_jobs() - .mark_result_publishing( - job, - &receipt_event_id, - &result_event_id, - result_event_json.as_str(), - now_unix_ms(), - ) - .await - .map_err(processed_job_store_error)?; - if intent.status == RhiProcessedJobStatus::Completed { - return Ok(()); - } - let published_result_event_id = io.publish_signed_event(result_event).await?; - if published_result_event_id != result_event_id { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); +async fn publish_signed_event_io( + client: &RadrootsNostrClient, + event: RadrootsNostrEvent, +) -> Result<String, TradeValidationReceiptJobError> { + #[cfg(test)] + if let Some(result) = pop_publish_signed_event_hook(&event) { + result?; + return Ok(event.id.to_hex()); } - mark_job_completed(runtime, job, &receipt_event_id, &result_event_id).await + let output = client.send_event(&event).await?; + Ok(output.val.to_hex()) } fn signed_event_from_parts( @@ -1538,598 +667,68 @@ fn signed_event_from_json( Ok(event) } -fn proof_metadata_from_json( - value: Option<&str>, -) -> Result<Option<TradeValidationReceiptResultProofMetadata>, TradeValidationReceiptJobError> { - value - .map(serde_json::from_str::<TradeValidationReceiptResultProofMetadata>) - .transpose() - .map_err(Into::into) +fn event_kind_u32(event: &RadrootsNostrEvent) -> Result<u32, TradeValidationReceiptJobError> { + match event.kind { + RadrootsNostrKind::Custom(value) => Ok(u32::from(value)), + _ => Err(TradeValidationReceiptJobError::UnsupportedKind), + } } -fn validate_recovered_result_event( - event: &RadrootsNostrEvent, - request_event: &RadrootsNostrEvent, - job: &RhiProcessedJobState, - envelope: &RadrootsTradeTransitionProofRequestEnvelope, - receipt_event_id: &str, - verified_receipt: &RadrootsVerifiedValidationReceipt, - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result<(), TradeValidationReceiptJobError> { - if event_kind_u32(event)? != KIND_TRADE_TRANSITION_PROOF_RESULT { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); - } - if event.pubkey.to_hex() != envelope.tags.worker_pubkey.as_str() { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); - } - let result: TradeValidationReceiptJobResult = serde_json::from_str(event.content.as_str())?; - validate_recovered_result_payload( - &result, - request_event, - job, - envelope, - receipt_event_id, - verified_receipt, - prover_policy, - )?; - let expected_tags = - result_tags_from_dvm(request_event, &envelope.tags.inputs, receipt_event_id)?; - let event_tags: Vec<Vec<String>> = event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect(); - if event_tags != expected_tags { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); +fn accepted_event_created_at(event: &RadrootsNostrEvent) -> u32 { + u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX) +} + +fn event_hash_hex(event: &RadrootsNostrEvent) -> Result<String, TradeValidationReceiptJobError> { + let canonical_event_json = serde_json::to_string(event)?; + Ok(hash_hex_for_bytes(canonical_event_json.as_bytes())) +} + +fn hash32_for_parts<'a>(domain: &str, parts: impl IntoIterator<Item = &'a str>) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain.as_bytes()); + for part in parts { + hasher.update([0]); + hasher.update(part.as_bytes()); } - Ok(()) + format!("0x{}", lower_hex(hasher.finalize().as_slice())) } -fn validate_recovered_result_payload( - result: &TradeValidationReceiptJobResult, - request_event: &RadrootsNostrEvent, - job: &RhiProcessedJobState, - envelope: &RadrootsTradeTransitionProofRequestEnvelope, - receipt_event_id: &str, - verified_receipt: &RadrootsVerifiedValidationReceipt, - prover_policy: &TradeValidationReceiptProverPolicy, -) -> Result<(), TradeValidationReceiptJobError> { - let request = &envelope.content; - if result.prover_backend != prover_policy.backend - || result.proof_mode != prover_policy.proof_mode - { - return Err(TradeValidationReceiptJobError::RecoveredResultPolicyMismatch); +fn hash_hex_for_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + lower_hex(hasher.finalize().as_slice()) +} + +fn lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + value.push(HEX[(byte >> 4) as usize] as char); + value.push(HEX[(byte & 0x0f) as usize] as char); } - let proof_metadata = TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: result.cryptographic_proof_verified, - proof_generated: result.proof_generated, - sp1_execute_checked: result.sp1_execute_checked, - sp1_execute_public_values_hash: result.sp1_execute_public_values_hash.clone(), - }; - validate_recovered_proof_metadata(verified_receipt, prover_policy, Some(&proof_metadata))?; - let expected_authority = validation_authority_for_result(prover_policy, Some(&proof_metadata)); - let expected_confidence = - commitment_confidence_for_result(verified_receipt.receipt.result, expected_authority); - if result.status != TradeValidationReceiptJobStatus::Succeeded - || result.worker_role != TradeValidationReceiptWorkerRole::NonAuthoritativeProver - || result.receipt_event_id != receipt_event_id - || result.receipt_kind != KIND_TRADE_VALIDATION_RECEIPT - || result.request_hash != job.request_hash - || result.customer_pubkey != request_event.pubkey.to_hex() - || result.worker_pubkey != envelope.tags.worker_pubkey.as_str() - || result.order_id != request.request.order_id.as_str() - || result.listing_event_id != request.listing_event_id.as_str() - || result.request_event_id != request.request_event_id.as_str() - || result.decision_event_id != request.decision_event_id.as_str() - || result.event_set_root != verified_receipt.receipt.event_set_root - || result.reducer_output_root != verified_receipt.receipt.new_state_root - || result.public_values_hash != verified_receipt.receipt.public_values_hash - || result.proof_system != verified_receipt.receipt.proof.system.as_str() - || sp1_proof_mode_label(result.proof_mode) - != verified_receipt - .receipt - .proof - .mode - .as_deref() - .unwrap_or("none") - || result.validation_authority != expected_authority - || result.confidence != expected_confidence + value +} + +fn validate_optional_hash32(value: &Option<String>) -> Result<(), TradeValidationReceiptJobError> { + if let Some(value) = value + && (value.len() != 66 || !value.starts_with("0x") || !is_lower_hex(&value[2..])) { - return Err(TradeValidationReceiptJobError::RecoveredResultMismatch); + return Err(TradeValidationReceiptJobError::InvalidHashField); } Ok(()) } -fn result_payload( - request_event: &RadrootsNostrEvent, - job: &RhiProcessedJobState, - envelope: &RadrootsTradeTransitionProofRequestEnvelope, - receipt_event_id: &str, - verified_receipt: RadrootsVerifiedValidationReceipt, - prover_policy: &TradeValidationReceiptProverPolicy, - proof_metadata: Option<&TradeValidationReceiptResultProofMetadata>, -) -> Result<TradeValidationReceiptJobResult, TradeValidationReceiptJobError> { - let request = &envelope.content; - let proof_metadata = - validate_recovered_proof_metadata(&verified_receipt, prover_policy, proof_metadata)?; - let proof_generated = proof_metadata - .map(|metadata| metadata.proof_generated) - .unwrap_or(false); - let validation_authority = validation_authority_for_result(prover_policy, proof_metadata); - let confidence = - commitment_confidence_for_result(verified_receipt.receipt.result, validation_authority); - Ok(TradeValidationReceiptJobResult { - cryptographic_proof_verified: proof_metadata - .map(|metadata| metadata.cryptographic_proof_verified) - .unwrap_or(proof_generated), - decision_event_id: request.decision_event_id.as_str().to_string(), - event_set_root: verified_receipt.receipt.event_set_root, - listing_event_id: request.listing_event_id.as_str().to_string(), - order_id: request.request.order_id.as_str().to_string(), - proof_generated, - proof_mode: prover_policy.proof_mode, - proof_system: verified_receipt.receipt.proof.system.as_str().to_string(), - public_values_hash: verified_receipt.receipt.public_values_hash, - prover_backend: prover_policy.backend, - receipt_event_id: receipt_event_id.to_string(), - receipt_kind: KIND_TRADE_VALIDATION_RECEIPT, - reducer_output_root: verified_receipt.receipt.new_state_root, - request_event_id: request.request_event_id.as_str().to_string(), - request_hash: job.request_hash.clone(), - customer_pubkey: request_event.pubkey.to_hex(), - worker_pubkey: envelope.tags.worker_pubkey.as_str().to_string(), - sp1_execute_checked: proof_metadata - .map(|metadata| metadata.sp1_execute_checked) - .unwrap_or(false), - sp1_execute_public_values_hash: proof_metadata - .and_then(|metadata| metadata.sp1_execute_public_values_hash.clone()), - status: TradeValidationReceiptJobStatus::Succeeded, - validation_authority, - confidence, - worker_role: TradeValidationReceiptWorkerRole::NonAuthoritativeProver, - }) +fn is_lower_hex(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } -fn validate_recovered_proof_metadata<'a>( - verified_receipt: &RadrootsVerifiedValidationReceipt, - prover_policy: &TradeValidationReceiptProverPolicy, - proof_metadata: Option<&'a TradeValidationReceiptResultProofMetadata>, -) -> Result<Option<&'a TradeValidationReceiptResultProofMetadata>, TradeValidationReceiptJobError> { - let proof_generated_expected = - verified_receipt.receipt.proof.system != RadrootsValidationReceiptProofSystem::None; - let Some(proof_metadata) = proof_metadata else { - if prover_policy.backend == TradeValidationReceiptProverBackend::DeterministicNone - && !proof_generated_expected - { - return Ok(None); - } - return Err(TradeValidationReceiptJobError::MissingRecoveredProofMetadata); +fn now_unix_ms() -> i64 { + let Ok(elapsed) = SystemTime::now().duration_since(UNIX_EPOCH) else { + return 0; }; - if proof_metadata.proof_generated != proof_generated_expected - || proof_metadata.cryptographic_proof_verified != proof_generated_expected - { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - if proof_metadata.sp1_execute_checked { - if proof_metadata.sp1_execute_public_values_hash.as_deref() - != Some(verified_receipt.receipt.public_values_hash.as_str()) - { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - } else if proof_metadata.sp1_execute_public_values_hash.is_some() || proof_generated_expected { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - match prover_policy.backend { - TradeValidationReceiptProverBackend::DeterministicNone => { - if proof_metadata.proof_generated - || proof_metadata.cryptographic_proof_verified - || proof_metadata.sp1_execute_checked - || proof_metadata.sp1_execute_public_values_hash.is_some() - { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - } - TradeValidationReceiptProverBackend::LocalExecute => { - if proof_generated_expected - || proof_metadata.proof_generated - || proof_metadata.cryptographic_proof_verified - || !proof_metadata.sp1_execute_checked - { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - } - TradeValidationReceiptProverBackend::LocalCpuProve - | TradeValidationReceiptProverBackend::RemoteHttpProve => { - if !proof_generated_expected - || !proof_metadata.proof_generated - || !proof_metadata.cryptographic_proof_verified - || !proof_metadata.sp1_execute_checked - { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - } - TradeValidationReceiptProverBackend::Disabled - | TradeValidationReceiptProverBackend::LocalCudaProve => { - return Err(TradeValidationReceiptJobError::RecoveredProofMetadataMismatch); - } - } - Ok(Some(proof_metadata)) -} - -fn sp1_proof_mode_label(mode: RadrootsSp1TradeProofMode) -> &'static str { - mode.mode_label().unwrap_or("none") -} - -fn validation_authority_for_result( - prover_policy: &TradeValidationReceiptProverPolicy, - proof_metadata: Option<&TradeValidationReceiptResultProofMetadata>, -) -> RadrootsTradeValidationAuthority { - let proof_verified = proof_metadata - .map(|metadata| metadata.cryptographic_proof_verified) - .unwrap_or(false); - match prover_policy.backend { - TradeValidationReceiptProverBackend::DeterministicNone => { - RadrootsTradeValidationAuthority::DevDeterministicOnly - } - TradeValidationReceiptProverBackend::LocalCpuProve - | TradeValidationReceiptProverBackend::RemoteHttpProve - if proof_verified => - { - RadrootsTradeValidationAuthority::TrustedServiceAndProofVerified - } - TradeValidationReceiptProverBackend::LocalExecute => { - RadrootsTradeValidationAuthority::TrustedRhiServiceKey - } - _ if proof_verified => RadrootsTradeValidationAuthority::CryptographicProofVerified, - _ => RadrootsTradeValidationAuthority::TrustedRhiServiceKey, - } -} - -fn commitment_confidence_for_result( - result: RadrootsValidationReceiptResult, - authority: RadrootsTradeValidationAuthority, -) -> RadrootsTradeCommitmentConfidence { - if result == RadrootsValidationReceiptResult::Invalid { - return RadrootsTradeCommitmentConfidence::Invalid; - } - match authority { - RadrootsTradeValidationAuthority::DevDeterministicOnly => { - RadrootsTradeCommitmentConfidence::LocalOnly - } - RadrootsTradeValidationAuthority::TrustedRhiServiceKey => { - RadrootsTradeCommitmentConfidence::CommittedByTrustedService - } - RadrootsTradeValidationAuthority::CryptographicProofVerified => { - RadrootsTradeCommitmentConfidence::CommittedByCryptographicProof - } - RadrootsTradeValidationAuthority::TrustedServiceAndProofVerified => { - RadrootsTradeCommitmentConfidence::CommittedByTrustedServiceAndProof - } - } -} - -fn result_tags_from_dvm( - request_event: &RadrootsNostrEvent, - inputs: &[radroots_trade::dvm::RadrootsTradeDvmInputTag], - receipt_event_id: &str, -) -> Result<Vec<Vec<String>>, TradeValidationReceiptJobError> { - let request = radroots_event_from_nostr(request_event); - let envelope = parse_transition_proof_request_event(&request)?; - let binding = RadrootsTradeTransitionProofResultBinding { - listing_event_id: envelope.content.listing_event_id, - root_event_id: envelope.content.request_event_id, - target_event_id: envelope.content.decision_event_id, - validation_receipt_event_id: Some( - RadrootsEventId::parse(receipt_event_id) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?, - ), - }; - let customer_pubkey = radroots_event::ids::RadrootsPublicKey::parse(request.author_str()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?; - build_transition_proof_result_tags(&request, &customer_pubkey, inputs, &binding) - .map_err(TradeValidationReceiptJobError::from) -} - -fn expected_receipt_binding<'a>( - request: &'a RadrootsTradeTransitionProofRequestV1, - prover_policy: &'a TradeValidationReceiptProverPolicy, - event_set_root: Option<&'a str>, - public_values_hash: Option<&'a str>, - reducer_output_root: Option<&'a str>, - proof_system: Option<RadrootsValidationReceiptProofSystem>, -) -> RadrootsValidationReceiptExpectedBinding<'a> { - RadrootsValidationReceiptExpectedBinding { - event_set_root, - listing_event_id: Some(request.listing_event_id.as_str()), - order_id: Some(request.request.order_id.as_str()), - program_hash: prover_policy.expected_sp1_program_hash.as_deref(), - proof_system: proof_system.or(Some(prover_policy.proof_mode.proof_system())), - public_values_hash, - reducer_output_root, - root_event_id: Some(request.request_event_id.as_str()), - target_event_id: Some(request.decision_event_id.as_str()), - verifying_key_hash: prover_policy.expected_sp1_verifying_key_hash.as_deref(), - } -} - -fn sp1_witness_from_dvm_request( - request: &RadrootsTradeTransitionProofRequestV1, -) -> Result<RadrootsSp1TradeOrderAcceptanceWitness, TradeValidationReceiptJobError> { - Ok(RadrootsSp1TradeOrderAcceptanceWitness { - witness_version: request.witness_version, - proof_target: request.proof_target.clone(), - listing_event_id: request.listing_event_id.as_str().to_string(), - request_event_id: request.request_event_id.as_str().to_string(), - decision_event_id: request.decision_event_id.as_str().to_string(), - event_evidence: request - .event_evidence - .iter() - .map(sp1_event_evidence_from_dvm) - .collect(), - request: sp1_order_request_witness_from_dvm(&request.request), - decision: sp1_order_decision_witness_from_dvm(&request.decision), - inventory_bins: request - .inventory_bins - .iter() - .map(sp1_inventory_bin_witness_from_dvm) - .collect(), - inventory_sequence: request.inventory_sequence, - previous_state_root: request.previous_state_root.clone(), - reducer_program_hash: request.reducer_program_hash.clone(), - radroots_protocol_version: request.radroots_protocol_version.clone(), - sp1_program_hash: request.sp1_program_hash.clone(), - sp1_verifying_key_hash: request.sp1_verifying_key_hash.clone(), - }) -} - -fn sp1_event_evidence_from_dvm( - evidence: &RadrootsTradeCanonicalEventEvidenceDto, -) -> RadrootsSp1TradeCanonicalEventEvidence { - RadrootsSp1TradeCanonicalEventEvidence { - event_id: evidence.event_id.as_str().to_string(), - signer_pubkey: evidence.signer_pubkey.as_str().to_string(), - kind: evidence.kind, - canonical_event_hash: evidence.canonical_event_hash.clone(), - signature_hash: evidence.signature_hash.clone(), - preverified_signature: evidence.preverified_signature, - role: match evidence.role { - RadrootsTradeCanonicalEventEvidenceRole::Buyer => { - RadrootsSp1TradeEventEvidenceRole::Buyer - } - RadrootsTradeCanonicalEventEvidenceRole::Seller => { - RadrootsSp1TradeEventEvidenceRole::Seller - } - }, - workflow_position: match evidence.workflow_position { - RadrootsTradeCanonicalEventWorkflowPosition::Listing => { - RadrootsSp1TradeEventWorkflowPosition::Listing - } - RadrootsTradeCanonicalEventWorkflowPosition::OrderRequest => { - RadrootsSp1TradeEventWorkflowPosition::OrderRequest - } - RadrootsTradeCanonicalEventWorkflowPosition::OrderDecision => { - RadrootsSp1TradeEventWorkflowPosition::OrderDecision - } - }, - content_hash: evidence.content_hash.clone(), - tags_hash: evidence.tags_hash.clone(), - ordering_key: evidence.ordering_key.clone(), - } -} - -fn sp1_order_request_witness_from_dvm( - request: &RadrootsOrderRequestWitnessDto, -) -> RadrootsSp1TradeOrderRequestWitness { - RadrootsSp1TradeOrderRequestWitness { - order_id: request.order_id.as_str().to_string(), - listing_addr: request.listing_addr.as_str().to_string(), - buyer_pubkey: request.buyer_pubkey.as_str().to_string(), - seller_pubkey: request.seller_pubkey.as_str().to_string(), - items: request - .items - .iter() - .map(|item| RadrootsSp1TradeOrderItemWitness { - bin_id: item.bin_id.as_str().to_string(), - bin_count: item.bin_count, - }) - .collect(), - } -} - -fn sp1_order_decision_witness_from_dvm( - decision: &RadrootsOrderDecisionEventWitnessDto, -) -> RadrootsSp1TradeOrderDecisionEventWitness { - RadrootsSp1TradeOrderDecisionEventWitness { - order_id: decision.order_id.as_str().to_string(), - listing_addr: decision.listing_addr.as_str().to_string(), - buyer_pubkey: decision.buyer_pubkey.as_str().to_string(), - seller_pubkey: decision.seller_pubkey.as_str().to_string(), - decision: match &decision.decision { - RadrootsOrderDecisionWitnessDto::Accepted { - inventory_commitments, - } => RadrootsSp1TradeOrderDecisionWitness::Accepted { - inventory_commitments: inventory_commitments - .iter() - .map(|commitment| RadrootsSp1TradeInventoryCommitmentWitness { - bin_id: commitment.bin_id.as_str().to_string(), - bin_count: commitment.bin_count, - }) - .collect(), - }, - RadrootsOrderDecisionWitnessDto::Declined { reason } => { - RadrootsSp1TradeOrderDecisionWitness::Declined { - reason: reason.clone(), - } - } - }, - } -} - -fn sp1_inventory_bin_witness_from_dvm( - bin: &RadrootsTradeInventoryBinWitnessDto, -) -> RadrootsSp1TradeInventoryBinWitness { - RadrootsSp1TradeInventoryBinWitness { - bin_id: bin.bin_id.as_str().to_string(), - listing_capacity: bin.listing_capacity, - previous_reserved: bin.previous_reserved, - } -} - -fn dvm_order_request_witness_from_payload( - payload: &RadrootsOrderRequest, -) -> RadrootsOrderRequestWitnessDto { - RadrootsOrderRequestWitnessDto { - order_id: payload.order_id.clone(), - listing_addr: payload.listing_addr.clone(), - buyer_pubkey: payload.buyer_pubkey.clone(), - seller_pubkey: payload.seller_pubkey.clone(), - items: payload - .items - .iter() - .map(|item| RadrootsOrderItemWitnessDto { - bin_id: item.bin_id.clone(), - bin_count: item.bin_count, - }) - .collect(), - } -} - -fn dvm_order_decision_witness_from_payload( - payload: &RadrootsOrderDecision, -) -> RadrootsOrderDecisionEventWitnessDto { - RadrootsOrderDecisionEventWitnessDto { - order_id: payload.order_id.clone(), - listing_addr: payload.listing_addr.clone(), - buyer_pubkey: payload.buyer_pubkey.clone(), - seller_pubkey: payload.seller_pubkey.clone(), - decision: match &payload.decision { - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments, - } => RadrootsOrderDecisionWitnessDto::Accepted { - inventory_commitments: inventory_commitments - .iter() - .map(|commitment| RadrootsTradeInventoryCommitmentWitnessDto { - bin_id: commitment.bin_id.clone(), - bin_count: commitment.bin_count, - }) - .collect(), - }, - RadrootsOrderDecisionOutcome::Declined { reason } => { - RadrootsOrderDecisionWitnessDto::Declined { - reason: reason.clone(), - } - } - }, - } -} - -fn canonical_dvm_event_evidence_from_event( - listing_event: &RadrootsNostrEvent, - order_request_event: &RadrootsNostrEvent, - order_decision_event: &RadrootsNostrEvent, -) -> Result<Vec<RadrootsTradeCanonicalEventEvidenceDto>, TradeValidationReceiptJobError> { - Ok(vec![ - canonical_dvm_event_evidence( - listing_event, - RadrootsTradeCanonicalEventEvidenceRole::Seller, - RadrootsTradeCanonicalEventWorkflowPosition::Listing, - "001:listing", - )?, - canonical_dvm_event_evidence( - order_request_event, - RadrootsTradeCanonicalEventEvidenceRole::Buyer, - RadrootsTradeCanonicalEventWorkflowPosition::OrderRequest, - "002:order_request", - )?, - canonical_dvm_event_evidence( - order_decision_event, - RadrootsTradeCanonicalEventEvidenceRole::Seller, - RadrootsTradeCanonicalEventWorkflowPosition::OrderDecision, - "003:order_decision", - )?, - ]) -} - -fn canonical_dvm_event_evidence( - event: &RadrootsNostrEvent, - role: RadrootsTradeCanonicalEventEvidenceRole, - workflow_position: RadrootsTradeCanonicalEventWorkflowPosition, - ordering_key: &'static str, -) -> Result<RadrootsTradeCanonicalEventEvidenceDto, TradeValidationReceiptJobError> { - event - .verify() - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent)?; - let canonical_event_json = serde_json::to_string(event)?; - let tags_json = serde_json::to_vec(&event.tags)?; - Ok(RadrootsTradeCanonicalEventEvidenceDto { - event_id: RadrootsEventId::parse(event.id.to_hex()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?, - signer_pubkey: radroots_event::ids::RadrootsPublicKey::parse(event.pubkey.to_hex()) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?, - kind: event_kind_u32(event)?, - canonical_event_hash: hash_bytes( - "radroots:canonical-event:v1", - canonical_event_json.as_bytes(), - ), - signature_hash: hash_bytes( - "radroots:event-signature:v1", - event.sig.to_string().as_bytes(), - ), - preverified_signature: true, - role, - workflow_position, - content_hash: hash_bytes("radroots:event-content:v1", event.content.as_bytes()), - tags_hash: hash_bytes("radroots:event-tags:v1", &tags_json), - ordering_key: ordering_key.to_string(), - }) -} - -fn sp1_proof_mode_from_dvm(mode: RadrootsTradeProofMode) -> RadrootsSp1TradeProofMode { - match mode { - RadrootsTradeProofMode::None => RadrootsSp1TradeProofMode::None, - RadrootsTradeProofMode::Core => RadrootsSp1TradeProofMode::Core, - RadrootsTradeProofMode::Compressed => RadrootsSp1TradeProofMode::Compressed, - RadrootsTradeProofMode::Groth16 => RadrootsSp1TradeProofMode::Groth16, - RadrootsTradeProofMode::Plonk => RadrootsSp1TradeProofMode::Plonk, - } -} - -fn dvm_proof_mode_from_sp1(mode: RadrootsSp1TradeProofMode) -> RadrootsTradeProofMode { - match mode { - RadrootsSp1TradeProofMode::None => RadrootsTradeProofMode::None, - RadrootsSp1TradeProofMode::Core => RadrootsTradeProofMode::Core, - RadrootsSp1TradeProofMode::Compressed => RadrootsTradeProofMode::Compressed, - RadrootsSp1TradeProofMode::Groth16 => RadrootsTradeProofMode::Groth16, - RadrootsSp1TradeProofMode::Plonk => RadrootsTradeProofMode::Plonk, - } -} - -fn request_event_hash( - request_event: &radroots_event::RadrootsEventEnvelope, -) -> Result<String, TradeValidationReceiptJobError> { - Ok(hash_bytes( - "radroots:rhi-dvm-request-event:v1", - serde_json::to_vec(request_event)?.as_slice(), - )) -} - -fn nostr_timestamp_u32(value: u64) -> u32 { - u32::try_from(value).unwrap_or(u32::MAX) -} - -fn now_unix_u32() -> u32 { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - nostr_timestamp_u32(seconds) -} - -fn now_unix_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) - .unwrap_or(0) + i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX) } fn processed_job_store_error(error: RhiProcessedJobStoreError) -> TradeValidationReceiptJobError { @@ -2143,5075 +742,107 @@ fn processed_job_store_error(error: RhiProcessedJobStoreError) -> TradeValidatio RhiProcessedJobStoreError::DuplicateConflictingResult => { TradeValidationReceiptJobError::DuplicateConflictingResult } - error => TradeValidationReceiptJobError::Runtime(TradeListingRuntimeError::from(error)), - } -} - -fn validate_shared_workflow_pending_agreement( - listing_event_id: &str, - request_event: &radroots_event::RadrootsEventEnvelope, - decision_event: &radroots_event::RadrootsEventEnvelope, -) -> Result<(), TradeValidationReceiptJobError> { - let request_record = match order_event_record_from_event(request_event).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })? { - RadrootsOrderEventRecord::Request(record) => record, - _ => return Err(TradeValidationReceiptJobError::EventSetMismatch), - }; - let decision_record = match order_event_record_from_event(decision_event).map_err(|error| { - TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) - })? { - RadrootsOrderEventRecord::Decision(record) => record, - _ => return Err(TradeValidationReceiptJobError::EventSetMismatch), - }; - let listing_event_id = RadrootsEventId::parse(listing_event_id) - .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?; - let order_id = request_record.payload.order_id.clone(); - let projection = reduce_trade_workflow_records( - &order_id, - RadrootsTradeWorkflowRecords { - order_events: RadrootsGroupedOrderEventRecords { - requests: vec![request_record], - decisions: vec![decision_record], - revision_proposals: Vec::new(), - revision_decisions: Vec::new(), - cancellations: Vec::new(), - }, - validation_receipts: Vec::new(), - deterministic_failures: Vec::new(), - expected_listing_event_id: Some(listing_event_id.clone()), - current_listing_event_id: Some(listing_event_id), - }, - ); - if projection.status == RadrootsTradeWorkflowState::AgreedPendingRhi - && projection.issues.is_empty() - { - return Ok(()); - } - Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( - format!("{:?}:{:?}", projection.status, projection.issues), - )) -} - -fn validate_fetched_event( - event: &RadrootsNostrEvent, - expected_event_id: &str, -) -> Result<(), TradeValidationReceiptJobError> { - if event.id.to_hex() != expected_event_id { - return Err(TradeValidationReceiptJobError::EventSetMismatch); - } - event - .verify() - .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent) -} - -fn hash_bytes(domain: &'static str, bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(domain.as_bytes()); - hasher.update(bytes); - format!("0x{}", hex_lower(hasher.finalize().as_slice())) -} - -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out -} - -struct TradeValidationReceiptProofOutcome { - bundle: RadrootsSp1TradeProofBundle, - proof_generated: bool, - sp1_execute_checked: bool, - sp1_execute_public_values_hash: Option<String>, - cryptographic_proof_verified: bool, -} - -impl From<&TradeValidationReceiptProofOutcome> for TradeValidationReceiptResultProofMetadata { - fn from(outcome: &TradeValidationReceiptProofOutcome) -> Self { - Self { - cryptographic_proof_verified: outcome.cryptographic_proof_verified, - proof_generated: outcome.proof_generated, - sp1_execute_checked: outcome.sp1_execute_checked, - sp1_execute_public_values_hash: outcome.sp1_execute_public_values_hash.clone(), - } - } -} - -async fn proof_bundle_for_policy( - witness: &RadrootsSp1TradeOrderAcceptanceWitness, - policy: &TradeValidationReceiptProverPolicy, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - match policy.backend { - TradeValidationReceiptProverBackend::Disabled => { - Err(TradeValidationReceiptJobError::ProverBackendDisabled) - } - TradeValidationReceiptProverBackend::DeterministicNone => { - let bundle = generate_order_acceptance_proof(witness, policy.proof_mode)?; - Ok(TradeValidationReceiptProofOutcome { - bundle, - proof_generated: false, - sp1_execute_checked: false, - sp1_execute_public_values_hash: None, - cryptographic_proof_verified: false, - }) - } - TradeValidationReceiptProverBackend::LocalExecute => { - run_local_execute_backend(witness, policy.proof_mode).await - } - TradeValidationReceiptProverBackend::LocalCpuProve => { - run_local_cpu_prove_backend(witness, policy.proof_mode).await - } - TradeValidationReceiptProverBackend::LocalCudaProve => Err( - TradeValidationReceiptJobError::ProverBackendUnavailable(policy.backend.as_str()), - ), - TradeValidationReceiptProverBackend::RemoteHttpProve => { - run_remote_http_prove_backend(witness, policy).await - } + other => TradeValidationReceiptJobError::ProcessedJobStore(other), } } -#[cfg(feature = "sp1_proving")] -async fn run_local_execute_backend( - witness: &RadrootsSp1TradeOrderAcceptanceWitness, - proof_mode: RadrootsSp1TradeProofMode, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - let sp1_execution = - radroots_trade_sp1_host::execute_order_acceptance_sp1_public_values(witness).await?; - let bundle = generate_order_acceptance_proof(witness, proof_mode)?; - Ok(TradeValidationReceiptProofOutcome { - bundle, - proof_generated: false, - sp1_execute_checked: true, - sp1_execute_public_values_hash: Some(sp1_execution.execution.public_values_hash), - cryptographic_proof_verified: false, - }) -} - -#[cfg(not(feature = "sp1_proving"))] -async fn run_local_execute_backend( - _witness: &RadrootsSp1TradeOrderAcceptanceWitness, - _proof_mode: RadrootsSp1TradeProofMode, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - TradeValidationReceiptProverBackend::LocalExecute.as_str(), - )) -} - -#[cfg(feature = "sp1_proving")] -async fn run_local_cpu_prove_backend( - witness: &RadrootsSp1TradeOrderAcceptanceWitness, - proof_mode: RadrootsSp1TradeProofMode, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - let bundle = - radroots_trade_sp1_host::generate_order_acceptance_sp1_proof(witness, proof_mode).await?; - radroots_trade_sp1_host::verify_order_acceptance_resolved_sp1_proof_artifact( - &bundle.execution, - &RadrootsSp1TradeResolvedProofArtifact::inline(bundle.proof.clone()), - ) - .await?; - Ok(TradeValidationReceiptProofOutcome { - sp1_execute_public_values_hash: Some(bundle.execution.public_values_hash.clone()), - bundle, - proof_generated: true, - sp1_execute_checked: true, - cryptographic_proof_verified: true, - }) -} - -#[cfg(feature = "sp1_verify")] -async fn run_remote_http_prove_backend( - witness: &RadrootsSp1TradeOrderAcceptanceWitness, - policy: &TradeValidationReceiptProverPolicy, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - let remote_http = policy - .remote_http - .as_ref() - .ok_or(TradeValidationReceiptJobError::RemoteHttpConfigRequired)?; - let expected_sp1_program_hash = policy - .expected_sp1_program_hash - .as_deref() - .ok_or(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired)?; - let expected_sp1_verifying_key_hash = policy - .expected_sp1_verifying_key_hash - .as_deref() - .ok_or(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired)?; - let execution = radroots_trade_sp1_host::execute_order_acceptance_public_values(witness)?; - let request = RadrootsSp1TradeRemoteProverRequest { - schema_version: RADROOTS_SP1_TRADE_REMOTE_PROVER_SCHEMA_VERSION, - request_id: remote_http_request_id(witness)?, - proof_target: RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET.to_string(), - proof_mode: policy.proof_mode, - sp1_version_line: RADROOTS_SP1_TRADE_SP1_VERSION_LINE.to_string(), - witness: witness.clone(), - expected_sp1_program_hash: expected_sp1_program_hash.to_owned(), - expected_sp1_verifying_key_hash: expected_sp1_verifying_key_hash.to_owned(), - expected_public_values_hash: execution.public_values_hash.clone(), - expected_reducer_program_hash: RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH.to_string(), - expected_protocol_version: RADROOTS_SP1_TRADE_PROTOCOL_VERSION.to_string(), - expected_witness_version: RADROOTS_SP1_TRADE_WITNESS_VERSION, - }; - let response = remote_http_completed_response(remote_http, &request).await?; - let artifact = remote_http_verified_artifact( - &execution, - policy, - expected_sp1_program_hash, - expected_sp1_verifying_key_hash, - &request, - response, - ) - .await?; - Ok(TradeValidationReceiptProofOutcome { - sp1_execute_public_values_hash: Some(execution.public_values_hash.clone()), - bundle: RadrootsSp1TradeProofBundle { - execution, - proof: artifact, - }, - proof_generated: true, - sp1_execute_checked: true, - cryptographic_proof_verified: true, - }) -} - -#[cfg(not(feature = "sp1_verify"))] -async fn run_remote_http_prove_backend( - _witness: &RadrootsSp1TradeOrderAcceptanceWitness, - _policy: &TradeValidationReceiptProverPolicy, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - TradeValidationReceiptProverBackend::RemoteHttpProve.as_str(), - )) -} - -#[cfg(feature = "sp1_verify")] -fn remote_http_request_id( - witness: &RadrootsSp1TradeOrderAcceptanceWitness, -) -> Result<String, TradeValidationReceiptJobError> { - let bytes = serde_json::to_vec(witness)?; - Ok(hash_bytes("radroots:rhi-remote-proof-request:v1", &bytes)) -} - -#[cfg(feature = "sp1_verify")] -async fn remote_http_completed_response( - config: &TradeValidationReceiptRemoteHttpProverConfig, - request: &RadrootsSp1TradeRemoteProverRequest, -) -> Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError> { - let mut response = - remote_http_post_json_io(config, config.endpoint_url.as_str(), request).await?; - remote_http_validate_response_identity(&response, request)?; - match response.status { - RadrootsSp1TradeRemoteProverStatus::Completed => return Ok(response), - RadrootsSp1TradeRemoteProverStatus::Failed => { - return Err(remote_http_terminal_error("failed", response)); - } - RadrootsSp1TradeRemoteProverStatus::Rejected => { - return Err(remote_http_terminal_error("rejected", response)); - } - RadrootsSp1TradeRemoteProverStatus::Accepted - | RadrootsSp1TradeRemoteProverStatus::Running => {} - } - for _ in 0..config.max_poll_attempts { - let status_url = remote_http_status_url(config, &response)?; - tokio::time::sleep(Duration::from_millis(config.poll_interval_ms)).await; - response = remote_http_get_json_io(config, status_url.as_str(), request).await?; - remote_http_validate_response_identity(&response, request)?; - match response.status { - RadrootsSp1TradeRemoteProverStatus::Completed => return Ok(response), - RadrootsSp1TradeRemoteProverStatus::Failed => { - return Err(remote_http_terminal_error("failed", response)); - } - RadrootsSp1TradeRemoteProverStatus::Rejected => { - return Err(remote_http_terminal_error("rejected", response)); - } - RadrootsSp1TradeRemoteProverStatus::Accepted - | RadrootsSp1TradeRemoteProverStatus::Running => {} - } - } - Err(TradeValidationReceiptJobError::RemoteHttpTimeout) +#[cfg(test)] +#[derive(Default)] +struct TradeValidationReceiptTestHooks { + fetch_event_by_id_results: + std::collections::VecDeque<Result<RadrootsNostrEvent, TradeValidationReceiptJobError>>, + publish_event_results: + std::collections::VecDeque<Result<String, TradeValidationReceiptJobError>>, + published_events: Vec<RadrootsNostrEvent>, } -#[cfg(feature = "sp1_verify")] -fn remote_http_validate_response_identity( - response: &RadrootsSp1TradeRemoteProverResponse, - request: &RadrootsSp1TradeRemoteProverRequest, -) -> Result<(), TradeValidationReceiptJobError> { - if response.schema_version != RADROOTS_SP1_TRADE_REMOTE_PROVER_SCHEMA_VERSION { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidResponse( - "schema_version", - )); - } - if response.request_id != request.request_id { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "request_id", - )); - } - Ok(()) -} +#[cfg(test)] +static TRADE_VALIDATION_RECEIPT_TEST_HOOKS: std::sync::OnceLock< + std::sync::Mutex<TradeValidationReceiptTestHooks>, +> = std::sync::OnceLock::new(); -#[cfg(feature = "sp1_verify")] -fn remote_http_terminal_error( - status: &'static str, - response: RadrootsSp1TradeRemoteProverResponse, -) -> TradeValidationReceiptJobError { - TradeValidationReceiptJobError::RemoteHttpTerminal { - status, - reason_code: response - .reason_code - .unwrap_or_else(|| "remote_prover_terminal".to_string()), - message: response - .message - .unwrap_or_else(|| "remote prover reached a terminal non-success state".to_string()), - } +#[cfg(test)] +fn trade_validation_receipt_test_hooks() +-> &'static std::sync::Mutex<TradeValidationReceiptTestHooks> { + TRADE_VALIDATION_RECEIPT_TEST_HOOKS + .get_or_init(|| std::sync::Mutex::new(TradeValidationReceiptTestHooks::default())) } -#[cfg(feature = "sp1_verify")] -async fn remote_http_verified_artifact( - execution: &radroots_trade_sp1_guest::RadrootsSp1TradePublicValuesExecution, - policy: &TradeValidationReceiptProverPolicy, - expected_sp1_program_hash: &str, - expected_sp1_verifying_key_hash: &str, - request: &RadrootsSp1TradeRemoteProverRequest, - response: RadrootsSp1TradeRemoteProverResponse, -) -> Result<radroots_trade_sp1_host::RadrootsSp1TradeProofArtifact, TradeValidationReceiptJobError> +#[cfg(test)] +fn pop_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeValidationReceiptJobError>> { - if response.schema_version != RADROOTS_SP1_TRADE_REMOTE_PROVER_SCHEMA_VERSION { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidResponse( - "schema_version", - )); - } - if response.request_id != request.request_id { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "request_id", - )); - } - if response.proof_mode != Some(policy.proof_mode) { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "proof_mode", - )); - } - if response.proof_system != Some(policy.proof_mode.proof_system()) { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "proof_system", - )); - } - if response.public_values_hash.as_deref() != Some(execution.public_values_hash.as_str()) { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "public_values_hash", - )); - } - if response.sp1_program_hash.as_deref() != Some(expected_sp1_program_hash) { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "sp1_program_hash", - )); - } - if response.sp1_verifying_key_hash.as_deref() != Some(expected_sp1_verifying_key_hash) { - return Err(TradeValidationReceiptJobError::RemoteHttpIdentityMismatch( - "sp1_verifying_key_hash", - )); - } - let artifact = response.proof_artifact.ok_or( - TradeValidationReceiptJobError::RemoteHttpInvalidResponse("proof_artifact"), - )?; - let resolved = RadrootsSp1TradeResolvedProofArtifact { - artifact, - resolved_proof_envelope_base64: response.resolved_proof_envelope_base64, - }; - verify_remote_proof_artifact_io(execution, &resolved).await?; - Ok(resolved.artifact) + trade_validation_receipt_test_hooks() + .lock() + .expect("trade validation receipt hooks lock") + .fetch_event_by_id_results + .pop_front() } -#[cfg(feature = "sp1_verify")] -async fn verify_remote_proof_artifact_io( - execution: &radroots_trade_sp1_guest::RadrootsSp1TradePublicValuesExecution, - resolved: &RadrootsSp1TradeResolvedProofArtifact, -) -> Result<(), TradeValidationReceiptJobError> { - #[cfg(test)] - if let Some(result) = pop_remote_proof_verification_hook() { - return result; - } - - radroots_trade_sp1_host::verify_order_acceptance_resolved_sp1_proof_artifact( - execution, resolved, - ) - .await?; - Ok(()) +#[cfg(test)] +fn pop_publish_signed_event_hook( + event: &RadrootsNostrEvent, +) -> Option<Result<String, TradeValidationReceiptJobError>> { + let mut hooks = trade_validation_receipt_test_hooks() + .lock() + .expect("trade validation receipt hooks lock"); + hooks.published_events.push(event.clone()); + hooks.publish_event_results.pop_front() } -#[cfg(feature = "sp1_verify")] -async fn remote_http_post_json_io( - config: &TradeValidationReceiptRemoteHttpProverConfig, - url: &str, - request: &RadrootsSp1TradeRemoteProverRequest, -) -> Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError> { - #[cfg(test)] - if let Some(result) = pop_remote_http_response_hook(request) { - return result; - } +#[cfg(test)] +mod tests { + use super::{ + TradeValidationReceiptProverBackend, TradeValidationReceiptProverPolicy, + trade_validation_receipt_test_hooks, + }; + use radroots_trade_sp1_host::RadrootsSp1TradeProofMode; - let client = remote_http_client(config)?; - let mut builder = client.post(url).json(request); - if let Some(token) = remote_http_auth_token(config)? { - builder = builder.bearer_auth(token); + #[test] + fn default_policy_is_enabled_deterministic_receipt_path() { + let policy = TradeValidationReceiptProverPolicy::default(); + assert_eq!( + policy.backend, + TradeValidationReceiptProverBackend::LocalExecute + ); + assert_eq!(policy.proof_mode, RadrootsSp1TradeProofMode::None); } - remote_http_response_json(config, builder.send().await).await -} -#[cfg(feature = "sp1_verify")] -async fn remote_http_get_json_io( - config: &TradeValidationReceiptRemoteHttpProverConfig, - url: &str, - _request: &RadrootsSp1TradeRemoteProverRequest, -) -> Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError> { - #[cfg(test)] - if let Some(result) = pop_remote_http_response_hook(_request) { - return result; + #[test] + fn validator_binding_requires_address_and_event_id() { + let policy = TradeValidationReceiptProverPolicy::default(); + let error = policy.validator_binding().expect_err("binding must fail"); + assert!(error.to_string().contains("validator_set_addr")); } - let client = remote_http_client(config)?; - let mut builder = client.get(url); - if let Some(token) = remote_http_auth_token(config)? { - builder = builder.bearer_auth(token); + #[test] + fn validator_binding_accepts_canonical_set_reference() { + let policy = TradeValidationReceiptProverPolicy { + validator_set_addr: Some( + "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" + .to_string(), + ), + validator_set_event_id: Some( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + .to_string(), + ), + ..TradeValidationReceiptProverPolicy::default() + }; + let binding = policy.validator_binding().expect("validator binding"); + assert_eq!( + binding.0.as_str(), + "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" + ); + assert_eq!( + binding.1, + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ); } - remote_http_response_json(config, builder.send().await).await -} - -#[cfg(feature = "sp1_verify")] -fn remote_http_client( - config: &TradeValidationReceiptRemoteHttpProverConfig, -) -> Result<reqwest::Client, TradeValidationReceiptJobError> { - reqwest::Client::builder() - .timeout(Duration::from_millis(config.request_timeout_ms)) - .build() - .map_err(|error| TradeValidationReceiptJobError::RemoteHttpTransport(error.to_string())) -} -#[cfg(feature = "sp1_verify")] -async fn remote_http_response_json( - config: &TradeValidationReceiptRemoteHttpProverConfig, - response: Result<reqwest::Response, reqwest::Error>, -) -> Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError> { - let mut response = response - .map_err(|error| TradeValidationReceiptJobError::RemoteHttpTransport(error.to_string()))?; - if !response.status().is_success() { - return Err(TradeValidationReceiptJobError::RemoteHttpTransport( - format!("http status {}", response.status().as_u16()), - )); - } - if response - .content_length() - .is_some_and(|length| length > config.max_response_bytes as u64) - { - return Err(TradeValidationReceiptJobError::RemoteHttpResponseTooLarge); - } - let mut bytes = Vec::with_capacity(config.max_response_bytes.min(8192)); - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| TradeValidationReceiptJobError::RemoteHttpTransport(error.to_string()))? - { - if chunk.len() > config.max_response_bytes.saturating_sub(bytes.len()) { - return Err(TradeValidationReceiptJobError::RemoteHttpResponseTooLarge); - } - bytes.extend_from_slice(&chunk); - } - serde_json::from_slice::<RadrootsSp1TradeRemoteProverResponse>(&bytes) - .map_err(TradeValidationReceiptJobError::Serde) -} - -#[cfg(feature = "sp1_verify")] -fn remote_http_status_url( - config: &TradeValidationReceiptRemoteHttpProverConfig, - response: &RadrootsSp1TradeRemoteProverResponse, -) -> Result<String, TradeValidationReceiptJobError> { - let base = remote_http_endpoint_url(config)?; - if let Some(url) = response.status_url.as_deref() { - let parsed = reqwest::Url::parse(url) - .map_err(|_| TradeValidationReceiptJobError::RemoteHttpInvalidResponse("status_url"))?; - if (parsed.scheme() != "http" && parsed.scheme() != "https") - || !remote_http_same_origin(&base, &parsed) - { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidResponse( - "status_url", - )); - } - return Ok(parsed.to_string()); - } - if let Some(path) = response.status_path.as_deref() { - if path.trim() != path - || !path.starts_with('/') - || path.starts_with("//") - || reqwest::Url::parse(path).is_ok() - { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidResponse( - "status_path", - )); - } - let parsed = base.join(path).map_err(|_| { - TradeValidationReceiptJobError::RemoteHttpInvalidResponse("status_path") - })?; - if !remote_http_same_origin(&base, &parsed) { - return Err(TradeValidationReceiptJobError::RemoteHttpInvalidResponse( - "status_path", - )); - } - return Ok(parsed.to_string()); - } - Err(TradeValidationReceiptJobError::RemoteHttpInvalidResponse( - "status_url", - )) -} - -#[cfg(feature = "sp1_verify")] -fn remote_http_same_origin(base: &reqwest::Url, candidate: &reqwest::Url) -> bool { - base.scheme() == candidate.scheme() - && base.host_str() == candidate.host_str() - && base.port_or_known_default() == candidate.port_or_known_default() -} - -#[cfg(not(feature = "sp1_proving"))] -async fn run_local_cpu_prove_backend( - _witness: &RadrootsSp1TradeOrderAcceptanceWitness, - _proof_mode: RadrootsSp1TradeProofMode, -) -> Result<TradeValidationReceiptProofOutcome, TradeValidationReceiptJobError> { - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - TradeValidationReceiptProverBackend::LocalCpuProve.as_str(), - )) -} - -fn validate_job_request_shape( - request: &RadrootsTradeTransitionProofRequestV1, -) -> Result<(), TradeValidationReceiptJobError> { - if request.proof_target.trim().is_empty() - || request.reducer_program_hash.trim().is_empty() - || request.radroots_protocol_version.trim().is_empty() - || request.event_evidence.is_empty() - || request.inventory_bins.is_empty() - { - return Err(TradeValidationReceiptJobError::InvalidJobRequest); - } - if request.witness_version != RADROOTS_SP1_TRADE_WITNESS_VERSION { - return Err(TradeValidationReceiptJobError::UnsupportedWitnessVersion); - } - if request.proof_target != RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET { - return Err(TradeValidationReceiptJobError::UnsupportedProofTarget); - } - if request.reducer_program_hash != RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH { - return Err(TradeValidationReceiptJobError::ExpectedReducerProgramHashMismatch); - } - if request.radroots_protocol_version != RADROOTS_SP1_TRADE_PROTOCOL_VERSION { - return Err(TradeValidationReceiptJobError::ExpectedProtocolVersionMismatch); - } - Ok(()) -} - -fn validate_optional_hash32(value: &Option<String>) -> Result<(), TradeValidationReceiptJobError> { - 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(TradeValidationReceiptJobError::InvalidJobRequest); - } - } - Ok(()) -} - -fn is_lower_hex(value: &str) -> bool { - value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn event_kind_u32(event: &RadrootsNostrEvent) -> Result<u32, TradeValidationReceiptJobError> { - match event.kind { - RadrootsNostrKind::Custom(value) => Ok(u32::from(value)), - _ => Err(TradeValidationReceiptJobError::UnsupportedKind), - } -} - -async fn fetch_event_by_id_io( - client: &RadrootsNostrClient, - event_id: &str, -) -> Result<RadrootsNostrEvent, TradeValidationReceiptJobError> { - #[cfg(test)] - if let Some(result) = pop_fetch_event_by_id_hook() { - return result; - } - - Ok(radroots_nostr_fetch_event_by_id(client, event_id).await?) -} - -async fn fetch_events_io( - client: &RadrootsNostrClient, - filter: RadrootsNostrFilter, - timeout: Duration, -) -> Result<Vec<RadrootsNostrEvent>, TradeValidationReceiptJobError> { - #[cfg(test)] - { - let _ = (client, filter, timeout); - return pop_fetch_events_hook().unwrap_or_else(|| Ok(Vec::new())); - } - - #[cfg(not(test))] - client - .fetch_events(filter, timeout) - .await - .map_err(TradeValidationReceiptJobError::from) -} - -async fn publish_signed_event_io( - client: &RadrootsNostrClient, - event: RadrootsNostrEvent, -) -> Result<String, TradeValidationReceiptJobError> { - #[cfg(test)] - if let Some(result) = pop_publish_signed_event_hook(&event) { - result?; - return Ok(event.id.to_hex()); - } - - let output = client.send_event(&event).await?; - Ok(output.val.to_hex()) -} - -fn zero_event_id() -> String { - "0000000000000000000000000000000000000000000000000000000000000000".to_string() -} - -fn zero_signature() -> String { - "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000".to_string() -} - -#[cfg(test)] -#[derive(Clone, Debug, PartialEq, Eq)] -struct PublishedEventParts { - event_id: Option<String>, - event_json: Option<String>, - kind: u32, - content: String, - tags: Vec<Vec<String>>, -} - -#[cfg(test)] -#[derive(Default)] -struct TradeValidationReceiptTestHooks { - fetch_event_by_id_results: - std::collections::VecDeque<Result<RadrootsNostrEvent, TradeValidationReceiptJobError>>, - fetch_events_results: - std::collections::VecDeque<Result<Vec<RadrootsNostrEvent>, TradeValidationReceiptJobError>>, - remote_http_auth_env_values: std::collections::BTreeMap<String, Option<String>>, - publish_event_results: - std::collections::VecDeque<Result<String, TradeValidationReceiptJobError>>, - #[cfg(feature = "sp1_verify")] - remote_http_results: std::collections::VecDeque< - Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError>, - >, - #[cfg(feature = "sp1_verify")] - remote_http_requests: Vec<RadrootsSp1TradeRemoteProverRequest>, - #[cfg(feature = "sp1_verify")] - remote_proof_verification_results: - std::collections::VecDeque<Result<(), TradeValidationReceiptJobError>>, - published_events: Vec<PublishedEventParts>, -} - -#[cfg(test)] -static TRADE_VALIDATION_RECEIPT_TEST_HOOKS: std::sync::OnceLock< - std::sync::Mutex<TradeValidationReceiptTestHooks>, -> = std::sync::OnceLock::new(); - -#[cfg(test)] -fn trade_validation_receipt_test_hooks() --> &'static std::sync::Mutex<TradeValidationReceiptTestHooks> { - TRADE_VALIDATION_RECEIPT_TEST_HOOKS - .get_or_init(|| std::sync::Mutex::new(TradeValidationReceiptTestHooks::default())) -} - -#[cfg(test)] -fn remote_http_auth_env_var_value_test_hook( - env_var: &str, -) -> Option<Result<String, TradeValidationReceiptJobError>> { - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remote_http_auth_env_values - .get(env_var) - .map(|value| match value { - Some(value) => Ok(value.clone()), - None => Err(TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing( - env_var.to_owned(), - )), - }) -} - -#[cfg(test)] -fn pop_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeValidationReceiptJobError>> -{ - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fetch_event_by_id_results - .pop_front() -} - -#[cfg(test)] -fn pop_fetch_events_hook() -> Option<Result<Vec<RadrootsNostrEvent>, TradeValidationReceiptJobError>> -{ - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fetch_events_results - .pop_front() -} - -#[cfg(test)] -fn pop_publish_signed_event_hook( - event: &RadrootsNostrEvent, -) -> Option<Result<String, TradeValidationReceiptJobError>> { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.published_events.push(PublishedEventParts { - event_id: Some(event.id.to_hex()), - event_json: Some(serde_json::to_string(event).expect("signed event json")), - kind: event_kind_u32(event).unwrap_or(0), - content: event.content.clone(), - tags: event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect(), - }); - hooks.publish_event_results.pop_front() -} - -#[cfg(all(test, feature = "sp1_verify"))] -fn pop_remote_http_response_hook( - request: &RadrootsSp1TradeRemoteProverRequest, -) -> Option<Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError>> { - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remote_http_requests - .push(request.clone()); - pop_remote_http_response_hook_without_request().map(|result| { - result.and_then(|response| remote_http_test_response_for_request(request, response)) - }) -} - -#[cfg(all(test, feature = "sp1_verify"))] -fn pop_remote_http_response_hook_without_request() --> Option<Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError>> { - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remote_http_results - .pop_front() -} - -#[cfg(all(test, feature = "sp1_verify"))] -fn remote_http_test_response_for_request( - request: &RadrootsSp1TradeRemoteProverRequest, - mut response: RadrootsSp1TradeRemoteProverResponse, -) -> Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError> { - if response.request_id == "__request_id__" { - response.request_id = request.request_id.clone(); - } - if response.status == RadrootsSp1TradeRemoteProverStatus::Completed - && response.proof_artifact.is_none() - { - let execution = - radroots_trade_sp1_host::execute_order_acceptance_public_values(&request.witness)?; - let artifact = - radroots_trade_sp1_host::referenced_order_acceptance_proof_artifact_for_execution( - &execution, - request.proof_mode, - format!("radroots-proof://sha256/{}", "1".repeat(64)), - )?; - if response.proof_system.is_none() { - response.proof_system = Some(request.proof_mode.proof_system()); - } - if response.proof_mode.is_none() { - response.proof_mode = Some(request.proof_mode); - } - if response.public_values_hash.is_none() { - response.public_values_hash = Some(execution.public_values_hash); - } - if response.sp1_program_hash.is_none() { - response.sp1_program_hash = Some(request.expected_sp1_program_hash.clone()); - } - if response.sp1_verifying_key_hash.is_none() { - response.sp1_verifying_key_hash = Some(request.expected_sp1_verifying_key_hash.clone()); - } - if response.proof_artifact.is_none() { - response.proof_artifact = Some(artifact); - } - } - Ok(response) -} - -#[cfg(all(test, feature = "sp1_verify"))] -fn pop_remote_proof_verification_hook() -> Option<Result<(), TradeValidationReceiptJobError>> { - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remote_proof_verification_results - .pop_front() -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - TradeValidationReceiptJobError, TradeValidationReceiptJobResult, - TradeValidationReceiptLocalWorkerRequest, TradeValidationReceiptProverBackend, - TradeValidationReceiptProverPolicy, TradeValidationReceiptRemoteHttpAuth, - TradeValidationReceiptRemoteHttpProverConfig, TradeValidationReceiptRuntimePolicy, - TradeValidationReceiptTestHooks, build_trade_validation_receipt_job_request_event, - handle_trade_validation_receipt_job_request, - handle_trade_validation_receipt_local_worker_request, trade_validation_receipt_test_hooks, - }; - use crate::features::trade_listing::processed_jobs::{ - RhiProcessedJobClaim, RhiProcessedJobState, RhiProcessedJobStatus, - }; - use crate::features::trade_listing::state::TradeListingRuntime; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, - }; - use radroots_event::RadrootsEventPtr; - use radroots_event::ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, - }; - use radroots_event::kinds::{ - KIND_LISTING, KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT, - KIND_TRADE_VALIDATION_RECEIPT, - }; - use radroots_event::order::{ - RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, - RadrootsOrderEconomicLine, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, - RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, - }; - use radroots_event_codec::order::{ - order_decision_event_build, order_decision_from_event, order_request_event_build, - order_request_from_event, - }; - use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrKeys, radroots_event_from_nostr, - radroots_nostr_build_event, - }; - use radroots_trade::dvm::{ - RadrootsTradeInventoryBinWitnessDto, RadrootsTradeProofMode, - RadrootsTradeTransitionProofRequestV1, build_transition_proof_request_tags, - parse_transition_proof_request_event, - }; - use radroots_trade::validation_receipt::{ - RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority, - RadrootsTradeValidationReceipt, RadrootsValidationReceiptExpectedBinding, - RadrootsValidationReceiptProof, RadrootsValidationReceiptProofSystem, - RadrootsValidationReceiptResult, RadrootsValidationReceiptStatement, - RadrootsValidationReceiptTags, RadrootsValidationReceiptType, - RadrootsVerifiedValidationReceipt, verify_validation_receipt_event, - }; - use radroots_trade_sp1_guest::{ - RADROOTS_SP1_TRADE_PROTOCOL_VERSION, RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH, - }; - #[cfg(feature = "sp1_verify")] - use radroots_trade_sp1_host::RadrootsSp1TradeHostError; - use radroots_trade_sp1_host::RadrootsSp1TradeProofMode; - #[cfg(feature = "sp1_verify")] - use radroots_trade_sp1_host::{ - RADROOTS_SP1_TRADE_REMOTE_PROVER_SCHEMA_VERSION, RadrootsSp1TradeRemoteProverRequest, - RadrootsSp1TradeRemoteProverResponse, RadrootsSp1TradeRemoteProverStatus, - }; - use std::sync::{Mutex, MutexGuard}; - - static TEST_LOCK: Mutex<()> = Mutex::new(()); - const RECOVERED_RESULT_MISSING_AUTH_ENV: &str = - "RHI_TEST_RECOVERED_RESULT_AUTH_TOKEN_MUST_BE_UNSET"; - - fn test_guard() -> MutexGuard<'static, ()> { - let guard = TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - guard - } - - fn publish_result_id(index: u8) -> String { - format!("{index:064x}") - } - - fn published_event(parts: &super::PublishedEventParts) -> RadrootsNostrEvent { - serde_json::from_str(parts.event_json.as_deref().expect("published event json")) - .expect("published event") - } - - fn listing_addr_for_seller(seller: &RadrootsNostrKeys) -> String { - format!( - "30402:{}:AAAAAAAAAAAAAAAAAAAAAA", - seller.public_key().to_hex() - ) - } - - fn typed_listing_addr(listing_addr: &str) -> RadrootsListingAddress { - RadrootsListingAddress::parse(listing_addr).expect("listing address") - } - - fn typed_order_id(order_id: &str) -> RadrootsOrderId { - RadrootsOrderId::parse(order_id).expect("order id") - } - - fn typed_quote_id(order_id: &str) -> RadrootsOrderQuoteId { - RadrootsOrderQuoteId::parse(format!("{order_id}-quote")).expect("quote id") - } - - fn typed_bin_id() -> RadrootsInventoryBinId { - RadrootsInventoryBinId::parse("bin-1").expect("bin id") - } - - fn typed_pubkey(keys: &RadrootsNostrKeys) -> RadrootsPublicKey { - RadrootsPublicKey::parse(keys.public_key().to_hex()).expect("public key") - } - - fn typed_event_id(event: &RadrootsNostrEvent) -> RadrootsEventId { - RadrootsEventId::parse(event.id.to_hex()).expect("event id") - } - - fn signed_event( - keys: &RadrootsNostrKeys, - kind: u32, - content: impl Into<String>, - tags: Vec<Vec<String>>, - ) -> RadrootsNostrEvent { - radroots_nostr_build_event(kind, content.into(), tags) - .expect("event builder") - .sign_with_keys(keys) - .expect("signed event") - } - - fn listing_event(seller: &RadrootsNostrKeys) -> RadrootsNostrEvent { - signed_event( - seller, - KIND_LISTING, - "{}", - vec![vec!["d".to_string(), "listing-1".to_string()]], - ) - } - - fn request_payload( - order_id: &str, - listing_addr: &str, - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsOrderRequest { - RadrootsOrderRequest { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer), - seller_pubkey: typed_pubkey(seller), - items: vec![RadrootsOrderItem { - bin_id: typed_bin_id(), - bin_count: 2, - }], - economics: economics(order_id, 2), - } - } - - fn decision_payload( - order_id: &str, - listing_addr: &str, - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer), - seller_pubkey: typed_pubkey(seller), - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: typed_bin_id(), - bin_count: 2, - }], - }, - } - } - - fn declined_decision_payload( - order_id: &str, - listing_addr: &str, - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - ) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: typed_order_id(order_id), - listing_addr: typed_listing_addr(listing_addr), - buyer_pubkey: typed_pubkey(buyer), - seller_pubkey: typed_pubkey(seller), - decision: RadrootsOrderDecisionOutcome::Declined { - reason: "not available".to_string(), - }, - } - } - - fn economics(order_id: &str, bin_count: u32) -> RadrootsOrderEconomics { - let subtotal = RadrootsCoreDecimal::from(5u32) * RadrootsCoreDecimal::from(bin_count); - let money = RadrootsCoreMoney::new(subtotal, RadrootsCoreCurrency::USD); - RadrootsOrderEconomics { - quote_id: typed_quote_id(order_id), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: typed_bin_id(), - bin_count, - quantity_amount: RadrootsCoreDecimal::from(1u32), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: RadrootsCoreDecimal::from(5u32), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: money.clone(), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: money.clone(), - discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), - adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), - total: money, - } - } - - fn inventory_bins() -> Vec<RadrootsTradeInventoryBinWitnessDto> { - vec![RadrootsTradeInventoryBinWitnessDto { - bin_id: typed_bin_id(), - listing_capacity: 5, - previous_reserved: 1, - }] - } - - fn signed_order_events( - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - listing_event: &RadrootsNostrEvent, - ) -> (RadrootsNostrEvent, RadrootsNostrEvent) { - let listing_addr = listing_addr_for_seller(seller); - let order_id = "order-1"; - let listing_ptr = RadrootsEventPtr { - id: listing_event.id.to_hex(), - relays: None, - }; - let request_wire = order_request_event_build( - &listing_ptr, - &request_payload(order_id, &listing_addr, buyer, seller), - ) - .expect("request wire"); - let request_event = signed_event( - buyer, - request_wire.kind, - request_wire.content, - request_wire.tags, - ); - let decision_wire = order_decision_event_build( - &typed_event_id(&request_event), - &typed_event_id(&request_event), - &decision_payload(order_id, &listing_addr, buyer, seller), - ) - .expect("decision wire"); - let decision_event = signed_event( - seller, - decision_wire.kind, - decision_wire.content, - decision_wire.tags, - ); - (request_event, decision_event) - } - - fn signed_declined_order_events( - buyer: &RadrootsNostrKeys, - seller: &RadrootsNostrKeys, - listing_event: &RadrootsNostrEvent, - ) -> (RadrootsNostrEvent, RadrootsNostrEvent) { - let listing_addr = listing_addr_for_seller(seller); - let order_id = "order-1"; - let listing_ptr = RadrootsEventPtr { - id: listing_event.id.to_hex(), - relays: None, - }; - let request_wire = order_request_event_build( - &listing_ptr, - &request_payload(order_id, &listing_addr, buyer, seller), - ) - .expect("request wire"); - let request_event = signed_event( - buyer, - request_wire.kind, - request_wire.content, - request_wire.tags, - ); - let decision_wire = order_decision_event_build( - &typed_event_id(&request_event), - &typed_event_id(&request_event), - &declined_decision_payload(order_id, &listing_addr, buyer, seller), - ) - .expect("decision wire"); - let decision_event = signed_event( - seller, - decision_wire.kind, - decision_wire.content, - decision_wire.tags, - ); - (request_event, decision_event) - } - - fn job_request( - requester: &RadrootsNostrKeys, - worker: &RadrootsNostrKeys, - listing_event: &RadrootsNostrEvent, - request_event: &RadrootsNostrEvent, - decision_event: &RadrootsNostrEvent, - proof_mode: RadrootsSp1TradeProofMode, - sp1_program_hash: Option<String>, - sp1_verifying_key_hash: Option<String>, - ) -> RadrootsNostrEvent { - let request_rr = radroots_event_from_nostr(request_event); - let decision_rr = radroots_event_from_nostr(decision_event); - let request_envelope = order_request_from_event(&request_rr).expect("request envelope"); - let decision_envelope = order_decision_from_event(&decision_rr).expect("decision envelope"); - let request = RadrootsTradeTransitionProofRequestV1 { - witness_version: radroots_trade_sp1_guest::RADROOTS_SP1_TRADE_WITNESS_VERSION, - proof_target: - radroots_trade_sp1_guest::RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET - .to_string(), - listing_event_id: typed_event_id(listing_event), - request_event_id: typed_event_id(request_event), - decision_event_id: typed_event_id(decision_event), - event_evidence: super::canonical_dvm_event_evidence_from_event( - listing_event, - request_event, - decision_event, - ) - .expect("canonical evidence"), - request: super::dvm_order_request_witness_from_payload(&request_envelope.payload), - decision: super::dvm_order_decision_witness_from_payload(&decision_envelope.payload), - inventory_bins: inventory_bins(), - inventory_sequence: 7, - previous_state_root: None, - proof_mode: dvm_proof_mode(proof_mode), - reducer_program_hash: RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH.to_string(), - radroots_protocol_version: RADROOTS_SP1_TRADE_PROTOCOL_VERSION.to_string(), - sp1_program_hash, - sp1_verifying_key_hash, - }; - signed_event( - requester, - KIND_TRADE_TRANSITION_PROOF_REQUEST, - serde_json::to_string(&request).expect("job json"), - build_transition_proof_request_tags(&typed_pubkey(worker), &request), - ) - } - - fn dvm_proof_mode(proof_mode: RadrootsSp1TradeProofMode) -> RadrootsTradeProofMode { - match proof_mode { - RadrootsSp1TradeProofMode::None => RadrootsTradeProofMode::None, - RadrootsSp1TradeProofMode::Core => RadrootsTradeProofMode::Core, - RadrootsSp1TradeProofMode::Compressed => RadrootsTradeProofMode::Compressed, - RadrootsSp1TradeProofMode::Groth16 => RadrootsTradeProofMode::Groth16, - RadrootsSp1TradeProofMode::Plonk => RadrootsTradeProofMode::Plonk, - } - } - - fn policy_request( - proof_mode: RadrootsSp1TradeProofMode, - sp1_program_hash: Option<String>, - sp1_verifying_key_hash: Option<String>, - ) -> RadrootsTradeTransitionProofRequestV1 { - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_addr = typed_listing_addr(&listing_addr_for_seller(&seller)); - RadrootsTradeTransitionProofRequestV1 { - witness_version: radroots_trade_sp1_guest::RADROOTS_SP1_TRADE_WITNESS_VERSION, - proof_target: - radroots_trade_sp1_guest::RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET - .to_string(), - listing_event_id: RadrootsEventId::parse( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("listing event id"), - request_event_id: RadrootsEventId::parse( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("request event id"), - decision_event_id: RadrootsEventId::parse( - "3333333333333333333333333333333333333333333333333333333333333333", - ) - .expect("decision event id"), - event_evidence: Vec::new(), - request: radroots_trade::dvm::RadrootsOrderRequestWitnessDto { - order_id: typed_order_id("order-1"), - listing_addr: listing_addr.clone(), - buyer_pubkey: typed_pubkey(&buyer), - seller_pubkey: typed_pubkey(&seller), - items: vec![radroots_trade::dvm::RadrootsOrderItemWitnessDto { - bin_id: typed_bin_id(), - bin_count: 2, - }], - }, - decision: radroots_trade::dvm::RadrootsOrderDecisionEventWitnessDto { - order_id: typed_order_id("order-1"), - listing_addr, - buyer_pubkey: typed_pubkey(&buyer), - seller_pubkey: typed_pubkey(&seller), - decision: radroots_trade::dvm::RadrootsOrderDecisionWitnessDto::Accepted { - inventory_commitments: vec![ - radroots_trade::dvm::RadrootsTradeInventoryCommitmentWitnessDto { - bin_id: typed_bin_id(), - bin_count: 2, - }, - ], - }, - }, - inventory_bins: vec![RadrootsTradeInventoryBinWitnessDto { - bin_id: typed_bin_id(), - listing_capacity: 5, - previous_reserved: 1, - }], - inventory_sequence: 7, - previous_state_root: None, - proof_mode: dvm_proof_mode(proof_mode), - reducer_program_hash: RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH.to_string(), - radroots_protocol_version: RADROOTS_SP1_TRADE_PROTOCOL_VERSION.to_string(), - sp1_program_hash, - sp1_verifying_key_hash, - } - } - - fn client_for(keys: &RadrootsNostrKeys) -> RadrootsNostrClient { - RadrootsNostrClient::new(keys.clone()) - } - - fn processed_job_for_test(job: &RadrootsNostrEvent) -> RhiProcessedJobState { - super::processed_job_for_request( - job, - KIND_TRADE_TRANSITION_PROOF_REQUEST, - &radroots_event_from_nostr(job), - ) - .expect("processed job") - } - - fn signed_result_event_with_metadata_for_test( - worker: &RadrootsNostrKeys, - job: &RadrootsNostrEvent, - receipt_event: &RadrootsNostrEvent, - policy: &TradeValidationReceiptProverPolicy, - proof_metadata: Option<&super::TradeValidationReceiptResultProofMetadata>, - ) -> RadrootsNostrEvent { - let request_event = radroots_event_from_nostr(job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let verified_receipt = - super::verify_existing_receipt_event(receipt_event, &envelope.content, policy) - .expect("verified receipt"); - let processed = processed_job_for_test(job); - let receipt_event_id = receipt_event.id.to_hex(); - let result = super::result_payload( - job, - &processed, - &envelope, - receipt_event_id.as_str(), - verified_receipt, - policy, - proof_metadata, - ) - .expect("result payload"); - let result_content = serde_json::to_string(&result).expect("result json"); - let result_tags = - super::result_tags_from_dvm(job, &envelope.tags.inputs, receipt_event_id.as_str()) - .expect("result tags"); - super::signed_event_from_parts( - worker, - KIND_TRADE_TRANSITION_PROOF_RESULT, - result_content, - result_tags, - Some(job.created_at.as_secs()), - ) - .expect("signed result") - } - - fn signed_result_event_for_test( - worker: &RadrootsNostrKeys, - job: &RadrootsNostrEvent, - receipt_event: &RadrootsNostrEvent, - policy: &TradeValidationReceiptProverPolicy, - ) -> RadrootsNostrEvent { - signed_result_event_with_metadata_for_test(worker, job, receipt_event, policy, None) - } - - fn signed_result_event_with_payload_field_for_test( - worker: &RadrootsNostrKeys, - result_event: &RadrootsNostrEvent, - field: &str, - replacement: impl Into<serde_json::Value>, - ) -> RadrootsNostrEvent { - let mut result_json: serde_json::Value = - serde_json::from_str(result_event.content.as_str()).expect("result json"); - set_result_json_field(&mut result_json, field, replacement); - let result_tags: Vec<Vec<String>> = result_event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect(); - super::signed_event_from_parts( - worker, - KIND_TRADE_TRANSITION_PROOF_RESULT, - serde_json::to_string(&result_json).expect("result json"), - result_tags, - Some(result_event.created_at.as_secs()), - ) - .expect("signed result") - } - - struct RecoveredResultValidationFixture { - worker: RadrootsNostrKeys, - job: RadrootsNostrEvent, - envelope: radroots_trade::dvm::RadrootsTradeTransitionProofRequestEnvelope, - processed: RhiProcessedJobState, - verified_receipt: RadrootsVerifiedValidationReceipt, - receipt_event_id: String, - result_json: serde_json::Value, - result_tags: Vec<Vec<String>>, - } - - fn recovered_result_validation_fixture() -> RecoveredResultValidationFixture { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::None, - ); - let receipt_event_id = publish_result_id(9); - let result = super::result_payload( - &job, - &processed, - &envelope, - receipt_event_id.as_str(), - verified_receipt.clone(), - &deterministic_policy(), - None, - ) - .expect("result payload"); - let result_json = serde_json::to_value(&result).expect("result json"); - let result_tags = - super::result_tags_from_dvm(&job, &envelope.tags.inputs, receipt_event_id.as_str()) - .expect("result tags"); - RecoveredResultValidationFixture { - worker, - job, - envelope, - processed, - verified_receipt, - receipt_event_id, - result_json, - result_tags, - } - } - - struct RecoveredResultIntentScenario { - worker: RadrootsNostrKeys, - job: RadrootsNostrEvent, - runtime: TradeListingRuntime, - receipt_event_id: String, - receipt_event_json: String, - result_event_id: String, - result_event_json: String, - } - - fn sp1_result_proof_metadata( - public_values_hash: String, - ) -> super::TradeValidationReceiptResultProofMetadata { - super::TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: true, - proof_generated: true, - sp1_execute_checked: true, - sp1_execute_public_values_hash: Some(public_values_hash), - } - } - - async fn recovered_result_intent_scenario() -> RecoveredResultIntentScenario { - recovered_result_intent_scenario_with_stored_events(None, None).await - } - - async fn recovered_result_intent_scenario_with_stored_events( - stored_receipt_event_json: Option<&str>, - stored_result_event_json: Option<&str>, - ) -> RecoveredResultIntentScenario { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - let result_event = - signed_result_event_for_test(&worker, &job, &receipt_event, &deterministic_policy()); - let result_event_id = result_event.id.to_hex(); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 1) - .await - .expect("claim processed job"); - let receipt_event_json = stored_receipt_event_json - .map(str::to_owned) - .unwrap_or_else(|| serde_json::to_string(&receipt_event).expect("receipt json")); - runtime - .processed_jobs() - .mark_receipt_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - None, - 2, - ) - .await - .expect("record receipt intent"); - runtime - .processed_jobs() - .mark_receipt_published(&processed, receipt_event.id.to_hex().as_str(), 3) - .await - .expect("record receipt"); - assert_eq!( - runtime - .processed_jobs() - .claim_job(&processed, 3, 1) - .await - .expect("claim result"), - RhiProcessedJobClaim::RecoverResult { - receipt_event_id: receipt_event.id.to_hex(), - receipt_event_json: receipt_event_json.clone(), - result_event_id: None, - result_event_json: None, - proof_metadata_json: None, - } - ); - let result_event_json = stored_result_event_json - .map(str::to_owned) - .unwrap_or_else(|| serde_json::to_string(&result_event).expect("result json")); - runtime - .processed_jobs() - .mark_result_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - result_event_id.as_str(), - result_event_json.as_str(), - 4, - ) - .await - .expect("record result intent"); - - RecoveredResultIntentScenario { - worker, - job, - runtime, - receipt_event_id: receipt_event.id.to_hex(), - receipt_event_json, - result_event_id, - result_event_json, - } - } - - async fn recovered_sp1_result_intent_scenario( - policy: &TradeValidationReceiptProverPolicy, - ) -> RecoveredResultIntentScenario { - recovered_sp1_result_intent_scenario_with_stored_events(policy, None, None).await - } - - async fn recovered_sp1_result_intent_scenario_with_stored_events( - policy: &TradeValidationReceiptProverPolicy, - stored_receipt_event_json: Option<&str>, - stored_result_event_json: Option<&str>, - ) -> RecoveredResultIntentScenario { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - policy.proof_mode, - policy.expected_sp1_program_hash.clone(), - policy.expected_sp1_verifying_key_hash.clone(), - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::Sp1Core, - ); - let proof_metadata = - sp1_result_proof_metadata(verified_receipt.receipt.public_values_hash.clone()); - let proof_metadata_json = - serde_json::to_string(&proof_metadata).expect("proof metadata json"); - let receipt_parts = super::validation_receipt_event_build( - envelope.content.request.order_id.as_str(), - &verified_receipt.receipt, - ) - .expect("receipt parts"); - let receipt_event = super::signed_event_from_parts( - &worker, - receipt_parts.kind, - receipt_parts.content, - receipt_parts.tags, - Some(job.created_at.as_secs()), - ) - .expect("receipt event"); - let result_event = signed_result_event_with_metadata_for_test( - &worker, - &job, - &receipt_event, - policy, - Some(&proof_metadata), - ); - let result_event_id = result_event.id.to_hex(); - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 1) - .await - .expect("claim processed job"); - let receipt_event_json = stored_receipt_event_json - .map(str::to_owned) - .unwrap_or_else(|| serde_json::to_string(&receipt_event).expect("receipt json")); - runtime - .processed_jobs() - .mark_receipt_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - Some(proof_metadata_json.as_str()), - 2, - ) - .await - .expect("record receipt intent"); - runtime - .processed_jobs() - .mark_receipt_published(&processed, receipt_event.id.to_hex().as_str(), 3) - .await - .expect("record receipt"); - assert_eq!( - runtime - .processed_jobs() - .claim_job(&processed, 3, 1) - .await - .expect("claim result"), - RhiProcessedJobClaim::RecoverResult { - receipt_event_id: receipt_event.id.to_hex(), - receipt_event_json: receipt_event_json.clone(), - result_event_id: None, - result_event_json: None, - proof_metadata_json: Some(proof_metadata_json), - } - ); - let result_event_json = stored_result_event_json - .map(str::to_owned) - .unwrap_or_else(|| serde_json::to_string(&result_event).expect("result json")); - runtime - .processed_jobs() - .mark_result_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - result_event_id.as_str(), - result_event_json.as_str(), - 4, - ) - .await - .expect("record result intent"); - - RecoveredResultIntentScenario { - worker, - job, - runtime, - receipt_event_id: receipt_event.id.to_hex(), - receipt_event_json, - result_event_id, - result_event_json, - } - } - - fn set_result_json_field( - value: &mut serde_json::Value, - field: &str, - replacement: impl Into<serde_json::Value>, - ) { - value[field] = replacement.into(); - } - - async fn assert_result_intent_preserved_after_recovered_failure( - runtime: &TradeListingRuntime, - job: &RadrootsNostrEvent, - receipt_event_id: &str, - receipt_event_json: &str, - result_event_id: &str, - result_event_json: &str, - ) { - let processed = runtime - .processed_jobs() - .get_job(&job.id.to_hex()) - .await - .expect("processed job lookup") - .expect("processed job"); - assert_eq!(processed.status, RhiProcessedJobStatus::ResultPublishing); - assert_eq!(processed.completed_timestamp, None); - assert_eq!( - processed.receipt_event_id.as_deref(), - Some(receipt_event_id) - ); - assert_eq!( - processed.receipt_event_json.as_deref(), - Some(receipt_event_json) - ); - assert_eq!(processed.result_event_id.as_deref(), Some(result_event_id)); - assert_eq!( - processed.result_event_json.as_deref(), - Some(result_event_json) - ); - } - - fn verified_receipt_for_payload( - request: &RadrootsTradeTransitionProofRequestV1, - proof_system: RadrootsValidationReceiptProofSystem, - ) -> RadrootsVerifiedValidationReceipt { - let event_set_root = hash32('1'); - let public_values_hash = hash32('2'); - let reducer_output_root = hash32('3'); - RadrootsVerifiedValidationReceipt { - receipt: RadrootsTradeValidationReceipt { - changed_records_root: hash32('4'), - domain: "radroots.receipt".to_string(), - error_bitmap: "0x00000000000000000000000000000000".to_string(), - event_set_root: event_set_root.clone(), - new_state_root: reducer_output_root.clone(), - previous_state_root: hash32('5'), - proof: RadrootsValidationReceiptProof { - inline_proof_base64: None, - mode: Some("core".to_string()), - program_hash: request.sp1_program_hash.clone(), - proof_reference: request - .sp1_program_hash - .as_ref() - .map(|_| format!("radroots-proof://sha256/{}", "1".repeat(64))), - system: proof_system, - verifying_key_hash: request.sp1_verifying_key_hash.clone(), - }, - public_values_hash: public_values_hash.clone(), - receipt_type: RadrootsValidationReceiptType::TradeTransition, - result: RadrootsValidationReceiptResult::Valid, - statement: RadrootsValidationReceiptStatement { - listing_event_id: request.listing_event_id.as_str().to_string(), - root_event_id: request.request_event_id.as_str().to_string(), - target_event_id: request.decision_event_id.as_str().to_string(), - statement_type: RadrootsValidationReceiptType::TradeTransition, - }, - version: 1, - }, - tags: RadrootsValidationReceiptTags { - event_set_root, - listing_event_id: request.listing_event_id.as_str().to_string(), - order_id: request.request.order_id.as_str().to_string(), - proof_system, - public_values_hash, - receipt_type: RadrootsValidationReceiptType::TradeTransition, - reducer_output_root, - root_event_id: request.request_event_id.as_str().to_string(), - target_event_id: request.decision_event_id.as_str().to_string(), - }, - } - } - - async fn handle_job_request_for_test( - job: &RadrootsNostrEvent, - worker: &RadrootsNostrKeys, - policy: &TradeValidationReceiptProverPolicy, - ) -> Result<(), TradeValidationReceiptJobError> { - let runtime = TradeListingRuntime::new(); - handle_trade_validation_receipt_job_request( - job, - worker, - &client_for(worker), - &runtime, - policy, - ) - .await - } - - fn deterministic_policy() -> TradeValidationReceiptProverPolicy { - TradeValidationReceiptProverPolicy::repo_local_deterministic_none() - } - - fn hash32(ch: char) -> String { - format!("0x{}", ch.to_string().repeat(64)) - } - - fn remote_http_config() -> TradeValidationReceiptRemoteHttpProverConfig { - TradeValidationReceiptRemoteHttpProverConfig { - endpoint_url: "http://127.0.0.1:65535/prove".to_string(), - auth: TradeValidationReceiptRemoteHttpAuth::NoAuth, - request_timeout_ms: 1000, - poll_interval_ms: 1, - max_poll_attempts: 1, - max_response_bytes: 65_536, - } - } - - fn remote_http_policy() -> TradeValidationReceiptProverPolicy { - TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::RemoteHttpProve, - proof_mode: RadrootsSp1TradeProofMode::Core, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: Some(hash32('a')), - expected_sp1_verifying_key_hash: Some(hash32('b')), - remote_http: Some(remote_http_config()), - } - } - - fn recovered_result_missing_auth_policy() -> TradeValidationReceiptProverPolicy { - let mut policy = remote_http_policy(); - let remote_http = policy.remote_http.as_mut().expect("remote config"); - remote_http.endpoint_url = "https://example.test/prove".to_string(); - remote_http.auth = TradeValidationReceiptRemoteHttpAuth::BearerTokenEnv { - env_var: RECOVERED_RESULT_MISSING_AUTH_ENV.to_string(), - }; - policy - } - - fn set_remote_http_auth_env_missing(env_var: &str) { - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remote_http_auth_env_values - .insert(env_var.to_string(), None); - } - - fn set_remote_http_auth_env_value(env_var: &str, value: &str) { - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remote_http_auth_env_values - .insert(env_var.to_string(), Some(value.to_string())); - } - - #[cfg(feature = "sp1_verify")] - fn remote_response( - status: RadrootsSp1TradeRemoteProverStatus, - ) -> RadrootsSp1TradeRemoteProverResponse { - RadrootsSp1TradeRemoteProverResponse { - schema_version: RADROOTS_SP1_TRADE_REMOTE_PROVER_SCHEMA_VERSION, - request_id: "__request_id__".to_string(), - status, - status_url: None, - status_path: None, - proof_system: None, - proof_mode: None, - public_values_hash: None, - sp1_program_hash: None, - sp1_verifying_key_hash: None, - proof_artifact: None, - resolved_proof_envelope_base64: None, - reason_code: None, - message: None, - detail: None, - } - } - - #[cfg(feature = "sp1_verify")] - fn remote_http_local_response_url(response: &'static str) -> String { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("test listener"); - let addr = listener.local_addr().expect("test listener address"); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("test connection"); - let mut buffer = [0; 4096]; - let _ = std::io::Read::read(&mut stream, &mut buffer); - std::io::Write::write_all(&mut stream, response.as_bytes()).expect("test response"); - }); - format!("http://{addr}/prove") - } - - #[cfg(feature = "sp1_verify")] - async fn run_remote_http_job( - remote_http_results: Vec< - Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError>, - >, - remote_proof_verification_results: Vec<Result<(), TradeValidationReceiptJobError>>, - publish_results: Vec<Result<String, TradeValidationReceiptJobError>>, - ) -> Result<Vec<super::PublishedEventParts>, TradeValidationReceiptJobError> { - run_remote_http_job_with_policy( - remote_http_policy(), - remote_http_results, - remote_proof_verification_results, - publish_results, - ) - .await - } - - #[cfg(feature = "sp1_verify")] - async fn run_remote_http_job_with_policy( - policy: TradeValidationReceiptProverPolicy, - remote_http_results: Vec< - Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError>, - >, - remote_proof_verification_results: Vec<Result<(), TradeValidationReceiptJobError>>, - publish_results: Vec<Result<String, TradeValidationReceiptJobError>>, - ) -> Result<Vec<super::PublishedEventParts>, TradeValidationReceiptJobError> { - run_remote_http_job_with_policy_and_requests( - policy, - remote_http_results, - remote_proof_verification_results, - publish_results, - ) - .await - .map(|(published, _)| published) - } - - #[cfg(feature = "sp1_verify")] - async fn run_remote_http_job_with_policy_and_requests( - policy: TradeValidationReceiptProverPolicy, - remote_http_results: Vec< - Result<RadrootsSp1TradeRemoteProverResponse, TradeValidationReceiptJobError>, - >, - remote_proof_verification_results: Vec<Result<(), TradeValidationReceiptJobError>>, - publish_results: Vec<Result<String, TradeValidationReceiptJobError>>, - ) -> Result< - ( - Vec<super::PublishedEventParts>, - Vec<RadrootsSp1TradeRemoteProverRequest>, - ), - TradeValidationReceiptJobError, - > { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Core, - Some(hash32('a')), - Some(hash32('b')), - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - hooks.remote_http_results.extend(remote_http_results); - hooks - .remote_proof_verification_results - .extend(remote_proof_verification_results); - hooks.publish_event_results.extend(publish_results); - } - - handle_job_request_for_test(&job, &worker, &policy).await?; - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - Ok(( - hooks.published_events.clone(), - hooks.remote_http_requests.clone(), - )) - } - - #[tokio::test] - async fn local_worker_request_publishes_signed_receipt_result_and_processed_job() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let policy = deterministic_policy(); - let job = build_trade_validation_receipt_job_request_event( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - inventory_bins(), - 7, - None, - &policy, - 1_700_000_000, - ) - .expect("job request"); - let runtime = TradeListingRuntime::new(); - - let output = handle_trade_validation_receipt_local_worker_request( - TradeValidationReceiptLocalWorkerRequest::new( - job, - listing_event.clone(), - request_event.clone(), - decision_event.clone(), - ) - .with_publish_created_at_secs(1_700_000_001), - &worker, - &runtime, - &policy, - ) - .await - .expect("local worker"); - - assert_eq!(output.published_events.len(), 2); - let receipt_event = output - .published_events - .iter() - .find(|event| { - super::event_kind_u32(event).expect("event kind") == KIND_TRADE_VALIDATION_RECEIPT - }) - .expect("receipt event"); - let result_event = output - .published_events - .iter() - .find(|event| { - super::event_kind_u32(event).expect("event kind") - == KIND_TRADE_TRANSITION_PROOF_RESULT - }) - .expect("result event"); - assert_eq!(receipt_event.pubkey, worker.public_key()); - assert_eq!(result_event.pubkey, worker.public_key()); - let verified = verify_validation_receipt_event( - &radroots_event_from_nostr(receipt_event), - RadrootsValidationReceiptExpectedBinding { - listing_event_id: Some(listing_event.id.to_hex().as_str()), - root_event_id: Some(request_event.id.to_hex().as_str()), - target_event_id: Some(decision_event.id.to_hex().as_str()), - order_id: Some("order-1"), - proof_system: Some(RadrootsValidationReceiptProofSystem::None), - ..RadrootsValidationReceiptExpectedBinding::default() - }, - ) - .expect("receipt verifies"); - let result: TradeValidationReceiptJobResult = - serde_json::from_str(&result_event.content).expect("result content"); - assert_eq!(result.receipt_event_id, receipt_event.id.to_hex()); - assert_eq!( - result.public_values_hash, - verified.receipt.public_values_hash - ); - let processed_job = output.processed_job.expect("processed job"); - assert_eq!(processed_job.status, RhiProcessedJobStatus::Completed); - assert_eq!( - processed_job.receipt_event_id.as_deref(), - Some(receipt_event.id.to_hex().as_str()) - ); - assert_eq!( - processed_job.result_event_id.as_deref(), - Some(result_event.id.to_hex().as_str()) - ); - } - - #[test] - fn deterministic_none_policy_requires_explicit_repo_local_runtime_policy() { - let policy = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::DeterministicNone, - proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: None, - remote_http: None, - }; - assert!(matches!( - policy.validate(), - Err(TradeValidationReceiptJobError::DeterministicNoneRequiresRepoLocalDevelopment) - )); - - assert!(deterministic_policy().validate().is_ok()); - - let policy = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::LocalExecute, - proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::RepoLocalDevelopment, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: None, - remote_http: None, - }; - assert!(matches!( - policy.validate(), - Err( - TradeValidationReceiptJobError::RepoLocalDevelopmentPolicyRequiresDeterministicNone - ) - )); - } - - #[test] - fn prover_policy_requires_configured_sp1_identity_for_local_cpu() { - let missing_identity = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::LocalCpuProve, - proof_mode: RadrootsSp1TradeProofMode::Core, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: Some(hash32('b')), - remote_http: None, - }; - assert!(matches!( - missing_identity.validate(), - Err(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired) - )); - - let policy = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::LocalCpuProve, - proof_mode: RadrootsSp1TradeProofMode::Core, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: Some(hash32('a')), - expected_sp1_verifying_key_hash: Some(hash32('b')), - remote_http: None, - }; - let request = policy_request( - RadrootsSp1TradeProofMode::Core, - Some(hash32('c')), - Some(hash32('b')), - ); - assert!(matches!( - policy.validate_request(&request), - Err(TradeValidationReceiptJobError::ExpectedSp1ProgramHashMismatch) - )); - - let mut request = request; - request.sp1_program_hash = None; - request.sp1_verifying_key_hash = None; - assert!(matches!( - policy.validate_request(&request), - Err(TradeValidationReceiptJobError::ExpectedSp1ProgramHashMismatch) - )); - } - - #[test] - fn remote_http_policy_requires_explicit_config_and_identity_before_relay_fetch() { - let missing_config = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::RemoteHttpProve, - proof_mode: RadrootsSp1TradeProofMode::Core, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: Some(hash32('a')), - expected_sp1_verifying_key_hash: Some(hash32('b')), - remote_http: None, - }; - assert!(matches!( - missing_config.validate(), - Err(TradeValidationReceiptJobError::RemoteHttpConfigRequired) - )); - - let missing_identity = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::RemoteHttpProve, - proof_mode: RadrootsSp1TradeProofMode::Core, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: Some(hash32('b')), - remote_http: Some(remote_http_config()), - }; - assert!(matches!( - missing_identity.validate(), - Err(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired) - )); - - let mut invalid_config = remote_http_policy(); - invalid_config - .remote_http - .as_mut() - .expect("remote config") - .endpoint_url = "file:///tmp/prove".to_string(); - assert!(matches!( - invalid_config.validate(), - Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "endpoint_url" - )) - )); - - let mut bearer_over_http = remote_http_policy(); - bearer_over_http - .remote_http - .as_mut() - .expect("remote config") - .auth = TradeValidationReceiptRemoteHttpAuth::BearerTokenEnv { - env_var: "RHI_TEST_REMOTE_HTTP_TOKEN".to_string(), - }; - assert!(matches!( - bearer_over_http.validate(), - Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "auth.endpoint_url_scheme" - )) - )); - } - - #[test] - fn remote_http_auth_env_var_must_use_rhi_prefix_before_process_env_read() { - let mut policy = remote_http_policy(); - let remote_http = policy.remote_http.as_mut().expect("remote config"); - remote_http.endpoint_url = "https://example.test/prove".to_string(); - remote_http.auth = TradeValidationReceiptRemoteHttpAuth::BearerTokenEnv { - env_var: "RADROOTS_TEST_REMOTE_HTTP_TOKEN".to_string(), - }; - - assert!(matches!( - policy.validate(), - Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "auth.env_var" - )) - )); - } - - #[test] - fn remote_http_auth_test_hook_controls_process_env_state() { - let _guard = test_guard(); - let mut policy = recovered_result_missing_auth_policy(); - set_remote_http_auth_env_missing(RECOVERED_RESULT_MISSING_AUTH_ENV); - - assert!(matches!( - policy.validate(), - Err(TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing(env_var)) - if env_var == RECOVERED_RESULT_MISSING_AUTH_ENV - )); - - set_remote_http_auth_env_value(RECOVERED_RESULT_MISSING_AUTH_ENV, " test-token "); - match policy.validate() { - Ok(()) if cfg!(feature = "sp1_verify") => {} - Err(TradeValidationReceiptJobError::ProverBackendUnavailable("remote_http_prove")) - if !cfg!(feature = "sp1_verify") => {} - result => panic!("unexpected remote auth validation result: {result:?}"), - } - - policy.remote_http.as_mut().expect("remote config").auth = - TradeValidationReceiptRemoteHttpAuth::BearerTokenEnv { - env_var: "RADROOTS_TEST_REMOTE_HTTP_TOKEN".to_string(), - }; - assert!(matches!( - policy.validate(), - Err(TradeValidationReceiptJobError::RemoteHttpInvalidConfig( - "auth.env_var" - )) - )); - } - - #[cfg(feature = "sp1_verify")] - #[test] - fn remote_http_policy_accepts_core_mode_when_configured() { - assert!(remote_http_policy().validate().is_ok()); - } - - #[cfg(not(feature = "sp1_verify"))] - #[test] - fn remote_http_policy_recognizes_non_core_sp1_mode_before_backend_availability() { - let mut policy = remote_http_policy(); - policy.proof_mode = RadrootsSp1TradeProofMode::Compressed; - - assert!(matches!( - policy.validate(), - Err(TradeValidationReceiptJobError::ProverBackendUnavailable( - "remote_http_prove" - )) - )); - } - - #[cfg(feature = "sp1_verify")] - #[test] - fn remote_http_policy_accepts_all_explicit_sp1_modes_when_configured() { - for proof_mode in [ - RadrootsSp1TradeProofMode::Core, - RadrootsSp1TradeProofMode::Compressed, - RadrootsSp1TradeProofMode::Groth16, - RadrootsSp1TradeProofMode::Plonk, - ] { - let mut policy = remote_http_policy(); - policy.proof_mode = proof_mode; - assert!(policy.validate().is_ok()); - } - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_publishes_only_after_remote_artifact_verification() { - let (published, requests) = run_remote_http_job_with_policy_and_requests( - remote_http_policy(), - vec![Ok(remote_response( - RadrootsSp1TradeRemoteProverStatus::Completed, - ))], - vec![Ok(())], - vec![Ok(publish_result_id(1)), Ok(publish_result_id(2))], - ) - .await - .expect("remote proof job"); - - assert_eq!(published.len(), 2); - assert_eq!(published[0].kind, KIND_TRADE_VALIDATION_RECEIPT); - assert_eq!(published[1].kind, KIND_TRADE_TRANSITION_PROOF_RESULT); - let result: TradeValidationReceiptJobResult = - serde_json::from_str(&published[1].content).expect("result json"); - assert_eq!( - result.prover_backend, - TradeValidationReceiptProverBackend::RemoteHttpProve - ); - assert!(result.proof_generated); - assert_eq!(result.proof_mode, RadrootsSp1TradeProofMode::Core); - assert_eq!(result.proof_system, "sp1_core"); - assert!(result.sp1_execute_checked); - assert_eq!( - result.validation_authority, - RadrootsTradeValidationAuthority::TrustedServiceAndProofVerified - ); - assert_eq!( - result.confidence, - RadrootsTradeCommitmentConfidence::CommittedByTrustedServiceAndProof - ); - assert_eq!( - result.sp1_execute_public_values_hash.as_deref(), - Some(result.public_values_hash.as_str()) - ); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].expected_public_values_hash, - result.public_values_hash - ); - assert!(result.cryptographic_proof_verified); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_polls_running_until_completed() { - let mut policy = remote_http_policy(); - policy - .remote_http - .as_mut() - .expect("remote config") - .max_poll_attempts = 2; - let mut accepted = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - accepted.status_path = Some("/prove/status/request-1".to_string()); - let mut running = remote_response(RadrootsSp1TradeRemoteProverStatus::Running); - running.status_path = Some("/prove/status/request-1".to_string()); - let published = run_remote_http_job_with_policy( - policy, - vec![ - Ok(accepted), - Ok(running), - Ok(remote_response( - RadrootsSp1TradeRemoteProverStatus::Completed, - )), - ], - vec![Ok(())], - vec![Ok(publish_result_id(1)), Ok(publish_result_id(2))], - ) - .await - .expect("polled remote proof job"); - - assert_eq!(published.len(), 2); - let result: TradeValidationReceiptJobResult = - serde_json::from_str(&published[1].content).expect("result json"); - assert_eq!( - result.prover_backend, - TradeValidationReceiptProverBackend::RemoteHttpProve - ); - assert!(result.cryptographic_proof_verified); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_accepts_same_origin_status_url() { - let mut policy = remote_http_policy(); - policy - .remote_http - .as_mut() - .expect("remote config") - .max_poll_attempts = 1; - let mut accepted = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - accepted.status_url = Some("http://127.0.0.1:65535/prove/status/request-1".to_string()); - let published = run_remote_http_job_with_policy( - policy, - vec![ - Ok(accepted), - Ok(remote_response( - RadrootsSp1TradeRemoteProverStatus::Completed, - )), - ], - vec![Ok(())], - vec![Ok(publish_result_id(1)), Ok(publish_result_id(2))], - ) - .await - .expect("same-origin status url"); - - assert_eq!(published.len(), 2); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_rejects_cross_origin_status_url() { - let mut accepted = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - accepted.status_url = Some("http://127.0.0.2:65535/prove/status/request-1".to_string()); - let error = run_remote_http_job( - vec![Ok(accepted)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("cross-origin status url"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpInvalidResponse("status_url") - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_rejects_absolute_or_scheme_relative_status_path() { - let mut absolute = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - absolute.status_path = Some("https://example.invalid/status".to_string()); - let error = run_remote_http_job( - vec![Ok(absolute)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("absolute status path"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpInvalidResponse("status_path") - )); - - let mut scheme_relative = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - scheme_relative.status_path = Some("//example.invalid/status".to_string()); - let error = run_remote_http_job( - vec![Ok(scheme_relative)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("scheme-relative status path"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpInvalidResponse("status_path") - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_rejects_polling_request_id_mismatch_before_next_poll() { - let mut accepted = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - accepted.request_id = "wrong-request".to_string(); - accepted.status_path = Some("/prove/status/request-1".to_string()); - let error = run_remote_http_job( - vec![ - Ok(accepted), - Ok(remote_response( - RadrootsSp1TradeRemoteProverStatus::Completed, - )), - ], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("polling identity mismatch"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpIdentityMismatch("request_id") - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_does_not_publish_when_verification_fails() { - let error = run_remote_http_job( - vec![Ok(remote_response( - RadrootsSp1TradeRemoteProverStatus::Completed, - ))], - vec![Err(TradeValidationReceiptJobError::Proof( - RadrootsSp1TradeHostError::Sp1ProofVerificationFailed("test".to_string()), - ))], - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote verification failure"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::Proof( - RadrootsSp1TradeHostError::Sp1ProofVerificationFailed(_) - ) - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_does_not_publish_when_reference_digest_mismatches() { - let mut response = remote_response(RadrootsSp1TradeRemoteProverStatus::Completed); - response.resolved_proof_envelope_base64 = Some("cHJvb2Y=".to_string()); - let error = run_remote_http_job( - vec![Ok(response)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote proof reference mismatch"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::Proof( - RadrootsSp1TradeHostError::Sp1ProofReferenceDigestMismatch - ) - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_does_not_publish_when_sp1_identity_mismatches() { - let mut response = remote_response(RadrootsSp1TradeRemoteProverStatus::Completed); - response.sp1_program_hash = Some(hash32('c')); - let error = run_remote_http_job( - vec![Ok(response)], - vec![Ok(())], - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote sp1 identity mismatch"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpIdentityMismatch("sp1_program_hash") - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_does_not_publish_when_public_values_mismatch() { - let mut response = remote_response(RadrootsSp1TradeRemoteProverStatus::Completed); - response.public_values_hash = Some(hash32('d')); - let error = run_remote_http_job( - vec![Ok(response)], - vec![Ok(())], - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote public values mismatch"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpIdentityMismatch("public_values_hash") - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_does_not_publish_terminal_failed_or_rejected() { - let mut failed = remote_response(RadrootsSp1TradeRemoteProverStatus::Failed); - failed.reason_code = Some("remote_failed".to_string()); - failed.message = Some("remote prover failed".to_string()); - let error = run_remote_http_job( - vec![Ok(failed)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote failed"); - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpTerminal { - status: "failed", - .. - } - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - - let mut rejected = remote_response(RadrootsSp1TradeRemoteProverStatus::Rejected); - rejected.reason_code = Some("remote_rejected".to_string()); - rejected.message = Some("remote prover rejected request".to_string()); - let error = run_remote_http_job( - vec![Ok(rejected)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote rejected"); - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpTerminal { - status: "rejected", - .. - } - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_does_not_publish_timeout_or_oversized_response() { - let mut accepted = remote_response(RadrootsSp1TradeRemoteProverStatus::Accepted); - accepted.status_path = Some("/prove/status/request-1".to_string()); - let mut running = remote_response(RadrootsSp1TradeRemoteProverStatus::Running); - running.status_path = Some("/prove/status/request-1".to_string()); - let error = run_remote_http_job( - vec![Ok(accepted), Ok(running)], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote timeout"); - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpTimeout - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - - let error = run_remote_http_job( - vec![Err( - TradeValidationReceiptJobError::RemoteHttpResponseTooLarge, - )], - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("remote oversized response"); - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpResponseTooLarge - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_rejects_oversized_content_length_before_publish() { - let endpoint = remote_http_local_response_url( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", - ); - let mut policy = remote_http_policy(); - { - let remote_http = policy.remote_http.as_mut().expect("remote config"); - remote_http.endpoint_url = endpoint; - remote_http.max_response_bytes = 1; - } - let error = run_remote_http_job_with_policy( - policy, - Vec::new(), - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("oversized content-length response"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpResponseTooLarge - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[cfg(feature = "sp1_verify")] - #[tokio::test] - async fn remote_http_prove_rejects_oversized_http_response_before_publish() { - let endpoint = remote_http_local_response_url( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\n{}\r\n0\r\n\r\n", - ); - let mut policy = remote_http_policy(); - { - let remote_http = policy.remote_http.as_mut().expect("remote config"); - remote_http.endpoint_url = endpoint; - remote_http.max_response_bytes = 1; - } - let error = run_remote_http_job_with_policy( - policy, - Vec::new(), - Vec::new(), - vec![Err(TradeValidationReceiptJobError::InvalidJobRequest)], - ) - .await - .expect_err("oversized streamed response"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpResponseTooLarge - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[tokio::test] - async fn proof_job_publishes_verified_receipt_and_result_after_proof_verification() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("proof job"); - - let published = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .clone(); - assert_eq!(published.len(), 2); - assert_eq!(published[0].kind, KIND_TRADE_VALIDATION_RECEIPT); - assert_eq!(published[1].kind, KIND_TRADE_TRANSITION_PROOF_RESULT); - - let receipt_event = published_event(&published[0]); - let verified = verify_validation_receipt_event( - &radroots_event_from_nostr(&receipt_event), - RadrootsValidationReceiptExpectedBinding { - order_id: Some("order-1"), - proof_system: Some(RadrootsValidationReceiptProofSystem::None), - ..RadrootsValidationReceiptExpectedBinding::default() - }, - ) - .expect("receipt verifies"); - let result: TradeValidationReceiptJobResult = - serde_json::from_str(&published[1].content).expect("result json"); - assert_eq!(result.receipt_event_id, receipt_event.id.to_hex()); - assert_eq!( - result.prover_backend, - TradeValidationReceiptProverBackend::DeterministicNone - ); - assert!(!result.proof_generated); - assert_eq!(result.proof_mode, RadrootsSp1TradeProofMode::None); - assert!(!result.sp1_execute_checked); - assert!(result.sp1_execute_public_values_hash.is_none()); - assert!(!result.cryptographic_proof_verified); - assert_eq!( - result.validation_authority, - RadrootsTradeValidationAuthority::DevDeterministicOnly - ); - assert_eq!( - result.confidence, - RadrootsTradeCommitmentConfidence::LocalOnly - ); - assert_eq!( - result.public_values_hash, - verified.receipt.public_values_hash - ); - assert_eq!(result.worker_role.to_string(), "non_authoritative_prover"); - assert!(published[1].tags.iter().any(|tag| { - tag.get(0).map(String::as_str) == Some("radroots:validation_receipt") - && tag.get(1).map(String::as_str) == Some(receipt_event.id.to_hex().as_str()) - })); - } - - #[test] - fn result_payload_rejects_recovered_sp1_receipt_without_execution_metadata() { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Core, - Some(hash32('a')), - Some(hash32('b')), - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::Sp1Core, - ); - - let error = super::result_payload( - &job, - &processed, - &envelope, - "receipt-id", - verified_receipt, - &remote_http_policy(), - None, - ) - .expect_err("missing metadata rejected"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::MissingRecoveredProofMetadata - )); - } - - #[test] - fn result_payload_uses_recovered_sp1_execution_metadata() { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Core, - Some(hash32('a')), - Some(hash32('b')), - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::Sp1Core, - ); - let public_values_hash = verified_receipt.receipt.public_values_hash.clone(); - let metadata = super::TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: true, - proof_generated: true, - sp1_execute_checked: true, - sp1_execute_public_values_hash: Some(public_values_hash.clone()), - }; - - let result = super::result_payload( - &job, - &processed, - &envelope, - "receipt-id", - verified_receipt, - &remote_http_policy(), - Some(&metadata), - ) - .expect("result payload"); - - assert!(result.proof_generated); - assert!(result.cryptographic_proof_verified); - assert!(result.sp1_execute_checked); - assert_eq!( - result.sp1_execute_public_values_hash.as_deref(), - Some(public_values_hash.as_str()) - ); - assert_eq!(result.proof_system, "sp1_core"); - assert_eq!( - result.validation_authority, - RadrootsTradeValidationAuthority::TrustedServiceAndProofVerified - ); - assert_eq!( - result.confidence, - RadrootsTradeCommitmentConfidence::CommittedByTrustedServiceAndProof - ); - } - - #[test] - fn result_payload_rejects_recovered_sp1_receipt_with_unchecked_execution_metadata() { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Core, - Some(hash32('a')), - Some(hash32('b')), - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::Sp1Core, - ); - let metadata = super::TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: true, - proof_generated: true, - sp1_execute_checked: false, - sp1_execute_public_values_hash: None, - }; - - let error = super::result_payload( - &job, - &processed, - &envelope, - "receipt-id", - verified_receipt, - &remote_http_policy(), - Some(&metadata), - ) - .expect_err("unchecked SP1 metadata rejected"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredProofMetadataMismatch - )); - } - - #[test] - fn result_payload_rejects_recovered_sp1_receipt_with_wrong_execution_hash() { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Core, - Some(hash32('a')), - Some(hash32('b')), - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::Sp1Core, - ); - let metadata = super::TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: true, - proof_generated: true, - sp1_execute_checked: true, - sp1_execute_public_values_hash: Some(hash32('9')), - }; - - let error = super::result_payload( - &job, - &processed, - &envelope, - "receipt-id", - verified_receipt, - &remote_http_policy(), - Some(&metadata), - ) - .expect_err("wrong SP1 execution hash rejected"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredProofMetadataMismatch - )); - } - - #[test] - fn result_payload_rejects_deterministic_metadata_with_execution_binding() { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::None, - ); - let public_values_hash = verified_receipt.receipt.public_values_hash.clone(); - let metadata = super::TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: false, - proof_generated: false, - sp1_execute_checked: true, - sp1_execute_public_values_hash: Some(public_values_hash), - }; - - let error = super::result_payload( - &job, - &processed, - &envelope, - "receipt-id", - verified_receipt, - &deterministic_policy(), - Some(&metadata), - ) - .expect_err("deterministic SP1 execution metadata rejected"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredProofMetadataMismatch - )); - } - - #[test] - fn validate_recovered_result_event_rejects_stored_sp1_result_without_execution_binding() { - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Core, - Some(hash32('a')), - Some(hash32('b')), - ); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - let processed = processed_job_for_test(&job); - let verified_receipt = verified_receipt_for_payload( - &envelope.content, - RadrootsValidationReceiptProofSystem::Sp1Core, - ); - let receipt_event_id = publish_result_id(9); - let metadata = super::TradeValidationReceiptResultProofMetadata { - cryptographic_proof_verified: true, - proof_generated: true, - sp1_execute_checked: true, - sp1_execute_public_values_hash: Some( - verified_receipt.receipt.public_values_hash.clone(), - ), - }; - let mut result = super::result_payload( - &job, - &processed, - &envelope, - receipt_event_id.as_str(), - verified_receipt.clone(), - &remote_http_policy(), - Some(&metadata), - ) - .expect("valid result payload"); - result.sp1_execute_checked = false; - result.sp1_execute_public_values_hash = None; - let result_tags = - super::result_tags_from_dvm(&job, &envelope.tags.inputs, receipt_event_id.as_str()) - .expect("result tags"); - let result_event = super::signed_event_from_parts( - &worker, - KIND_TRADE_TRANSITION_PROOF_RESULT, - serde_json::to_string(&result).expect("result json"), - result_tags, - Some(job.created_at.as_secs()), - ) - .expect("result event"); - - let error = super::validate_recovered_result_event( - &result_event, - &job, - &processed, - &envelope, - receipt_event_id.as_str(), - &verified_receipt, - &remote_http_policy(), - ) - .expect_err("stored unchecked SP1 result rejected"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredProofMetadataMismatch - )); - } - - #[test] - fn validate_recovered_result_event_rejects_critical_payload_field_corruption() { - let cases: Vec<(&str, Box<dyn Fn(&mut serde_json::Value)>)> = vec![ - ( - "status", - Box::new(|value| set_result_json_field(value, "status", "failed")), - ), - ( - "worker_role", - Box::new(|value| { - set_result_json_field(value, "worker_role", "authoritative_prover") - }), - ), - ( - "receipt_kind", - Box::new(|value| value["receipt_kind"] = serde_json::json!(1_u32)), - ), - ( - "prover_backend", - Box::new(|value| set_result_json_field(value, "prover_backend", "local_execute")), - ), - ( - "receipt_event_id", - Box::new(|value| { - set_result_json_field(value, "receipt_event_id", publish_result_id(8)) - }), - ), - ( - "request_hash", - Box::new(|value| set_result_json_field(value, "request_hash", hash32('8'))), - ), - ( - "customer_pubkey", - Box::new(|value| set_result_json_field(value, "customer_pubkey", "8".repeat(64))), - ), - ( - "worker_pubkey", - Box::new(|value| set_result_json_field(value, "worker_pubkey", "7".repeat(64))), - ), - ( - "order_id", - Box::new(|value| set_result_json_field(value, "order_id", "order-other")), - ), - ( - "listing_event_id", - Box::new(|value| { - set_result_json_field(value, "listing_event_id", publish_result_id(7)) - }), - ), - ( - "request_event_id", - Box::new(|value| { - set_result_json_field(value, "request_event_id", publish_result_id(6)) - }), - ), - ( - "decision_event_id", - Box::new(|value| { - set_result_json_field(value, "decision_event_id", publish_result_id(5)) - }), - ), - ( - "event_set_root", - Box::new(|value| set_result_json_field(value, "event_set_root", hash32('6'))), - ), - ( - "reducer_output_root", - Box::new(|value| set_result_json_field(value, "reducer_output_root", hash32('7'))), - ), - ( - "public_values_hash", - Box::new(|value| set_result_json_field(value, "public_values_hash", hash32('8'))), - ), - ( - "proof_system", - Box::new(|value| set_result_json_field(value, "proof_system", "sp1_core")), - ), - ( - "proof_mode", - Box::new(|value| set_result_json_field(value, "proof_mode", "core")), - ), - ( - "validation_authority", - Box::new(|value| { - set_result_json_field(value, "validation_authority", "trusted_rhi_service_key") - }), - ), - ( - "confidence", - Box::new(|value| { - set_result_json_field(value, "confidence", "committed_by_trusted_service") - }), - ), - ]; - - for (name, corrupt) in cases { - let fixture = recovered_result_validation_fixture(); - let mut result_json = fixture.result_json; - corrupt(&mut result_json); - let result_event = super::signed_event_from_parts( - &fixture.worker, - KIND_TRADE_TRANSITION_PROOF_RESULT, - serde_json::to_string(&result_json).expect("result json"), - fixture.result_tags.clone(), - Some(fixture.job.created_at.as_secs()), - ) - .expect("result event"); - - let error = super::validate_recovered_result_event( - &result_event, - &fixture.job, - &fixture.processed, - &fixture.envelope, - fixture.receipt_event_id.as_str(), - &fixture.verified_receipt, - &deterministic_policy(), - ) - .expect_err(name); - - assert!( - matches!( - error, - TradeValidationReceiptJobError::RecoveredResultMismatch - | TradeValidationReceiptJobError::RecoveredResultPolicyMismatch - | TradeValidationReceiptJobError::Serde(_) - ), - "{name}: {error:?}" - ); - } - } - - #[test] - fn validate_recovered_result_event_rejects_current_policy_drift_without_stored_mutation() { - let current_backend_policy = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::LocalExecute, - proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: None, - remote_http: None, - }; - let current_proof_mode_policy = remote_http_policy(); - - for (name, current_policy) in [ - ("backend", current_backend_policy), - ("proof_mode", current_proof_mode_policy), - ] { - let fixture = recovered_result_validation_fixture(); - let result_event = super::signed_event_from_parts( - &fixture.worker, - KIND_TRADE_TRANSITION_PROOF_RESULT, - serde_json::to_string(&fixture.result_json).expect("result json"), - fixture.result_tags.clone(), - Some(fixture.job.created_at.as_secs()), - ) - .expect("result event"); - - let error = super::validate_recovered_result_event( - &result_event, - &fixture.job, - &fixture.processed, - &fixture.envelope, - fixture.receipt_event_id.as_str(), - &fixture.verified_receipt, - &current_policy, - ) - .expect_err(name); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredResultPolicyMismatch - )); - } - } - - #[tokio::test] - async fn verify_existing_receipt_event_rejects_current_policy_proof_contract_drift() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - let request_event = radroots_event_from_nostr(&job); - let envelope = parse_transition_proof_request_event(&request_event).expect("envelope"); - - let error = super::verify_existing_receipt_event( - &receipt_event, - &envelope.content, - &remote_http_policy(), - ) - .expect_err("current policy proof contract drift"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredResultPolicyMismatch - )); - } - - #[tokio::test] - async fn proof_job_records_completed_job_and_skips_duplicate_replay() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let runtime = TradeListingRuntime::new(); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect("first proof job"); - - let published_event = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .clone(); - let receipt_event_id = published_event[0] - .event_id - .clone() - .expect("signed receipt event id"); - let result_event_id = published_event[1] - .event_id - .clone() - .expect("signed result event id"); - let processed = runtime - .processed_jobs() - .get_job(&job.id.to_hex()) - .await - .expect("processed job lookup") - .expect("processed job"); - assert_eq!(processed.status, RhiProcessedJobStatus::Completed); - assert_eq!( - processed.request_hash, - super::request_event_hash(&radroots_event_from_nostr(&job)).expect("request hash") - ); - assert_eq!( - processed.receipt_event_id.as_deref(), - Some(receipt_event_id.as_str()) - ); - assert_eq!( - processed.result_event_id.as_deref(), - Some(result_event_id.as_str()) - ); - - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &TradeValidationReceiptProverPolicy::default(), - ) - .await - .expect("duplicate completed proof job"); - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!(hooks.fetch_event_by_id_results.is_empty()); - assert!(hooks.fetch_events_results.is_empty()); - assert!(hooks.published_events.is_empty()); - } - - #[tokio::test] - async fn proof_job_skips_duplicate_in_progress_replay_with_disabled_policy() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - assert_eq!( - runtime - .processed_jobs() - .claim_job( - &processed, - super::now_unix_ms(), - super::RHI_PROCESSED_JOB_CLAIM_LEASE_MS, - ) - .await - .expect("initial claim"), - RhiProcessedJobClaim::Execute - ); - - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &TradeValidationReceiptProverPolicy::default(), - ) - .await - .expect("duplicate in-progress proof job"); - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!(hooks.fetch_event_by_id_results.is_empty()); - assert!(hooks.fetch_events_results.is_empty()); - assert!(hooks.published_events.is_empty()); - } - - #[tokio::test] - async fn proof_job_recovers_result_publication_from_recorded_receipt() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 10_000) - .await - .expect("claim processed job"); - let receipt_event_json = serde_json::to_string(&receipt_event).expect("receipt json"); - runtime - .processed_jobs() - .mark_receipt_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - None, - 2, - ) - .await - .expect("record receipt intent"); - runtime - .processed_jobs() - .mark_receipt_published(&processed, receipt_event.id.to_hex().as_str(), 3) - .await - .expect("record receipt"); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(3))); - } - - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect("recovered result"); - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert_eq!(hooks.published_events.len(), 1); - assert_eq!( - hooks.published_events[0].kind, - KIND_TRADE_TRANSITION_PROOF_RESULT - ); - let result: TradeValidationReceiptJobResult = - serde_json::from_str(&hooks.published_events[0].content).expect("result json"); - assert_eq!(result.receipt_event_id, receipt_event.id.to_hex()); - let result_event_id = hooks.published_events[0] - .event_id - .clone() - .expect("signed result event id"); - drop(hooks); - - let processed = runtime - .processed_jobs() - .get_job(&job.id.to_hex()) - .await - .expect("processed job lookup") - .expect("processed job"); - assert_eq!(processed.status, RhiProcessedJobStatus::Completed); - assert_eq!( - processed.receipt_event_id.as_deref(), - Some(receipt_event.id.to_hex().as_str()) - ); - assert_eq!( - processed.result_event_id.as_deref(), - Some(result_event_id.as_str()) - ); - } - - #[tokio::test] - async fn proof_job_recovers_receipt_publication_from_recorded_receipt_intent() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - let receipt_event_json = serde_json::to_string(&receipt_event).expect("receipt json"); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 1) - .await - .expect("claim processed job"); - runtime - .processed_jobs() - .mark_receipt_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - None, - 2, - ) - .await - .expect("record receipt intent"); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(3))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(4))); - } - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect("recovered receipt intent"); - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.published_events.len(), 2); - assert_eq!( - hooks.published_events[0].kind, - KIND_TRADE_VALIDATION_RECEIPT - ); - assert_eq!( - hooks.published_events[0] - .event_id - .as_ref() - .map(String::as_str), - Some(receipt_event.id.to_hex().as_str()) - ); - assert_eq!( - hooks.published_events[1].kind, - KIND_TRADE_TRANSITION_PROOF_RESULT - ); - let result_event_id = hooks.published_events[1] - .event_id - .clone() - .expect("signed result event id"); - drop(hooks); - - let processed = runtime - .processed_jobs() - .get_job(&job.id.to_hex()) - .await - .expect("processed job lookup") - .expect("processed job"); - assert_eq!(processed.status, RhiProcessedJobStatus::Completed); - assert_eq!( - processed.receipt_event_id.as_deref(), - Some(receipt_event.id.to_hex().as_str()) - ); - assert_eq!( - processed.result_event_id.as_deref(), - Some(result_event_id.as_str()) - ); - } - - #[tokio::test] - async fn proof_job_recovers_result_publication_from_recorded_result_intent() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - let result_event = - signed_result_event_for_test(&worker, &job, &receipt_event, &deterministic_policy()); - let result_event_id = result_event.id.to_hex(); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 1) - .await - .expect("claim processed job"); - let receipt_event_json = serde_json::to_string(&receipt_event).expect("receipt json"); - runtime - .processed_jobs() - .mark_receipt_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - None, - 2, - ) - .await - .expect("record receipt intent"); - runtime - .processed_jobs() - .mark_receipt_published(&processed, receipt_event.id.to_hex().as_str(), 3) - .await - .expect("record receipt"); - assert_eq!( - runtime - .processed_jobs() - .claim_job(&processed, 3, 1) - .await - .expect("claim result"), - RhiProcessedJobClaim::RecoverResult { - receipt_event_id: receipt_event.id.to_hex(), - receipt_event_json: receipt_event_json.clone(), - result_event_id: None, - result_event_json: None, - proof_metadata_json: None, - } - ); - let result_event_json = serde_json::to_string(&result_event).expect("result json"); - runtime - .processed_jobs() - .mark_result_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - result_event_id.as_str(), - result_event_json.as_str(), - 4, - ) - .await - .expect("record result intent"); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(3))); - } - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect("recovered result intent"); - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert_eq!(hooks.published_events.len(), 1); - assert_eq!( - hooks.published_events[0] - .event_id - .as_ref() - .map(String::as_str), - Some(result_event_id.as_str()) - ); - drop(hooks); - - let processed = runtime - .processed_jobs() - .get_job(&job.id.to_hex()) - .await - .expect("processed job lookup") - .expect("processed job"); - assert_eq!(processed.status, RhiProcessedJobStatus::Completed); - assert_eq!( - processed.result_event_id.as_deref(), - Some(result_event_id.as_str()) - ); - } - - #[tokio::test] - async fn proof_job_rejects_recovered_result_with_invalid_stored_receipt_json() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 1) - .await - .expect("claim processed job"); - runtime - .processed_jobs() - .mark_receipt_publishing(&processed, receipt_event.id.to_hex().as_str(), "{", None, 2) - .await - .expect("record invalid receipt intent"); - runtime - .processed_jobs() - .mark_receipt_published(&processed, receipt_event.id.to_hex().as_str(), 3) - .await - .expect("record receipt"); - - let error = handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect_err("invalid stored receipt json"); - - assert!(matches!(error, TradeValidationReceiptJobError::Serde(_))); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert!(hooks.published_events.is_empty()); - } - - #[tokio::test] - async fn proof_job_rejects_recovered_result_intent_payload_policy_field_drift() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); - let result_event = - signed_result_event_for_test(&worker, &job, &receipt_event, &deterministic_policy()); - let result_event = signed_result_event_with_payload_field_for_test( - &worker, - &result_event, - "prover_backend", - "local_execute", - ); - let result_event_id = result_event.id.to_hex(); - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - let processed = processed_job_for_test(&job); - runtime - .processed_jobs() - .claim_job(&processed, 1, 1) - .await - .expect("claim processed job"); - let receipt_event_json = serde_json::to_string(&receipt_event).expect("receipt json"); - runtime - .processed_jobs() - .mark_receipt_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - None, - 2, - ) - .await - .expect("record receipt intent"); - runtime - .processed_jobs() - .mark_receipt_published(&processed, receipt_event.id.to_hex().as_str(), 3) - .await - .expect("record receipt"); - assert_eq!( - runtime - .processed_jobs() - .claim_job(&processed, 3, 1) - .await - .expect("claim result"), - RhiProcessedJobClaim::RecoverResult { - receipt_event_id: receipt_event.id.to_hex(), - receipt_event_json: receipt_event_json.clone(), - result_event_id: None, - result_event_json: None, - proof_metadata_json: None, - } - ); - let result_event_json = serde_json::to_string(&result_event).expect("result json"); - runtime - .processed_jobs() - .mark_result_publishing( - &processed, - receipt_event.id.to_hex().as_str(), - result_event_id.as_str(), - result_event_json.as_str(), - 4, - ) - .await - .expect("record result intent"); - - let error = handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect_err("policy-drifted recovered result intent"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RecoveredResultPolicyMismatch - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!(hooks.published_events.is_empty()); - drop(hooks); - assert_result_intent_preserved_after_recovered_failure( - &runtime, - &job, - receipt_event.id.to_hex().as_str(), - receipt_event_json.as_str(), - result_event_id.as_str(), - result_event_json.as_str(), - ) - .await; - } - - #[tokio::test] - async fn proof_job_rejects_recovered_result_intent_current_policy_drift_before_availability() { - let _guard = test_guard(); - - for (name, current_policy) in [ - ( - "backend", - TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::LocalExecute, - proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: None, - remote_http: None, - }, - ), - ("proof_contract", remote_http_policy()), - ] { - let scenario = recovered_result_intent_scenario().await; - - let error = handle_trade_validation_receipt_job_request( - &scenario.job, - &scenario.worker, - &client_for(&scenario.worker), - &scenario.runtime, - &current_policy, - ) - .await - .expect_err(name); - - match error { - TradeValidationReceiptJobError::RecoveredResultPolicyMismatch - if (name == "backend" && cfg!(feature = "sp1_proving")) - || (name == "proof_contract" && cfg!(feature = "sp1_verify")) => {} - TradeValidationReceiptJobError::ProverBackendUnavailable("local_execute") - if name == "backend" && !cfg!(feature = "sp1_proving") => {} - TradeValidationReceiptJobError::ProverBackendUnavailable("remote_http_prove") - if name == "proof_contract" && !cfg!(feature = "sp1_verify") => {} - error => panic!("{name}: unexpected error {error:?}"), - } - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert!(hooks.published_events.is_empty()); - drop(hooks); - assert_result_intent_preserved_after_recovered_failure( - &scenario.runtime, - &scenario.job, - scenario.receipt_event_id.as_str(), - scenario.receipt_event_json.as_str(), - scenario.result_event_id.as_str(), - scenario.result_event_json.as_str(), - ) - .await; - *trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - } - } - - #[tokio::test] - async fn proof_job_rejects_recovered_result_intent_missing_remote_auth_before_publish() { - let _guard = test_guard(); - let current_policy = recovered_result_missing_auth_policy(); - set_remote_http_auth_env_missing(RECOVERED_RESULT_MISSING_AUTH_ENV); - let scenario = recovered_sp1_result_intent_scenario(&current_policy).await; - - let error = handle_trade_validation_receipt_job_request( - &scenario.job, - &scenario.worker, - &client_for(&scenario.worker), - &scenario.runtime, - &current_policy, - ) - .await - .expect_err("missing recovered result remote auth"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing(env_var) - if env_var == RECOVERED_RESULT_MISSING_AUTH_ENV - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert!(hooks.published_events.is_empty()); - drop(hooks); - assert_result_intent_preserved_after_recovered_failure( - &scenario.runtime, - &scenario.job, - scenario.receipt_event_id.as_str(), - scenario.receipt_event_json.as_str(), - scenario.result_event_id.as_str(), - scenario.result_event_json.as_str(), - ) - .await; - } - - #[tokio::test] - async fn proof_job_rejects_missing_remote_auth_before_invalid_stored_result_json() { - let _guard = test_guard(); - let current_policy = recovered_result_missing_auth_policy(); - set_remote_http_auth_env_missing(RECOVERED_RESULT_MISSING_AUTH_ENV); - let scenario = recovered_sp1_result_intent_scenario_with_stored_events( - &current_policy, - None, - Some("{"), - ) - .await; - - let error = handle_trade_validation_receipt_job_request( - &scenario.job, - &scenario.worker, - &client_for(&scenario.worker), - &scenario.runtime, - &current_policy, - ) - .await - .expect_err("missing remote auth before invalid stored result json"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing(env_var) - if env_var == RECOVERED_RESULT_MISSING_AUTH_ENV - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert!(hooks.published_events.is_empty()); - drop(hooks); - assert_result_intent_preserved_after_recovered_failure( - &scenario.runtime, - &scenario.job, - scenario.receipt_event_id.as_str(), - scenario.receipt_event_json.as_str(), - scenario.result_event_id.as_str(), - scenario.result_event_json.as_str(), - ) - .await; - } - - #[tokio::test] - async fn proof_job_rejects_missing_remote_auth_before_invalid_stored_receipt_json() { - let _guard = test_guard(); - let current_policy = recovered_result_missing_auth_policy(); - set_remote_http_auth_env_missing(RECOVERED_RESULT_MISSING_AUTH_ENV); - let scenario = recovered_sp1_result_intent_scenario_with_stored_events( - &current_policy, - Some("{"), - None, - ) - .await; - - let error = handle_trade_validation_receipt_job_request( - &scenario.job, - &scenario.worker, - &client_for(&scenario.worker), - &scenario.runtime, - &current_policy, - ) - .await - .expect_err("missing remote auth before invalid stored receipt json"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::RemoteHttpAuthTokenMissing(env_var) - if env_var == RECOVERED_RESULT_MISSING_AUTH_ENV - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert!(hooks.published_events.is_empty()); - drop(hooks); - assert_result_intent_preserved_after_recovered_failure( - &scenario.runtime, - &scenario.job, - scenario.receipt_event_id.as_str(), - scenario.receipt_event_json.as_str(), - scenario.result_event_id.as_str(), - scenario.result_event_json.as_str(), - ) - .await; - } - - #[tokio::test] - async fn proof_job_rejects_recovered_result_intent_disabled_policy_before_stored_corruption() { - let _guard = test_guard(); - let current_policy = TradeValidationReceiptProverPolicy::disabled(); - let scenario = recovered_result_intent_scenario_with_stored_events(None, Some("{")).await; - - let error = handle_trade_validation_receipt_job_request( - &scenario.job, - &scenario.worker, - &client_for(&scenario.worker), - &scenario.runtime, - &current_policy, - ) - .await - .expect_err("disabled current policy before stored corruption"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::ProverBackendDisabled - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert!(hooks.published_events.is_empty()); - drop(hooks); - assert_result_intent_preserved_after_recovered_failure( - &scenario.runtime, - &scenario.job, - scenario.receipt_event_id.as_str(), - scenario.receipt_event_json.as_str(), - scenario.result_event_id.as_str(), - scenario.result_event_json.as_str(), - ) - .await; - } - - #[tokio::test] - async fn proof_job_reuses_existing_relay_receipt_before_proof_execution() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_event_by_id_results - .push_back(Ok(listing_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(request_event.clone())); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event.clone())); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(2))); - } - handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect("setup proof job"); - let receipt_parts = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .first() - .expect("receipt event") - .clone(); - let receipt_event = published_event(&receipt_parts); + #[test] + fn test_hooks_reset() { *trade_validation_receipt_test_hooks() .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - TradeValidationReceiptTestHooks::default(); - - let runtime = TradeListingRuntime::new(); - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks - .fetch_events_results - .push_back(Ok(vec![receipt_event.clone()])); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(3))); - } - - handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect("relay receipt replay"); - - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 0); - assert_eq!(hooks.published_events.len(), 1); - assert_eq!( - hooks.published_events[0].kind, - KIND_TRADE_TRANSITION_PROOF_RESULT - ); - let result_event_id = hooks.published_events[0] - .event_id - .clone() - .expect("signed result event id"); - drop(hooks); - - let processed = runtime - .processed_jobs() - .get_job(&job.id.to_hex()) - .await - .expect("processed job lookup") - .expect("processed job"); - assert_eq!(processed.status, RhiProcessedJobStatus::Completed); - assert_eq!( - processed.receipt_event_id.as_deref(), - Some(receipt_event.id.to_hex().as_str()) - ); - assert_eq!( - processed.result_event_id.as_deref(), - Some(result_event_id.as_str()) - ); - } - - #[tokio::test] - async fn proof_job_rejects_conflicting_processed_job_before_publication() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let runtime = TradeListingRuntime::new(); - let mut processed = processed_job_for_test(&job); - processed.request_hash = - "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string(); - runtime - .processed_jobs() - .claim_job(&processed, 1, 10_000) - .await - .expect("claim conflicting processed job"); - - let error = handle_trade_validation_receipt_job_request( - &job, - &worker, - &client_for(&worker), - &runtime, - &deterministic_policy(), - ) - .await - .expect_err("conflicting processed job"); - - assert!(matches!( - error, - TradeValidationReceiptJobError::DuplicateConflictingJob - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[tokio::test] - async fn proof_job_rejects_non_pending_shared_workflow_before_publication() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = - signed_declined_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - hooks - .publish_event_results - .push_back(Ok(publish_result_id(1))); - } - - let error = handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect_err("declined decision is not pending RHI"); - assert!(matches!( - error, - TradeValidationReceiptJobError::InvalidActiveTradeEvent(_) - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[tokio::test] - async fn proof_job_rejects_unverified_proof_before_publication() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::Compressed, - None, - None, - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - let error = handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect_err("backend rejects sp1 proof claim"); - assert!(matches!( - error, - TradeValidationReceiptJobError::ProverBackendPolicyMismatch - )); - assert_eq!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fetch_event_by_id_results - .len(), - 3 - ); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[tokio::test] - async fn proof_job_rejects_request_prover_backend_override_before_relay_fetch() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let mut request_json: serde_json::Value = - serde_json::from_str(&job.content).expect("request json"); - request_json["prover_backend"] = serde_json::Value::String("local_cpu_prove".to_string()); - let tags = radroots_event_from_nostr(&job).tags_as_vec(); - let job = signed_event( - &requester, - KIND_TRADE_TRANSITION_PROOF_REQUEST, - serde_json::to_string(&request_json).expect("request json"), - tags, - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - let error = handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect_err("request backend override rejected"); - assert!(matches!(error, TradeValidationReceiptJobError::Dvm(_))); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 3); - assert!(hooks.published_events.is_empty()); - } - - #[tokio::test] - async fn proof_job_rejects_disabled_policy_before_relay_fetch() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - let error = handle_job_request_for_test( - &job, - &worker, - &TradeValidationReceiptProverPolicy::default(), - ) - .await - .expect_err("disabled policy rejected"); - assert!(matches!( - error, - TradeValidationReceiptJobError::ProverBackendDisabled - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 3); - assert!(hooks.published_events.is_empty()); - } - - #[tokio::test] - async fn proof_job_rejects_unverified_signed_event_evidence_before_publication() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (mut request_event, decision_event) = - signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - request_event.content.push(' '); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - let error = handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect_err("signed evidence rejected"); - assert!(matches!( - error, - TradeValidationReceiptJobError::InvalidSignedEvent - )); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[tokio::test] - async fn proof_job_rejects_identity_mismatch_before_relay_fetch() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - let mut request: RadrootsTradeTransitionProofRequestV1 = - serde_json::from_str(&job.content).expect("job request json"); - request.reducer_program_hash = - "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_string(); - let job = signed_event( - &requester, - KIND_TRADE_TRANSITION_PROOF_REQUEST, - serde_json::to_string(&request).expect("job json"), - build_transition_proof_request_tags(&typed_pubkey(&worker), &request), - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - let error = handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect_err("identity mismatch rejected"); - assert!(matches!( - error, - TradeValidationReceiptJobError::ExpectedReducerProgramHashMismatch - )); - let hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(hooks.fetch_event_by_id_results.len(), 3); - assert!(hooks.published_events.is_empty()); - } - - #[cfg(not(feature = "sp1_proving"))] - #[tokio::test] - async fn proof_job_rejects_unavailable_prover_backend_before_publication() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - - { - let mut hooks = trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); - hooks.fetch_event_by_id_results.push_back(Ok(request_event)); - hooks - .fetch_event_by_id_results - .push_back(Ok(decision_event)); - } - - let local_execute_policy = TradeValidationReceiptProverPolicy { - backend: TradeValidationReceiptProverBackend::LocalExecute, - proof_mode: RadrootsSp1TradeProofMode::None, - runtime_policy: TradeValidationReceiptRuntimePolicy::Production, - expected_sp1_program_hash: None, - expected_sp1_verifying_key_hash: None, - remote_http: None, - }; - let error = handle_job_request_for_test(&job, &worker, &local_execute_policy) - .await - .expect_err("backend unavailable"); - assert!(matches!( - error, - TradeValidationReceiptJobError::ProverBackendUnavailable("local_execute") - )); - assert_eq!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fetch_event_by_id_results - .len(), - 3 - ); - assert!( - trade_validation_receipt_test_hooks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .published_events - .is_empty() - ); - } - - #[tokio::test] - async fn proof_job_requires_worker_recipient_tag() { - let _guard = test_guard(); - let worker = RadrootsNostrKeys::generate(); - let requester = RadrootsNostrKeys::generate(); - let other_worker = RadrootsNostrKeys::generate(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let job = job_request( - &requester, - &other_worker, - &listing_event, - &request_event, - &decision_event, - RadrootsSp1TradeProofMode::None, - None, - None, - ); - - let error = handle_job_request_for_test(&job, &worker, &deterministic_policy()) - .await - .expect_err("missing recipient"); - assert!(matches!( - error, - TradeValidationReceiptJobError::MissingRecipient - )); - } - - trait WorkerRoleLabel { - fn to_string(self) -> String; - } - - impl WorkerRoleLabel for super::TradeValidationReceiptWorkerRole { - fn to_string(self) -> String { - serde_json::to_value(self) - .expect("role json") - .as_str() - .expect("role string") - .to_string() - } - } - - #[test] - fn signed_events_are_canonical_order_events() { - let _guard = test_guard(); - let buyer = RadrootsNostrKeys::generate(); - let seller = RadrootsNostrKeys::generate(); - let listing_event = listing_event(&seller); - let (request_event, decision_event) = signed_order_events(&buyer, &seller, &listing_event); - let request_rr = radroots_event_from_nostr(&request_event); - let decision_rr = radroots_event_from_nostr(&decision_event); - assert!( - order_request_event_build( - &RadrootsEventPtr { - id: listing_event.id.to_hex(), - relays: None, - }, - &request_payload( - "order-1", - &listing_addr_for_seller(&seller), - &buyer, - &seller - ), - ) - .is_ok() - ); - assert_eq!(request_rr.kind_u32(), 3422); - assert_eq!(decision_rr.kind_u32(), 3423); + .expect("trade validation receipt hooks lock") = + super::TradeValidationReceiptTestHooks::default(); } } diff --git a/src/lib.rs b/src/lib.rs @@ -13,9 +13,7 @@ pub mod rhi; pub use cli::Args as cli_args; use anyhow::Result; -use radroots_event::kinds::{ - KIND_LISTING, KIND_LISTING_DRAFT, ORDER_EVENT_KINDS, TRADE_VALIDATION_EVENT_KINDS, -}; +use radroots_event::kinds::{KIND_LISTING, ORDER_EVENT_KINDS, TRADE_VALIDATION_EVENT_KINDS}; use std::time::Duration; use crate::features::trade_listing::state::{TradeListingRuntime, TradeListingRuntimeConfig}; @@ -141,7 +139,7 @@ pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()> let md = settings.metadata.clone(); if !relays.is_empty() { - let handler_kinds = [KIND_LISTING, KIND_LISTING_DRAFT] + let handler_kinds = [KIND_LISTING] .into_iter() .chain(ORDER_EVENT_KINDS) .chain(TRADE_VALIDATION_EVENT_KINDS) diff --git a/src/proof_smoke.rs b/src/proof_smoke.rs @@ -47,7 +47,7 @@ pub enum RhiProofSmokeOperation { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RhiProofSmokeBackend { - DeterministicNone, + ValidatorSetDeterministic, LocalExecute, } @@ -114,7 +114,7 @@ pub async fn handle_request_bytes(bytes: &[u8]) -> RhiProofSmokeResponse { Ok(request) => response_for_request(request, started).await, Err(error) => response_for_error( RhiProofSmokeOperation::Health, - RhiProofSmokeBackend::DeterministicNone, + RhiProofSmokeBackend::ValidatorSetDeterministic, started, error.to_string(), ), @@ -189,7 +189,7 @@ async fn run_proof_smoke( let witness = order_acceptance_tiny_witness(); match backend { - RhiProofSmokeBackend::DeterministicNone => deterministic_smoke(&witness), + RhiProofSmokeBackend::ValidatorSetDeterministic => deterministic_smoke(&witness), RhiProofSmokeBackend::LocalExecute => local_execute_smoke(&witness).await, } } @@ -320,7 +320,7 @@ fn capabilities() -> Vec<String> { let mut values = vec![ "health".to_string(), "proof_smoke".to_string(), - "deterministic_none".to_string(), + "validator_set_deterministic".to_string(), ]; if cfg!(feature = "sp1_proving") && radroots_trade_sp1_host::order_acceptance_sp1_guest_elf_available() @@ -340,6 +340,11 @@ pub(crate) fn order_acceptance_tiny_witness() -> RadrootsSp1TradeOrderAcceptance .to_string(), decision_event_id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" .to_string(), + validator_set_addr: + "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" + .to_string(), + validator_set_event_id: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + .to_string(), event_evidence: order_acceptance_tiny_event_evidence(), request: RadrootsSp1TradeOrderRequestWitness { order_id: "order-1".to_string(), @@ -494,7 +499,7 @@ mod tests { let bytes = serde_json::to_vec(&RhiProofSmokeRequest { protocol_version: PROTOCOL_VERSION.to_string(), operation: RhiProofSmokeOperation::Health, - backend: RhiProofSmokeBackend::DeterministicNone, + backend: RhiProofSmokeBackend::ValidatorSetDeterministic, fixture: None, }) .expect("request json"); @@ -509,7 +514,7 @@ mod tests { async fn deterministic_proof_smoke_returns_public_values() { let response = handle_request_bytes(&request( RhiProofSmokeOperation::ProofSmoke, - RhiProofSmokeBackend::DeterministicNone, + RhiProofSmokeBackend::ValidatorSetDeterministic, )) .await; assert!(response.ok, "{:?}", response.error); @@ -538,7 +543,7 @@ mod tests { &serde_json::to_vec(&RhiProofSmokeRequest { protocol_version: PROTOCOL_VERSION.to_string(), operation: RhiProofSmokeOperation::ProofSmoke, - backend: RhiProofSmokeBackend::DeterministicNone, + backend: RhiProofSmokeBackend::ValidatorSetDeterministic, fixture: Some("other".to_string()), }) .expect("request json"), @@ -554,7 +559,7 @@ mod tests { #[tokio::test] async fn proof_smoke_rejects_full_proof_request_fields() { let response = handle_request_bytes( - br#"{"protocol_version":"radroots.rhi.proof_smoke.v0","operation":"proof_smoke","backend":"deterministic_none","fixture":"order_acceptance_tiny_v1","proof_mode":"core"}"#, + br#"{"protocol_version":"radroots.rhi.proof_smoke.v0","operation":"proof_smoke","backend":"validator_set_deterministic","fixture":"order_acceptance_tiny_v1","proof_mode":"core"}"#, ) .await; assert!(!response.ok); @@ -586,7 +591,7 @@ mod tests { async fn local_execute_returns_sp1_public_values_without_proof_generation() { let deterministic = handle_request_bytes(&request( RhiProofSmokeOperation::ProofSmoke, - RhiProofSmokeBackend::DeterministicNone, + RhiProofSmokeBackend::ValidatorSetDeterministic, )) .await; let response = handle_request_bytes(&request( diff --git a/tests/source_guards.rs b/tests/source_guards.rs @@ -12,54 +12,48 @@ fn rhi_manifest_has_no_sdk_dependency() { } #[test] -fn rhi_dvm_transition_and_feedback_paths_use_trade_dvm_contract() { - let listing_dvm = read_repo_file("src/features/trade_listing/handlers/dvm.rs"); +fn rhi_validation_receipt_paths_use_validator_set_contract() { + let listing_events = read_repo_file("src/features/trade_listing/handlers/events.rs"); let receipt_worker = read_repo_file("src/features/trade_validation_receipt.rs"); - let listing_feedback_segment = source_segment( - &listing_dvm, - "pub async fn handle_error(", - "\n#[cfg(test)]\n#[cfg_attr(coverage_nightly, coverage(off))]\nmod tests", - ); assert!( - listing_dvm.contains( - "use radroots_trade::dvm::{RadrootsTradeDvmFeedbackStatus, build_job_feedback_tags};" - ), - "trade listing DVM handler must import radroots_trade::dvm feedback contract" + listing_events.contains("publish_validation_receipt("), + "trade listing event handler must publish V1 validation receipts from accepted orders" ); assert!( - listing_feedback_segment.contains("build_job_feedback_tags("), - "trade listing DVM handler must build feedback tags through radroots_trade::dvm" + !listing_events.contains("radroots_trade::dvm"), + "trade listing handler must not import the removed DVM contract" ); for required in [ - "RadrootsTradeTransitionProofRequestEnvelope", - "RadrootsTradeTransitionProofResultBinding", + "validator_set_addr", + "validator_set_event_id", + "validator_set_address_from_str", + "validation_receipt_event_build", + "verify_validation_receipt_event", + "MissingValidatorSetBinding", + "mark_receipt_completed", ] { assert!( receipt_worker.contains(required), - "trade validation receipt worker must use radroots_trade::dvm transition contract `{required}`" + "trade validation receipt worker must retain validator-set receipt contract `{required}`" ); } - let result_segment = source_segment( - &receipt_worker, - "fn result_tags_from_dvm(", - "fn expected_receipt_binding", - ); - for required in [ - "parse_transition_proof_request_event(", - "build_transition_proof_result_tags(", + for forbidden in [ + "radroots_trade::dvm", + "KIND_TRADE_TRANSITION_PROOF_REQUEST", + "KIND_TRADE_TRANSITION_PROOF_RESULT", + "RadrootsTradeTransitionProofRequest", + "build_transition_proof_result_tags", + "deterministic_none", + "DeterministicNone", ] { assert!( - result_segment.contains(required), - "trade validation receipt worker must delegate result tags through radroots_trade::dvm `{required}`" + !receipt_worker.contains(forbidden), + "trade validation receipt worker must not retain retired contract `{forbidden}`" ); } - assert!( - !result_segment.contains("vec!["), - "transition proof result tags must be delegated to radroots_trade::dvm" - ); } #[test] @@ -71,7 +65,13 @@ fn rhi_sources_do_not_import_removed_sdk_or_protocol_bypasses() { "SdkDvmInventoryBinWitness", "TradeProtocolClient", "KIND_TRADE_LISTING_VALIDATE_REQ", + "KIND_TRADE_LISTING_VALIDATION_REQUEST", "KIND_WORKER_TRADE_TRANSITION_PROOF_REQ", + "KIND_TRADE_TRANSITION_PROOF_REQUEST", + "KIND_TRADE_TRANSITION_PROOF_RESULT", + "radroots_trade::dvm", + "deterministic_none", + "DeterministicNone", ] { assert!( !source.contains(forbidden), @@ -106,10 +106,12 @@ fn rhi_processed_job_state_is_durable_workflow_authority() { "pub async fn mark_receipt_published(", "pub async fn mark_result_publishing(", "pub async fn mark_completed(", + "pub async fn mark_receipt_completed(", "RhiProcessedJobClaim::InProgress", "RhiProcessedJobClaim::RecoverReceipt", "RhiProcessedJobStatus::ReceiptPublishing", "RhiProcessedJobStatus::ResultPublishing", + "RhiProcessedJobStatus::Completed", "receipt_event_json", "result_event_json", "proof_metadata_json", @@ -122,12 +124,11 @@ fn rhi_processed_job_state_is_durable_workflow_authority() { } for required in [ - "fn processed_job_for_request(", - "async fn processed_job_action(", - "claim_job_result_publication(", - "publish_signed_event(", - "mark_job_completed(", - "RhiProcessedJobStatus::Completed", + "fn processed_job_for_receipt(", + "async fn publish_receipt_with_processed_job(", + "publish_signed_event_io(", + "mark_receipt_completed(", + "RhiProcessedJobClaim::Completed", ] { assert!( receipt_worker.contains(required), @@ -137,37 +138,36 @@ fn rhi_processed_job_state_is_durable_workflow_authority() { } #[test] -fn rhi_deterministic_prover_is_repo_local_only() { +fn rhi_validation_receipt_policy_is_enabled_and_validator_set_bound() { let config = read_repo_file("src/config.rs"); let receipt_worker = read_repo_file("src/features/trade_validation_receipt.rs"); for required in [ - "TradeValidationReceiptRuntimePolicy", - "RepoLocalDevelopment", - "repo_local_deterministic_none", - "DeterministicNoneRequiresRepoLocalDevelopment", - "RepoLocalDevelopmentPolicyRequiresDeterministicNone", + "TradeValidationReceiptProverBackend::LocalExecute", + "validator_binding(", + "MissingValidatorSetBinding", + "validator_set_addr", + "validator_set_event_id", + "ProverBackendRequiresNone", ] { assert!( receipt_worker.contains(required), - "trade validation receipt policy must retain deterministic backend governance `{required}`" + "trade validation receipt policy must retain enabled validator-set governance `{required}`" ); } assert!( - !receipt_worker.contains("pub fn deterministic_none("), - "RHI must not expose an ambiguous deterministic_none constructor" + !receipt_worker.contains("Disabled") && !receipt_worker.contains("DeterministicNone"), + "RHI must not expose disabled or deterministic-none validation receipt backends" ); for required in [ - "validate_trade_validation_receipt_runtime_profile", - "RadrootsPathProfile::RepoLocal", - "runtime_policy repo_local_development", - "runtime profile repo_local", + "settings.config.trade_validation_receipt.validate()?", + "TradeValidationReceiptProverBackend::LocalExecute", ] { assert!( config.contains(required), - "RHI config loading must retain repo-local deterministic governance `{required}`" + "RHI config loading must retain enabled validator-set policy validation `{required}`" ); } } @@ -178,15 +178,6 @@ fn read_repo_file(relative_path: &str) -> String { .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) } -fn source_segment<'a>(source: &'a str, start: &str, end: &str) -> &'a str { - let start_index = source.find(start).expect("source segment start"); - let end_index = source[start_index..] - .find(end) - .map(|index| start_index + index) - .expect("source segment end"); - &source[start_index..end_index] -} - fn rust_sources_under(relative_root: &str) -> Vec<(String, String)> { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let mut paths = Vec::new();