rhi

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

commit 5aa3f1d260a2c51a5dcff831b2436a946ed3cd61
parent 0d943280d4cd7efada3c6fdfa64fb906d962ee75
Author: triesap <tyson@radroots.org>
Date:   Tue, 19 May 2026 08:06:52 +0000

proof: build signed event evidence

- build witness evidence from fetched signed events
- verify fetched event ids and signatures before proving
- carry canonical proof target and witness version
- cover tampered event evidence rejection

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Msrc/features/trade_validation_receipt.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/proof_smoke.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 228 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3647,6 +3647,7 @@ dependencies = [ "radroots_trade", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml @@ -45,6 +45,7 @@ anyhow = { version = "1" } clap = { version = "4", features = ["derive"] } serde = { version = "1", default-features = false } serde_json = { version = "1", default-features = false } +sha2 = { version = "0.10" } tokio = { version = "1", features = ["full"] } thiserror = { version = "2" } toml = { version = "0.8" } diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs @@ -18,10 +18,12 @@ use radroots_nostr::prelude::{ radroots_nostr_fetch_event_by_id, radroots_nostr_send_event, }; use radroots_sp1_guest_trade::{ - RadrootsSp1TradeInventoryBinWitness, RadrootsSp1TradeInventoryCommitmentWitness, - RadrootsSp1TradeOrderAcceptanceWitness, RadrootsSp1TradeOrderDecisionEventWitness, - RadrootsSp1TradeOrderDecisionWitness, RadrootsSp1TradeOrderItemWitness, - RadrootsSp1TradeOrderRequestWitness, + RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET, RADROOTS_SP1_TRADE_WITNESS_VERSION, + RadrootsSp1TradeCanonicalEventEvidence, RadrootsSp1TradeEventEvidenceRole, + RadrootsSp1TradeEventWorkflowPosition, RadrootsSp1TradeInventoryBinWitness, + RadrootsSp1TradeInventoryCommitmentWitness, RadrootsSp1TradeOrderAcceptanceWitness, + RadrootsSp1TradeOrderDecisionEventWitness, RadrootsSp1TradeOrderDecisionWitness, + RadrootsSp1TradeOrderItemWitness, RadrootsSp1TradeOrderRequestWitness, }; use radroots_sp1_host_trade::{ RadrootsSp1TradeHostError, RadrootsSp1TradeProofMode, generate_order_acceptance_proof, @@ -32,6 +34,7 @@ use radroots_trade::validation_receipt::{ validation_receipt_event_build, verify_validation_receipt_event, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use thiserror::Error; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -115,6 +118,8 @@ pub enum TradeValidationReceiptJobError { InvalidJobRequest, #[error("invalid listing event")] InvalidListingEvent, + #[error("invalid signed event evidence")] + InvalidSignedEvent, #[error("job request does not match fetched event set")] EventSetMismatch, #[error("invalid active trade event: {0}")] @@ -158,6 +163,9 @@ pub async fn handle_trade_validation_receipt_job_request( let listing_event = fetch_event_by_id_io(client, &request.listing_event_id).await?; let order_request_event = fetch_event_by_id_io(client, &request.request_event_id).await?; let order_decision_event = fetch_event_by_id_io(client, &request.decision_event_id).await?; + validate_fetched_event(&listing_event, &request.listing_event_id)?; + validate_fetched_event(&order_request_event, &request.request_event_id)?; + validate_fetched_event(&order_decision_event, &request.decision_event_id)?; let listing_kind = event_kind_u32(&listing_event) .map_err(|_| TradeValidationReceiptJobError::InvalidListingEvent)?; @@ -198,9 +206,16 @@ pub async fn handle_trade_validation_receipt_job_request( } let witness = RadrootsSp1TradeOrderAcceptanceWitness { + witness_version: RADROOTS_SP1_TRADE_WITNESS_VERSION, + proof_target: RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET.to_string(), listing_event_id: request.listing_event_id.clone(), request_event_id: request.request_event_id.clone(), decision_event_id: request.decision_event_id.clone(), + event_evidence: canonical_event_evidence_from_events( + &listing_event, + &order_request_event, + &order_decision_event, + )?, request: order_request_witness_from_payload(request_envelope.payload), decision: order_decision_witness_from_payload(decision_envelope.payload), inventory_bins: request.inventory_bins.clone(), @@ -272,6 +287,94 @@ pub async fn handle_trade_validation_receipt_job_request( Ok(()) } +fn canonical_event_evidence_from_events( + listing_event: &RadrootsNostrEvent, + order_request_event: &RadrootsNostrEvent, + order_decision_event: &RadrootsNostrEvent, +) -> Result<Vec<RadrootsSp1TradeCanonicalEventEvidence>, TradeValidationReceiptJobError> { + Ok(vec![ + canonical_event_evidence( + listing_event, + RadrootsSp1TradeEventEvidenceRole::Seller, + RadrootsSp1TradeEventWorkflowPosition::Listing, + "001:listing", + )?, + canonical_event_evidence( + order_request_event, + RadrootsSp1TradeEventEvidenceRole::Buyer, + RadrootsSp1TradeEventWorkflowPosition::OrderRequest, + "002:order_request", + )?, + canonical_event_evidence( + order_decision_event, + RadrootsSp1TradeEventEvidenceRole::Seller, + RadrootsSp1TradeEventWorkflowPosition::OrderDecision, + "003:order_decision", + )?, + ]) +} + +fn canonical_event_evidence( + event: &RadrootsNostrEvent, + role: RadrootsSp1TradeEventEvidenceRole, + workflow_position: RadrootsSp1TradeEventWorkflowPosition, + ordering_key: &'static str, +) -> Result<RadrootsSp1TradeCanonicalEventEvidence, TradeValidationReceiptJobError> { + event + .verify() + .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent)?; + let canonical_event_json = serde_json::to_string(event)?; + let tags_json = serde_json::to_vec(&event.tags)?; + Ok(RadrootsSp1TradeCanonicalEventEvidence { + event_id: event.id.to_hex(), + signer_pubkey: event.pubkey.to_hex(), + kind: event_kind_u32(event)?, + canonical_event_hash: hash_bytes( + "radroots:canonical-event:v1", + canonical_event_json.as_bytes(), + ), + signature_hash: hash_bytes( + "radroots:event-signature:v1", + event.sig.to_string().as_bytes(), + ), + preverified_signature: true, + role, + workflow_position, + content_hash: hash_bytes("radroots:event-content:v1", event.content.as_bytes()), + tags_hash: hash_bytes("radroots:event-tags:v1", &tags_json), + ordering_key: ordering_key.to_string(), + }) +} + +fn validate_fetched_event( + event: &RadrootsNostrEvent, + expected_event_id: &str, +) -> Result<(), TradeValidationReceiptJobError> { + if event.id.to_hex() != expected_event_id { + return Err(TradeValidationReceiptJobError::EventSetMismatch); + } + event + .verify() + .map_err(|_| TradeValidationReceiptJobError::InvalidSignedEvent) +} + +fn hash_bytes(domain: &'static str, bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain.as_bytes()); + hasher.update(bytes); + format!("0x{}", hex_lower(hasher.finalize().as_slice())) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + fn order_request_witness_from_payload( payload: RadrootsTradeOrderRequested, ) -> RadrootsSp1TradeOrderRequestWitness { @@ -948,6 +1051,56 @@ mod tests { ); } + #[tokio::test] + async fn proof_job_rejects_unverified_signed_event_evidence_before_publication() { + 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 (mut 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, + ); + request_event.content.push(' '); + + { + 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("signed evidence rejected"); + assert!(matches!( + error, + TradeValidationReceiptJobError::InvalidSignedEvent + )); + assert!( + trade_validation_receipt_test_hooks() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .published_events + .is_empty() + ); + } + #[cfg(not(feature = "sp1_proving"))] #[tokio::test] async fn proof_job_rejects_unavailable_prover_backend_before_publication() { diff --git a/src/proof_smoke.rs b/src/proof_smoke.rs @@ -3,7 +3,11 @@ use crate::cli::Command; use radroots_sp1_guest_trade::{ + RADROOTS_SP1_TRADE_KIND_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION, + RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST, 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, RadrootsSp1TradeOrderDecisionEventWitness, RadrootsSp1TradeOrderDecisionWitness, RadrootsSp1TradeOrderItemWitness, @@ -286,12 +290,15 @@ fn capabilities() -> Vec<String> { fn order_acceptance_tiny_witness() -> RadrootsSp1TradeOrderAcceptanceWitness { RadrootsSp1TradeOrderAcceptanceWitness { + witness_version: RADROOTS_SP1_TRADE_WITNESS_VERSION, + proof_target: RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET.to_string(), listing_event_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" .to_string(), request_event_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" .to_string(), decision_event_id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" .to_string(), + event_evidence: order_acceptance_tiny_event_evidence(), request: RadrootsSp1TradeOrderRequestWitness { order_id: "order-1".to_string(), listing_addr: @@ -336,6 +343,68 @@ fn order_acceptance_tiny_witness() -> RadrootsSp1TradeOrderAcceptanceWitness { } } +fn order_acceptance_tiny_event_evidence() -> Vec<RadrootsSp1TradeCanonicalEventEvidence> { + vec![ + RadrootsSp1TradeCanonicalEventEvidence { + event_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + signer_pubkey: "1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), + kind: RADROOTS_SP1_TRADE_KIND_LISTING, + canonical_event_hash: + "0x1010101010101010101010101010101010101010101010101010101010101010".to_string(), + signature_hash: "0x1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), + preverified_signature: true, + role: RadrootsSp1TradeEventEvidenceRole::Seller, + workflow_position: RadrootsSp1TradeEventWorkflowPosition::Listing, + content_hash: "0x1212121212121212121212121212121212121212121212121212121212121212" + .to_string(), + tags_hash: "0x1313131313131313131313131313131313131313131313131313131313131313" + .to_string(), + ordering_key: "001:listing".to_string(), + }, + RadrootsSp1TradeCanonicalEventEvidence { + event_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + signer_pubkey: "2222222222222222222222222222222222222222222222222222222222222222" + .to_string(), + kind: RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST, + canonical_event_hash: + "0x2020202020202020202020202020202020202020202020202020202020202020".to_string(), + signature_hash: "0x2121212121212121212121212121212121212121212121212121212121212121" + .to_string(), + preverified_signature: true, + role: RadrootsSp1TradeEventEvidenceRole::Buyer, + workflow_position: RadrootsSp1TradeEventWorkflowPosition::OrderRequest, + content_hash: "0x2222222222222222222222222222222222222222222222222222222222222222" + .to_string(), + tags_hash: "0x2323232323232323232323232323232323232323232323232323232323232323" + .to_string(), + ordering_key: "002:order_request".to_string(), + }, + RadrootsSp1TradeCanonicalEventEvidence { + event_id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + .to_string(), + signer_pubkey: "1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), + kind: RADROOTS_SP1_TRADE_KIND_ORDER_DECISION, + canonical_event_hash: + "0x3030303030303030303030303030303030303030303030303030303030303030".to_string(), + signature_hash: "0x3131313131313131313131313131313131313131313131313131313131313131" + .to_string(), + preverified_signature: true, + role: RadrootsSp1TradeEventEvidenceRole::Seller, + workflow_position: RadrootsSp1TradeEventWorkflowPosition::OrderDecision, + content_hash: "0x3232323232323232323232323232323232323232323232323232323232323232" + .to_string(), + tags_hash: "0x3333333333333333333333333333333333333333333333333333333333333333" + .to_string(), + ordering_key: "003:order_decision".to_string(), + }, + ] +} + fn read_input(input: Option<&Path>) -> anyhow::Result<Vec<u8>> { match input { Some(path) => Ok(std::fs::read(path)?),