rhi

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

commit b67d674c12890b1747c3fa57ca2fbbce2857cd45
parent dd7d1b23b7d06516a3e7e90b4aaf0cf46b953067
Author: triesap <tyson@radroots.org>
Date:   Fri,  3 Jul 2026 07:29:39 +0000

receipt: gate recovered result replay on current policy

- require full prover policy validation before stored result publication
- remove the narrow recovered-result availability check
- add SP1 recovered-result auth-missing replay coverage
- preserve recovered intent state when current policy auth fails

Diffstat:
Msrc/features/trade_validation_receipt.rs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 186 insertions(+), 14 deletions(-)

diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs @@ -195,13 +195,6 @@ impl TradeValidationReceiptProverPolicy { Ok(()) } - fn validate_recovered_result_availability(&self) -> Result<(), TradeValidationReceiptJobError> { - if self.backend == TradeValidationReceiptProverBackend::Disabled { - return Err(TradeValidationReceiptJobError::ProverBackendDisabled); - } - self.validate_build_availability() - } - fn validate_shape(&self) -> Result<(), TradeValidationReceiptJobError> { validate_optional_hash32(&self.expected_sp1_program_hash)?; validate_optional_hash32(&self.expected_sp1_verifying_key_hash)?; @@ -1431,7 +1424,7 @@ async fn publish_result_and_complete( &verified_receipt, prover_policy, )?; - prover_policy.validate_recovered_result_availability()?; + validate_job_execution_policy(prover_policy, &envelope.content)?; let result_event_id = result_event.id.to_hex(); if claimed_result_event_id .as_ref() @@ -3362,11 +3355,12 @@ mod tests { .expect("processed job") } - fn signed_result_event_for_test( + 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"); @@ -3382,7 +3376,7 @@ mod tests { receipt_event_id.as_str(), verified_receipt, policy, - None, + proof_metadata, ) .expect("result payload"); let result_content = serde_json::to_string(&result).expect("result json"); @@ -3399,6 +3393,15 @@ mod tests { .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, @@ -3495,6 +3498,17 @@ mod tests { 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 { let worker = RadrootsNostrKeys::generate(); let requester = RadrootsNostrKeys::generate(); @@ -3613,6 +3627,118 @@ mod tests { } } + async fn recovered_sp1_result_intent_scenario( + policy: &TradeValidationReceiptProverPolicy, + ) -> 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 = 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 = 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, @@ -3663,17 +3789,20 @@ mod tests { receipt: RadrootsTradeValidationReceipt { changed_records_root: hash32('4'), domain: "radroots.receipt".to_string(), - error_bitmap: "0x00".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: None, - proof_reference: None, + 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: None, + verifying_key_hash: request.sp1_verifying_key_hash.clone(), }, public_values_hash: public_values_hash.clone(), receipt_type: RadrootsValidationReceiptType::TradeTransition, @@ -6182,6 +6311,49 @@ mod tests { } #[tokio::test] + async fn proof_job_rejects_recovered_result_intent_missing_remote_auth_before_publish() { + let _guard = test_guard(); + let mut current_policy = remote_http_policy(); + let remote_http = current_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: "RHI_TEST_RECOVERED_RESULT_AUTH_TOKEN_MUST_BE_UNSET".to_string(), + }; + 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 == "RHI_TEST_RECOVERED_RESULT_AUTH_TOKEN_MUST_BE_UNSET" + )); + 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();