diff --git a/crates/events/src/enclave_event/proof.rs b/crates/events/src/enclave_event/proof.rs index bee8ce63e8..5bf0999eea 100644 --- a/crates/events/src/enclave_event/proof.rs +++ b/crates/events/src/enclave_event/proof.rs @@ -53,7 +53,8 @@ impl Proof { /// Extract a named public input field from this proof's public signals. /// /// Public inputs sit at the **start** of `public_signals`, before any - /// return values. + /// return values. The field name must match one declared in the circuit's + /// [`CircuitInputLayout`]. pub fn extract_input(&self, field_name: &str) -> Option { let layout = self.circuit.input_layout(); layout @@ -180,6 +181,8 @@ impl CircuitName { format!("recursive_aggregation/wrapper/{}", self.dir_path()) } + /// Public input layout for this circuit. + /// /// Public output (return value) layout for this circuit. pub fn output_layout(&self) -> CircuitOutputLayout { match self { @@ -344,4 +347,43 @@ mod tests { let proof = make_proof(CircuitName::PkGeneration, &[]); assert!(proof.extract_output("pk_commitment").is_none()); } + + #[test] + fn input_layout_share_encryption() { + let layout = CircuitName::ShareEncryption.input_layout(); + assert_eq!(layout.field_count(), Some(2)); + } + + #[test] + fn input_layout_other_circuits_none() { + assert_eq!(CircuitName::PkBfv.input_layout().field_count(), Some(0)); + assert_eq!( + CircuitName::PkGeneration.input_layout().field_count(), + Some(0) + ); + } + + #[test] + fn extract_input_from_share_encryption() { + // C3: 2 pub inputs at HEAD + rest of signals + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0xAA; 32]); // expected_pk_commitment + signals[32..64].copy_from_slice(&[0xBB; 32]); // expected_message_commitment + + let proof = make_proof(CircuitName::ShareEncryption, &signals); + assert_eq!( + &*proof.extract_input("expected_pk_commitment").unwrap(), + &[0xAA; 32] + ); + assert_eq!( + &*proof.extract_input("expected_message_commitment").unwrap(), + &[0xBB; 32] + ); + } + + #[test] + fn extract_input_from_non_input_circuit() { + let proof = make_proof(CircuitName::PkBfv, &[0u8; 32]); + assert!(proof.extract_input("anything").is_none()); + } } diff --git a/crates/zk-helpers/src/circuits/output_layout.rs b/crates/zk-helpers/src/circuits/output_layout.rs index a9dd797a16..5e4c31d843 100644 --- a/crates/zk-helpers/src/circuits/output_layout.rs +++ b/crates/zk-helpers/src/circuits/output_layout.rs @@ -110,42 +110,6 @@ impl CircuitOutputLayout { } } -// ── Public input layout (fields at the HEAD of public_signals) ────────────── - -/// Describes the public input fields of a circuit. -/// Inputs sit at the **start** of `public_signals`, before any return values. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum CircuitInputLayout { - /// Fixed number of named `Field`-sized inputs, known at compile time. - Fixed { fields: &'static [OutputField] }, - /// No known public input layout. - None, -} - -impl CircuitInputLayout { - /// Extract a named public input field from raw `public_signals` bytes. - /// Inputs sit at the **start** of `public_signals`. - pub fn extract_field<'a>(&self, public_signals: &'a [u8], name: &str) -> Option<&'a [u8]> { - let fields = match self { - CircuitInputLayout::Fixed { fields } => fields, - _ => return None, - }; - let idx = fields.iter().position(|f| f.name == name)?; - let offset = idx * FIELD_BYTE_LEN; - let end = offset + FIELD_BYTE_LEN; - if public_signals.len() < end { - return None; - } - Some(&public_signals[offset..end]) - } -} - -/// C3 — Share encryption public inputs. -pub const SHARE_ENCRYPTION_INPUTS: &[OutputField] = &[ - f("expected_pk_commitment"), - f("expected_message_commitment"), -]; - /// C6 — Threshold share decryption public inputs. pub const THRESHOLD_SHARE_DECRYPTION_INPUTS: &[OutputField] = &[f("expected_sk_commitment"), f("expected_e_sm_commitment")]; @@ -178,6 +142,61 @@ pub const PK_AGGREGATION_OUTPUTS: &[OutputField] = &[f("commitment")]; /// C6 — Threshold share decryption (prefix commitment to `d`, per CRT limb). pub const THRESHOLD_SHARE_DECRYPTION_OUTPUTS: &[OutputField] = &[f("d_commitment")]; +// ── Per-circuit input field constants ─────────────────────────────────────── + +/// C3 — Share encryption public inputs (at HEAD of `public_signals`). +pub const SHARE_ENCRYPTION_INPUTS: &[OutputField] = &[ + f("expected_pk_commitment"), + f("expected_message_commitment"), +]; + +/// Describes the public input layout of a circuit. +/// +/// Unlike [`CircuitOutputLayout`] which indexes from the TAIL of +/// `public_signals`, input fields sit at the HEAD. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CircuitInputLayout { + /// Fixed number of `Field`-sized inputs, names known at compile time. + Fixed { fields: &'static [OutputField] }, + /// The circuit has no named public inputs (or they are not tracked). + None, +} + +impl CircuitInputLayout { + /// Number of fixed input fields, or `None` for void layouts. + pub fn field_count(&self) -> Option { + match self { + CircuitInputLayout::Fixed { fields } => Some(fields.len()), + CircuitInputLayout::None => Some(0), + } + } + + /// Look up a field index by name. + pub fn field_index(&self, name: &str) -> Option { + match self { + CircuitInputLayout::Fixed { fields } => fields.iter().position(|f| f.name == name), + _ => None, + } + } + + /// Extract a named input field from raw `public_signals` bytes. + /// + /// Input fields sit at the **beginning** of `public_signals`. + /// This method indexes from the head (offset = idx * FIELD_BYTE_LEN). + pub fn extract_field<'a>(&self, public_signals: &'a [u8], name: &str) -> Option<&'a [u8]> { + let fields = match self { + CircuitInputLayout::Fixed { fields } => fields, + _ => return None, + }; + let idx = fields.iter().position(|f| f.name == name)?; + let offset = idx * FIELD_BYTE_LEN; + if public_signals.len() < offset + FIELD_BYTE_LEN { + return None; + } + Some(&public_signals[offset..offset + FIELD_BYTE_LEN]) + } +} + #[cfg(test)] mod tests { use super::*; @@ -332,6 +351,31 @@ mod tests { ); } + // ── CircuitInputLayout tests ──────────────────────────────────────── + + #[test] + fn extract_input_field_from_head() { + let layout = CircuitInputLayout::Fixed { + fields: SHARE_ENCRYPTION_INPUTS, + }; + let mut signals = vec![0u8; 128]; + signals[0..32].copy_from_slice(&[0xAA; 32]); + signals[32..64].copy_from_slice(&[0xBB; 32]); + + assert_eq!( + layout + .extract_field(&signals, "expected_pk_commitment") + .unwrap(), + &[0xAA; 32] + ); + assert_eq!( + layout + .extract_field(&signals, "expected_message_commitment") + .unwrap(), + &[0xBB; 32] + ); + } + #[test] fn extract_c6_public_inputs_via_input_layout() { let layout = CircuitInputLayout::Fixed { @@ -366,6 +410,45 @@ mod tests { .is_none()); } + #[test] + fn input_layout_nonexistent_field_returns_none() { + let layout = CircuitInputLayout::Fixed { + fields: SHARE_ENCRYPTION_INPUTS, + }; + let signals = vec![0u8; 64]; + assert!(layout.extract_field(&signals, "nonexistent").is_none()); + } + + #[test] + fn input_layout_none_returns_none() { + let layout = CircuitInputLayout::None; + let signals = vec![0u8; 64]; + assert!(layout.extract_field(&signals, "anything").is_none()); + } + + #[test] + fn input_signals_too_short_returns_none() { + let layout = CircuitInputLayout::Fixed { + fields: SHARE_ENCRYPTION_INPUTS, + }; + let signals = vec![0u8; 32]; + assert!(layout + .extract_field(&signals, "expected_message_commitment") + .is_none()); + } + + #[test] + fn input_field_count() { + assert_eq!( + CircuitInputLayout::Fixed { + fields: SHARE_ENCRYPTION_INPUTS + } + .field_count(), + Some(2) + ); + assert_eq!(CircuitInputLayout::None.field_count(), Some(0)); + } + /// C7 (`DecryptedSharesAggregation`) has no `-> pub` return values; metadata uses `None`. #[test] fn c7_void_output_extract_field_returns_none() { diff --git a/crates/zk-prover/src/actors/commitment_consistency_checker.rs b/crates/zk-prover/src/actors/commitment_consistency_checker.rs index e86f601e37..5719d48680 100644 --- a/crates/zk-prover/src/actors/commitment_consistency_checker.rs +++ b/crates/zk-prover/src/actors/commitment_consistency_checker.rs @@ -66,7 +66,8 @@ pub struct CommitmentConsistencyChecker { e3_id: E3id, links: Vec>, /// Verified proof outputs: `(address, proof_type) → data`. - verified: HashMap<(Address, ProofType), VerifiedProofData>, + /// Multiple proofs per key are supported (e.g. N-1 C3a proofs per sender). + verified: HashMap<(Address, ProofType), Vec>, } impl CommitmentConsistencyChecker { @@ -79,6 +80,21 @@ impl CommitmentConsistencyChecker { } } + /// Insert a proof into the cache, deduplicating by `data_hash` to avoid + /// double-counting when the same proof arrives via both the pre-ZK batch + /// and the post-ZK `ProofVerificationPassed` path. + fn insert_verified( + &mut self, + address: Address, + proof_type: ProofType, + data: VerifiedProofData, + ) { + let entries = self.verified.entry((address, proof_type)).or_default(); + if !entries.iter().any(|e| e.data_hash == data.data_hash) { + entries.push(data); + } + } + pub fn setup(bus: &BusHandle, e3_id: E3id, links: Vec>) -> Addr { let actor = Self::new(bus, e3_id, links); let addr = actor.start(); @@ -97,50 +113,118 @@ impl CommitmentConsistencyChecker { let tgt_type = link.target_proof_type(); match link.scope() { - LinkScope::SameParty => self - .verified - .keys() - .filter(|(_, pt)| *pt == src_type) - .filter_map(|(addr, _)| { - let src = self.verified.get(&(*addr, src_type))?; - let tgt = self.verified.get(&(*addr, tgt_type))?; - let vals = link.extract_source_values(&src.public_signals); - (!link.check_consistency(&vals, &tgt.public_signals)).then(|| Mismatch { - party_id: src.party_id, - address: *addr, - proof_type: src_type, - data_hash: src.data_hash, - }) - }) - .collect(), + // Same address: each source entry must be consistent with each + // target entry from the same address. + LinkScope::SameParty => { + let mut mismatches = Vec::new(); + for ((addr, pt), srcs) in &self.verified { + if *pt != src_type { + continue; + } + let Some(tgts) = self.verified.get(&(*addr, tgt_type)) else { + continue; + }; + for src in srcs { + let vals = link.extract_source_values(&src.public_signals); + for tgt in tgts { + if !link.check_consistency(&vals, &tgt.public_signals) { + mismatches.push(Mismatch { + party_id: src.party_id, + address: *addr, + proof_type: src_type, + data_hash: src.data_hash, + }); + break; // one mismatch per source entry is enough + } + } + } + } + mismatches + } + // Cross-party: each source's extracted value must appear in at + // least one target's public signals. Fault the source if no match. + // If no targets are cached yet, skip — the check will run again + // when a target arrives. LinkScope::CrossParty => { - let targets: Vec<_> = self + let all_targets: Vec<&VerifiedProofData> = self .verified .iter() .filter(|((_, pt), _)| *pt == tgt_type) - .map(|(_, v)| v) + .flat_map(|(_, entries)| entries) .collect(); - self.verified + if all_targets.is_empty() { + return Vec::new(); + } + + let mut mismatches = Vec::new(); + for ((_, pt), srcs) in &self.verified { + if *pt != src_type { + continue; + } + for src in srcs { + let vals = link.extract_source_values(&src.public_signals); + if vals.is_empty() { + continue; + } + // Source must match AT LEAST ONE target. + let found = all_targets + .iter() + .any(|tgt| link.check_consistency(&vals, &tgt.public_signals)); + if !found { + mismatches.push(Mismatch { + party_id: src.party_id, + address: src.address, + proof_type: src_type, + data_hash: src.data_hash, + }); + } + } + } + mismatches + } + + // Each source claims a value that must exist among any target's + // outputs. Fault the source (e.g. C3) when no target (e.g. C0) + // matches. If no targets are cached yet, skip — the check will + // run when a target arrives via post-ZK ProofVerificationPassed. + LinkScope::SourceMustExistInTargets => { + let all_targets: Vec<&VerifiedProofData> = self + .verified .iter() - .filter(|((_, pt), _)| *pt == src_type) - .filter_map(|(_, src)| { + .filter(|((_, pt), _)| *pt == tgt_type) + .flat_map(|(_, entries)| entries) + .collect(); + + if all_targets.is_empty() { + return Vec::new(); + } + + let mut mismatches = Vec::new(); + for ((_, pt), srcs) in &self.verified { + if *pt != src_type { + continue; + } + for src in srcs { let vals = link.extract_source_values(&src.public_signals); if vals.is_empty() { - return None; + continue; } - targets + let found = all_targets .iter() - .any(|tgt| !link.check_consistency(&vals, &tgt.public_signals)) - .then(|| Mismatch { + .any(|tgt| link.check_consistency(&vals, &tgt.public_signals)); + if !found { + mismatches.push(Mismatch { party_id: src.party_id, address: src.address, proof_type: src_type, data_hash: src.data_hash, - }) - }) - .collect() + }); + } + } + } + mismatches } } } @@ -245,8 +329,9 @@ impl Handler> for CommitmentConsistencyCheck let proof_type = data.proof_type; let address = data.address; - self.verified.insert( - (address, proof_type), + self.insert_verified( + address, + proof_type, VerifiedProofData { party_id: data.party_id, address, @@ -274,8 +359,9 @@ impl Handler> for CommitmentCons // Cache each party's proof data for link evaluation. for party in &data.party_proofs { for (proof_type, public_signals, data_hash) in &party.proofs { - self.verified.insert( - (party.address, *proof_type), + self.insert_verified( + party.address, + *proof_type, VerifiedProofData { party_id: party.party_id, address: party.address, @@ -307,8 +393,10 @@ impl Handler> for CommitmentCons // Remove cached entries for inconsistent parties so they don't // participate in future post-ZK `find_mismatches` evaluations. if !inconsistent_parties.is_empty() { - self.verified - .retain(|_, v| !inconsistent_parties.contains(&v.party_id)); + self.verified.retain(|_, entries| { + entries.retain(|v| !inconsistent_parties.contains(&v.party_id)); + !entries.is_empty() + }); } // Respond to ShareVerificationActor. diff --git a/crates/zk-prover/src/actors/commitment_links/c0_to_c3.rs b/crates/zk-prover/src/actors/commitment_links/c0_to_c3.rs new file mode 100644 index 0000000000..ef47552c14 --- /dev/null +++ b/crates/zk-prover/src/actors/commitment_links/c0_to_c3.rs @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! C3a/C3b (ShareEncryption) → C0 (PkBfv) pk_commitment consistency links. +//! +//! Each C3 proof declares an `expected_pk_commitment` (the recipient's +//! individual DKG public key commitment). This link verifies that the +//! declared value matches some C0 proof's `pk_commitment` output. +//! +//! ## Direction +//! +//! Source is C3 (the proof making the claim), target is C0 (the proof that +//! established the commitment). Fault is attributed to the C3 sender if its +//! claimed pk_commitment doesn't match any C0 output. +//! +//! ## Scope +//! +//! `SourceMustExistInTargets` — each C3's `expected_pk_commitment` must +//! appear among the set of C0 `pk_commitment` outputs from any party. + +use super::{CommitmentLink, FieldValue, LinkScope}; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::FIELD_BYTE_LEN; + +/// C3a → C0 pk_commitment consistency link. +pub struct C3aToC0PkCommitmentLink; + +impl CommitmentLink for C3aToC0PkCommitmentLink { + fn name(&self) -> &'static str { + "C3a->C0 pk_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C3aSkShareEncryption + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C0PkBfv + } + + fn scope(&self) -> LinkScope { + LinkScope::SourceMustExistInTargets + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + extract_expected_pk_commitment(public_signals) + } + + fn check_consistency( + &self, + source_values: &[FieldValue], + target_public_signals: &[u8], + ) -> bool { + check_pk_exists_in_c0(source_values, target_public_signals) + } +} + +/// C3b → C0 pk_commitment consistency link. +pub struct C3bToC0PkCommitmentLink; + +impl CommitmentLink for C3bToC0PkCommitmentLink { + fn name(&self) -> &'static str { + "C3b->C0 pk_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C3bESmShareEncryption + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C0PkBfv + } + + fn scope(&self) -> LinkScope { + LinkScope::SourceMustExistInTargets + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + extract_expected_pk_commitment(public_signals) + } + + fn check_consistency( + &self, + source_values: &[FieldValue], + target_public_signals: &[u8], + ) -> bool { + check_pk_exists_in_c0(source_values, target_public_signals) + } +} + +/// Extract `expected_pk_commitment` from C3's public inputs (HEAD of signals). +fn extract_expected_pk_commitment(public_signals: &[u8]) -> Vec { + let layout = CircuitName::ShareEncryption.input_layout(); + let Some(bytes) = layout.extract_field(public_signals, "expected_pk_commitment") else { + return vec![]; + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); + vec![value] +} + +/// Check whether the source's `expected_pk_commitment` matches C0's +/// `pk_commitment` output. +fn check_pk_exists_in_c0(source_values: &[FieldValue], target_public_signals: &[u8]) -> bool { + if source_values.is_empty() { + return false; + } + let layout = CircuitName::PkBfv.output_layout(); + let Some(target_bytes) = layout.extract_field(target_public_signals, "pk_commitment") else { + return false; + }; + target_bytes == source_values[0] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_field(val: u8) -> [u8; 32] { + let mut f = [0u8; 32]; + f[31] = val; + f + } + + /// Build C3 public signals: 2 inputs at HEAD + ciphertext bytes after. + fn c3_signals(expected_pk: [u8; 32], expected_msg: [u8; 32]) -> Vec { + let mut signals = Vec::new(); + signals.extend_from_slice(&expected_pk); + signals.extend_from_slice(&expected_msg); + signals + } + + /// Build C0 public signals: just 1 output (pk_commitment). + fn c0_signals(pk_commitment: [u8; 32]) -> Vec { + pk_commitment.to_vec() + } + + #[test] + fn extract_expected_pk_from_c3() { + let link = C3aToC0PkCommitmentLink; + let pk = make_field(42); + let signals = c3_signals(pk, make_field(99)); + let values = link.extract_source_values(&signals); + assert_eq!(values.len(), 1); + assert_eq!(values[0], pk); + } + + #[test] + fn consistency_passes_when_c3_pk_matches_c0() { + let link = C3aToC0PkCommitmentLink; + let pk = make_field(42); + let source_values = vec![pk]; + let target = c0_signals(pk); + assert!(link.check_consistency(&source_values, &target)); + } + + #[test] + fn consistency_fails_when_c3_pk_doesnt_match_c0() { + let link = C3aToC0PkCommitmentLink; + let source_values = vec![make_field(42)]; + let target = c0_signals(make_field(99)); + assert!(!link.check_consistency(&source_values, &target)); + } + + #[test] + fn consistency_passes_c3b_variant() { + let link = C3bToC0PkCommitmentLink; + let pk = make_field(7); + let source_values = vec![pk]; + let target = c0_signals(pk); + assert!(link.check_consistency(&source_values, &target)); + } + + #[test] + fn consistency_fails_c3b_variant() { + let link = C3bToC0PkCommitmentLink; + let source_values = vec![make_field(7)]; + let target = c0_signals(make_field(8)); + assert!(!link.check_consistency(&source_values, &target)); + } + + #[test] + fn empty_source_is_inconsistent() { + let link = C3aToC0PkCommitmentLink; + assert!(!link.check_consistency(&[], &c0_signals(make_field(1)))); + } + + #[test] + fn short_target_signals_is_inconsistent() { + let link = C3aToC0PkCommitmentLink; + assert!(!link.check_consistency(&[make_field(1)], &[0u8; 16])); + } + + #[test] + fn short_source_signals_returns_empty() { + let link = C3aToC0PkCommitmentLink; + assert!(link.extract_source_values(&[0u8; 16]).is_empty()); + } +} diff --git a/crates/zk-prover/src/actors/commitment_links/mod.rs b/crates/zk-prover/src/actors/commitment_links/mod.rs index 11487bc20e..1741b70a66 100644 --- a/crates/zk-prover/src/actors/commitment_links/mod.rs +++ b/crates/zk-prover/src/actors/commitment_links/mod.rs @@ -12,6 +12,7 @@ //! [`CommitmentConsistencyChecker`](super::commitment_consistency_checker::CommitmentConsistencyChecker) //! evaluates these links as verified proofs arrive. +pub mod c0_to_c3; pub mod c1_to_c5; pub mod c4a_to_c6; pub mod c4b_to_c6; @@ -22,14 +23,23 @@ use e3_events::ProofType; /// A 32-byte BN254 field element extracted from public signals. pub type FieldValue = [u8; 32]; -/// Whether the linked proofs come from the same node or different nodes. +/// How source and target proofs relate and where faults are attributed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LinkScope { /// Both proofs are generated by the same party (same Ethereum address). + /// One source entry is compared to one target entry per address. SameParty, /// The source proof is per-party while the target is from a different - /// party (e.g. per-node C1 vs aggregator C5). + /// party (e.g. per-node C1 vs aggregator C5). Each source's extracted + /// value must appear in at least one target's public signals. + /// Fault is attributed to the source if no target matches. CrossParty, + /// Each source proof claims a value that must exist among the set of + /// target proof outputs (from any party). Fault is attributed to the + /// **source** when no target matches. Used when there are many source + /// proofs (e.g. N-1 C3 proofs per sender) each referencing a different + /// target (e.g. the recipient's C0 pk_commitment). + SourceMustExistInTargets, } /// Defines a cross-circuit commitment consistency check. @@ -62,6 +72,8 @@ pub trait CommitmentLink: Send + Sync { /// Returns the default set of commitment links to register. pub fn default_links() -> Vec> { vec![ + Box::new(c0_to_c3::C3aToC0PkCommitmentLink), + Box::new(c0_to_c3::C3bToC0PkCommitmentLink), Box::new(c1_to_c5::C1ToC5PkCommitmentLink), Box::new(c4a_to_c6::C4aToC6SkCommitmentLink), Box::new(c4b_to_c6::C4bToC6ESmCommitmentLink), diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index c2f4356e98..a18b9b4feb 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use actix::{Actor, Addr, Context, Handler}; +use alloy::primitives::{keccak256, Bytes}; use alloy::signers::local::PrivateKeySigner; +use alloy::sol_types::SolValue; use e3_events::{ AggregationProofPending, AggregationProofSigned, BusHandle, ComputeRequest, ComputeRequestError, ComputeRequestErrorKind, ComputeResponse, ComputeResponseKind, @@ -17,9 +19,10 @@ use e3_events::{ EnclaveEvent, EnclaveEventData, EncryptionKey, EncryptionKeyCreated, EncryptionKeyPending, EventContext, EventPublisher, EventSubscriber, EventType, FailureReason, PkAggregationProofPending, PkAggregationProofRequest, PkAggregationProofSigned, - PkBfvProofRequest, PkGenerationProofSigned, Proof, ProofPayload, ProofType, Sequenced, - ShareDecryptionProofPending, SignedProofFailed, SignedProofPayload, ThresholdShare, - ThresholdShareCreated, ThresholdSharePending, TypedEvent, ZkRequest, ZkResponse, + PkBfvProofRequest, PkGenerationProofSigned, Proof, ProofPayload, ProofType, + ProofVerificationPassed, Sequenced, ShareDecryptionProofPending, SignedProofFailed, + SignedProofPayload, ThresholdShare, ThresholdShareCreated, ThresholdSharePending, TypedEvent, + ZkRequest, ZkResponse, }; use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; @@ -1368,6 +1371,7 @@ impl ProofRequestActor { } } + let local_party_id = key.party_id; if let Err(err) = self.bus.publish( EncryptionKeyCreated { e3_id: e3_id.clone(), @@ -1379,6 +1383,33 @@ impl ProofRequestActor { error!("Failed to publish EncryptionKeyCreated: {err}"); } + // Publish the local node's own C0 as ProofVerificationPassed so the + // CommitmentConsistencyChecker caches it. Without this, C3 proofs from + // other parties that encrypt under this node's pk would fail the C3→C0 + // consistency check (the local C0 target wouldn't exist in the cache). + { + let msg = ( + Bytes::copy_from_slice(&proof.data), + Bytes::copy_from_slice(&proof.public_signals), + ) + .abi_encode(); + let data_hash: [u8; 32] = keccak256(&msg).into(); + + if let Err(err) = self.bus.publish( + ProofVerificationPassed { + e3_id: e3_id.clone(), + party_id: local_party_id, + address: self.signer.address(), + proof_type: ProofType::C0PkBfv, + data_hash, + public_signals: proof.public_signals.clone(), + }, + ec.clone(), + ) { + error!("Failed to publish local C0 ProofVerificationPassed: {err}"); + } + } + // Emit DKGInnerProofReady for C0, or buffer if meta not yet available if let Some(meta) = self.node_agg_meta.get(&e3_id) { if meta.proof_aggregation_enabled {