rhi

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

commit b5cd7fd0f7bbcf9a6dfed8a5330cedfe7a0e0700
parent 917cb7a40e650fa4249b5b3901a7b37b9e157202
Author: triesap <tyson@radroots.org>
Date:   Thu,  2 Jul 2026 20:06:45 +0000

rhi: validate recovered worker evidence

- validate recovered result payload fields against the receipt and request envelope
- reject recovered proof metadata that fails current execution-binding semantics
- cover invalid SP1 and deterministic recovered metadata with focused tests
- verify the slice with RHI fmt, focused tests, and all-targets check

Diffstat:
Msrc/features/trade_validation_receipt.rs | 373++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 362 insertions(+), 11 deletions(-)

diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs @@ -466,6 +466,10 @@ pub enum TradeValidationReceiptJobError { 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 validation receipt")] + RecoveredResultMismatch, #[error("invalid active trade event: {0}")] InvalidActiveTradeEvent(String), #[error("rhi prover backend is disabled")] @@ -1340,6 +1344,7 @@ async fn publish_result_and_complete( envelope, &receipt_event_id, &verified_receipt, + prover_policy, )?; let result_event_id = result_event.id.to_hex(); if claimed_result_event_id @@ -1444,6 +1449,7 @@ fn validate_recovered_result_event( 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); @@ -1452,12 +1458,15 @@ fn validate_recovered_result_event( return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); } let result: TradeValidationReceiptJobResult = serde_json::from_str(event.content.as_str())?; - if result.receipt_event_id != receipt_event_id - || result.request_hash != job.request_hash - || result.public_values_hash != verified_receipt.receipt.public_values_hash - { - return Err(TradeValidationReceiptJobError::DuplicateConflictingResult); - } + 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 @@ -1471,6 +1480,58 @@ fn validate_recovered_result_event( Ok(()) } +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; + 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.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 + || 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.proof_mode != prover_policy.proof_mode + || result.validation_authority != expected_authority + || result.confidence != expected_confidence + { + return Err(TradeValidationReceiptJobError::RecoveredResultMismatch); + } + Ok(()) +} + fn result_payload( request_event: &RadrootsNostrEvent, job: &RhiProcessedJobState, @@ -1481,11 +1542,8 @@ fn result_payload( proof_metadata: Option<&TradeValidationReceiptResultProofMetadata>, ) -> Result<TradeValidationReceiptJobResult, TradeValidationReceiptJobError> { let request = &envelope.content; - let proof_system_is_none = - verified_receipt.receipt.proof.system == RadrootsValidationReceiptProofSystem::None; - if !proof_system_is_none && proof_metadata.is_none() { - return Err(TradeValidationReceiptJobError::MissingRecoveredProofMetadata); - } + 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); @@ -1524,6 +1582,76 @@ fn result_payload( }) } +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); + }; + 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>, @@ -4372,6 +4500,229 @@ mod tests { ); } + #[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 + )); + } + #[tokio::test] async fn proof_job_records_completed_job_and_skips_duplicate_replay() { let _guard = test_guard();