commit 35e4179107d1ff34e3c928841cfe0b82ca75b598
parent 5aa3f1d260a2c51a5dcff831b2436a946ed3cd61
Author: triesap <tyson@radroots.org>
Date: Tue, 19 May 2026 08:16:21 +0000
proof: preflight receipt requests
- require canonical witness and proof target constraints
- reject mismatched reducer and protocol expectations
- fail invalid backend and proof-mode pairs before fetch
- cover prefetch rejection for identity mismatches
Diffstat:
1 file changed, 173 insertions(+), 2 deletions(-)
diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs
@@ -18,7 +18,8 @@ use radroots_nostr::prelude::{
radroots_nostr_fetch_event_by_id, radroots_nostr_send_event,
};
use radroots_sp1_guest_trade::{
- RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET, RADROOTS_SP1_TRADE_WITNESS_VERSION,
+ 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,
@@ -40,6 +41,8 @@ use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TradeValidationReceiptJobRequest {
+ pub witness_version: u32,
+ pub proof_target: String,
pub listing_event_id: String,
pub request_event_id: String,
pub decision_event_id: String,
@@ -50,6 +53,7 @@ pub struct TradeValidationReceiptJobRequest {
pub proof_mode: RadrootsSp1TradeProofMode,
pub reducer_program_hash: String,
pub radroots_protocol_version: String,
+ pub sp1_program_hash: Option<String>,
pub sp1_verifying_key_hash: Option<String>,
}
@@ -116,6 +120,18 @@ pub enum TradeValidationReceiptJobError {
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("SP1 identity constraints require an SP1 proof mode")]
+ Sp1IdentityConstraintsRequireSp1Proof,
#[error("invalid listing event")]
InvalidListingEvent,
#[error("invalid signed event evidence")]
@@ -223,7 +239,7 @@ pub async fn handle_trade_validation_receipt_job_request(
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: None,
+ sp1_program_hash: request.sp1_program_hash.clone(),
sp1_verifying_key_hash: request.sp1_verifying_key_hash.clone(),
};
let proof_backend = request.prover_backend;
@@ -522,15 +538,93 @@ fn validate_job_request_shape(
if request.listing_event_id.trim().is_empty()
|| request.request_event_id.trim().is_empty()
|| request.decision_event_id.trim().is_empty()
+ || request.proof_target.trim().is_empty()
|| request.reducer_program_hash.trim().is_empty()
|| request.radroots_protocol_version.trim().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);
+ }
+ validate_optional_hash32(&request.sp1_program_hash)?;
+ validate_optional_hash32(&request.sp1_verifying_key_hash)?;
+ validate_proof_config(request)?;
+ Ok(())
+}
+
+fn validate_proof_config(
+ request: &TradeValidationReceiptJobRequest,
+) -> Result<(), TradeValidationReceiptJobError> {
+ match request.prover_backend {
+ TradeValidationReceiptProverBackend::Disabled => {
+ return Err(TradeValidationReceiptJobError::ProverBackendDisabled);
+ }
+ TradeValidationReceiptProverBackend::DeterministicNone
+ | TradeValidationReceiptProverBackend::LocalExecute => {
+ if request.proof_mode != RadrootsSp1TradeProofMode::None {
+ return Err(TradeValidationReceiptJobError::ProverBackendRequiresNone);
+ }
+ if request.sp1_program_hash.is_some() || request.sp1_verifying_key_hash.is_some() {
+ return Err(TradeValidationReceiptJobError::Sp1IdentityConstraintsRequireSp1Proof);
+ }
+ if request.prover_backend == TradeValidationReceiptProverBackend::LocalExecute
+ && !cfg!(feature = "sp1_proving")
+ {
+ return Err(TradeValidationReceiptJobError::ProverBackendUnavailable(
+ request.prover_backend.as_str(),
+ ));
+ }
+ }
+ TradeValidationReceiptProverBackend::LocalCpuProve => {
+ if request.proof_mode == RadrootsSp1TradeProofMode::None {
+ return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof);
+ }
+ if request.proof_mode != RadrootsSp1TradeProofMode::Core {
+ return Err(TradeValidationReceiptJobError::UnsupportedProofMode);
+ }
+ if !cfg!(feature = "sp1_proving") {
+ return Err(TradeValidationReceiptJobError::ProverBackendUnavailable(
+ request.prover_backend.as_str(),
+ ));
+ }
+ }
+ TradeValidationReceiptProverBackend::LocalCudaProve
+ | TradeValidationReceiptProverBackend::RemoteHttpProve => {
+ return Err(TradeValidationReceiptJobError::ProverBackendUnavailable(
+ request.prover_backend.as_str(),
+ ));
+ }
+ }
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)),
@@ -879,6 +973,10 @@ mod tests {
sp1_verifying_key_hash: Option<String>,
) -> RadrootsNostrEvent {
let request = TradeValidationReceiptJobRequest {
+ witness_version: radroots_sp1_guest_trade::RADROOTS_SP1_TRADE_WITNESS_VERSION,
+ proof_target:
+ radroots_sp1_guest_trade::RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET
+ .to_string(),
listing_event_id: listing_event.id.to_hex(),
request_event_id: request_event.id.to_hex(),
decision_event_id: decision_event.id.to_hex(),
@@ -893,6 +991,7 @@ mod tests {
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: None,
sp1_verifying_key_hash,
};
signed_event(
@@ -1042,6 +1141,14 @@ mod tests {
error,
TradeValidationReceiptJobError::ProverBackendRequiresNone
));
+ 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()
@@ -1101,6 +1208,62 @@ mod tests {
);
}
+ #[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,
+ TradeValidationReceiptProverBackend::DeterministicNone,
+ RadrootsSp1TradeProofMode::None,
+ None,
+ );
+ let mut request: TradeValidationReceiptJobRequest =
+ serde_json::from_str(&job.content).expect("job request json");
+ request.reducer_program_hash =
+ "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_string();
+ let job = signed_event(
+ &requester,
+ KIND_WORKER_TRADE_TRANSITION_PROOF_REQ,
+ serde_json::to_string(&request).expect("job json"),
+ vec![vec!["p".to_string(), worker.public_key().to_string()]],
+ );
+
+ {
+ 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_trade_validation_receipt_job_request(&job, &worker, &client_for(&worker))
+ .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() {
@@ -1141,6 +1304,14 @@ mod tests {
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()