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:
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)?),