commit 253972ab94fa980979201ae343a5bebe7f6ec067
parent 68004da0ebacae178f39135900a05f194163f10c
Author: triesap <tyson@radroots.org>
Date: Thu, 2 Jul 2026 22:27:29 +0000
rhi: classify recovered policy drift in handler
Diffstat:
1 file changed, 347 insertions(+), 49 deletions(-)
diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs
@@ -162,6 +162,55 @@ 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_recovered_replay(&self) -> Result<(), TradeValidationReceiptJobError> {
+ self.validate_shape()?;
+ if self.backend == TradeValidationReceiptProverBackend::Disabled {
+ return Err(TradeValidationReceiptJobError::ProverBackendDisabled);
+ }
+ 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)?;
if self.backend != TradeValidationReceiptProverBackend::DeterministicNone
@@ -205,16 +254,10 @@ impl TradeValidationReceiptProverPolicy {
TradeValidationReceiptJobError::Sp1IdentityConstraintsRequireSp1Proof,
);
}
- if self.backend == TradeValidationReceiptProverBackend::LocalExecute
- && !cfg!(feature = "sp1_proving")
- {
- return Err(TradeValidationReceiptJobError::ProverBackendUnavailable(
- self.backend.as_str(),
- ));
- }
Ok(())
}
- TradeValidationReceiptProverBackend::LocalCpuProve => {
+ TradeValidationReceiptProverBackend::LocalCpuProve
+ | TradeValidationReceiptProverBackend::LocalCudaProve => {
if self.proof_mode == RadrootsSp1TradeProofMode::None {
return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof);
}
@@ -223,16 +266,8 @@ impl TradeValidationReceiptProverPolicy {
{
return Err(TradeValidationReceiptJobError::Sp1IdentityPolicyRequired);
}
- if !cfg!(feature = "sp1_proving") {
- return Err(TradeValidationReceiptJobError::ProverBackendUnavailable(
- self.backend.as_str(),
- ));
- }
Ok(())
}
- TradeValidationReceiptProverBackend::LocalCudaProve => Err(
- TradeValidationReceiptJobError::ProverBackendUnavailable(self.backend.as_str()),
- ),
TradeValidationReceiptProverBackend::RemoteHttpProve => {
if self.proof_mode == RadrootsSp1TradeProofMode::None {
return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof);
@@ -246,40 +281,41 @@ impl TradeValidationReceiptProverPolicy {
.remote_http
.as_ref()
.ok_or(TradeValidationReceiptJobError::RemoteHttpConfigRequired)?;
- remote_http.validate()?;
- remote_http_auth_token(remote_http)?;
- if !cfg!(feature = "sp1_verify") {
- return Err(TradeValidationReceiptJobError::ProverBackendUnavailable(
- self.backend.as_str(),
- ));
- }
- Ok(())
+ remote_http.validate()
}
}
}
- 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);
+ 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(),
+ ))
}
- 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);
+ 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(()),
}
- Ok(())
}
}
@@ -392,6 +428,40 @@ fn remote_http_auth_token(
}
}
+fn validate_job_execution_policy(
+ prover_policy: &TradeValidationReceiptProverPolicy,
+ request: &RadrootsTradeTransitionProofRequestV1,
+) -> Result<(), TradeValidationReceiptJobError> {
+ prover_policy.validate()?;
+ if prover_policy.backend == TradeValidationReceiptProverBackend::Disabled {
+ return Err(TradeValidationReceiptJobError::ProverBackendDisabled);
+ }
+ prover_policy.validate_request(request)
+}
+
+fn validate_recovered_replay_request_policy(
+ prover_policy: &TradeValidationReceiptProverPolicy,
+ request: &RadrootsTradeTransitionProofRequestV1,
+) -> Result<(), TradeValidationReceiptJobError> {
+ 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 {
@@ -703,13 +773,9 @@ async fn process_trade_validation_receipt_job_request(
return Err(TradeValidationReceiptJobError::MissingRecipient);
}
- prover_policy.validate()?;
- if prover_policy.backend == TradeValidationReceiptProverBackend::Disabled {
- return Err(TradeValidationReceiptJobError::ProverBackendDisabled);
- }
let request = &envelope.content;
validate_job_request_shape(&request)?;
- prover_policy.validate_request(&request)?;
+ prover_policy.validate_recovered_replay()?;
let job = processed_job_for_request(event, kind, &request_event)?;
match processed_job_action(runtime, &job).await? {
@@ -722,6 +788,7 @@ async fn process_trade_validation_receipt_job_request(
result_event_json,
proof_metadata,
} => {
+ validate_recovered_replay_request_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);
@@ -749,12 +816,14 @@ async fn process_trade_validation_receipt_job_request(
receipt_event_id,
receipt_event_json,
} => {
+ validate_recovered_replay_request_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)?;
+ validate_job_execution_policy(prover_policy, request)?;
let published_receipt_event_id = io.publish_signed_event(receipt_event).await?;
if published_receipt_event_id != receipt_event_id {
return Err(TradeValidationReceiptJobError::DuplicateConflictingReceipt);
@@ -785,6 +854,8 @@ async fn process_trade_validation_receipt_job_request(
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?
{
@@ -1367,6 +1438,7 @@ async fn publish_result_and_complete(
&verified_receipt,
prover_policy,
)?;
+ prover_policy.validate_recovered_result_availability()?;
let result_event_id = result_event.id.to_hex();
if claimed_result_event_id
.as_ref()
@@ -1380,6 +1452,7 @@ async fn publish_result_and_complete(
}
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,
@@ -3419,6 +3492,134 @@ mod tests {
}
}
+ struct RecoveredResultIntentScenario {
+ worker: RadrootsNostrKeys,
+ job: RadrootsNostrEvent,
+ runtime: TradeListingRuntime,
+ receipt_event_id: String,
+ receipt_event_json: String,
+ result_event_id: String,
+ result_event_json: String,
+ }
+
+ async fn recovered_result_intent_scenario() -> 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 = 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");
+
+ 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,
@@ -3427,6 +3628,37 @@ mod tests {
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,
@@ -5837,6 +6069,72 @@ mod tests {
.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,
+ ¤t_policy,
+ )
+ .await
+ .expect_err(name);
+
+ assert!(matches!(
+ error,
+ TradeValidationReceiptJobError::RecoveredResultPolicyMismatch
+ ));
+ 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]