rhi

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

commit b0aeb5deab6ef7800ad5b495393c3742906932aa
parent f0ec6424160c9bf0265ba9bcd24c9bef6f0e0e8c
Author: triesap <tyson@radroots.org>
Date:   Thu,  2 Jul 2026 21:12:05 +0000

rhi: fail recovered result policy drift explicitly

- add a typed recovered result policy mismatch error
- reject stored result backend and proof-mode drift before replay publication
- cover persisted result-intent recovery under policy drift
- validate with recovered and trade validation receipt test lanes

Diffstat:
Msrc/features/trade_validation_receipt.rs | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 174 insertions(+), 2 deletions(-)

diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs @@ -468,6 +468,8 @@ pub enum TradeValidationReceiptJobError { 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}")] @@ -1490,6 +1492,11 @@ fn validate_recovered_result_payload( 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); + } let proof_metadata = TradeValidationReceiptResultProofMetadata { cryptographic_proof_verified: result.cryptographic_proof_verified, proof_generated: result.proof_generated, @@ -1502,7 +1509,6 @@ fn validate_recovered_result_payload( commitment_confidence_for_result(verified_receipt.receipt.result, expected_authority); if result.status != TradeValidationReceiptJobStatus::Succeeded || result.worker_role != TradeValidationReceiptWorkerRole::NonAuthoritativeProver - || result.prover_backend != prover_policy.backend || result.receipt_event_id != receipt_event_id || result.receipt_kind != KIND_TRADE_VALIDATION_RECEIPT || result.request_hash != job.request_hash @@ -1523,7 +1529,6 @@ fn validate_recovered_result_payload( .mode .as_deref() .unwrap_or("none") - || result.proof_mode != prover_policy.proof_mode || result.validation_authority != expected_authority || result.confidence != expected_confidence { @@ -3309,6 +3314,30 @@ mod tests { .expect("signed result") } + 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, @@ -4916,6 +4945,7 @@ mod tests { matches!( error, TradeValidationReceiptJobError::RecoveredResultMismatch + | TradeValidationReceiptJobError::RecoveredResultPolicyMismatch | TradeValidationReceiptJobError::Serde(_) ), "{name}: {error:?}" @@ -5458,6 +5488,148 @@ mod tests { } #[tokio::test] + async fn proof_job_rejects_recovered_result_intent_policy_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(), + 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 + .fetch_event_by_id_results + .push_back(Ok(receipt_event.clone())); + } + 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()); + } + + #[tokio::test] async fn proof_job_reuses_existing_relay_receipt_before_proof_execution() { let _guard = test_guard(); let worker = RadrootsNostrKeys::generate();