diff --git a/Cargo.lock b/Cargo.lock index 48b8666def..09d0d9c5d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3051,6 +3051,7 @@ dependencies = [ "async-trait", "bincode 1.3.3", "e3-bfv-client", + "e3-committee-hash", "e3-config", "e3-data", "e3-events", @@ -3108,6 +3109,7 @@ dependencies = [ "e3-multithread", "e3-net", "e3-request", + "e3-slashing", "e3-sortition", "e3-sync", "e3-test-helpers", @@ -3156,6 +3158,7 @@ dependencies = [ "opentelemetry-stdout", "opentelemetry_sdk", "petname", + "phf", "rand 0.8.5", "serde", "serde_json", @@ -3168,6 +3171,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "e3-committee-hash" +version = "0.1.15" +dependencies = [ + "alloy", + "hex", +] + [[package]] name = "e3-compute-provider" version = "0.1.15" @@ -3686,6 +3697,25 @@ dependencies = [ "e3-indexer", ] +[[package]] +name = "e3-slashing" +version = "0.1.15" +dependencies = [ + "actix", + "alloy", + "anyhow", + "async-trait", + "chrono", + "e3-events", + "e3-fhe-params", + "e3-request", + "e3-utils", + "hex", + "serde", + "sha2", + "tracing", +] + [[package]] name = "e3-sortition" version = "0.1.15" @@ -3856,6 +3886,7 @@ dependencies = [ "alloy", "anyhow", "derivative", + "e3-committee-hash", "e3-utils-derive", "hex", "rand 0.8.5", @@ -3935,6 +3966,7 @@ dependencies = [ "bn254_blackbox_solver", "chrono", "directories 5.0.1", + "e3-committee-hash", "e3-config", "e3-data", "e3-events", @@ -3942,6 +3974,7 @@ dependencies = [ "e3-fhe-params", "e3-polynomial", "e3-request", + "e3-slashing", "e3-test-helpers", "e3-utils", "e3-zk-helpers", diff --git a/Cargo.toml b/Cargo.toml index 18d1eef430..9a16241a6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/bfv-client", "crates/ciphernode-builder", "crates/cli", + "crates/committee-hash", "crates/compute-provider", "crates/config", "crates/console", @@ -32,6 +33,7 @@ members = [ "crates/safe", "crates/sdk", "crates/daemon-server", + "crates/slashing", "crates/sortition", "crates/support-scripts", "crates/sync", @@ -87,6 +89,7 @@ e3-data = { version = "0.1.15", path = "./crates/data" } e3-request = { version = "0.1.15", path = "./crates/request" } e3-sdk = { version = "0.1.15", path = "./crates/sdk" } e3-cli = { version = "0.1.15", path = "./crates/cli" } +e3-committee-hash = { version = "0.1.15", path = "./crates/committee-hash" } e3-entrypoint = { version = "0.1.15", path = "./crates/entrypoint" } e3-init = { version = "0.1.15", path = "./crates/init" } e3-events = { version = "0.1.15", path = "./crates/events" } @@ -113,6 +116,7 @@ e3-trbfv = { version = "0.1.15", path = "./crates/trbfv" } e3-utils = { version = "0.1.15", path = "./crates/utils" } e3-utils-derive = { version = "0.1.15", path = "./crates/utils-derive" } e3-safe = { version = "0.1.15", path = "./crates/safe" } +e3-slashing = { version = "0.1.15", path = "./crates/slashing" } e3-zk-prover = { version = "0.1.15", path = "./crates/zk-prover" } e3-zk-helpers = { version = "0.1.15", path = "./crates/zk-helpers" } e3-parity-matrix = { version = "0.1.15", path = "./crates/parity-matrix" } diff --git a/crates/Dockerfile b/crates/Dockerfile index 2724d7cd9c..dbd86839e3 100644 --- a/crates/Dockerfile +++ b/crates/Dockerfile @@ -48,6 +48,7 @@ COPY crates/bfv-client/Cargo.toml ./bfv-client/Cargo.toml COPY crates/cli/Cargo.toml ./cli/Cargo.toml COPY crates/ciphernode-builder/Cargo.toml ./ciphernode-builder/Cargo.toml COPY crates/compute-provider/Cargo.toml ./compute-provider/Cargo.toml +COPY crates/committee-hash/Cargo.toml ./committee-hash/Cargo.toml COPY crates/config/Cargo.toml ./config/Cargo.toml COPY crates/console/Cargo.toml ./console/Cargo.toml COPY crates/crypto/Cargo.toml ./crypto/Cargo.toml @@ -75,6 +76,7 @@ COPY crates/program-server/Cargo.toml ./program-server/Cargo.toml COPY crates/request/Cargo.toml ./request/Cargo.toml COPY crates/safe/Cargo.toml ./safe/Cargo.toml COPY crates/sdk/Cargo.toml ./sdk/Cargo.toml +COPY crates/slashing/Cargo.toml ./slashing/Cargo.toml COPY crates/sortition/Cargo.toml ./sortition/Cargo.toml COPY crates/dashboard/Cargo.toml ./dashboard/Cargo.toml COPY crates/daemon-server/Cargo.toml ./daemon-server/Cargo.toml diff --git a/crates/aggregator/Cargo.toml b/crates/aggregator/Cargo.toml index 4204aee146..3c4159641f 100644 --- a/crates/aggregator/Cargo.toml +++ b/crates/aggregator/Cargo.toml @@ -24,6 +24,7 @@ e3-trbfv = { workspace = true } fhe-math = { workspace = true } num-bigint = { workspace = true } e3-bfv-client = { workspace = true } +e3-committee-hash = { workspace = true } e3-request = { workspace = true } e3-sortition = { workspace = true } e3-zk-helpers = { workspace = true } diff --git a/crates/aggregator/src/committee.rs b/crates/aggregator/src/committee.rs index 4f92e0f8b3..ed13bea53f 100644 --- a/crates/aggregator/src/committee.rs +++ b/crates/aggregator/src/committee.rs @@ -46,7 +46,7 @@ pub fn committee_addresses_in_party_order( mod tests { use super::*; use alloy::primitives::address; - use e3_utils::committee_hash::hash_committee_addresses; + use e3_committee_hash::hash_committee_addresses; #[test] fn party_order_differs_from_address_sorted_set() { diff --git a/crates/aggregator/src/committee_hash.rs b/crates/aggregator/src/committee_hash.rs index 9fa41132bf..42ac66c263 100644 --- a/crates/aggregator/src/committee_hash.rs +++ b/crates/aggregator/src/committee_hash.rs @@ -4,4 +4,4 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -pub use e3_utils::committee_hash::*; +pub use e3_committee_hash::*; diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 5bd76cb39c..c32c3bdf4c 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -1035,7 +1035,7 @@ impl PublicKeyAggregator { let dkg_attestation_bundle = match dkg_aggregated_proof.as_ref() { Some(_) => { - let bundle = e3_evm::encode_dkg_attestation_bundle( + let bundle = e3_zk_prover::encode_dkg_attestation_bundle( &honest_party_ids, &party_nodes, &dkg_fold_attestations, diff --git a/crates/ciphernode-builder/Cargo.toml b/crates/ciphernode-builder/Cargo.toml index 8e19b71d3d..d1739b5e6d 100644 --- a/crates/ciphernode-builder/Cargo.toml +++ b/crates/ciphernode-builder/Cargo.toml @@ -23,6 +23,7 @@ e3-fhe-params.workspace = true e3-keyshare.workspace = true e3-multithread.workspace = true e3-net.workspace = true +e3-slashing.workspace = true e3-zk-prover.workspace = true e3-request.workspace = true e3-sortition.workspace = true diff --git a/crates/ciphernode-builder/src/ciphernode.rs b/crates/ciphernode-builder/src/ciphernode.rs index ddf1bfa7f9..f4c59eba32 100644 --- a/crates/ciphernode-builder/src/ciphernode.rs +++ b/crates/ciphernode-builder/src/ciphernode.rs @@ -11,17 +11,42 @@ use e3_events::{BusHandle, EnclaveEvent, HistoryCollector}; use e3_net::NetChannelBridge; use libp2p::PeerId; -/// A Sharable handle to a Ciphernode. NOTE: clones are available for use in the CiphernodeSystem -/// but they cannot await the task. +/// The kind of network interface backing a ciphernode. +#[derive(Debug, Clone)] +pub enum NetInterfaceKind { + /// Real libp2p networking (production). + Libp2p, + /// In-process channel bridge (tests / benchmarks). + ChannelBridge(NetChannelBridge), +} + +impl NetInterfaceKind { + /// Extract the channel bridge, failing if this is a libp2p interface. + pub fn into_channel_bridge(self) -> Result { + match self { + NetInterfaceKind::ChannelBridge(bridge) => Ok(bridge), + NetInterfaceKind::Libp2p => Err(anyhow::anyhow!( + "No channel bridge exists — node is using libp2p networking" + )), + } + } +} + +/// A sharable handle to a Ciphernode. Clones are available for use in the +/// CiphernodeSystem but they cannot await the task. #[derive(Debug)] pub struct CiphernodeHandle { pub address: String, pub store: DataStore, pub bus: BusHandle, + /// Optional event history collector. Populated when the builder is configured + /// with [`CiphernodeBuilder::with_history_collector`]. pub history: Option>>, + /// Optional error event collector. Populated when the builder is configured + /// with [`CiphernodeBuilder::with_error_collector`]. pub errors: Option>>, pub peer_id: PeerId, - pub channel_bridge: Option, + pub net_interface: NetInterfaceKind, } impl PartialEq for CiphernodeHandle { @@ -40,7 +65,7 @@ impl CiphernodeHandle { history: Option>>, errors: Option>>, peer_id: PeerId, - channel_bridge: Option, + net_interface: NetInterfaceKind, ) -> Self { Self { address, @@ -49,7 +74,7 @@ impl CiphernodeHandle { history, errors, peer_id, - channel_bridge, + net_interface, } } @@ -73,18 +98,17 @@ impl CiphernodeHandle { &self.store } + /// Extract the channel bridge for test network simulation. + /// Returns an error if the node is using libp2p networking. pub fn channel_bridge(&self) -> Result { - Ok(self.channel_bridge.clone().ok_or(anyhow::anyhow!( - "No channel bridge exists. We are likely not in test mode" - ))?) + self.net_interface.clone().into_channel_bridge() } pub fn in_mem_store(&self) -> Option<&Addr> { let addr = self.store.get_addr(); if let StoreAddr::InMem(ref store) = addr { return Some(store); - }; - + } None } } diff --git a/crates/ciphernode-builder/src/ciphernode_builder.rs b/crates/ciphernode-builder/src/ciphernode_builder.rs index a3ae8c55d0..231fcfec11 100644 --- a/crates/ciphernode-builder/src/ciphernode_builder.rs +++ b/crates/ciphernode-builder/src/ciphernode_builder.rs @@ -4,10 +4,12 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use crate::{CiphernodeHandle, EventSystem, EvmSystemChainBuilder, ProviderCache, WriteEnabled}; +use crate::{ + CiphernodeHandle, EventSystem, EvmSystemChainBuilder, NetInterfaceKind, ProviderCache, + WriteEnabled, +}; use actix::{Actor, Addr}; use alloy::primitives::Address; -use alloy::signers::local::PrivateKeySigner; use anyhow::Result; use derivative::Derivative; use e3_aggregator::ext::{PublicKeyAggregatorExtension, ThresholdPlaintextAggregatorExtension}; @@ -31,6 +33,7 @@ use e3_net::{ NetRepositoryFactory, }; use e3_request::E3Router; +use e3_slashing::{AccusationManagerExtension, CommitmentConsistencyCheckerExtension}; use e3_sortition::{ CiphernodeSelector, CiphernodeSelectorFactory, EmitPersistedAggregatorState, FinalizedCommitteesRepositoryFactory, NodeStateRepositoryFactory, Sortition, SortitionBackend, @@ -38,9 +41,7 @@ use e3_sortition::{ }; use e3_sync::sync; use e3_utils::SharedRng; -use e3_zk_prover::{ - setup_zk_actors, AccusationManagerExtension, CommitmentConsistencyCheckerExtension, ZkBackend, -}; +use e3_zk_prover::{setup_zk_actors, ZkBackend}; use libp2p::PeerId; use std::time::Duration; use std::{collections::HashMap, path::PathBuf, sync::Arc}; @@ -53,9 +54,10 @@ enum EventSystemType { } /// Build a ciphernode configuration. -// NOTE: We could use a typestate pattern here to separate production and testing methods. I hummed -// and hawed about it for quite a while and in the end felt it was too complex while we dont know -// the exact configurations we will use yet +/// +/// Follows a builder pattern. Production nodes are assembled via +/// [`entrypoint::start`](e3_entrypoint::start); tests and benchmarks use the same +/// builder with in-memory stores and forked buses. #[derive(Derivative)] #[derivative(Debug)] pub struct CiphernodeBuilder { @@ -75,19 +77,16 @@ pub struct CiphernodeBuilder { rng: SharedRng, sortition_backend: SortitionBackend, source_bus: Option>>>, - testmode_errors: bool, - testmode_history: bool, task_pool: Option, threads: Option, - testmode_signer: Option, + signer: Option, threshold_plaintext_agg: bool, zk_backend: Option, - /// Test/benchmark: EIP-712 verifying contract for accusation votes (no RPC required). - slashing_manager: Option
, net_config: Option, - ignore_address_check: bool, global_shared_store: bool, global_shared_eventstore: bool, + collect_history: bool, + collect_errors: bool, } // Simple Net Configuration @@ -125,10 +124,6 @@ pub enum KeyshareKind { impl CiphernodeBuilder { /// Create a new ciphernode builder. - /// - /// - name - Unique name for the ciphernode - /// - rng - Arc Mutex wrapped random number generator - /// - cipher - Cipher for encryption and decryption of sensitive data pub fn new(rng: SharedRng, cipher: Arc) -> Self { Self { address: None, @@ -146,18 +141,16 @@ impl CiphernodeBuilder { rng, sortition_backend: SortitionBackend::score(), source_bus: None, - testmode_errors: false, - testmode_history: false, task_pool: None, threads: None, - testmode_signer: None, + signer: None, threshold_plaintext_agg: false, - slashing_manager: None, net_config: None, zk_backend: None, - ignore_address_check: false, global_shared_store: false, global_shared_eventstore: false, + collect_history: false, + collect_errors: false, } } @@ -167,9 +160,11 @@ impl CiphernodeBuilder { self } - /// Fork all events from the given source bus. Events will be both broadcast on the source bus - /// and a local bus created for this instance - pub fn testmode_with_forked_bus(mut self, bus: &Addr>) -> Self { + /// Fork events from the given source bus to a local bus. Events from the + /// source are forwarded to the local bus created for this instance. + /// Useful for tests and monitoring subscribers that need an isolated + /// event stream that mirrors the source. + pub fn with_forked_bus(mut self, bus: &Addr>) -> Self { self.source_bus = Some(BusMode::Forked(bus.clone())); self } @@ -186,6 +181,20 @@ impl CiphernodeBuilder { self } + /// Subscribe a [`HistoryCollector`] to the event bus for inspecting all events. + /// Useful for tests, benchmarks, and debugging. + pub fn with_history_collector(mut self) -> Self { + self.collect_history = true; + self + } + + /// Subscribe a [`HistoryCollector`] to only `EnclaveError` events. + /// Useful for tests and debugging. + pub fn with_error_collector(mut self) -> Self { + self.collect_errors = true; + self + } + /// Add persistence information for storing events and data. Without persistence information /// the node will run in memory by default. pub fn with_persistence(mut self, log_path: &PathBuf, kv_path: &PathBuf) -> Self { @@ -196,20 +205,6 @@ impl CiphernodeBuilder { self } - /// Attach a history collecting test module. - /// This is conspicuously named so we understand that this should only be used when testing - pub fn testmode_with_history(mut self) -> Self { - self.testmode_history = true; - self - } - - /// Attach an error collecting test module - /// This is conspicuously named so we understand that this should only be used when testing - pub fn testmode_with_errors(mut self) -> Self { - self.testmode_errors = true; - self - } - /// Use the node configuration on these specific chains. This will overwrite any previously /// given chains. pub fn with_chains(mut self, chains: &[ChainConfig]) -> Self { @@ -217,20 +212,12 @@ impl CiphernodeBuilder { self } - /// Benchmark/test: set slashing manager address (EIP-712 verifyingContract for - /// accusation votes) without configuring EVM chains (no RPC). - pub fn testmode_with_slashing_manager(mut self, slashing_manager: Address) -> Self { - self.slashing_manager = Some(slashing_manager); - self - } - + /// Resolve the slashing manager address from ChainConfig. All chains are + /// checked (enabled or not) since this is just config, not RPC-dependent. fn resolve_slashing_manager(&self) -> Result
{ - if let Some(addr) = self.slashing_manager { - return Ok(addr); - } self.chains - .first() - .and_then(|c| c.contracts.slashing_manager.as_ref()) + .iter() + .find_map(|c| c.contracts.slashing_manager.as_ref()) .map(|c| c.address()) .transpose()? .ok_or_else(|| { @@ -242,7 +229,7 @@ impl CiphernodeBuilder { } /// Fetch `CiphernodeRegistry.dkgFoldAttestationVerifier()` for one chain (EIP-712 verifying contract). - async fn fetch_dkg_fold_attestation_verifier_from_registry( + async fn fetch_fold_verifier( provider_cache: &mut ProviderCache, chain: &ChainConfig, ) -> Result> { @@ -392,9 +379,9 @@ impl CiphernodeBuilder { } /// Pre-populate the signer cache with the given signer. - /// This is conspicuously named so we understand that this should only be used when testing. - pub fn testmode_with_signer(mut self, signer: PrivateKeySigner) -> Self { - self.testmode_signer = Some(signer); + /// The signer is used for EVM transactions and EIP-712 signatures. + pub fn with_signer(mut self, signer: alloy::signers::local::PrivateKeySigner) -> Self { + self.signer = Some(signer); self } @@ -452,11 +439,6 @@ impl CiphernodeBuilder { self } - pub fn testmode_ignore_address_check(mut self) -> Self { - self.ignore_address_check = true; - self - } - fn create_local_bus() -> Addr> { EventBus::::new(EventBusConfig { deduplicate: true }).start() } @@ -467,7 +449,7 @@ impl CiphernodeBuilder { provider_cache: &mut ProviderCache, ) -> Result { let mut chain_providers = Vec::new(); - for chain in &self.chains { + for chain in self.chains.iter().filter(|c| c.enabled.unwrap_or(true)) { let provider = provider_cache.ensure_read_provider(chain).await?; chain_providers.push((chain.clone(), provider.chain_id())); } @@ -477,139 +459,202 @@ impl CiphernodeBuilder { } pub async fn build(mut self) -> anyhow::Result { - // Local bus for ciphernode events can either be forked from a bus or it can be directly - // attached to a source bus - let local_bus = match self.source_bus { - // Forked bus - pipe all events from the source to dest - Some(BusMode::Forked(ref bus)) => { - let local_bus = Self::create_local_bus(); - info!("Setting up Event pipe"); - EventBus::pipe(&bus, &local_bus); - local_bus - } - // Source bus - simply attach to the source bus - Some(BusMode::Source(ref bus)) => bus.clone(), - // Nothing specified - None => Self::create_local_bus(), - }; + let local_bus = self.resolve_bus(); - // History collector for taking historical events for analysis and testing - let history = if self.testmode_history { + // Optional event collectors for debugging / testing. + let history = if self.collect_history { info!("Setting up history collector"); Some(EventBus::::history(&local_bus)) } else { None }; - - // Error collector for taking historical events for analysis and testing - let errors = if self.testmode_errors { + let errors = if self.collect_errors { info!("Setting up error collector"); Some(EventBus::::error(&local_bus)) } else { None }; - // Create provider cache early to use for chain validation - let mut provider_cache = if let Some(signer) = self.testmode_signer.take() { + // Create provider cache and aggregate config + let mut provider_cache = if let Some(signer) = self.signer.take() { ProviderCache::new().with_signer(signer) } else { ProviderCache::new() }; let aggregate_config = self.create_aggregate_config(&mut provider_cache).await?; - // Get an event system instance. - let event_system = - if let EventSystemType::Persisted { kv_path, log_path } = self.event_system.clone() { - EventSystem::persisted(log_path, kv_path) - .with_event_bus(local_bus) - .with_aggregate_config(aggregate_config.clone()) - .with_global_shared_store(self.global_shared_store) - .with_global_shared_eventstore(self.global_shared_eventstore) - } else { - if let Some(ref store) = self.in_mem_store { - EventSystem::in_mem_from_store(store) - .with_event_bus(local_bus) - .with_aggregate_config(aggregate_config.clone()) - .with_global_shared_store(self.global_shared_store) - .with_global_shared_eventstore(self.global_shared_eventstore) - } else { - EventSystem::in_mem() - .with_event_bus(local_bus) - .with_aggregate_config(aggregate_config.clone()) - .with_global_shared_store(self.global_shared_store) - .with_global_shared_eventstore(self.global_shared_eventstore) - } - }; + // Build the event system (store + eventstore) + let event_system = self.create_event_system(local_bus, &aggregate_config); let store = event_system.store()?; let eventstore = event_system.eventstore_reader()?; - let cipher = &self.cipher; let repositories = Arc::new(store.repositories()); let mut provider_cache = - provider_cache.with_write_support(Arc::clone(cipher), Arc::clone(&repositories)); + provider_cache.with_write_support(Arc::clone(&self.cipher), Arc::clone(&repositories)); - // We need to supply the Hlc to the bus handle in order to enable it + // Resolve node address and enable the bus let addr = provider_cache.ensure_signer().await?.address().to_string(); let bus = event_system.handle()?.enable(&addr); - // Use the configured sortition backend directly - let default_backend = self.sortition_backend.clone(); + // Setup sortition + let (sortition, ciphernode_selector) = + self.setup_sortition(&bus, &repositories, &addr).await?; - let ciphernode_selector = - CiphernodeSelector::attach(&bus, repositories.ciphernode_selector(), &addr).await?; + // Setup EVM contract event listeners + let evm_config = self.setup_evm_system(&mut provider_cache, &bus).await?; - let sortition = Sortition::attach( + // Fetch on-chain ZK/slashing configuration + let (dkg_fold_verifier_by_chain, accusation_vote_validity_by_chain) = + self.fetch_chain_configuration(&mut provider_cache).await?; + + // Setup protocol extensions (keyshare, aggregation, ZK, accusation, commitment) + let e3_builder = self + .setup_extensions( + &bus, + store.clone(), + &mut provider_cache, + &sortition, + &addr, + &dkg_fold_verifier_by_chain, + &accusation_vote_validity_by_chain, + ) + .await?; + + e3_builder.build().await?; + ciphernode_selector.do_send(EmitPersistedAggregatorState); + + // Setup networking + let topic = "enclave-gossip"; + let (peer_id, interface, net_kind) = self.setup_networking(&store, topic).await?; + setup_net(topic, bus.clone(), eventstore.ts(), interface)?; + + // Run the sync routine + sync( &bus, + &evm_config, + &repositories, + &aggregate_config, + &eventstore.seq(), + ) + .await?; + + Ok(CiphernodeHandle::new( + addr.to_owned(), + store, + bus, + history, + errors, + peer_id, + net_kind, + )) + } + + // ── build() sub-functions ────────────────────────────────────────── + + fn resolve_bus(&self) -> Addr> { + match self.source_bus { + Some(BusMode::Forked(ref bus)) => { + let local_bus = Self::create_local_bus(); + info!("Setting up Event pipe"); + EventBus::pipe(bus, &local_bus); + local_bus + } + Some(BusMode::Source(ref bus)) => bus.clone(), + None => Self::create_local_bus(), + } + } + + fn create_event_system( + &self, + bus: Addr>, + aggregate_config: &AggregateConfig, + ) -> EventSystem { + let base = match self.event_system.clone() { + EventSystemType::Persisted { kv_path, log_path } => { + EventSystem::persisted(log_path, kv_path) + } + EventSystemType::InMem => { + if let Some(ref store) = self.in_mem_store { + EventSystem::in_mem_from_store(store) + } else { + EventSystem::in_mem() + } + } + }; + base.with_event_bus(bus) + .with_aggregate_config(aggregate_config.clone()) + .with_global_shared_store(self.global_shared_store) + .with_global_shared_eventstore(self.global_shared_eventstore) + } + + async fn setup_sortition( + &self, + bus: &BusHandle, + repositories: &e3_data::Repositories, + addr: &str, + ) -> Result<(Addr, Addr)> { + let ciphernode_selector = + CiphernodeSelector::attach(bus, repositories.ciphernode_selector(), addr).await?; + let sortition = Sortition::attach( + bus, repositories.sortition(), repositories.node_state(), repositories.finalized_committees(), - default_backend, + self.sortition_backend.clone(), ciphernode_selector.clone(), - &addr, + addr, ) .await?; + Ok((sortition, ciphernode_selector)) + } - // Setup evm system - // TODO: gather an async handle from the event readers in thre following function - // that closes when they shutdown and join it with the network manager joinhandle externally - let evm_config = setup_evm_system( + async fn setup_evm_system( + &self, + provider_cache: &mut ProviderCache, + bus: &BusHandle, + ) -> Result { + setup_evm_system( &self.chains, - &mut provider_cache, - &bus, + provider_cache, + bus, &self.contract_components, self.pubkey_agg, ) - .await?; + .await + } + + /// Fetch DKG fold attestation verifier and accusation vote validity from on-chain + /// registries. Requires enabled chains with RPC configured. + async fn fetch_chain_configuration( + &self, + provider_cache: &mut ProviderCache, + ) -> Result<(HashMap>, HashMap)> { + let needs_zk = self.keyshare.is_some() || (self.pubkey_agg && self.keyshare.is_none()); - let needs_zk_actors = - self.keyshare.is_some() || (self.pubkey_agg && self.keyshare.is_none()); let mut dkg_fold_verifier_by_chain: HashMap> = HashMap::new(); - if needs_zk_actors { - if !self.chains.is_empty() { - for chain in self.chains.iter().filter(|c| c.enabled.unwrap_or(true)) { - let provider = provider_cache.ensure_read_provider(chain).await?; - let chain_id = provider.chain_id(); - validate_chain_id(chain, chain_id)?; - let verifier = Self::fetch_dkg_fold_attestation_verifier_from_registry( - &mut provider_cache, - chain, - ) - .await?; - dkg_fold_verifier_by_chain.insert(chain_id, verifier); - } - } else { - // In-process benchmark harness (no EVM chains): optional env override. - if let Some(verifier) = std::env::var("BENCHMARK_DKG_FOLD_ATTESTATION_VERIFIER") - .ok() - .and_then(|s| s.parse().ok()) - { - dkg_fold_verifier_by_chain.insert(benchmark_default_chain_id(), Some(verifier)); + if needs_zk { + for chain in self.chains.iter().filter(|c| c.enabled.unwrap_or(true)) { + let provider = provider_cache.ensure_read_provider(chain).await?; + let chain_id = provider.chain_id(); + validate_chain_id(chain, chain_id)?; + let verifier = Self::fetch_fold_verifier(provider_cache, chain).await?; + dkg_fold_verifier_by_chain.insert(chain_id, verifier); + } + // For disabled chains with a statically-configured verifier address (e.g. benchmark), + // populate from config so ZK actors can function without an RPC connection. + for chain in self.chains.iter().filter(|c| !c.enabled.unwrap_or(true)) { + let Some(chain_id) = chain.chain_id else { + continue; + }; + if let Some(ref contract) = chain.contracts.dkg_fold_attestation_verifier { + if let Ok(addr) = contract.address() { + dkg_fold_verifier_by_chain + .entry(chain_id) + .or_insert(Some(addr)); + } } } } - // Off-chain freshness window for accusation votes — fetched per chain so - // AccusationManagerExtension can look up by `e3_id.chain_id()`. Benchmark - // harness falls back to the env var so deterministic builds don't require RPC. let mut accusation_vote_validity_by_chain: HashMap = HashMap::new(); if !self.chains.is_empty() { for chain in self.chains.iter().filter(|c| c.enabled.unwrap_or(true)) { @@ -617,54 +662,57 @@ impl CiphernodeBuilder { let chain_id = provider.chain_id(); validate_chain_id(chain, chain_id)?; let validity = - Self::fetch_accusation_vote_validity_from_registry(&mut provider_cache, chain) + Self::fetch_accusation_vote_validity_from_registry(provider_cache, chain) .await?; accusation_vote_validity_by_chain.insert(chain_id, validity); } - } else { - let validity = std::env::var("BENCHMARK_ACCUSATION_VOTE_VALIDITY_SECS") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - accusation_vote_validity_by_chain.insert(benchmark_default_chain_id(), validity); } - // E3 specific setup - let mut e3_builder = E3Router::builder(&bus, store.clone()); + Ok(( + dkg_fold_verifier_by_chain, + accusation_vote_validity_by_chain, + )) + } + #[allow(clippy::too_many_arguments)] + async fn setup_extensions( + &mut self, + bus: &BusHandle, + store: e3_data::DataStore, + provider_cache: &mut ProviderCache, + sortition: &Addr, + addr: &str, + dkg_fold_verifier_by_chain: &HashMap>, + accusation_vote_validity_by_chain: &HashMap, + ) -> Result { + let mut e3_builder = E3Router::builder(bus, store.clone()); + + // ── Threshold keyshare + ZK actors ── if let Some(KeyshareKind::Threshold) = self.keyshare { - let _ = self.ensure_multithread(&bus); + let _ = self.ensure_multithread(bus); let backend = self .zk_backend .as_ref() .ok_or_else(|| anyhow::anyhow!("ZK backend is required for threshold keyshare"))?; - backend.ensure_installed().await?; - - // Ensure signer is available before setting up extensions that need it - let signer = provider_cache.ensure_signer().await?; + let _signer = provider_cache.ensure_signer().await?; info!("Setting up ThresholdKeyshareExtension"); - e3_builder = e3_builder.with(ThresholdKeyshareExtension::create( - &bus, - &self.cipher, - &addr, - )); + e3_builder = + e3_builder.with(ThresholdKeyshareExtension::create(bus, &self.cipher, addr)); info!("Setting up ZK actors"); - setup_zk_actors(&bus, backend, signer, dkg_fold_verifier_by_chain.clone()); + setup_zk_actors(bus, backend, _signer, dkg_fold_verifier_by_chain.clone()); } + // ── Public key aggregation ── if self.pubkey_agg { info!("Setting up FheExtension"); - e3_builder = e3_builder.with(FheExtension::create(&bus, &self.rng)) - } + e3_builder = e3_builder.with(FheExtension::create(bus, &self.rng)); - if self.pubkey_agg { info!("Setting up PublicKeyAggregationExtension"); - // Ensure multithread worker is available for C1 verification and C5 proof generation - let _ = self.ensure_multithread(&bus); - e3_builder = e3_builder.with(PublicKeyAggregatorExtension::create(&bus)); + let _ = self.ensure_multithread(bus); + e3_builder = e3_builder.with(PublicKeyAggregatorExtension::create(bus)); if self.keyshare.is_none() { let backend = self @@ -673,36 +721,22 @@ impl CiphernodeBuilder { .ok_or_else(|| anyhow::anyhow!("ZK backend is required for aggregator"))?; let signer = provider_cache.ensure_signer().await?; info!("Setting up ZK actors for aggregator"); - setup_zk_actors(&bus, backend, signer, dkg_fold_verifier_by_chain.clone()); + setup_zk_actors(bus, backend, signer, dkg_fold_verifier_by_chain.clone()); } } + // ── Threshold plaintext aggregation ── if self.threshold_plaintext_agg { info!("Setting up ThresholdPlaintextAggregatorExtension"); - let _ = self.ensure_multithread(&bus); + let _ = self.ensure_multithread(bus); e3_builder = e3_builder.with(ThresholdPlaintextAggregatorExtension::create( - &bus, &sortition, - )) + bus, sortition, + )); } - // Clock-skew allowance for peer accusation deadlines. - let accusation_deadline_skew_secs = match std::env::var("ACCUSATION_DEADLINE_SKEW_SECS") { - Ok(raw) => match raw.parse::() { - Ok(v) => v, - Err(err) => { - warn!( - value = %raw, - error = %err, - "invalid ACCUSATION_DEADLINE_SKEW_SECS; falling back to default" - ); - 30 - } - }, - Err(_) => 30, - }; - - // AccusationManager extension — per-E3 fault attribution quorum + // ── Accusation manager ── { + let accusation_deadline_skew_secs = parse_env_u64("ACCUSATION_DEADLINE_SKEW_SECS", 30); let signer = provider_cache.ensure_signer().await?; let slashing_manager_addr = self.resolve_slashing_manager()?; info!( @@ -710,74 +744,60 @@ impl CiphernodeBuilder { accusation_deadline_skew_secs, "Setting up AccusationManagerExtension" ); e3_builder = e3_builder.with(AccusationManagerExtension::create( - &bus, + bus, signer, slashing_manager_addr, - accusation_vote_validity_by_chain, + accusation_vote_validity_by_chain.clone(), accusation_deadline_skew_secs, )); } - // CommitmentConsistencyChecker extension — per-E3 cross-circuit commitment validation + // ── Commitment consistency checker ── { info!("Setting up CommitmentConsistencyCheckerExtension"); - e3_builder = e3_builder.with(CommitmentConsistencyCheckerExtension::create(&bus)); + e3_builder = e3_builder.with(CommitmentConsistencyCheckerExtension::create( + bus, + |preset| e3_zk_prover::default_links(preset), + )); } - info!("E3Router building..."); - - e3_builder.build().await?; - ciphernode_selector.do_send(EmitPersistedAggregatorState); + Ok(e3_builder) + } - let topic = "enclave-gossip"; - let (peer_id, interface, channel_bridge) = if let Some(net_config) = self.net_config { - // Setup real net interface + async fn setup_networking( + &self, + store: &e3_data::DataStore, + topic: &str, + ) -> Result<(PeerId, e3_net::NetInterfaceHandle, NetInterfaceKind)> { + if let Some(ref net_config) = self.net_config { let repositories = store.repositories(); let keypair = setup_libp2p_keypair(repositories.libp2p_keypair(), &self.cipher).await?; let peer_id = keypair.peer_id(); - let interface = - setup_net_interface(topic, keypair, net_config.peers, net_config.quic_port)?; - (peer_id, interface, None) + let interface = setup_net_interface( + topic, + keypair, + net_config.peers.clone(), + net_config.quic_port, + )?; + Ok((peer_id, interface, NetInterfaceKind::Libp2p)) } else { - // Setup test net interface with random PeerId let (interface, channel_bridge) = create_channel_bridge(); let peer_id = PeerId::random(); - let channel_bridge = Some(channel_bridge); - (peer_id, interface, channel_bridge) - }; - - setup_net(topic, bus.clone(), eventstore.ts(), interface)?; - - // Run the sync routine - sync( - &bus, - &evm_config, - &repositories, - &aggregate_config, - &eventstore.seq(), - ) - .await?; - - Ok(CiphernodeHandle::new( - addr.to_owned(), - store, - bus, - history, - errors, - peer_id, - channel_bridge, - )) + Ok(( + peer_id, + interface, + NetInterfaceKind::ChannelBridge(channel_bridge), + )) + } } fn ensure_multithread(&mut self, bus: &BusHandle) -> Addr { - // If we have it cached return it if let Some(cached) = self.multithread_cache.clone() { return cached; } info!("Setting up multithread actor..."); - // Setup threadpool if not set let task_pool = self.task_pool.clone().unwrap_or_else(|| { let pool_threads = self.threads.unwrap_or(1); let concurrent_jobs = self.multithread_concurrent_jobs.unwrap_or(1); @@ -785,7 +805,6 @@ impl CiphernodeBuilder { Multithread::create_taskpool(pool_threads, concurrent_jobs) }); - // Create it with or without ZK prover let addr = if let Some(ref backend) = self.zk_backend { info!("Multithread actor with ZK prover"); Multithread::attach_with_zk( @@ -806,17 +825,28 @@ impl CiphernodeBuilder { ) }; - // Set the cache self.multithread_cache = Some(addr.clone()); - - // return it addr } } -/// Chain id used by in-process benchmark harnesses when no EVM chains are configured. -fn benchmark_default_chain_id() -> u64 { - 1 +/// Parse a `u64` env var, returning `default_val` on any error. +fn parse_env_u64(name: &str, default_val: u64) -> u64 { + match std::env::var(name) { + Ok(raw) => match raw.parse::() { + Ok(v) => v, + Err(err) => { + warn!( + value = %raw, + error = %err, + "invalid {}; falling back to default ({})", + name, default_val + ); + default_val + } + }, + Err(_) => default_val, + } } /// Validate chain ID matches expected configuration diff --git a/crates/ciphernode-builder/src/event_system.rs b/crates/ciphernode-builder/src/event_system.rs index 3e1eb01489..5e124a9a11 100644 --- a/crates/ciphernode-builder/src/event_system.rs +++ b/crates/ciphernode-builder/src/event_system.rs @@ -16,8 +16,8 @@ use e3_data::{ use e3_events::hlc_factory::HlcFactory; use e3_events::{ AggregateConfig, BusHandle, Disabled, EnclaveEvent, EventBus, EventBusConfig, EventStore, - EventStoreQueryBy, EventStoreRouter, EventSubscriber, EventType, InsertBatch, SeqAgg, - Sequencer, SnapshotBuffer, StoreEventRequested, TsAgg, UpdateDestination, + EventStoreQueryBy, EventStoreRouter, EventSubscriber, EventType, InsertBatch, Sequencer, + SnapshotBuffer, StoreEventRequested, UpdateDestination, }; use e3_utils::enumerate_path; use once_cell::sync::OnceCell; diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 6062c2c679..c40224e62d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -42,6 +42,7 @@ opentelemetry-otlp = { workspace = true } opentelemetry-stdout = { workspace = true } opentelemetry_sdk = { workspace = true } petname = { workspace = true } +phf = { workspace = true } rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -52,3 +53,6 @@ tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } zeroize = { workspace = true } + +[build-dependencies] +serde_json = { workspace = true } diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 23239d653c..227aa70996 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -4,13 +4,22 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use serde_json::{from_reader, Value}; +use std::env; +use std::fs::{self, File}; +use std::path::Path; use std::process::Command; -fn main() { +fn main() -> std::io::Result<()> { + generate_git_sha(); + generate_contract_deployments()?; + Ok(()) +} + +fn generate_git_sha() { let git_sha = if let Ok(sha) = std::env::var("GIT_SHA") { sha } else { - // Try to get local git SHA first let output = Command::new("git") .args(&["rev-parse", "--short=9", "HEAD"]) .output(); @@ -22,12 +31,8 @@ fn main() { _ => get_remote_commit_hash().unwrap_or_else(|| "unknown".to_string()), } }; - - // Set environment variable for compilation println!("cargo:rustc-env=GIT_SHA={}", git_sha); println!("cargo:rerun-if-env-changed=GIT_SHA"); - - // Rebuild if git HEAD changes println!("cargo:rerun-if-changed=.git/HEAD"); } @@ -59,3 +64,63 @@ fn get_remote_commit_hash() -> Option { Some(commit_hash) } } + +fn generate_contract_deployments() -> std::io::Result<()> { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let deployments_path = Path::new(&manifest_dir) + .join("..") + .join("..") + .join("packages") + .join("enclave-contracts") + .join("deployed_contracts.json"); + + let mut contract_info = String::from( + "pub struct ContractInfo {\n pub address: &'static str,\n pub deploy_block: u64,\n}\n\n" + ); + contract_info.push_str( + "pub static CONTRACT_DEPLOYMENTS: phf::Map<&'static str, ContractInfo> = phf::phf_map! {\n", + ); + + let file = File::open(&deployments_path)?; + let json: Value = from_reader(file)?; + + let mut contract_count = 0u32; + if let Some(networks) = json.as_object() { + if let Some(sepolia_data) = networks.get("sepolia") { + if let Some(contracts) = sepolia_data.as_object() { + for (contract_name, contract_data) in contracts { + if let (Some(address), Some(deploy_block)) = ( + contract_data["address"].as_str(), + contract_data["blockNumber"].as_u64(), + ) { + contract_info.push_str(&format!( + " \"{}\" => ContractInfo {{\n address: \"{}\",\n deploy_block: {},\n }},\n", + contract_name, address, deploy_block + )); + contract_count += 1; + } else { + panic!( + "Contract '{}' in deployed_contracts.json is missing 'address' or 'blockNumber'", + contract_name + ); + } + } + } + } + } + + if contract_count == 0 { + panic!( + "No contracts found in deployed_contracts.json — \ + expected a 'sepolia' key with contract entries containing 'address' and 'blockNumber'" + ); + } + + contract_info.push_str("};\n"); + + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("contract_deployments.rs"); + fs::write(dest_path, contract_info)?; + println!("cargo:rerun-if-changed=../../packages/enclave-contracts/deployed_contracts.json"); + Ok(()) +} diff --git a/crates/cli/src/ciphernode/setup.rs b/crates/cli/src/ciphernode/setup.rs index a35f893af3..ce2dee2d85 100644 --- a/crates/cli/src/ciphernode/setup.rs +++ b/crates/cli/src/ciphernode/setup.rs @@ -4,12 +4,12 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use crate::config_setup as setup; use alloy::primitives::Address; use anyhow::Result; use dialoguer::{theme::ColorfulTheme, Input}; use e3_config::AppConfig; use e3_console::{log, Console}; -use e3_entrypoint::config::setup; use e3_utils::{colorize, Color}; use std::path::PathBuf; use tracing::instrument; diff --git a/crates/entrypoint/src/config/setup.rs b/crates/cli/src/config_setup.rs similarity index 82% rename from crates/entrypoint/src/config/setup.rs rename to crates/cli/src/config_setup.rs index 49a9d044a1..71a5a3f7a7 100644 --- a/crates/entrypoint/src/config/setup.rs +++ b/crates/cli/src/config_setup.rs @@ -1,9 +1,17 @@ // SPDX-License-Identifier: LGPL-3.0-only // // This file is provided WITHOUT ANY WARRANTY; -// without even even the implied warranty of MERCHANTABILITY +// without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +//! Configuration bootstrapping for `enclave setup`. +//! +//! Generates an `enclave.config.yaml` from compiled-in contract deployment +//! addresses. This is a **dev-bootstrap tool** — it couples to the Solidity +//! build artifacts at compile time and is only invoked by the `enclave setup` +//! CLI command. Production deployments should use a proper deployment artifact +//! file instead of this compile-time approach. + use alloy::primitives::Address; use anyhow::{anyhow, bail, Result}; use e3_config::load_config; @@ -13,12 +21,9 @@ use std::fs; use std::path::PathBuf; use tracing::instrument; -// Import a built file: -// see /target/debug/enclave-xxxxxx/out/contract_deployments.rs -// also see build.rs +// Generated by build.rs from packages/enclave-contracts/deployed_contracts.json include!(concat!(env!("OUT_DIR"), "/contract_deployments.rs")); -// Get the ContractInfo object fn get_contract_info(name: &str) -> Result<&ContractInfo> { Ok(CONTRACT_DEPLOYMENTS .get(name) @@ -39,7 +44,7 @@ pub fn validate_eth_address(address: &String) -> Result<()> { #[instrument(name = "app", skip_all)] pub fn execute(rpc_url: &str, config_dir: &PathBuf) -> Result { - fs::create_dir_all(&config_dir)?; + fs::create_dir_all(config_dir)?; let config_path = config_dir.join("enclave.config.yaml"); @@ -76,7 +81,6 @@ chains: fs::write(config_path.clone(), config_content)?; - // Load with default location let config = load_config("_default", Some(config_path.display().to_string()), None)?; Ok(config) @@ -99,7 +103,6 @@ mod tests { assert!( validate_eth_address(&"0x0000000000000000000000000000000000000000".to_string()).is_ok() ); - Ok(()) } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1b0d8c6801..a990664842 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -15,6 +15,7 @@ use tracing::info; mod ciphernode; mod cli; mod config; +mod config_setup; mod events; pub mod helpers; mod init; diff --git a/crates/committee-hash/Cargo.toml b/crates/committee-hash/Cargo.toml new file mode 100644 index 0000000000..b32b70115a --- /dev/null +++ b/crates/committee-hash/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "e3-committee-hash" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Canonical committee hash for DKG / decryption aggregator proofs" +repository = "https://github.com/gnosisguild/enclave/crates/committee-hash" + +[dependencies] +alloy = { workspace = true } +hex = { workspace = true } diff --git a/crates/committee-hash/src/lib.rs b/crates/committee-hash/src/lib.rs new file mode 100644 index 0000000000..798ecd244e --- /dev/null +++ b/crates/committee-hash/src/lib.rs @@ -0,0 +1,92 @@ +// 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. + +//! Canonical committee hash for DKG / decryption aggregator proofs. +//! Must match `CommitteeHashLib.sol` (`keccak256(abi.encodePacked(addresses))`). + +use alloy::primitives::{keccak256, Address, B256}; + +/// Hi/lo limbs of `keccak256(abi.encodePacked(addresses))` for Noir public inputs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CommitteeHashLimbs { + pub hi: B256, + pub lo: B256, +} + +/// `keccak256(abi.encodePacked(addresses))` for the ordered on-chain committee. +pub fn hash_committee_addresses(addresses: &[Address]) -> B256 { + let packed: Vec = addresses + .iter() + .flat_map(|addr| addr.into_array()) + .collect(); + keccak256(packed) +} + +/// Split a committee hash into 128-bit limbs for BN254 public inputs. +/// Each limb is a bytes32 with its 128 bits right-aligned, matching `CommitteeHashLib`. +pub fn split_committee_hash(hash: B256) -> CommitteeHashLimbs { + let mut hi = [0u8; 32]; + hi[16..].copy_from_slice(&hash.0[..16]); + let mut lo = [0u8; 32]; + lo[16..].copy_from_slice(&hash.0[16..]); + CommitteeHashLimbs { + hi: B256::from(hi), + lo: B256::from(lo), + } +} + +/// Hash and split in one step. +pub fn committee_hash_limbs_from_addresses(addresses: &[Address]) -> CommitteeHashLimbs { + split_committee_hash(hash_committee_addresses(addresses)) +} + +/// Field hex strings (`0x…`, 32 bytes) for Noir witness `committee_hash_hi` / `committee_hash_lo`. +pub fn committee_hash_field_hex(addresses: &[Address]) -> (String, String) { + let limbs = committee_hash_limbs_from_addresses(addresses); + (field_hex_from_b256(limbs.hi), field_hex_from_b256(limbs.lo)) +} + +fn field_hex_from_b256(value: B256) -> String { + format!("0x{}", hex::encode(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + #[test] + fn encode_packed_matches_solidity_layout() { + let nodes = vec![ + address!("0x0000000000000000000000000000000000000001"), + address!("0x0000000000000000000000000000000000000002"), + ]; + let hash = hash_committee_addresses(&nodes); + let limbs = split_committee_hash(hash); + assert_ne!(limbs.hi, B256::ZERO); + assert_ne!(limbs.lo, B256::ZERO); + } + + /// Limb bytes32 layout must match `CommitteeHashLib.hi` / `lo`. + #[test] + fn split_limbs_match_solidity_bytes32_layout() { + let nodes = vec![ + address!("0x0000000000000000000000000000000000000001"), + address!("0x0000000000000000000000000000000000000002"), + address!("0x0000000000000000000000000000000000000003"), + ]; + let hash = hash_committee_addresses(&nodes); + let limbs = split_committee_hash(hash); + + let mut expected_hi = [0u8; 32]; + expected_hi[16..].copy_from_slice(&hash.0[..16]); + assert_eq!(limbs.hi.0, expected_hi); + + let mut expected_lo = [0u8; 32]; + expected_lo[16..].copy_from_slice(&hash.0[16..]); + assert_eq!(limbs.lo.0, expected_lo); + } +} diff --git a/crates/config/src/app_config.rs b/crates/config/src/app_config.rs index b407431b60..cfcfcf723c 100644 --- a/crates/config/src/app_config.rs +++ b/crates/config/src/app_config.rs @@ -9,6 +9,7 @@ use crate::load_config::find_in_parent; use crate::load_config::resolve_config_path; use crate::paths_engine::PathsEngine; use crate::paths_engine::DEFAULT_CONFIG_NAME; +use crate::program_config::{BoundlessConfig, ProgramConfig, Risc0Config}; use crate::yaml::load_yaml_with_env; use alloy_primitives::Address; use anyhow::bail; @@ -87,66 +88,6 @@ impl Default for NodeDefinition { } } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -pub struct BoundlessConfig { - /// RPC URL for blockchain (e.g., Sepolia) - pub rpc_url: String, - /// Private key for submitting requests - pub private_key: String, - /// Pinata JWT for uploading programs/inputs - #[serde(default)] - pub pinata_jwt: Option, - /// Pre-uploaded program URL (if program is already on IPFS) - #[serde(default)] - pub program_url: Option, - /// Submit requests onchain (true) or offchain (false) - #[serde(default = "default_true")] - pub onchain: bool, -} - -fn default_true() -> bool { - true -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -pub struct Risc0Config { - /// Dev mode: 0 = production, 1 = dev mode (fake proofs) - #[serde(default)] - pub risc0_dev_mode: u8, - /// Boundless configuration - #[serde(default)] - pub boundless: Option, -} - -impl Default for Risc0Config { - fn default() -> Self { - Risc0Config { - risc0_dev_mode: 1, // Default to dev mode for safety - boundless: None, - } - } -} - -/// Configuration for the program runner -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct ProgramConfig { - risc0: Option, - dev: Option, -} - -impl ProgramConfig { - pub fn risc0(&self) -> Option<&Risc0Config> { - self.risc0.as_ref() - } - - pub fn dev(&self) -> bool { - if let Some(dev) = self.dev { - return dev; - } - false - } -} - /// The config actually used throughout the app #[derive(Debug, Deserialize, Serialize)] pub struct AppConfig { diff --git a/crates/config/src/contract.rs b/crates/config/src/contract.rs index 32c856d2ad..377de3dc3e 100644 --- a/crates/config/src/contract.rs +++ b/crates/config/src/contract.rs @@ -49,6 +49,7 @@ pub struct ContractAddresses { pub e3_program: Option, pub fee_token: Option, pub slashing_manager: Option, + pub dkg_fold_attestation_verifier: Option, } impl ContractAddresses { @@ -60,6 +61,7 @@ impl ContractAddresses { self.e3_program.as_ref(), self.fee_token.as_ref(), self.slashing_manager.as_ref(), + self.dkg_fold_attestation_verifier.as_ref(), ] .into_iter() .flatten() diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2e18c6f92d..14258000bb 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -9,10 +9,12 @@ pub mod chain_config; pub mod contract; pub mod load_config; pub mod paths_engine; +pub mod program_config; pub mod rpc; pub mod validation; mod yaml; pub use app_config::*; pub use contract::*; +pub use program_config::*; pub use rpc::*; diff --git a/crates/config/src/program_config.rs b/crates/config/src/program_config.rs new file mode 100644 index 0000000000..55690b575d --- /dev/null +++ b/crates/config/src/program_config.rs @@ -0,0 +1,65 @@ +// 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. + +//! Program execution configuration (RISC Zero / Boundless). +//! +//! Extracted from [`AppConfig`] — these types configure external program +//! execution, not the ciphernode itself. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct BoundlessConfig { + pub rpc_url: String, + pub private_key: String, + #[serde(default)] + pub pinata_jwt: Option, + #[serde(default)] + pub program_url: Option, + #[serde(default = "default_true")] + pub onchain: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Risc0Config { + #[serde(default = "default_risc0_dev_mode")] + pub risc0_dev_mode: u8, + #[serde(default)] + pub boundless: Option, +} + +fn default_risc0_dev_mode() -> u8 { + 1 +} + +impl Default for Risc0Config { + fn default() -> Self { + Risc0Config { + risc0_dev_mode: 1, + boundless: None, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ProgramConfig { + risc0: Option, + dev: Option, +} + +impl ProgramConfig { + pub fn risc0(&self) -> Option<&Risc0Config> { + self.risc0.as_ref() + } + + pub fn dev(&self) -> bool { + self.dev.unwrap_or(false) + } +} diff --git a/crates/entrypoint/Cargo.toml b/crates/entrypoint/Cargo.toml index f819ca753c..490e4da55d 100644 --- a/crates/entrypoint/Cargo.toml +++ b/crates/entrypoint/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true license.workspace = true description = "E3 - CLI Entrypoints" repository = "https://github.com/gnosisguild/enclave/crates/entrypoint" -build = "build.rs" [dependencies] actix = { workspace = true } diff --git a/crates/entrypoint/build.rs b/crates/entrypoint/build.rs deleted file mode 100644 index 10020bbe35..0000000000 --- a/crates/entrypoint/build.rs +++ /dev/null @@ -1,68 +0,0 @@ -// 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. - -// Here we build some contract information from the EVM deployment artifacts that we can use within -// our binaries. Specifically we wbuild out a rust file that has a structure we can import and use -// within our configuration builder -use serde_json::{from_reader, Value}; -use std::env; -use std::fs::{self, File}; -use std::path::Path; - -fn main() -> std::io::Result<()> { - // Get the manifest directory (where Cargo.toml is located) - let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - - // Path to deployment artifacts - let deployments_path = Path::new(&manifest_dir) - .join("..") - .join("..") - .join("packages") - .join("enclave-contracts") - .join("deployed_contracts.json"); - - // Create output string for contract info - let mut contract_info = String::from( - "pub struct ContractInfo {\n pub address: &'static str,\n pub deploy_block: u64,\n}\n\n" - ); - contract_info.push_str( - "pub static CONTRACT_DEPLOYMENTS: phf::Map<&'static str, ContractInfo> = phf::phf_map! {\n", - ); - - // Read the single JSON file - let file = File::open(&deployments_path)?; - let json: Value = from_reader(file)?; - - // Process Sepolia network from the JSON - if let Some(networks) = json.as_object() { - if let Some(sepolia_data) = networks.get("sepolia") { - if let Some(contracts) = sepolia_data.as_object() { - for (contract_name, contract_data) in contracts { - // Extract address and block number from the contract data - if let (Some(address), Some(deploy_block)) = ( - contract_data["address"].as_str(), - contract_data["blockNumber"].as_u64(), - ) { - contract_info.push_str(&format!( - " \"{}\" => ContractInfo {{\n address: \"{}\",\n deploy_block: {},\n }},\n", - contract_name, address, deploy_block - )); - } - } - } - } - } - - contract_info.push_str("};\n"); - - // Write the generated code to a file - let out_dir = env::var("OUT_DIR").unwrap(); - let dest_path = Path::new(&out_dir).join("contract_deployments.rs"); - fs::write(dest_path, contract_info)?; - println!("cargo:rerun-if-changed=../../packages/enclave-contracts/deployed_contracts.json"); - - Ok(()) -} diff --git a/crates/entrypoint/src/config/mod.rs b/crates/entrypoint/src/config/mod.rs index 598fd7416f..96ea269aec 100644 --- a/crates/entrypoint/src/config/mod.rs +++ b/crates/entrypoint/src/config/mod.rs @@ -4,4 +4,5 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -pub mod setup; +// config/setup.rs has been moved to crates/cli/src/config_setup.rs — +// it is a dev-bootstrap tool, not a production entrypoint concern. diff --git a/crates/events/src/bus_handle.rs b/crates/events/src/bus_handle.rs index 73e2505622..ac9e36162e 100644 --- a/crates/events/src/bus_handle.rs +++ b/crates/events/src/bus_handle.rs @@ -106,6 +106,11 @@ impl BusHandle { EventBus::>::history(&self.event_bus) } + /// Return a HistoryCollector that is subscribed only to EnclaveError events. + pub fn errors(&self) -> Addr>> { + EventBus::>::error(&self.event_bus) + } + /// Access the sequencer to internally dispatch an event to pub fn sequencer(&self) -> &Addr { &self.sequencer diff --git a/crates/events/src/commitment_link.rs b/crates/events/src/commitment_link.rs new file mode 100644 index 0000000000..5bb575cc07 --- /dev/null +++ b/crates/events/src/commitment_link.rs @@ -0,0 +1,70 @@ +// 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. + +//! Cross-circuit commitment consistency types. +//! +//! Defines the [`CommitmentLink`] trait and supporting types used by the +//! [`CommitmentConsistencyChecker`] in `e3-slashing`. + +use crate::ProofType; + +/// A 32-byte field element extracted from proof public signals. +pub type FieldValue = [u8; 32]; + +/// 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). + SameParty, + /// The source proof is per-party while the target is from a different party. + 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. + SourceMustExistInTargets, +} + +/// Defines a cross-circuit commitment consistency check. +/// +/// Implementations extract commitment values from the public signals of a +/// *source* proof type and verify they are consistent with the public signals +/// of a *target* proof type. +pub trait CommitmentLink: Send + Sync { + /// Human-readable name for logging. + fn name(&self) -> &'static str; + + /// The proof type that *produces* the commitment value. + fn source_proof_type(&self) -> ProofType; + + /// The proof type that *consumes* or must agree with the commitment value. + fn target_proof_type(&self) -> ProofType; + + /// Relationship scope between source and target parties. + fn scope(&self) -> LinkScope; + + /// Extract the commitment value(s) from the source proof's public signals. + fn extract_source_values(&self, public_signals: &[u8]) -> Vec; + + /// Return `true` when `source_values` are consistent with + /// `target_public_signals`, without party context. + /// + /// Default returns `false`. Implementations should override either this + /// method or [`check_consistency`]. + fn check_signals(&self, _source_values: &[FieldValue], _target_public_signals: &[u8]) -> bool { + false + } + + /// Return `true` when `source_values` are consistent with + /// `target_public_signals`, with party context. + fn check_consistency( + &self, + source_values: &[FieldValue], + target_public_signals: &[u8], + _src_party_id: u64, + _tgt_party_id: u64, + ) -> bool { + self.check_signals(source_values, target_public_signals) + } +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index a435677474..bcc2040be0 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. mod bus_handle; +mod commitment_link; mod committee; mod correlation_id; mod cursor; @@ -31,6 +32,7 @@ mod sync; mod traits; pub use bus_handle::*; +pub use commitment_link::*; pub use committee::*; pub use correlation_id::*; pub use cursor::*; diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 9332379eb5..888cbf76b2 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -6,7 +6,6 @@ mod bonding_registry_sol; mod ciphernode_registry_sol; -mod dkg_attestation_bundle; mod enclave_sol_reader; mod enclave_sol_writer; pub mod error_decoder; @@ -29,7 +28,6 @@ pub use ciphernode_registry_sol::{ fetch_accusation_vote_validity, fetch_dkg_fold_attestation_verifier, CiphernodeRegistrySol, CiphernodeRegistrySolReader, CiphernodeRegistrySolWriter, }; -pub use dkg_attestation_bundle::encode_dkg_attestation_bundle; pub use enclave_sol_reader::EnclaveSolReader; pub use enclave_sol_writer::EnclaveSolWriter; pub use events::*; diff --git a/crates/slashing/Cargo.toml b/crates/slashing/Cargo.toml new file mode 100644 index 0000000000..d0c1f55e60 --- /dev/null +++ b/crates/slashing/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "e3-slashing" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "E3 fault attribution, accusation quorum, and commitment consistency" +repository = "https://github.com/gnosisguild/enclave/crates/slashing" + +[dependencies] +actix = { workspace = true } +alloy = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +e3-events = { workspace = true } +e3-fhe-params = { workspace = true } +e3-request = { workspace = true } +e3-utils = { workspace = true } +hex = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +tracing = { workspace = true } diff --git a/crates/slashing/src/accusation_manager.rs b/crates/slashing/src/accusation_manager.rs new file mode 100644 index 0000000000..537cc849ee --- /dev/null +++ b/crates/slashing/src/accusation_manager.rs @@ -0,0 +1,1833 @@ +// 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. + +//! Off-chain accusation quorum protocol for fault attribution. +//! +//! When a node detects a ZK proof failure from another committee member, it +//! broadcasts a [`ProofFailureAccusation`] over gossip. Other committee members +//! independently check the same proof and respond with [`AccusationVote`]s. +//! Once a quorum of M (the cryptographic threshold) votes is reached, the +//! actor emits [`AccusationQuorumReached`] for downstream consumers (aggregator +//! exclusion, on-chain slash submission). +//! +//! ## Proof-type-specific behavior +//! +//! | Proof | Attestation | Notes | +//! |---------|----------------------------|--------------------------------------------| +//! | C0 | All nodes independently | Everyone receives via DHT | +//! | C1 | All nodes independently | Bundled in ThresholdShareCreated | +//! | C2a/C2b | All nodes independently | Same proof bytes for all recipients | +//! | C3a/C3b | Forwarding required | Per-recipient; accuser forwards payload | +//! | C4 | All nodes independently | Broadcast via gossip | +//! | C5 | Committee attests | Aggregator-generated; nodes verify off-chain| +//! | C6 | All nodes independently | Broadcast via gossip | +//! | C7 | On-chain verification | Not handled here (on-chain verifier) | + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use actix::{Actor, Addr, AsyncContext, Context, Handler, SpawnHandle}; +use alloy::primitives::{keccak256, Address, Bytes, U256}; +use alloy::signers::local::PrivateKeySigner; +use alloy::signers::SignerSync; +use alloy::sol_types::SolValue; +use e3_events::{ + AccusationOutcome, AccusationQuorumReached, AccusationVote, BusHandle, + CommitmentConsistencyViolation, ComputeRequest, ComputeRequestError, ComputeResponse, + ComputeResponseKind, CorrelationId, E3id, EnclaveEvent, EnclaveEventData, EventContext, + EventPublisher, EventSubscriber, EventType, PartyProofsToVerify, ProofFailureAccusation, + ProofType, ProofVerificationFailed, ProofVerificationPassed, Sequenced, SignedProofPayload, + SlashExecuted, TypedEvent, VerifyShareProofsRequest, ZkRequest, ZkResponse, VOTE_DOMAIN_NAME, + VOTE_DOMAIN_VERSION, VOTE_TYPEHASH_STR, +}; +use e3_utils::{ArcBytes, NotifySync}; +use tracing::{error, info, warn}; + +/// How long to wait for votes before declaring the accusation inconclusive. +const DEFAULT_VOTE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes +/// Default clock-skew allowance when validating peer-stamped accusation deadlines. +#[cfg(test)] +const DEFAULT_ACCUSATION_DEADLINE_SKEW_SECS: u64 = 30; + +/// Abstraction over wall-clock time so the deadline-stamping logic is +/// deterministically testable. Production uses [`SystemClock`], which reads +/// `SystemTime::now()`; tests can inject a mock clock that returns fixed +/// timestamps. +pub trait Clock: Send + Sync + 'static { + /// Current Unix time in seconds. Returns `0` if the platform clock is + /// pre-`UNIX_EPOCH` (a broken clock should not silently produce + /// signatures that look valid forever — the on-chain check will then + /// reject the resulting deadline immediately). + fn unix_now_secs(&self) -> u64; +} + +/// Production clock backed by `SystemTime::now()`. +pub struct SystemClock; + +impl Clock for SystemClock { + fn unix_now_secs(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } +} + +/// An active accusation awaiting agreement votes from committee members. +/// +/// There is no `votes_against` field: a peer who finds the disputed proof +/// passes simply stays silent rather than broadcasting a signed disagreement +/// (see `AccusationVote` docstring for rationale). The accusation runs to +/// quorum or to `vote_timeout`. +struct PendingAccusation { + accusation: ProofFailureAccusation, + votes_for: Vec, + /// Handle to the timeout future so it can be cancelled on early quorum. + timeout_handle: Option, + /// The EventContext from when this accusation was created — used for timeout emission. + ec: EventContext, +} + +/// Cached verification result for a proof from a specific (accused, proof_type) pair. +/// Populated as proofs are received and verified (pass or fail). +struct ReceivedProofData { + data_hash: [u8; 32], + /// `true` if our local verification passed, `false` if it failed. + verification_passed: bool, + /// Raw `abi.encode(proof.data, proof.public_signals)` — preimage of + /// `data_hash`. Forwarded to the on-chain slashing contract so it can + /// recompute and verify the dataHash bound in voter signatures. Empty + /// only on paths where the raw bytes weren't available locally; those + /// paths can still slash, but they fall back to off-chain trust for + /// the evidence binding. + evidence: Bytes, +} + +/// Tracks an in-flight ZK re-verification for a forwarded C3a/C3b proof. +struct PendingReVerification { + accusation_id: [u8; 32], + data_hash: [u8; 32], + accused: Address, + proof_type: ProofType, + /// Evidence preimage bytes from the forwarded proof, used to populate + /// `ReceivedProofData.evidence` after ZK re-verification completes. + evidence: Bytes, +} + +/// Manages the off-chain accusation quorum protocol. +/// +/// **Lifecycle**: One instance per E3 computation. Created by +/// [`AccusationManagerExtension`] when [`CommitteeFinalized`] fires and +/// destroyed when the E3 completes or the node shuts down. All internal +/// state (pending accusations, votes, caches) is therefore naturally +/// scoped to a single E3 — no cross-E3 data contamination is possible. +/// +/// **Ephemeral**: This actor does *not* persist state across restarts. +/// In-flight accusations are lost on node restart (accepted trade-off: +/// they would have timed out within [`DEFAULT_VOTE_TIMEOUT`] anyway). +/// A strategic node restart can delay slash submission but cannot +/// prevent it, because other committee members independently maintain +/// their own `AccusationManager` instances and will continue voting. +/// +/// Subscribes to: +/// - [`ProofVerificationFailed`] — local proof failure detection +/// - [`ProofVerificationPassed`] — cache successful verification for voting +/// - [`ProofFailureAccusation`] — incoming accusations from other nodes via gossip +/// - [`AccusationVote`] — incoming votes from other nodes via gossip +/// - [`SlashExecuted`] — on-chain slash confirmation for committee updates +/// +/// Publishes: +/// - [`ProofFailureAccusation`] — broadcast own accusations via gossip +/// - [`AccusationVote`] — broadcast own votes via gossip +/// - [`AccusationQuorumReached`] — quorum decision for downstream consumers +pub struct AccusationManager { + bus: BusHandle, + e3_id: E3id, + my_address: Address, + signer: PrivateKeySigner, + + /// On-chain `SlashingManager` address (EIP-712 `verifyingContract` for vote signatures). + slashing_manager: Address, + + /// All committee member addresses for this E3. + committee: Vec
, + /// Quorum threshold — matches the cryptographic threshold M. + threshold_m: usize, + + /// Active accusations keyed by accusation_id (keccak256 of accusation fields). + pending: HashMap<[u8; 32], PendingAccusation>, + + /// Dedup: (accused, proof_type) pairs we've already created an accusation for. + /// Prevents duplicate accusations when multiple local failure events fire. + accused_proofs: HashSet<(Address, ProofType)>, + + /// Cache of received data hashes per (accused, proof_type). + /// Populated by ProofVerificationFailed (failures) and ProofVerificationPassed (successes) + /// so the node can vote on accusations from other nodes. + received_data: HashMap<(Address, ProofType), ReceivedProofData>, + + /// Votes received before the corresponding accusation — replayed on accusation arrival. + buffered_votes: HashMap<[u8; 32], Vec>, + + /// In-flight C3a/C3b ZK re-verifications, keyed by CorrelationId. + pending_reverifications: HashMap, + + /// Vote timeout duration. + vote_timeout: Duration, + + /// Registry-wide off-chain freshness window (seconds) applied when stamping + /// `AccusationVote.deadline`. Fetched once per process from + /// `CiphernodeRegistry.accusationVoteValidity()` so a governance change + /// requires a node restart to take effect — same lifecycle as the fold + /// attestation verifier. + vote_validity_secs: u64, + /// Clock-skew allowance when validating peer accusation deadlines. + accusation_deadline_skew_secs: u64, + + /// Wall-clock source used to derive accusation deadlines. Production uses + /// [`SystemClock`]; tests can inject a deterministic mock. + clock: Arc, + + /// BFV preset for circuit artifact resolution. + params_preset: e3_fhe_params::BfvPreset, +} + +impl AccusationManager { + /// Construct an actor with the production [`SystemClock`]. Use + /// [`AccusationManager::new_with_clock`] in tests that need deterministic + /// timestamps. + pub fn new( + bus: &BusHandle, + e3_id: E3id, + signer: PrivateKeySigner, + slashing_manager: Address, + committee: Vec
, + threshold_m: usize, + vote_validity_secs: u64, + accusation_deadline_skew_secs: u64, + params_preset: e3_fhe_params::BfvPreset, + ) -> Self { + Self::new_with_clock( + bus, + e3_id, + signer, + slashing_manager, + committee, + threshold_m, + vote_validity_secs, + accusation_deadline_skew_secs, + params_preset, + Arc::new(SystemClock), + ) + } + + /// Construct an actor with an explicit [`Clock`]. Allows unit tests to + /// drive deadline computation without touching wall-clock time. + pub fn new_with_clock( + bus: &BusHandle, + e3_id: E3id, + signer: PrivateKeySigner, + slashing_manager: Address, + committee: Vec
, + threshold_m: usize, + vote_validity_secs: u64, + accusation_deadline_skew_secs: u64, + params_preset: e3_fhe_params::BfvPreset, + clock: Arc, + ) -> Self { + let my_address = signer.address(); + Self { + bus: bus.clone(), + e3_id, + my_address, + signer, + slashing_manager, + committee, + threshold_m, + pending: HashMap::new(), + accused_proofs: HashSet::new(), + received_data: HashMap::new(), + buffered_votes: HashMap::new(), + pending_reverifications: HashMap::new(), + vote_timeout: DEFAULT_VOTE_TIMEOUT, + vote_validity_secs, + accusation_deadline_skew_secs, + clock, + params_preset, + } + } + + pub fn setup( + bus: &BusHandle, + e3_id: E3id, + signer: PrivateKeySigner, + slashing_manager: Address, + committee: Vec
, + threshold_m: usize, + vote_validity_secs: u64, + accusation_deadline_skew_secs: u64, + params_preset: e3_fhe_params::BfvPreset, + ) -> Addr { + let addr = Self::new( + bus, + e3_id, + signer, + slashing_manager, + committee, + threshold_m, + vote_validity_secs, + accusation_deadline_skew_secs, + params_preset, + ) + .start(); + bus.subscribe(EventType::ProofVerificationFailed, addr.clone().into()); + bus.subscribe(EventType::ProofVerificationPassed, addr.clone().into()); + bus.subscribe(EventType::ProofFailureAccusation, addr.clone().into()); + bus.subscribe(EventType::AccusationVote, addr.clone().into()); + bus.subscribe(EventType::ComputeResponse, addr.clone().into()); + bus.subscribe(EventType::ComputeRequestError, addr.clone().into()); + bus.subscribe(EventType::SlashExecuted, addr.clone().into()); + bus.subscribe( + EventType::CommitmentConsistencyViolation, + addr.clone().into(), + ); + addr + } + + // ─── Deadline computation ──────────────────────────────────────────── + + /// Compute the on-chain vote-validity deadline (Unix seconds) the accuser + /// stamps on a fresh accusation. Voters then sign this exact value so the + /// aggregated evidence carries one shared deadline that `SlashingManager` + /// checks via `block.timestamp <= deadline`. + /// + /// `vote_validity_secs` is the registry-wide window fetched from + /// `CiphernodeRegistry.accusationVoteValidity()` at process startup — + /// governance can shorten or extend it; live nodes only pick up the new + /// value on restart. + /// + /// `saturating_add` guards against `u64` overflow in the unlikely event + /// governance sets the validity to a near-`u64::MAX` value. + fn compute_deadline(&self) -> u64 { + self.clock + .unix_now_secs() + .saturating_add(self.vote_validity_secs) + } + + /// Validate a peer-provided accusation deadline against this node's local + /// vote-validity policy and wall clock. + /// + /// Accept iff: + /// - validity is enabled (`vote_validity_secs > 0`) + /// - deadline is strictly in the future + /// - deadline is not farther than `now + vote_validity_secs + skew` + fn is_peer_deadline_acceptable( + deadline: u64, + now: u64, + vote_validity_secs: u64, + skew_secs: u64, + ) -> bool { + if vote_validity_secs == 0 { + return false; + } + let max_deadline = now + .saturating_add(vote_validity_secs) + .saturating_add(skew_secs); + deadline > now && deadline <= max_deadline + } + + // ─── Accusation ID computation ─────────────────────────────────────── + + /// Compute a deterministic ID for an accusation based on its key fields. + /// This ensures that the same (e3_id, accused, proof_type) produces the + /// same ID regardless of who the accuser is, enabling deduplication. + /// + /// `keccak256(abi.encodePacked(chainId, e3Id, accused, proofType))` + /// + /// Matches `SlashingManager.sol` which computes the same ID on-chain. + fn accusation_id(accusation: &ProofFailureAccusation) -> [u8; 32] { + let e3_id_u256: U256 = accusation + .e3_id + .clone() + .try_into() + .expect("E3id should be valid U256"); + let msg = ( + U256::from(accusation.e3_id.chain_id()), + e3_id_u256, + accusation.accused, + U256::from(accusation.proof_type as u8), + ) + .abi_encode_packed(); + keccak256(&msg).into() + } + + // ─── Signing / Verification ────────────────────────────────────────── + + fn sign_accusation_digest( + &self, + accusation: &ProofFailureAccusation, + ) -> Result, alloy::signers::Error> { + let digest = Self::accusation_digest(accusation); + let sig = self.signer.sign_message_sync(&digest)?; + Ok(sig.as_bytes().to_vec()) + } + + /// Structured digest for ECDSA signing of accusations. + /// + /// Off-chain only — this digest never reaches the chain. Includes `deadline` + /// so peers can verify the accuser's chosen on-chain validity window has not + /// been tampered with in transit: + /// ```text + /// keccak256(abi.encode( + /// ACCUSATION_TYPEHASH, + /// chainId, e3Id, accuser, accused, proofType, + /// dataHash, deadline + /// )) + /// ``` + fn accusation_digest(accusation: &ProofFailureAccusation) -> [u8; 32] { + let e3_id_u256: U256 = accusation + .e3_id + .clone() + .try_into() + .expect("E3id should be valid U256"); + let typehash: [u8; 32] = keccak256( + "ProofFailureAccusation(uint256 chainId,uint256 e3Id,address accuser,address accused,uint256 proofType,bytes32 dataHash,uint256 deadline)" + ).into(); + let encoded = ( + typehash, + U256::from(accusation.e3_id.chain_id()), + e3_id_u256, + accusation.accuser, + accusation.accused, + U256::from(accusation.proof_type as u8), + accusation.data_hash, + U256::from(accusation.deadline), + ) + .abi_encode(); + keccak256(&encoded).into() + } + + fn verify_accusation_signature(&self, accusation: &ProofFailureAccusation) -> bool { + let digest = Self::accusation_digest(accusation); + let sig = match alloy::primitives::Signature::try_from( + accusation.signature.extract_bytes().as_ref(), + ) { + Ok(s) => s, + Err(_) => return false, + }; + match sig.recover_address_from_msg(&digest) { + Ok(addr) => addr == accusation.accuser, + Err(_) => false, + } + } + + #[cfg_attr(test, allow(dead_code))] + fn sign_vote_digest(&self, vote: &AccusationVote) -> Result, alloy::signers::Error> { + let digest = Self::vote_digest(vote, self.slashing_manager); + // `sign_hash_sync` signs the raw 32-byte hash without EIP-191 wrapping, + // which is what EIP-712 requires (`digest` is already the + // `\x19\x01 || domainSeparator || structHash` hash). + let sig = self.signer.sign_hash_sync(&digest.into())?; + Ok(sig.as_bytes().to_vec()) + } + + /// Canonical EIP-712 domain separator for vote signatures. + /// + /// Must match `SlashingManager`'s domain construction exactly. The `name` + /// literal is `EIP712_DOMAIN_NAME` in the Solidity contract (see + /// `packages/enclave-contracts/contracts/slashing/SlashingManager.sol`); + /// keep these two strings in lockstep — divergence silently breaks + /// `ECDSA.recover` on chain. + fn vote_domain_separator(chain_id: u64, verifying_contract: Address) -> [u8; 32] { + let domain_typehash: [u8; 32] = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", + ) + .into(); + let name_hash: [u8; 32] = keccak256(VOTE_DOMAIN_NAME).into(); + let version_hash: [u8; 32] = keccak256(VOTE_DOMAIN_VERSION).into(); + let encoded = ( + domain_typehash, + name_hash, + version_hash, + U256::from(chain_id), + verifying_contract, + ) + .abi_encode(); + keccak256(&encoded).into() + } + + /// Canonical EIP-712 typed-data hash for a vote. + /// + /// `keccak256("\x19\x01" || domainSeparator || structHash)` where the struct + /// matches `SlashingManager.VOTE_TYPEHASH`: + /// `AccusationVote(uint256 e3Id,bytes32 accusationId,address voter,bytes32 dataHash,uint256 deadline)`. + /// + /// `AccusationVote` no longer carries an `agrees` field. The gossip wire + /// transmits only agreements; the on-chain verifier treats every submitted + /// signature as an affirmative vote. See the struct's docstring in + /// `e3_events::accusation_vote` for rationale. + /// + /// Exposed `pub` so the Anvil parity test in + /// `crates/zk-prover/tests/slashing_integration_tests.rs` can sign votes + /// through the **same** code path the production actor uses — if the + /// digest drifts from on-chain `_verifyVotes`, the parity test reverts on + /// chain immediately rather than allowing the actor to ship broken + /// signatures. + pub fn vote_digest(vote: &AccusationVote, verifying_contract: Address) -> [u8; 32] { + let e3_id_u256: U256 = vote + .e3_id + .clone() + .try_into() + .expect("E3id should be valid U256"); + let typehash: [u8; 32] = keccak256(VOTE_TYPEHASH_STR).into(); + let struct_hash: [u8; 32] = keccak256( + &( + typehash, + e3_id_u256, + vote.accusation_id, + vote.voter, + vote.data_hash, + U256::from(vote.deadline), + ) + .abi_encode(), + ) + .into(); + let domain = Self::vote_domain_separator(vote.e3_id.chain_id(), verifying_contract); + let mut buf = Vec::with_capacity(2 + 32 + 32); + buf.push(0x19); + buf.push(0x01); + buf.extend_from_slice(&domain); + buf.extend_from_slice(&struct_hash); + keccak256(&buf).into() + } + + fn verify_vote_signature(&self, vote: &AccusationVote) -> bool { + let digest = Self::vote_digest(vote, self.slashing_manager); + let sig = + match alloy::primitives::Signature::try_from(vote.signature.extract_bytes().as_ref()) { + Ok(s) => s, + Err(_) => return false, + }; + match sig.recover_address_from_prehash(&digest.into()) { + Ok(addr) => addr == vote.voter, + Err(_) => false, + } + } + + // ─── Core Protocol ─────────────────────────────────────────────────── + + /// Called when the local node detects a proof failure. + /// + /// Resolves the accused address, caches the failure, extracts C3a/C3b + /// forwarding payload, then delegates to [`initiate_accusation`]. + fn on_local_proof_failure( + &mut self, + event: ProofVerificationFailed, + ec: &EventContext, + ctx: &mut Context, + ) { + if event.e3_id != self.e3_id { + return; + } + + let accused_address = if event.accused_address == Address::ZERO { + if let Some(&addr) = self.committee.get(event.accused_party_id as usize) { + warn!( + "Resolved Address::ZERO for party {} to committee address {}", + event.accused_party_id, addr + ); + addr + } else { + error!( + "Cannot resolve address for party {} (out of committee bounds) — dropping accusation", + event.accused_party_id + ); + return; + } + } else { + event.accused_address + }; + + if !self.committee.contains(&accused_address) { + warn!( + "Ignoring proof failure for {} — not on E3 {} committee", + accused_address, self.e3_id + ); + return; + } + + // Cache the failed verification result. + // Evidence preimage = `abi.encode(proof.data, public_signals)` — matches + // the on-chain `keccak256(evidence) == dataHash` check in SlashingManager. + let evidence = Bytes::from( + ( + Bytes::copy_from_slice(&event.signed_payload.payload.proof.data), + Bytes::copy_from_slice(&event.signed_payload.payload.proof.public_signals), + ) + .abi_encode(), + ); + self.received_data.insert( + (accused_address, event.proof_type), + ReceivedProofData { + data_hash: event.data_hash, + verification_passed: false, + evidence, + }, + ); + + // For C3a/C3b, include the signed payload so other nodes can re-verify + let forwarded_payload = match event.proof_type { + ProofType::C3aSkShareEncryption | ProofType::C3bESmShareEncryption => { + Some(event.signed_payload.clone()) + } + _ => None, + }; + + self.initiate_accusation( + accused_address, + event.accused_party_id, + event.proof_type, + event.data_hash, + forwarded_payload, + ec, + ctx, + ); + } + + /// Called when the `CommitmentConsistencyChecker` detects a cross-circuit + /// commitment mismatch for a party. + /// + /// Caches the failure and delegates to `initiate_accusation` — the same + /// quorum protocol as ZK proof failures. + fn on_consistency_violation( + &mut self, + data: CommitmentConsistencyViolation, + ec: &EventContext, + ctx: &mut Context, + ) { + if data.e3_id != self.e3_id { + return; + } + + if !self.committee.contains(&data.accused_address) { + warn!( + "Ignoring commitment violation for {} — not on E3 {} committee", + data.accused_address, self.e3_id + ); + return; + } + + // Cache as a failed verification for voting on future accusations. + // `data.evidence` carries the raw `abi.encode(proof.data, public_signals)` + // preimage of `data_hash`, populated by the consistency checker. Slashing + // via this path now binds voter signatures to evidence bytes on-chain + // just like the ProofVerificationFailed path. + self.received_data.insert( + (data.accused_address, data.proof_type), + ReceivedProofData { + data_hash: data.data_hash, + verification_passed: false, + evidence: data.evidence.clone(), + }, + ); + + self.initiate_accusation( + data.accused_address, + data.accused_party_id, + data.proof_type, + data.data_hash, + None, // No forwarding needed — violations are detected from public signals all nodes have + ec, + ctx, + ); + } + + /// Shared accusation creation and broadcast logic. + /// + /// Called by [`on_local_proof_failure`] (ZK verification failure) and + /// [`on_consistency_violation`] (commitment consistency mismatch). + /// Deduplicates, creates and signs a [`ProofFailureAccusation`], casts + /// the node's own vote, and begins vote collection with a timeout. + fn initiate_accusation( + &mut self, + accused_address: Address, + accused_party_id: u64, + proof_type: ProofType, + data_hash: [u8; 32], + forwarded_payload: Option, + ec: &EventContext, + ctx: &mut Context, + ) { + if !self.committee.contains(&accused_address) { + warn!( + "Refusing accusation against {} — not on E3 {} committee", + accused_address, self.e3_id + ); + return; + } + + let key = (accused_address, proof_type); + + // Dedup: don't create multiple accusations for the same (accused, proof_type) + if !self.accused_proofs.insert(key) { + info!( + "Already accused {:?} for {:?} — skipping duplicate", + accused_address, proof_type + ); + return; + } + + // Governance-disabled validity window means no accusation voting should + // be produced by this node. + if self.vote_validity_secs == 0 { + warn!( + "Refusing accusation initiation for {:?} on E3 {}: vote_validity_secs is 0", + accused_address, self.e3_id + ); + self.accused_proofs.remove(&key); + return; + } + + // Pick the on-chain validity deadline once per accusation. Every voter + // (including ourselves below) signs the same value; otherwise the + // aggregated evidence cannot be encoded as a single `deadline`. + let deadline = self.compute_deadline(); + + // Create the accusation + let mut accusation = ProofFailureAccusation { + e3_id: self.e3_id.clone(), + accuser: self.my_address, + accused: accused_address, + accused_party_id, + proof_type, + data_hash, + deadline, + signed_payload: forwarded_payload, + signature: ArcBytes::default(), + }; + match self.sign_accusation_digest(&accusation) { + Ok(sig) => accusation.signature = ArcBytes::from_bytes(&sig), + Err(err) => { + error!("Failed to sign ProofFailureAccusation: {err}"); + self.accused_proofs.remove(&key); + return; + } + } + + let accusation_id = Self::accusation_id(&accusation); + + info!( + "Broadcasting accusation against {} for {:?} failure", + accused_address, proof_type + ); + + // Broadcast accusation via gossip + if let Err(err) = self.bus.publish(accusation.clone(), ec.clone()) { + error!("Failed to broadcast ProofFailureAccusation: {err}"); + self.accused_proofs.remove(&key); + return; + } + + // Cast our own agreement vote (we just observed the failure locally). + let mut own_vote = AccusationVote { + e3_id: self.e3_id.clone(), + accusation_id, + voter: self.my_address, + data_hash, + deadline, + signature: ArcBytes::default(), + }; + match self.sign_vote_digest(&own_vote) { + Ok(sig) => own_vote.signature = ArcBytes::from_bytes(&sig), + Err(err) => { + error!("Failed to sign own AccusationVote: {err}"); + self.accused_proofs.remove(&key); + return; + } + } + + if let Err(err) = self.bus.publish(own_vote.clone(), ec.clone()) { + error!("Failed to broadcast own AccusationVote: {err}"); + } + + // Start timeout + let timeout_handle = ctx.run_later(self.vote_timeout, move |act, _ctx| { + act.on_vote_timeout(accusation_id); + }); + + // Store pending accusation with own vote + self.pending.insert( + accusation_id, + PendingAccusation { + accusation, + votes_for: vec![own_vote], + timeout_handle: Some(timeout_handle), + ec: ec.clone(), + }, + ); + + // Replay any votes that arrived before this accusation + if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { + for vote in buffered { + self.on_vote_received(vote, ec, ctx); + } + } + + // Check quorum immediately (in case threshold_m == 1) + self.check_quorum(accusation_id, ec, ctx); + } + + /// Called when we receive an accusation from another node via gossip. + /// + /// Validates the accuser, checks our own verification cache, and casts a vote. + fn on_accusation_received( + &mut self, + accusation: ProofFailureAccusation, + ec: &EventContext, + ctx: &mut Context, + ) { + // Ignore accusations for other E3s + if accusation.e3_id != self.e3_id { + return; + } + + let now = self.clock.unix_now_secs(); + if !Self::is_peer_deadline_acceptable( + accusation.deadline, + now, + self.vote_validity_secs, + self.accusation_deadline_skew_secs, + ) { + let max_deadline = now + .saturating_add(self.vote_validity_secs) + .saturating_add(self.accusation_deadline_skew_secs); + warn!( + "Ignoring accusation from {} — deadline {} outside local validity window \ + (now={}, vote_validity_secs={}, skew_secs={}, max_accepted_deadline={})", + accusation.accuser, + accusation.deadline, + now, + self.vote_validity_secs, + self.accusation_deadline_skew_secs, + max_deadline + ); + return; + } + + // Verify accuser is in committee + if !self.committee.contains(&accusation.accuser) { + warn!( + "Ignoring accusation from non-committee member {}", + accusation.accuser + ); + return; + } + + // Verify accused is a committee member (defense-in-depth) + if !self.committee.contains(&accusation.accused) { + warn!( + "Ignoring accusation against non-committee member {}", + accusation.accused + ); + return; + } + + // Ignore our own accusations (we already voted) + if accusation.accuser == self.my_address { + return; + } + + // Verify accuser's ECDSA signature + if !self.verify_accusation_signature(&accusation) { + warn!( + "Invalid signature on accusation from {} — ignoring", + accusation.accuser + ); + return; + } + + let accusation_id = Self::accusation_id(&accusation); + + // Don't process duplicate accusations + if self.pending.contains_key(&accusation_id) { + return; + } + + // Determine our position based on our local verification state. + // + // The gossip wire no longer carries disagreement: if our local check + // *passed*, we stay silent (no broadcast, no pending state). The + // accusation will then either reach quorum from other agreeing peers + // or time out as Inconclusive. Only the "we also saw it fail" branch + // and the "we don't have local data yet (C3a/C3b)" branch proceed + // below. + let key = (accusation.accused, accusation.proof_type); + let our_data_hash = if let Some(received) = self.received_data.get(&key) { + if received.verification_passed { + info!( + "Local verification of {:?} from {} passed — abstaining \ + (no disagreement vote on the wire)", + accusation.proof_type, accusation.accused + ); + return; + } + received.data_hash + } else if let Some(ref forwarded) = accusation.signed_payload { + // C3a/C3b case: we didn't receive this proof directly. + // Validate the forwarded payload's ECDSA, then dispatch async ZK re-verification. + let forwarded_valid = match forwarded.recover_address() { + Ok(addr) => { + if addr != accusation.accused { + warn!( + "Forwarded C3a/C3b payload signer {} != accused {} — cannot verify", + addr, accusation.accused + ); + false + } else if forwarded.payload.e3_id != self.e3_id { + warn!("Forwarded C3a/C3b payload e3_id mismatch — cannot verify"); + false + } else { + let expected = forwarded.payload.proof_type.circuit_names(); + expected.contains(&forwarded.payload.proof.circuit) + } + } + Err(e) => { + warn!("Forwarded C3a/C3b payload signature invalid: {e} — cannot verify"); + false + } + }; + + if !forwarded_valid { + // Can't trust the forwarded proof — abstain + return; + } + + // Bind the forwarded proof to the accusation: proof_type and + // data_hash must match, otherwise a malicious accuser could attach + // a different valid proof from the same party. + if forwarded.payload.proof_type != accusation.proof_type { + warn!( + "Forwarded C3a/C3b proof_type {:?} != accusation proof_type {:?} — cannot verify", + forwarded.payload.proof_type, accusation.proof_type + ); + return; + } + let computed_hash = Self::compute_payload_hash(forwarded); + if computed_hash != accusation.data_hash { + warn!( + "Forwarded C3a/C3b data_hash mismatch (len {} vs {}) — cannot verify", + computed_hash.len(), + accusation.data_hash.len() + ); + return; + } + + let data_hash = Self::compute_payload_hash(forwarded); + let evidence: Bytes = ( + Bytes::copy_from_slice(&forwarded.payload.proof.data), + Bytes::copy_from_slice(&forwarded.payload.proof.public_signals), + ) + .abi_encode() + .into(); + let accused_party_id = accusation.accused_party_id; + let forwarded_clone = forwarded.clone(); + + // Create PendingAccusation without our vote — it arrives after ZK completes. + // + // NOTE (timeout race): If the async ZK re-verification takes longer than + // `vote_timeout` (default 5 min), the accusation will time out before this + // node casts its vote. This is an accepted trade-off: the node's contribution + // is lost, but the quorum can still be reached by other voters. In small + // committees near the threshold M, this could cause a valid accusation to + // become Inconclusive instead of AccusedFaulted. Operators should ensure ZK + // verification completes well within the vote timeout. + let timeout_handle = ctx.run_later(self.vote_timeout, move |act, _ctx| { + act.on_vote_timeout(accusation_id); + }); + self.pending.insert( + accusation_id, + PendingAccusation { + accusation, + votes_for: Vec::new(), + timeout_handle: Some(timeout_handle), + ec: ec.clone(), + }, + ); + + // Replay any buffered votes + if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { + for vote in buffered { + self.on_vote_received(vote, ec, ctx); + } + } + + // Dispatch ZK re-verification + let correlation_id = CorrelationId::new(); + self.pending_reverifications.insert( + correlation_id, + PendingReVerification { + accusation_id, + data_hash, + accused: key.0, + proof_type: key.1, + evidence, + }, + ); + + let party_proof = PartyProofsToVerify { + sender_party_id: accused_party_id, + signed_proofs: vec![forwarded_clone], + }; + let request = ComputeRequest::zk( + ZkRequest::VerifyShareProofs(VerifyShareProofsRequest { + party_proofs: vec![party_proof], + params_preset: self.params_preset, + }), + correlation_id, + self.e3_id.clone(), + ); + + if let Err(err) = self.bus.publish(request, ec.clone()) { + error!("Failed to dispatch C3a/C3b ZK re-verification: {err}"); + self.pending_reverifications.remove(&correlation_id); + } + + // Vote deferred — return without falling through to the normal vote path + return; + } else { + // We don't have the data and no payload was forwarded — abstain + info!( + "No local data for accused {} proof {:?} — abstaining from vote", + accusation.accused, accusation.proof_type + ); + return; + }; + + // We saw the proof fail locally — agree with the accusation. Adopt + // the accuser's deadline so every voter on this accusation signs the + // same on-chain validity window. + let mut vote = AccusationVote { + e3_id: self.e3_id.clone(), + accusation_id, + voter: self.my_address, + data_hash: our_data_hash, + deadline: accusation.deadline, + signature: ArcBytes::default(), + }; + match self.sign_vote_digest(&vote) { + Ok(sig) => vote.signature = ArcBytes::from_bytes(&sig), + Err(err) => { + error!("Failed to sign AccusationVote: {err}"); + return; + } + } + + info!( + "Agreeing with accusation against {} for {:?}", + accusation.accused, accusation.proof_type + ); + + // Broadcast vote via gossip + if let Err(err) = self.bus.publish(vote.clone(), ec.clone()) { + error!("Failed to broadcast AccusationVote: {err}"); + } + + // Start timeout for this accusation + let timeout_handle = ctx.run_later(self.vote_timeout, move |act, _ctx| { + act.on_vote_timeout(accusation_id); + }); + + // Record in pending + let pending = PendingAccusation { + accusation, + votes_for: vec![vote], + timeout_handle: Some(timeout_handle), + ec: ec.clone(), + }; + self.pending.insert(accusation_id, pending); + + // Replay any votes that arrived before this accusation + if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { + for vote in buffered { + self.on_vote_received(vote, ec, ctx); + } + } + + // Check quorum + self.check_quorum(accusation_id, ec, ctx); + } + + /// Called when we receive a vote from another node via gossip. + fn on_vote_received( + &mut self, + vote: AccusationVote, + ec: &EventContext, + ctx: &mut Context, + ) { + // Ignore votes for other E3s + if vote.e3_id != self.e3_id { + return; + } + + // Verify voter is in committee + if !self.committee.contains(&vote.voter) { + warn!("Ignoring vote from non-committee member {}", vote.voter); + return; + } + + // Ignore our own votes (already recorded) + if vote.voter == self.my_address { + return; + } + + // Verify voter's ECDSA signature + if !self.verify_vote_signature(&vote) { + warn!("Invalid signature on vote from {} — ignoring", vote.voter); + return; + } + + let vote_accusation_id = vote.accusation_id; + + // Find the pending accusation + let Some(pending) = self.pending.get_mut(&vote_accusation_id) else { + // Unknown accusation — buffer the vote for replay when the accusation arrives. + // Cap buffer size to prevent unbounded growth if the accusation never arrives. + let buf = self.buffered_votes.entry(vote_accusation_id).or_default(); + if buf.len() < self.committee.len() { + buf.push(vote); + } else { + warn!( + "Buffered votes for unknown accusation {:?} reached committee-size cap — dropping vote", + vote_accusation_id + ); + } + return; + }; + + // Reject votes whose deadline disagrees with the accusation's chosen + // deadline. All voters must sign the same deadline so the aggregated + // evidence carries a single value for `SlashingManager`'s + // `block.timestamp <= deadline` check. + if vote.deadline != pending.accusation.deadline { + warn!( + "Ignoring vote from {} — deadline {} does not match accusation deadline {}", + vote.voter, vote.deadline, pending.accusation.deadline + ); + return; + } + + // Reject votes from the accused party — they have a conflict of interest + if vote.voter == pending.accusation.accused { + warn!( + "Ignoring vote from accused party {} on their own accusation", + vote.voter + ); + return; + } + + // Dedup: don't count same voter twice + let already_voted = pending.votes_for.iter().any(|v| v.voter == vote.voter); + if already_voted { + return; + } + + // If the voter is the original accuser, their vote's data_hash must + // match the accusation's data_hash. A malicious accuser could otherwise + // send an accusation with one data_hash and a vote with a different one + // to create artificial data_hash diversity and trigger false equivocation. + if vote.voter == pending.accusation.accuser + && vote.data_hash != pending.accusation.data_hash + { + warn!( + "Accuser {} sent vote with data_hash inconsistent with their accusation — rejecting vote", + vote.voter + ); + return; + } + + // Every received `AccusationVote` is an agreement (the gossip wire + // carries no disagreement). Append to the agreeing pile and re-check + // quorum. + pending.votes_for.push(vote); + + self.check_quorum(vote_accusation_id, ec, ctx); + } + + /// Evaluate whether we have enough agreeing votes to decide. + /// + /// Quorum logic: + /// - `>= M` agreeing votes → `AccusedFaulted` (or `Equivocation` if those + /// votes disagree on `data_hash`, indicating the accused sent different + /// bytes to different peers). + /// - Otherwise → keep waiting; the timeout handler decides + /// `Inconclusive` if quorum never arrives. + /// + /// The gossip wire no longer carries disagreement, so there is no + /// fast-fail "quorum unreachable" branch — every silent peer might still + /// agree in flight. Silence beyond `vote_timeout` ⇒ `Inconclusive`. + fn check_quorum( + &mut self, + accusation_id: [u8; 32], + ec: &EventContext, + ctx: &mut Context, + ) { + let Some(pending) = self.pending.get(&accusation_id) else { + return; + }; + + let agree_count = pending.votes_for.len(); + if agree_count < self.threshold_m { + // Not yet at quorum — wait for more agreement votes or for the + // timeout to fire. + return; + } + + // Reached `M` — decide between AccusedFaulted and Equivocation by + // checking whether the agreeing voters all saw the same data_hash. + let agree_hashes: HashSet<[u8; 32]> = + pending.votes_for.iter().map(|v| v.data_hash).collect(); + if agree_hashes.len() > 1 { + info!( + "Equivocation detected at quorum: {} unique data hashes among {} agreeing voters for {} {:?}", + agree_hashes.len(), + agree_count, + pending.accusation.accused, + pending.accusation.proof_type + ); + self.emit_quorum_reached(accusation_id, AccusationOutcome::Equivocation, ec, ctx); + } else { + info!( + "Quorum reached: {} votes confirm {} sent bad {:?} proof — AccusedFaulted", + agree_count, pending.accusation.accused, pending.accusation.proof_type + ); + self.emit_quorum_reached(accusation_id, AccusationOutcome::AccusedFaulted, ec, ctx); + } + } + + /// Called when the vote timeout expires for an accusation. + fn on_vote_timeout(&mut self, accusation_id: [u8; 32]) { + let Some(pending) = self.pending.remove(&accusation_id) else { + return; // Already resolved + }; + + // All votes received are agreements (the wire carries no + // disagreement signal). At timeout, decide between AccusedFaulted, + // Equivocation, or Inconclusive purely from the agreeing pile. + let outcome = if pending.votes_for.len() >= self.threshold_m { + let agree_hashes: HashSet<[u8; 32]> = + pending.votes_for.iter().map(|v| v.data_hash).collect(); + if agree_hashes.len() > 1 { + AccusationOutcome::Equivocation + } else { + AccusationOutcome::AccusedFaulted + } + } else { + // Not enough agreements to convict and no signed disagreements + // exist; whether that's silence or active disagreement is + // indistinguishable on the wire. Report Inconclusive. + AccusationOutcome::Inconclusive + }; + + warn!( + "Accusation against {} for {:?} timed out with {} agreeing votes — outcome: {:?}", + pending.accusation.accused, + pending.accusation.proof_type, + pending.votes_for.len(), + outcome + ); + + let evidence = self + .received_data + .get(&(pending.accusation.accused, pending.accusation.proof_type)) + .map(|d| d.evidence.clone()) + .unwrap_or_default(); + if let Err(err) = self.bus.publish( + AccusationQuorumReached { + e3_id: self.e3_id.clone(), + accuser: pending.accusation.accuser, + accused: pending.accusation.accused, + proof_type: pending.accusation.proof_type, + votes_for: pending.votes_for, + outcome, + evidence, + }, + pending.ec, + ) { + error!("Failed to publish AccusationQuorumReached on timeout: {err}"); + } + } + + fn emit_quorum_reached( + &mut self, + accusation_id: [u8; 32], + outcome: AccusationOutcome, + ec: &EventContext, + ctx: &mut Context, + ) { + let Some(pending) = self.pending.remove(&accusation_id) else { + return; + }; + + // Cancel the timeout to avoid unnecessary timer fires + if let Some(handle) = pending.timeout_handle { + ctx.cancel_future(handle); + } + + info!( + "Accusation quorum reached for {} {:?}: {} agreeing votes — outcome: {}", + pending.accusation.accused, + pending.accusation.proof_type, + pending.votes_for.len(), + outcome + ); + + let evidence = self + .received_data + .get(&(pending.accusation.accused, pending.accusation.proof_type)) + .map(|d| d.evidence.clone()) + .unwrap_or_default(); + if let Err(err) = self.bus.publish( + AccusationQuorumReached { + e3_id: self.e3_id.clone(), + accuser: pending.accusation.accuser, + accused: pending.accusation.accused, + proof_type: pending.accusation.proof_type, + votes_for: pending.votes_for, + outcome, + evidence, + }, + ec.clone(), + ) { + error!("Failed to publish AccusationQuorumReached: {err}"); + } + } + + /// Handle an on-chain SlashExecuted event for this E3. + fn on_slash_executed(&mut self, data: SlashExecuted) { + if data.e3_id != self.e3_id { + return; + } + let prev_len = self.committee.len(); + self.committee.retain(|addr| *addr != data.operator); + if self.committee.len() < prev_len { + info!( + "Removed slashed operator {} from committee (now {} members)", + data.operator, + self.committee.len() + ); + + // Purge any votes from the expelled node in pending accusations + for pending in self.pending.values_mut() { + pending.votes_for.retain(|v| v.voter != data.operator); + } + + // Purge from buffered votes + for buf in self.buffered_votes.values_mut() { + buf.retain(|v| v.voter != data.operator); + } + } + } + + /// Cache a successful proof verification result for a specific (accused, proof_type). + /// This allows the node to vote on accusations from other nodes. + pub fn cache_verification_result( + &mut self, + accused: Address, + proof_type: ProofType, + data_hash: [u8; 32], + passed: bool, + evidence: Bytes, + ) { + self.received_data.insert( + (accused, proof_type), + ReceivedProofData { + data_hash, + verification_passed: passed, + evidence, + }, + ); + } + + /// Compute a keccak256 hash of a SignedProofPayload for data_hash comparison. + /// + /// `keccak256(abi.encode(zkProof, publicSignals))` + fn compute_payload_hash(payload: &SignedProofPayload) -> [u8; 32] { + let msg = ( + Bytes::copy_from_slice(&payload.payload.proof.data), + Bytes::copy_from_slice(&payload.payload.proof.public_signals), + ) + .abi_encode(); + keccak256(&msg).into() + } + + /// Handle ZK re-verification response for a forwarded C3a/C3b proof. + /// + /// Dispatched by `on_accusation_received` when the accused's forwarded proof + /// needs async ZK verification. Casts our vote based on the ZK result. + fn handle_reverification_response( + &mut self, + msg: TypedEvent, + ctx: &mut Context, + ) { + let (msg, _ec) = msg.into_components(); + + let correlation_id = msg.correlation_id; + let Some(reverif) = self.pending_reverifications.remove(&correlation_id) else { + return; // Not our correlation ID + }; + + let zk_passed = match msg.response { + ComputeResponseKind::Zk(ZkResponse::VerifyShareProofs(r)) => { + if r.party_results.is_empty() { + warn!("Empty ZK re-verification results — abstaining"); + return; + } + r.party_results.first().is_some_and(|r| r.all_verified) + } + _ => { + warn!("Unexpected ComputeResponse kind for C3a/C3b re-verification — abstaining"); + return; + } + }; + + // Cache the result for future accusations regardless of outcome. + self.cache_verification_result( + reverif.accused, + reverif.proof_type, + reverif.data_hash, + zk_passed, + reverif.evidence.clone(), + ); + + // ZK re-verification passed ⇒ the proof is actually valid ⇒ we + // disagree with the accusation. The gossip wire carries no + // disagreement signal, so just abstain (no broadcast, no pending + // mutation). Other agreeing peers will or won't reach quorum + // independently. + if zk_passed { + info!( + "C3a/C3b re-verification passed for {:?} — abstaining from vote", + reverif.proof_type + ); + return; + } + + // ZK re-verification failed ⇒ we agree with the accusation. + let (ec, deadline) = match self.pending.get(&reverif.accusation_id) { + Some(pending) => (pending.ec.clone(), pending.accusation.deadline), + None => { + // Accusation already resolved (timeout/quorum) before ZK finished + return; + } + }; + + let mut vote = AccusationVote { + e3_id: self.e3_id.clone(), + accusation_id: reverif.accusation_id, + voter: self.my_address, + data_hash: reverif.data_hash, + deadline, + signature: ArcBytes::default(), + }; + match self.sign_vote_digest(&vote) { + Ok(sig) => vote.signature = ArcBytes::from_bytes(&sig), + Err(err) => { + error!("Failed to sign C3a/C3b AccusationVote: {err}"); + return; + } + } + + info!( + "C3a/C3b re-verification confirmed failure for {:?} — agreeing with accusation", + reverif.proof_type + ); + + // Broadcast vote via gossip + if let Err(err) = self.bus.publish(vote.clone(), ec.clone()) { + error!("Failed to broadcast C3a/C3b AccusationVote: {err}"); + } + + // Record in pending + if let Some(pending) = self.pending.get_mut(&reverif.accusation_id) { + pending.votes_for.push(vote); + } + + // Check quorum + self.check_quorum(reverif.accusation_id, &ec, ctx); + } + + /// Handle ZK re-verification error for a forwarded C3a/C3b proof. + fn handle_reverification_error(&mut self, msg: TypedEvent) { + let (msg, _ec) = msg.into_components(); + + let correlation_id = msg.correlation_id(); + let Some(reverif) = self.pending_reverifications.remove(correlation_id) else { + return; // Not our correlation ID + }; + + error!( + "C3a/C3b ZK re-verification failed for {:?} — abstaining from vote", + reverif.proof_type + ); + // Don't vote — effectively abstain + } +} + +impl Actor for AccusationManager { + type Context = Context; +} + +impl Handler for AccusationManager { + type Result = (); + + fn handle(&mut self, msg: EnclaveEvent, ctx: &mut Self::Context) -> Self::Result { + let (msg, ec) = msg.into_components(); + match msg { + EnclaveEventData::ProofVerificationFailed(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::ProofVerificationPassed(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::ProofFailureAccusation(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::AccusationVote(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::ComputeResponse(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::ComputeRequestError(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::SlashExecuted(data) => { + self.on_slash_executed(data); + } + EnclaveEventData::CommitmentConsistencyViolation(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + _ => (), + } + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + ctx: &mut Self::Context, + ) -> Self::Result { + let (data, ec) = msg.into_components(); + self.on_local_proof_failure(data, &ec, ctx); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + let (data, _ec) = msg.into_components(); + if data.e3_id != self.e3_id { + return; + } + if !self.committee.contains(&data.address) { + return; + } + // Cache successful verification for voting on future accusations. + // Evidence preimage = `abi.encode(proof.data, public_signals)`. + let evidence: Bytes = ( + Bytes::copy_from_slice(&data.proof_data), + Bytes::copy_from_slice(&data.public_signals), + ) + .abi_encode() + .into(); + self.received_data.insert( + (data.address, data.proof_type), + ReceivedProofData { + data_hash: data.data_hash, + verification_passed: true, + evidence, + }, + ); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + ctx: &mut Self::Context, + ) -> Self::Result { + let (data, ec) = msg.into_components(); + self.on_accusation_received(data, &ec, ctx); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle(&mut self, msg: TypedEvent, ctx: &mut Self::Context) -> Self::Result { + let (data, ec) = msg.into_components(); + self.on_vote_received(data, &ec, ctx); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + ctx: &mut Self::Context, + ) -> Self::Result { + self.handle_reverification_response(msg, ctx); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + self.handle_reverification_error(msg); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + ctx: &mut Self::Context, + ) -> Self::Result { + let (data, ec) = msg.into_components(); + self.on_consistency_violation(data, &ec, ctx); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════════ +// +// These tests pin the actor's EIP-712 digest computation to the exact bytes +// that off-chain test helpers (and ultimately the on-chain +// `SlashingManager._verifyVotes`) expect. If anyone tweaks the typehash +// string, the domain name, or the struct field layout on EITHER side without +// updating the other, these tests fail before the broken signatures ever +// reach the chain. + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::FixedBytes; + use alloy::signers::SignerSync; + + /// Independent re-derivation of the EIP-712 vote digest, mirroring exactly + /// what `SlashingManager._verifyVotes` computes on chain. Kept here (and + /// not imported from a helper) so a regression in the actor's `vote_digest` + /// is caught by a byte-for-byte assertion against a hand-rolled reference. + fn reference_vote_digest( + chain_id: u64, + verifying_contract: Address, + e3_id: u64, + accusation_id: [u8; 32], + voter: Address, + data_hash: [u8; 32], + deadline: u64, + ) -> [u8; 32] { + let domain_typehash: [u8; 32] = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", + ) + .into(); + let name_hash: [u8; 32] = keccak256(VOTE_DOMAIN_NAME).into(); + let version_hash: [u8; 32] = keccak256(VOTE_DOMAIN_VERSION).into(); + let domain_separator: [u8; 32] = keccak256( + &( + domain_typehash, + name_hash, + version_hash, + U256::from(chain_id), + verifying_contract, + ) + .abi_encode(), + ) + .into(); + + let typehash: [u8; 32] = keccak256(VOTE_TYPEHASH_STR).into(); + let struct_hash: [u8; 32] = keccak256( + &( + typehash, + U256::from(e3_id), + FixedBytes::<32>::from(accusation_id), + voter, + FixedBytes::<32>::from(data_hash), + U256::from(deadline), + ) + .abi_encode(), + ) + .into(); + + let mut buf = Vec::with_capacity(2 + 32 + 32); + buf.push(0x19); + buf.push(0x01); + buf.extend_from_slice(&domain_separator); + buf.extend_from_slice(&struct_hash); + keccak256(&buf).into() + } + + /// The actor's `vote_digest` must equal the reference digest byte-for-byte. + /// If this fails, the actor's typehash / domain / struct layout has drifted + /// from what the on-chain verifier expects (or from the constants in + /// `e3_events::accusation_vote`). + #[test] + fn vote_digest_matches_reference() { + let chain_id = 31337u64; + let verifying_contract: Address = "0x9999999999999999999999999999999999999999" + .parse() + .unwrap(); + let voter: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + let accusation_id = [0xab; 32]; + let data_hash = [0xcd; 32]; + let deadline: u64 = 1_700_000_000; + + let vote = AccusationVote { + e3_id: E3id::new("42", chain_id), + accusation_id, + voter, + data_hash, + deadline, + signature: ArcBytes::default(), + }; + + let actor = AccusationManager::vote_digest(&vote, verifying_contract); + let reference = reference_vote_digest( + chain_id, + verifying_contract, + 42, + accusation_id, + voter, + data_hash, + deadline, + ); + + assert_eq!( + actor, reference, + "AccusationManager::vote_digest drifted from the reference EIP-712 \ + computation. Check VOTE_TYPEHASH_STR / VOTE_DOMAIN_NAME against \ + SlashingManager.sol — these MUST stay byte-equal across crates." + ); + } + + /// Sign-and-recover round-trip using the actor's digest. Since + /// `vote_digest_matches_reference` already pins the digest bytes, signing + /// that digest and recovering via `recover_address_from_prehash` must + /// return the voter — i.e. the actor's signatures will be accepted by the + /// on-chain `ECDSA.recover` step. + #[test] + fn actor_signature_recovers_to_voter() { + let signer: PrivateKeySigner = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .unwrap(); + let voter = signer.address(); + let verifying_contract: Address = "0x5555555555555555555555555555555555555555" + .parse() + .unwrap(); + let chain_id = 31337u64; + + let vote = AccusationVote { + e3_id: E3id::new("12345", chain_id), + accusation_id: [0x07; 32], + voter, + data_hash: [0x08; 32], + deadline: 1_700_000_000, + signature: ArcBytes::default(), + }; + + let digest = AccusationManager::vote_digest(&vote, verifying_contract); + let sig = signer + .sign_hash_sync(&FixedBytes::<32>::from(digest)) + .unwrap(); + let recovered = sig + .recover_address_from_prehash(&FixedBytes::<32>::from(digest)) + .expect("recover"); + assert_eq!( + recovered, voter, + "signing the actor's digest and recovering must yield the voter" + ); + } + + /// The accusation digest must include `deadline`. A malicious peer could + /// otherwise rewrite the deadline in transit without invalidating the + /// accuser's signature. Guard: changing only `deadline` must change the + /// digest. + #[test] + fn accusation_digest_binds_deadline() { + let make = |deadline: u64| ProofFailureAccusation { + e3_id: E3id::new("9", 31337), + accuser: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .parse() + .unwrap(), + accused: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .parse() + .unwrap(), + accused_party_id: 1, + proof_type: ProofType::C1PkGeneration, + data_hash: [0x42; 32], + deadline, + signed_payload: None, + signature: ArcBytes::default(), + }; + let a = AccusationManager::accusation_digest(&make(1_700_000_000)); + let b = AccusationManager::accusation_digest(&make(1_700_000_001)); + assert_ne!(a, b, "deadline must be part of the accusation digest"); + } + + #[test] + fn peer_deadline_acceptance_enforces_local_window() { + let now = 1_700_000_000u64; + let validity = 1_800u64; + let skew = DEFAULT_ACCUSATION_DEADLINE_SKEW_SECS; + let max_ok = now + validity + skew; + + assert!( + !AccusationManager::is_peer_deadline_acceptable(now, now, validity, skew), + "deadline equal to now must be rejected" + ); + assert!( + !AccusationManager::is_peer_deadline_acceptable(now - 1, now, validity, skew), + "expired deadline must be rejected" + ); + assert!( + AccusationManager::is_peer_deadline_acceptable(max_ok, now, validity, skew), + "deadline at upper bound must be accepted" + ); + assert!( + !AccusationManager::is_peer_deadline_acceptable(max_ok + 1, now, validity, skew), + "far-future deadline must be rejected" + ); + assert!( + !AccusationManager::is_peer_deadline_acceptable(now + 10, now, 0, skew), + "vote_validity_secs=0 must reject peer accusations" + ); + } +} diff --git a/crates/slashing/src/accusation_manager_ext.rs b/crates/slashing/src/accusation_manager_ext.rs new file mode 100644 index 0000000000..43d78ba949 --- /dev/null +++ b/crates/slashing/src/accusation_manager_ext.rs @@ -0,0 +1,156 @@ +// 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. + +//! E3Extension that wires up the [`AccusationManager`] per-E3 when the +//! committee is finalized. +//! +//! Listens for [`CommitteeFinalized`], reads `threshold_m` from [`E3Meta`], +//! parses committee addresses, and starts the actor with full context. + +use std::collections::HashMap; + +use crate::accusation_manager::AccusationManager; +use alloy::primitives::Address; +use alloy::signers::local::PrivateKeySigner; +use anyhow::Result; +use async_trait::async_trait; +use e3_events::{BusHandle, CommitteeFinalized, EnclaveEvent, EnclaveEventData, Event}; +use e3_request::{E3Context, E3ContextSnapshot, E3Extension, META_KEY}; +use tracing::{error, info, warn}; + +pub struct AccusationManagerExtension { + bus: BusHandle, + signer: PrivateKeySigner, + /// On-chain `SlashingManager` address (EIP-712 `verifyingContract` for vote sigs). + slashing_manager: Address, + /// Per-chain off-chain freshness window (seconds), read from + /// `CiphernodeRegistry.accusationVoteValidity()` at process startup. + /// Looked up by `e3_id.chain_id()` when each per-E3 actor starts; + /// governance changes require a node restart to take effect (same lifecycle + /// contract as `slashing_manager`). + vote_validity_secs_by_chain: HashMap, + /// Clock-skew allowance for peer accusation deadlines. + accusation_deadline_skew_secs: u64, +} + +impl AccusationManagerExtension { + pub fn create( + bus: &BusHandle, + signer: PrivateKeySigner, + slashing_manager: Address, + vote_validity_secs_by_chain: HashMap, + accusation_deadline_skew_secs: u64, + ) -> Box { + Box::new(Self { + bus: bus.clone(), + signer: signer.clone(), + slashing_manager, + vote_validity_secs_by_chain, + accusation_deadline_skew_secs, + }) + } + + fn vote_validity_secs_for(&self, chain_id: u64) -> u64 { + match self.vote_validity_secs_by_chain.get(&chain_id) { + Some(&secs) => secs, + None => { + warn!( + chain_id, + "no accusationVoteValidity configured for chain; accusation votes will not be stamped" + ); + 0 + } + } + } +} + +#[async_trait] +impl E3Extension for AccusationManagerExtension { + fn on_event(&self, ctx: &mut E3Context, evt: &EnclaveEvent) { + let EnclaveEventData::CommitteeFinalized(data) = evt.get_data() else { + return; + }; + + // Don't start twice + if ctx.get_event_recipient("accusation_manager").is_some() { + return; + } + + let CommitteeFinalized { + e3_id, committee, .. + } = data.clone(); + + // Parse committee addresses — all must be valid or we cannot start + let mut committee_addresses: Vec
= Vec::with_capacity(committee.len()); + for s in committee.iter() { + match s.parse::
() { + Ok(addr) => committee_addresses.push(addr), + Err(e) => { + error!( + "Failed to parse committee address {} — cannot start AccusationManager: {}", + s, e + ); + return; + } + } + } + + if committee_addresses.is_empty() { + error!("No committee addresses — cannot start AccusationManager"); + return; + } + + // Get threshold from meta + let Some(meta) = ctx.get_dependency(META_KEY) else { + error!("E3Meta not available — cannot start AccusationManager"); + return; + }; + let threshold_m = meta.threshold_m; + + info!( + "Starting AccusationManager for E3 {} with {} committee members, threshold={}", + e3_id, + committee_addresses.len(), + threshold_m + ); + + let vote_validity_secs = self.vote_validity_secs_for(e3_id.chain_id()); + + let addr = AccusationManager::setup( + &self.bus, + e3_id, + self.signer.clone(), + self.slashing_manager, + committee_addresses, + threshold_m, + vote_validity_secs, + self.accusation_deadline_skew_secs, + meta.params_preset, + ); + + ctx.set_event_recipient("accusation_manager", Some(addr.into())); + } + + /// Re-hydrates the `AccusationManager` after a node restart. + /// + /// Intentionally a no-op — `AccusationManager` is **ephemeral by design**: + /// + /// - Each instance is scoped to one E3 (created by [`AccusationManagerExtension::handle`] + /// when `CommitteeFinalized` is received) and holds only transient in-memory state + /// (pending accusations, buffered votes, verification caches). + /// - On restart, all in-flight accusations are lost. This is an accepted trade-off: + /// every pending accusation has a finite vote timeout (default 5 min). If the node + /// restarts, the accusation would have timed out anyway. Other committee members + /// running their own independent `AccusationManager` instances will continue the + /// protocol unaffected. + /// - A malicious node cannot exploit restart-induced state loss to prevent slashing: + /// restarting only loses *this node's* pending state — all other honest nodes still + /// independently verify, vote, and reach quorum without this node's participation + /// (as long as enough honest nodes remain to meet threshold M). + async fn hydrate(&self, _ctx: &mut E3Context, _snapshot: &E3ContextSnapshot) -> Result<()> { + Ok(()) + } +} diff --git a/crates/slashing/src/commitment_consistency_checker.rs b/crates/slashing/src/commitment_consistency_checker.rs new file mode 100644 index 0000000000..c77db5ae00 --- /dev/null +++ b/crates/slashing/src/commitment_consistency_checker.rs @@ -0,0 +1,461 @@ +// 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. + +//! Actor that cross-checks commitment values across different circuit proofs. +//! +//! Has two roles: +//! +//! 1. **Pre-ZK gating** (request/response): Subscribes to +//! [`CommitmentConsistencyCheckRequested`] from [`ShareVerificationActor`], +//! caches each party's public signals, evaluates all registered +//! [`CommitmentLink`]s, and responds with +//! [`CommitmentConsistencyCheckComplete`]. Inconsistent parties are excluded +//! from ZK verification. +//! +//! 2. **Post-ZK cross-circuit checking**: Subscribes to +//! [`ProofVerificationPassed`] events and, for each registered link, +//! compares commitment values across different circuit proofs. On mismatch, +//! publishes [`CommitmentConsistencyViolation`] for the accusation pipeline. +//! +//! ## Architecture +//! +//! - Caches verified proof outputs keyed by `(Address, ProofType)`. +//! - On each new event, evaluates every registered link to see if both sides +//! (source and target) are now available. +//! - For **same-party** links, compares proofs from the same Ethereum address. +//! - For **cross-party** links (e.g. per-node C1 vs aggregator C5), checks all +//! cached source proofs against the newly arrived target (or vice versa). +//! - Logs warnings on mismatch. Future iterations may emit an accusation event. + +use actix::{Actor, Addr, Context, Handler}; +use alloy::primitives::Address; +use alloy::sol_types::SolValue; +use e3_events::{ + BusHandle, CommitmentConsistencyCheckComplete, CommitmentConsistencyCheckRequested, + CommitmentConsistencyViolation, E3id, EnclaveEvent, EnclaveEventData, EventContext, + EventPublisher, EventSubscriber, EventType, ProofType, ProofVerificationPassed, Sequenced, + TypedEvent, +}; +use e3_events::{CommitmentLink, LinkScope}; +use e3_utils::utility_types::ArcBytes; +use e3_utils::NotifySync; +use std::collections::{BTreeSet, HashMap}; +use tracing::{error, info, warn}; + +/// Cached data from a verified proof. +struct VerifiedProofData { + party_id: u64, + address: Address, + public_signals: ArcBytes, + data_hash: [u8; 32], + /// Raw `proof.data` bytes. Together with `public_signals` they form the + /// preimage `abi.encode(proof.data, public_signals)` of `data_hash` — + /// forwarded to slashing so the on-chain contract can verify the dataHash + /// bound in voter signatures. + proof_data: ArcBytes, +} + +/// Describes a source entry whose commitments are inconsistent with a target. +struct Mismatch { + party_id: u64, + address: Address, + proof_type: ProofType, + data_hash: [u8; 32], + /// Same preimage as `VerifiedProofData.proof_data` paired with + /// `public_signals`. Carried from cache into the emitted violation so + /// downstream slashing can bind voter signatures to evidence bytes. + proof_data: ArcBytes, + public_signals: ArcBytes, +} + +/// Per-E3 actor that enforces cross-circuit commitment consistency. +pub struct CommitmentConsistencyChecker { + bus: BusHandle, + e3_id: E3id, + links: Vec>, + /// Verified proof outputs: `(address, proof_type) → data`. + /// Multiple proofs per key are supported (e.g. N-1 C3a proofs per sender). + verified: HashMap<(Address, ProofType), Vec>, +} + +impl CommitmentConsistencyChecker { + pub fn new(bus: &BusHandle, e3_id: E3id, links: Vec>) -> Self { + Self { + bus: bus.clone(), + e3_id, + links, + verified: HashMap::new(), + } + } + + /// 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(); + bus.subscribe( + EventType::CommitmentConsistencyCheckRequested, + addr.clone().into(), + ); + bus.subscribe(EventType::ProofVerificationPassed, addr.clone().into()); + addr + } + + /// Find all source entries whose commitments are inconsistent with cached + /// targets for a given link. + fn find_mismatches(&self, link: &dyn CommitmentLink) -> Vec { + let src_type = link.source_proof_type(); + let tgt_type = link.target_proof_type(); + + match link.scope() { + // 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, + src.party_id, + tgt.party_id, + ) { + mismatches.push(Mismatch { + party_id: src.party_id, + address: *addr, + proof_type: src_type, + data_hash: src.data_hash, + proof_data: src.proof_data.clone(), + public_signals: src.public_signals.clone(), + }); + 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 all_targets: Vec<&VerifiedProofData> = self + .verified + .iter() + .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() { + continue; + } + // Source must match AT LEAST ONE target. + let found = all_targets.iter().any(|tgt| { + link.check_consistency( + &vals, + &tgt.public_signals, + src.party_id, + tgt.party_id, + ) + }); + if !found { + mismatches.push(Mismatch { + party_id: src.party_id, + address: src.address, + proof_type: src_type, + data_hash: src.data_hash, + proof_data: src.proof_data.clone(), + public_signals: src.public_signals.clone(), + }); + } + } + } + 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 == 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() { + continue; + } + let found = all_targets.iter().any(|tgt| { + link.check_consistency( + &vals, + &tgt.public_signals, + src.party_id, + tgt.party_id, + ) + }); + if !found { + mismatches.push(Mismatch { + party_id: src.party_id, + address: src.address, + proof_type: src_type, + data_hash: src.data_hash, + proof_data: src.proof_data.clone(), + public_signals: src.public_signals.clone(), + }); + } + } + } + mismatches + } + } + } + + /// Post-ZK: evaluate links relevant to a newly arrived proof and emit + /// violations on mismatch. + fn check_links(&self, new_proof_type: ProofType, ec: &EventContext) { + for link in &self.links { + if new_proof_type != link.source_proof_type() + && new_proof_type != link.target_proof_type() + { + continue; + } + for m in self.find_mismatches(link.as_ref()) { + // Defense-in-depth: skip entries with unresolved data_hash + // (should not happen now that pre-ZK caching uses real hashes, + // but guards against future regressions). + if m.data_hash == [0u8; 32] { + warn!( + "[{}] Skipping mismatch with zero data_hash for party {} ({}) {:?}", + link.name(), + m.party_id, + m.address, + m.proof_type, + ); + continue; + } + warn!( + "[{}] Commitment mismatch for E3 {} — party {} ({}) {:?}", + link.name(), + self.e3_id, + m.party_id, + m.address, + m.proof_type, + ); + self.emit_violation(&m, ec); + } + } + } + + /// Publish a [`CommitmentConsistencyViolation`] for the accusation pipeline. + fn emit_violation(&self, m: &Mismatch, ec: &EventContext) { + // Evidence preimage = `abi.encode(proof.data, public_signals)`. The + // on-chain `SlashingManager.proposeSlash` recomputes `keccak256(evidence)` + // and requires it to equal each voter's signed `dataHash`. Without + // these bytes, slashing via the consistency-violation path would be + // gated by the evidence binding (safe but unable to slash). + let evidence = alloy::primitives::Bytes::from( + ( + alloy::primitives::Bytes::copy_from_slice(&m.proof_data), + alloy::primitives::Bytes::copy_from_slice(&m.public_signals), + ) + .abi_encode(), + ); + let violation = CommitmentConsistencyViolation { + e3_id: self.e3_id.clone(), + accused_party_id: m.party_id, + accused_address: m.address, + proof_type: m.proof_type, + data_hash: m.data_hash, + evidence, + }; + if let Err(err) = self.bus.publish(violation, ec.clone()) { + error!("Failed to publish CommitmentConsistencyViolation: {err}"); + } + } +} + +impl Actor for CommitmentConsistencyChecker { + type Context = Context; + + fn started(&mut self, _ctx: &mut Self::Context) { + info!( + "CommitmentConsistencyChecker started for E3 {} with {} link(s)", + self.e3_id, + self.links.len() + ); + } +} + +impl Handler for CommitmentConsistencyChecker { + type Result = (); + + fn handle(&mut self, msg: EnclaveEvent, ctx: &mut Self::Context) -> Self::Result { + let (msg, ec) = msg.into_components(); + match msg { + EnclaveEventData::CommitmentConsistencyCheckRequested(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::ProofVerificationPassed(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + _ => (), + } + } +} + +impl Handler> for CommitmentConsistencyChecker { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + let (data, ec) = msg.into_components(); + if data.e3_id != self.e3_id { + return; + } + + let proof_type = data.proof_type; + let address = data.address; + + self.insert_verified( + address, + proof_type, + VerifiedProofData { + party_id: data.party_id, + address, + public_signals: data.public_signals, + data_hash: data.data_hash, + proof_data: data.proof_data, + }, + ); + + self.check_links(proof_type, &ec); + } +} + +impl Handler> for CommitmentConsistencyChecker { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + let (data, ec) = msg.into_components(); + if data.e3_id != self.e3_id { + return; + } + + let mut inconsistent_parties = BTreeSet::new(); + + // Cache each party's proof data for link evaluation. + for party in &data.party_proofs { + for (proof_type, public_signals, data_hash, proof_data) in &party.proofs { + self.insert_verified( + party.address, + *proof_type, + VerifiedProofData { + party_id: party.party_id, + address: party.address, + public_signals: public_signals.clone(), + data_hash: *data_hash, + proof_data: proof_data.clone(), + }, + ); + } + } + + // Evaluate every link and collect inconsistent parties. + // Also emit violations so AccusationManager can initiate the quorum + // protocol — parties excluded pre-ZK would otherwise never trigger a + // post-ZK violation. + for link in &self.links { + for m in self.find_mismatches(link.as_ref()) { + warn!( + "[{}] Pre-ZK commitment mismatch for E3 {} — party {} ({})", + link.name(), + self.e3_id, + m.party_id, + m.address, + ); + inconsistent_parties.insert(m.party_id); + self.emit_violation(&m, &ec); + } + } + + // 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(|_, entries| { + entries.retain(|v| !inconsistent_parties.contains(&v.party_id)); + !entries.is_empty() + }); + } + + // Respond to ShareVerificationActor. + if let Err(err) = self.bus.publish( + CommitmentConsistencyCheckComplete { + e3_id: data.e3_id, + kind: data.kind, + correlation_id: data.correlation_id, + inconsistent_parties, + }, + ec, + ) { + error!("Failed to publish CommitmentConsistencyCheckComplete: {err}"); + } + } +} diff --git a/crates/slashing/src/commitment_consistency_checker_ext.rs b/crates/slashing/src/commitment_consistency_checker_ext.rs new file mode 100644 index 0000000000..4f64ecd409 --- /dev/null +++ b/crates/slashing/src/commitment_consistency_checker_ext.rs @@ -0,0 +1,74 @@ +// 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. + +//! E3Extension that wires up the [`CommitmentConsistencyChecker`] per-E3 +//! when the committee is finalized. +//! +//! Follows the same lifecycle pattern as [`AccusationManagerExtension`]: +//! listens for [`CommitteeFinalized`], creates the actor, and registers it +//! in the [`E3Context`] so it receives routed events. + +use crate::commitment_consistency_checker::CommitmentConsistencyChecker; +use anyhow::Result; +use async_trait::async_trait; +use e3_events::{BusHandle, CommitmentLink, EnclaveEvent, EnclaveEventData, Event}; +use e3_fhe_params::BfvPreset; +use e3_request::{E3Context, E3ContextSnapshot, E3Extension, META_KEY}; +use tracing::{error, info}; + +type LinksFactory = Box Vec> + Send + Sync>; + +pub struct CommitmentConsistencyCheckerExtension { + bus: BusHandle, + /// Factory that builds commitment links for a given BFV preset. + links_factory: LinksFactory, +} + +impl CommitmentConsistencyCheckerExtension { + pub fn create( + bus: &BusHandle, + links_factory: impl Fn(BfvPreset) -> Vec> + Send + Sync + 'static, + ) -> Box { + Box::new(Self { + bus: bus.clone(), + links_factory: Box::new(links_factory), + }) + } +} + +#[async_trait] +impl E3Extension for CommitmentConsistencyCheckerExtension { + fn on_event(&self, ctx: &mut E3Context, evt: &EnclaveEvent) { + let EnclaveEventData::CommitteeFinalized(data) = evt.get_data() else { + return; + }; + + if ctx + .get_event_recipient("commitment_consistency_checker") + .is_some() + { + return; + } + + let e3_id = data.e3_id.clone(); + + let Some(meta) = ctx.get_dependency(META_KEY) else { + error!("E3Meta not available — cannot start CommitmentConsistencyChecker"); + return; + }; + + info!("Starting CommitmentConsistencyChecker for E3 {}", e3_id); + + let links = (self.links_factory)(meta.params_preset); + let addr = CommitmentConsistencyChecker::setup(&self.bus, e3_id, links); + + ctx.set_event_recipient("commitment_consistency_checker", Some(addr.into())); + } + + async fn hydrate(&self, _ctx: &mut E3Context, _snapshot: &E3ContextSnapshot) -> Result<()> { + Ok(()) + } +} diff --git a/crates/slashing/src/lib.rs b/crates/slashing/src/lib.rs new file mode 100644 index 0000000000..f6f66d5d2d --- /dev/null +++ b/crates/slashing/src/lib.rs @@ -0,0 +1,20 @@ +// 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. + +//! E3 fault attribution and accusation quorum protocol. +//! +//! Extracted from `e3-zk-prover` — accusation management is protocol-level +//! fault-handling, not ZK infrastructure. + +pub mod accusation_manager; +pub mod accusation_manager_ext; +pub mod commitment_consistency_checker; +pub mod commitment_consistency_checker_ext; + +pub use accusation_manager::AccusationManager; +pub use accusation_manager_ext::AccusationManagerExtension; +pub use commitment_consistency_checker::CommitmentConsistencyChecker; +pub use commitment_consistency_checker_ext::CommitmentConsistencyCheckerExtension; diff --git a/crates/test-helpers/src/ciphernode_system.rs b/crates/test-helpers/src/ciphernode_system.rs index 247bc775a7..86c810c3f1 100644 --- a/crates/test-helpers/src/ciphernode_system.rs +++ b/crates/test-helpers/src/ciphernode_system.rs @@ -8,7 +8,7 @@ use crate::simulate_libp2p_net; use anyhow::bail; use anyhow::Context; use anyhow::Result; -use e3_ciphernode_builder::CiphernodeHandle; +use e3_ciphernode_builder::{CiphernodeHandle, NetInterfaceKind}; use e3_events::Event; use e3_events::{EnclaveEvent, GetEvents, ResetHistory, TakeEvents}; use std::time::Instant; @@ -332,7 +332,7 @@ mod tests { history: Some(history), errors: Some(errors), peer_id: PeerId::random(), - channel_bridge: None, + net_interface: NetInterfaceKind::Libp2p, }) } diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 85c7bdf7ad..8eebca8393 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -1183,21 +1183,41 @@ async fn test_trbfv_actor() -> Result<()> { // Actor system setup let concurrent_jobs = benchmark_multithread_concurrent_jobs(); - let _fold_verifier_env_guard = (benchmark_proof_aggregation_enabled() - && std::env::var("BENCHMARK_DKG_FOLD_ATTESTATION_VERIFIER").is_err()) - .then(|| { - // In-process benchmark has no RPC; same default as pre-registry-fetch harness. - ScopedEnvVar::set( - "BENCHMARK_DKG_FOLD_ATTESTATION_VERIFIER", - "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0", - ) - }); let slashing_manager_addr = benchmark_slashing_manager_address(); let max_threadroom = Multithread::get_max_threads_minus(1); let pool_threads = concurrent_jobs.min(max_threadroom).max(1); let task_pool = Multithread::create_taskpool(pool_threads, concurrent_jobs); let multithread_report = MultithreadReport::new(pool_threads, concurrent_jobs).start(); + // Minimal chain config for in-process benchmarks (no RPC needed). + // Provides slashing_manager address for EIP-712 accusation vote signatures. + let bench_chain_config = e3_config::chain_config::ChainConfig { + enabled: Some(false), + name: "bench".into(), + rpc_url: "http://localhost:8545".into(), + rpc_auth: Default::default(), + contracts: e3_config::ContractAddresses { + enclave: e3_config::Contract::AddressOnly( + "0x0000000000000000000000000000000000000000".into(), + ), + ciphernode_registry: e3_config::Contract::AddressOnly( + "0x0000000000000000000000000000000000000000".into(), + ), + bonding_registry: e3_config::Contract::AddressOnly( + "0x0000000000000000000000000000000000000000".into(), + ), + e3_program: None, + fee_token: None, + slashing_manager: Some(e3_config::Contract::AddressOnly( + slashing_manager_addr.to_string(), + )), + dkg_fold_attestation_verifier: benchmark_dkg_fold_attestation_verifier_address() + .map(|a| e3_config::Contract::AddressOnly(a.to_string())), + }, + finalization_ms: None, + chain_id: Some(1), + }; + // Setup ZK backend for proof generation/verification let (zk_backend, _zk_temp) = setup_test_zk_backend(benchmark_params.preset_subdir).await?; @@ -1211,19 +1231,18 @@ async fn test_trbfv_actor() -> Result<()> { println!("Building collector {}!", addr); { let mut b = CiphernodeBuilder::new(node_rng, cipher.clone()) - .testmode_with_history() + .with_history_collector() .with_shared_taskpool(&task_pool) .with_multithread_concurrent_jobs(concurrent_jobs) .with_shared_multithread_report(&multithread_report) .with_trbfv() .with_zkproof(zk_backend.clone()) - .testmode_with_signer(PrivateKeySigner::random()) + .with_signer(PrivateKeySigner::random()) .with_pubkey_aggregation() .with_sortition_score() .with_threshold_plaintext_aggregation() - .testmode_with_forked_bus(bus.event_bus()) - .testmode_ignore_address_check() - .testmode_with_slashing_manager(slashing_manager_addr) + .with_forked_bus(bus.event_bus()) + .with_chains(&[bench_chain_config.clone()]) .with_logging(); b.build().await } @@ -1234,19 +1253,18 @@ async fn test_trbfv_actor() -> Result<()> { println!("Building normal {}", &addr); { let mut b = CiphernodeBuilder::new(node_rng, cipher.clone()) - .testmode_with_history() + .with_history_collector() .with_shared_taskpool(&task_pool) .with_multithread_concurrent_jobs(concurrent_jobs) .with_shared_multithread_report(&multithread_report) .with_trbfv() .with_zkproof(zk_backend.clone()) - .testmode_with_signer(PrivateKeySigner::random()) + .with_signer(PrivateKeySigner::random()) .with_pubkey_aggregation() .with_sortition_score() .with_threshold_plaintext_aggregation() - .testmode_with_forked_bus(bus.event_bus()) - .testmode_ignore_address_check() - .testmode_with_slashing_manager(slashing_manager_addr) + .with_forked_bus(bus.event_bus()) + .with_chains(&[bench_chain_config.clone()]) .with_logging(); b.build().await } @@ -2093,10 +2111,10 @@ async fn test_stopped_keyshares_retain_state() -> Result<()> { let mut builder = CiphernodeBuilder::new(rng.clone(), cipher.clone()) .with_trbfv() .with_zkproof(zk_backend) - .testmode_with_signer(PrivateKeySigner::random()) - .testmode_with_forked_bus(bus.event_bus()) - .testmode_with_history() - .testmode_with_errors() + .with_signer(PrivateKeySigner::random()) + .with_forked_bus(bus.event_bus()) + .with_history_collector() + .with_error_collector() .with_pubkey_aggregation() .with_threshold_plaintext_aggregation() .with_sortition_score(); @@ -2314,10 +2332,10 @@ async fn test_duplicate_e3_id_with_different_chain_id() -> Result<()> { let mut builder = CiphernodeBuilder::new(rng.clone(), cipher.clone()) .with_trbfv() .with_zkproof(zk_backend) - .testmode_with_signer(PrivateKeySigner::random()) - .testmode_with_forked_bus(bus.event_bus()) - .testmode_with_history() - .testmode_with_errors() + .with_signer(PrivateKeySigner::random()) + .with_forked_bus(bus.event_bus()) + .with_history_collector() + .with_error_collector() .with_pubkey_aggregation() .with_threshold_plaintext_aggregation() .with_sortition_score(); diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index cebe484273..d09821b429 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -11,6 +11,7 @@ actix.workspace = true alloy.workspace = true anyhow.workspace = true derivative.workspace = true +e3-committee-hash.workspace = true rand.workspace = true hex.workspace = true regex.workspace = true diff --git a/crates/utils/src/committee_hash.rs b/crates/utils/src/committee_hash.rs index 798ecd244e..7dbdde951f 100644 --- a/crates/utils/src/committee_hash.rs +++ b/crates/utils/src/committee_hash.rs @@ -4,89 +4,5 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -//! Canonical committee hash for DKG / decryption aggregator proofs. -//! Must match `CommitteeHashLib.sol` (`keccak256(abi.encodePacked(addresses))`). - -use alloy::primitives::{keccak256, Address, B256}; - -/// Hi/lo limbs of `keccak256(abi.encodePacked(addresses))` for Noir public inputs. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CommitteeHashLimbs { - pub hi: B256, - pub lo: B256, -} - -/// `keccak256(abi.encodePacked(addresses))` for the ordered on-chain committee. -pub fn hash_committee_addresses(addresses: &[Address]) -> B256 { - let packed: Vec = addresses - .iter() - .flat_map(|addr| addr.into_array()) - .collect(); - keccak256(packed) -} - -/// Split a committee hash into 128-bit limbs for BN254 public inputs. -/// Each limb is a bytes32 with its 128 bits right-aligned, matching `CommitteeHashLib`. -pub fn split_committee_hash(hash: B256) -> CommitteeHashLimbs { - let mut hi = [0u8; 32]; - hi[16..].copy_from_slice(&hash.0[..16]); - let mut lo = [0u8; 32]; - lo[16..].copy_from_slice(&hash.0[16..]); - CommitteeHashLimbs { - hi: B256::from(hi), - lo: B256::from(lo), - } -} - -/// Hash and split in one step. -pub fn committee_hash_limbs_from_addresses(addresses: &[Address]) -> CommitteeHashLimbs { - split_committee_hash(hash_committee_addresses(addresses)) -} - -/// Field hex strings (`0x…`, 32 bytes) for Noir witness `committee_hash_hi` / `committee_hash_lo`. -pub fn committee_hash_field_hex(addresses: &[Address]) -> (String, String) { - let limbs = committee_hash_limbs_from_addresses(addresses); - (field_hex_from_b256(limbs.hi), field_hex_from_b256(limbs.lo)) -} - -fn field_hex_from_b256(value: B256) -> String { - format!("0x{}", hex::encode(value)) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy::primitives::address; - - #[test] - fn encode_packed_matches_solidity_layout() { - let nodes = vec![ - address!("0x0000000000000000000000000000000000000001"), - address!("0x0000000000000000000000000000000000000002"), - ]; - let hash = hash_committee_addresses(&nodes); - let limbs = split_committee_hash(hash); - assert_ne!(limbs.hi, B256::ZERO); - assert_ne!(limbs.lo, B256::ZERO); - } - - /// Limb bytes32 layout must match `CommitteeHashLib.hi` / `lo`. - #[test] - fn split_limbs_match_solidity_bytes32_layout() { - let nodes = vec![ - address!("0x0000000000000000000000000000000000000001"), - address!("0x0000000000000000000000000000000000000002"), - address!("0x0000000000000000000000000000000000000003"), - ]; - let hash = hash_committee_addresses(&nodes); - let limbs = split_committee_hash(hash); - - let mut expected_hi = [0u8; 32]; - expected_hi[16..].copy_from_slice(&hash.0[..16]); - assert_eq!(limbs.hi.0, expected_hi); - - let mut expected_lo = [0u8; 32]; - expected_lo[16..].copy_from_slice(&hash.0[16..]); - assert_eq!(limbs.lo.0, expected_lo); - } -} +//! Re-export from `e3-committee-hash` for backward compatibility. +pub use e3_committee_hash::*; diff --git a/crates/zk-prover/Cargo.toml b/crates/zk-prover/Cargo.toml index d1c972f2a6..7ca9b1323e 100644 --- a/crates/zk-prover/Cargo.toml +++ b/crates/zk-prover/Cargo.toml @@ -18,12 +18,14 @@ bincode = "1.3.3" bn254_blackbox_solver = { git = "https://github.com/noir-lang/noir", tag = "v1.0.0-beta.16" } chrono = { workspace = true } directories = "5" +e3-committee-hash.workspace = true e3-config.workspace = true e3-data.workspace = true e3-events.workspace = true e3-fhe-params.workspace = true e3-polynomial.workspace = true e3-request.workspace = true +e3-slashing.workspace = true e3-utils.workspace = true e3-zk-helpers.workspace = true fhe.workspace = true diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs index e6dceb21b3..8a64a3ef40 100644 --- a/crates/zk-prover/src/actors/accusation_manager.rs +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -4,1806 +4,5 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -//! Off-chain accusation quorum protocol for fault attribution. -//! -//! When a node detects a ZK proof failure from another committee member, it -//! broadcasts a [`ProofFailureAccusation`] over gossip. Other committee members -//! independently check the same proof and respond with [`AccusationVote`]s. -//! Once a quorum of M (the cryptographic threshold) votes is reached, the -//! actor emits [`AccusationQuorumReached`] for downstream consumers (aggregator -//! exclusion, on-chain slash submission). -//! -//! ## Proof-type-specific behavior -//! -//! | Proof | Attestation | Notes | -//! |---------|----------------------------|--------------------------------------------| -//! | C0 | All nodes independently | Everyone receives via DHT | -//! | C1 | All nodes independently | Bundled in ThresholdShareCreated | -//! | C2a/C2b | All nodes independently | Same proof bytes for all recipients | -//! | C3a/C3b | Forwarding required | Per-recipient; accuser forwards payload | -//! | C4 | All nodes independently | Broadcast via gossip | -//! | C5 | Committee attests | Aggregator-generated; nodes verify off-chain| -//! | C6 | All nodes independently | Broadcast via gossip | -//! | C7 | On-chain verification | Not handled here (on-chain verifier) | - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use actix::{Actor, Addr, AsyncContext, Context, Handler, SpawnHandle}; -use alloy::primitives::{keccak256, Address, Bytes, U256}; -use alloy::signers::local::PrivateKeySigner; -use alloy::signers::SignerSync; -use alloy::sol_types::SolValue; -use e3_events::{ - AccusationOutcome, AccusationQuorumReached, AccusationVote, BusHandle, - CommitmentConsistencyViolation, ComputeRequest, ComputeRequestError, ComputeResponse, - ComputeResponseKind, CorrelationId, E3id, EnclaveEvent, EnclaveEventData, EventContext, - EventPublisher, EventSubscriber, EventType, PartyProofsToVerify, ProofFailureAccusation, - ProofType, ProofVerificationFailed, ProofVerificationPassed, Sequenced, SignedProofPayload, - SlashExecuted, TypedEvent, VerifyShareProofsRequest, ZkRequest, ZkResponse, VOTE_DOMAIN_NAME, - VOTE_DOMAIN_VERSION, VOTE_TYPEHASH_STR, -}; -use e3_utils::{ArcBytes, NotifySync}; -use tracing::{error, info, warn}; - -/// How long to wait for votes before declaring the accusation inconclusive. -const DEFAULT_VOTE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes -/// Default clock-skew allowance when validating peer-stamped accusation deadlines. -#[cfg(test)] -const DEFAULT_ACCUSATION_DEADLINE_SKEW_SECS: u64 = 30; - -/// Abstraction over wall-clock time so the deadline-stamping logic is -/// deterministically testable. Production uses [`SystemClock`], which reads -/// `SystemTime::now()`; tests can inject a mock clock that returns fixed -/// timestamps. -pub trait Clock: Send + Sync + 'static { - /// Current Unix time in seconds. Returns `0` if the platform clock is - /// pre-`UNIX_EPOCH` (a broken clock should not silently produce - /// signatures that look valid forever — the on-chain check will then - /// reject the resulting deadline immediately). - fn unix_now_secs(&self) -> u64; -} - -/// Production clock backed by `SystemTime::now()`. -pub struct SystemClock; - -impl Clock for SystemClock { - fn unix_now_secs(&self) -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - } -} - -/// An active accusation awaiting agreement votes from committee members. -/// -/// There is no `votes_against` field: a peer who finds the disputed proof -/// passes simply stays silent rather than broadcasting a signed disagreement -/// (see `AccusationVote` docstring for rationale). The accusation runs to -/// quorum or to `vote_timeout`. -struct PendingAccusation { - accusation: ProofFailureAccusation, - votes_for: Vec, - /// Handle to the timeout future so it can be cancelled on early quorum. - timeout_handle: Option, - /// The EventContext from when this accusation was created — used for timeout emission. - ec: EventContext, -} - -/// Cached verification result for a proof from a specific (accused, proof_type) pair. -/// Populated as proofs are received and verified (pass or fail). -struct ReceivedProofData { - data_hash: [u8; 32], - /// `true` if our local verification passed, `false` if it failed. - verification_passed: bool, - /// Raw `abi.encode(proof.data, proof.public_signals)` — preimage of - /// `data_hash`. Forwarded to the on-chain slashing contract so it can - /// recompute and verify the dataHash bound in voter signatures. Empty - /// only on paths where the raw bytes weren't available locally; those - /// paths can still slash, but they fall back to off-chain trust for - /// the evidence binding. - evidence: Bytes, -} - -/// Tracks an in-flight ZK re-verification for a forwarded C3a/C3b proof. -struct PendingReVerification { - accusation_id: [u8; 32], - data_hash: [u8; 32], - accused: Address, - proof_type: ProofType, - /// Evidence preimage bytes from the forwarded proof, used to populate - /// `ReceivedProofData.evidence` after ZK re-verification completes. - evidence: Bytes, -} - -/// Manages the off-chain accusation quorum protocol. -/// -/// **Lifecycle**: One instance per E3 computation. Created by -/// [`AccusationManagerExtension`] when [`CommitteeFinalized`] fires and -/// destroyed when the E3 completes or the node shuts down. All internal -/// state (pending accusations, votes, caches) is therefore naturally -/// scoped to a single E3 — no cross-E3 data contamination is possible. -/// -/// **Ephemeral**: This actor does *not* persist state across restarts. -/// In-flight accusations are lost on node restart (accepted trade-off: -/// they would have timed out within [`DEFAULT_VOTE_TIMEOUT`] anyway). -/// A strategic node restart can delay slash submission but cannot -/// prevent it, because other committee members independently maintain -/// their own `AccusationManager` instances and will continue voting. -/// -/// Subscribes to: -/// - [`ProofVerificationFailed`] — local proof failure detection -/// - [`ProofVerificationPassed`] — cache successful verification for voting -/// - [`ProofFailureAccusation`] — incoming accusations from other nodes via gossip -/// - [`AccusationVote`] — incoming votes from other nodes via gossip -/// - [`SlashExecuted`] — on-chain slash confirmation for committee updates -/// -/// Publishes: -/// - [`ProofFailureAccusation`] — broadcast own accusations via gossip -/// - [`AccusationVote`] — broadcast own votes via gossip -/// - [`AccusationQuorumReached`] — quorum decision for downstream consumers -pub struct AccusationManager { - bus: BusHandle, - e3_id: E3id, - my_address: Address, - signer: PrivateKeySigner, - - /// On-chain `SlashingManager` address (EIP-712 `verifyingContract` for vote signatures). - slashing_manager: Address, - - /// All committee member addresses for this E3. - committee: Vec
, - /// Quorum threshold — matches the cryptographic threshold M. - threshold_m: usize, - - /// Active accusations keyed by accusation_id (keccak256 of accusation fields). - pending: HashMap<[u8; 32], PendingAccusation>, - - /// Dedup: (accused, proof_type) pairs we've already created an accusation for. - /// Prevents duplicate accusations when multiple local failure events fire. - accused_proofs: HashSet<(Address, ProofType)>, - - /// Cache of received data hashes per (accused, proof_type). - /// Populated by ProofVerificationFailed (failures) and ProofVerificationPassed (successes) - /// so the node can vote on accusations from other nodes. - received_data: HashMap<(Address, ProofType), ReceivedProofData>, - - /// Votes received before the corresponding accusation — replayed on accusation arrival. - buffered_votes: HashMap<[u8; 32], Vec>, - - /// In-flight C3a/C3b ZK re-verifications, keyed by CorrelationId. - pending_reverifications: HashMap, - - /// Vote timeout duration. - vote_timeout: Duration, - - /// Registry-wide off-chain freshness window (seconds) applied when stamping - /// `AccusationVote.deadline`. Fetched once per process from - /// `CiphernodeRegistry.accusationVoteValidity()` so a governance change - /// requires a node restart to take effect — same lifecycle as the fold - /// attestation verifier. - vote_validity_secs: u64, - /// Clock-skew allowance when validating peer accusation deadlines. - accusation_deadline_skew_secs: u64, - - /// Wall-clock source used to derive accusation deadlines. Production uses - /// [`SystemClock`]; tests can inject a deterministic mock. - clock: Arc, - - /// BFV preset for circuit artifact resolution. - params_preset: e3_fhe_params::BfvPreset, -} - -impl AccusationManager { - /// Construct an actor with the production [`SystemClock`]. Use - /// [`AccusationManager::new_with_clock`] in tests that need deterministic - /// timestamps. - pub fn new( - bus: &BusHandle, - e3_id: E3id, - signer: PrivateKeySigner, - slashing_manager: Address, - committee: Vec
, - threshold_m: usize, - vote_validity_secs: u64, - accusation_deadline_skew_secs: u64, - params_preset: e3_fhe_params::BfvPreset, - ) -> Self { - Self::new_with_clock( - bus, - e3_id, - signer, - slashing_manager, - committee, - threshold_m, - vote_validity_secs, - accusation_deadline_skew_secs, - params_preset, - Arc::new(SystemClock), - ) - } - - /// Construct an actor with an explicit [`Clock`]. Allows unit tests to - /// drive deadline computation without touching wall-clock time. - pub fn new_with_clock( - bus: &BusHandle, - e3_id: E3id, - signer: PrivateKeySigner, - slashing_manager: Address, - committee: Vec
, - threshold_m: usize, - vote_validity_secs: u64, - accusation_deadline_skew_secs: u64, - params_preset: e3_fhe_params::BfvPreset, - clock: Arc, - ) -> Self { - let my_address = signer.address(); - Self { - bus: bus.clone(), - e3_id, - my_address, - signer, - slashing_manager, - committee, - threshold_m, - pending: HashMap::new(), - accused_proofs: HashSet::new(), - received_data: HashMap::new(), - buffered_votes: HashMap::new(), - pending_reverifications: HashMap::new(), - vote_timeout: DEFAULT_VOTE_TIMEOUT, - vote_validity_secs, - accusation_deadline_skew_secs, - clock, - params_preset, - } - } - - pub fn setup( - bus: &BusHandle, - e3_id: E3id, - signer: PrivateKeySigner, - slashing_manager: Address, - committee: Vec
, - threshold_m: usize, - vote_validity_secs: u64, - accusation_deadline_skew_secs: u64, - params_preset: e3_fhe_params::BfvPreset, - ) -> Addr { - let addr = Self::new( - bus, - e3_id, - signer, - slashing_manager, - committee, - threshold_m, - vote_validity_secs, - accusation_deadline_skew_secs, - params_preset, - ) - .start(); - bus.subscribe(EventType::ProofVerificationFailed, addr.clone().into()); - bus.subscribe(EventType::ProofVerificationPassed, addr.clone().into()); - bus.subscribe(EventType::ProofFailureAccusation, addr.clone().into()); - bus.subscribe(EventType::AccusationVote, addr.clone().into()); - bus.subscribe(EventType::ComputeResponse, addr.clone().into()); - bus.subscribe(EventType::ComputeRequestError, addr.clone().into()); - bus.subscribe(EventType::SlashExecuted, addr.clone().into()); - bus.subscribe( - EventType::CommitmentConsistencyViolation, - addr.clone().into(), - ); - addr - } - - // ─── Deadline computation ──────────────────────────────────────────── - - /// Compute the on-chain vote-validity deadline (Unix seconds) the accuser - /// stamps on a fresh accusation. Voters then sign this exact value so the - /// aggregated evidence carries one shared deadline that `SlashingManager` - /// checks via `block.timestamp <= deadline`. - /// - /// `vote_validity_secs` is the registry-wide window fetched from - /// `CiphernodeRegistry.accusationVoteValidity()` at process startup — - /// governance can shorten or extend it; live nodes only pick up the new - /// value on restart. - /// - /// `saturating_add` guards against `u64` overflow in the unlikely event - /// governance sets the validity to a near-`u64::MAX` value. - fn compute_deadline(&self) -> u64 { - self.clock - .unix_now_secs() - .saturating_add(self.vote_validity_secs) - } - - /// Validate a peer-provided accusation deadline against this node's local - /// vote-validity policy and wall clock. - /// - /// Accept iff: - /// - validity is enabled (`vote_validity_secs > 0`) - /// - deadline is strictly in the future - /// - deadline is not farther than `now + vote_validity_secs + skew` - fn is_peer_deadline_acceptable( - deadline: u64, - now: u64, - vote_validity_secs: u64, - skew_secs: u64, - ) -> bool { - if vote_validity_secs == 0 { - return false; - } - let max_deadline = now - .saturating_add(vote_validity_secs) - .saturating_add(skew_secs); - deadline > now && deadline <= max_deadline - } - - // ─── Accusation ID computation ─────────────────────────────────────── - - /// Compute a deterministic ID for an accusation based on its key fields. - /// This ensures that the same (e3_id, accused, proof_type) produces the - /// same ID regardless of who the accuser is, enabling deduplication. - /// - /// `keccak256(abi.encodePacked(chainId, e3Id, accused, proofType))` - fn accusation_id(accusation: &ProofFailureAccusation) -> [u8; 32] { - let e3_id_u256: U256 = accusation - .e3_id - .clone() - .try_into() - .expect("E3id should be valid U256"); - let msg = ( - U256::from(accusation.e3_id.chain_id()), - e3_id_u256, - accusation.accused, - U256::from(accusation.proof_type as u8), - ) - .abi_encode_packed(); - keccak256(&msg).into() - } - - // ─── Signing / Verification ────────────────────────────────────────── - - fn sign_accusation_digest( - &self, - accusation: &ProofFailureAccusation, - ) -> Result, alloy::signers::Error> { - let digest = Self::accusation_digest(accusation); - let sig = self.signer.sign_message_sync(&digest)?; - Ok(sig.as_bytes().to_vec()) - } - - /// Structured digest for ECDSA signing of accusations. - /// - /// Off-chain only — this digest never reaches the chain. Includes `deadline` - /// so peers can verify the accuser's chosen on-chain validity window has not - /// been tampered with in transit: - /// ```text - /// keccak256(abi.encode( - /// ACCUSATION_TYPEHASH, - /// chainId, e3Id, accuser, accused, proofType, - /// dataHash, deadline - /// )) - /// ``` - fn accusation_digest(accusation: &ProofFailureAccusation) -> [u8; 32] { - let e3_id_u256: U256 = accusation - .e3_id - .clone() - .try_into() - .expect("E3id should be valid U256"); - let typehash: [u8; 32] = keccak256( - "ProofFailureAccusation(uint256 chainId,uint256 e3Id,address accuser,address accused,uint256 proofType,bytes32 dataHash,uint256 deadline)" - ).into(); - let encoded = ( - typehash, - U256::from(accusation.e3_id.chain_id()), - e3_id_u256, - accusation.accuser, - accusation.accused, - U256::from(accusation.proof_type as u8), - accusation.data_hash, - U256::from(accusation.deadline), - ) - .abi_encode(); - keccak256(&encoded).into() - } - - fn verify_accusation_signature(&self, accusation: &ProofFailureAccusation) -> bool { - let digest = Self::accusation_digest(accusation); - let sig = match alloy::primitives::Signature::try_from( - accusation.signature.extract_bytes().as_ref(), - ) { - Ok(s) => s, - Err(_) => return false, - }; - match sig.recover_address_from_msg(&digest) { - Ok(addr) => addr == accusation.accuser, - Err(_) => false, - } - } - - #[cfg_attr(test, allow(dead_code))] - fn sign_vote_digest(&self, vote: &AccusationVote) -> Result, alloy::signers::Error> { - let digest = Self::vote_digest(vote, self.slashing_manager); - // `sign_hash_sync` signs the raw 32-byte hash without EIP-191 wrapping, - // which is what EIP-712 requires (`digest` is already the - // `\x19\x01 || domainSeparator || structHash` hash). - let sig = self.signer.sign_hash_sync(&digest.into())?; - Ok(sig.as_bytes().to_vec()) - } - - /// Canonical EIP-712 domain separator for vote signatures. - /// - /// Must match `SlashingManager`'s domain construction exactly. The `name` - /// literal is `EIP712_DOMAIN_NAME` in the Solidity contract (see - /// `packages/enclave-contracts/contracts/slashing/SlashingManager.sol`); - /// keep these two strings in lockstep — divergence silently breaks - /// `ECDSA.recover` on chain. - fn vote_domain_separator(chain_id: u64, verifying_contract: Address) -> [u8; 32] { - let domain_typehash: [u8; 32] = keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", - ) - .into(); - let name_hash: [u8; 32] = keccak256(VOTE_DOMAIN_NAME).into(); - let version_hash: [u8; 32] = keccak256(VOTE_DOMAIN_VERSION).into(); - let encoded = ( - domain_typehash, - name_hash, - version_hash, - U256::from(chain_id), - verifying_contract, - ) - .abi_encode(); - keccak256(&encoded).into() - } - - /// Canonical EIP-712 typed-data hash for a vote. - /// - /// `keccak256("\x19\x01" || domainSeparator || structHash)` where the struct - /// matches `SlashingManager.VOTE_TYPEHASH`: - /// `AccusationVote(uint256 e3Id,bytes32 accusationId,address voter,bytes32 dataHash,uint256 deadline)`. - /// - /// `AccusationVote` no longer carries an `agrees` field. The gossip wire - /// transmits only agreements; the on-chain verifier treats every submitted - /// signature as an affirmative vote. See the struct's docstring in - /// `e3_events::accusation_vote` for rationale. - /// - /// Exposed `pub` so the Anvil parity test in - /// `crates/zk-prover/tests/slashing_integration_tests.rs` can sign votes - /// through the **same** code path the production actor uses — if the - /// digest drifts from on-chain `_verifyVotes`, the parity test reverts on - /// chain immediately rather than allowing the actor to ship broken - /// signatures. - pub fn vote_digest(vote: &AccusationVote, verifying_contract: Address) -> [u8; 32] { - let e3_id_u256: U256 = vote - .e3_id - .clone() - .try_into() - .expect("E3id should be valid U256"); - let typehash: [u8; 32] = keccak256(VOTE_TYPEHASH_STR).into(); - let struct_hash: [u8; 32] = keccak256( - &( - typehash, - e3_id_u256, - vote.accusation_id, - vote.voter, - vote.data_hash, - U256::from(vote.deadline), - ) - .abi_encode(), - ) - .into(); - let domain = Self::vote_domain_separator(vote.e3_id.chain_id(), verifying_contract); - let mut buf = Vec::with_capacity(2 + 32 + 32); - buf.push(0x19); - buf.push(0x01); - buf.extend_from_slice(&domain); - buf.extend_from_slice(&struct_hash); - keccak256(&buf).into() - } - - fn verify_vote_signature(&self, vote: &AccusationVote) -> bool { - let digest = Self::vote_digest(vote, self.slashing_manager); - let sig = - match alloy::primitives::Signature::try_from(vote.signature.extract_bytes().as_ref()) { - Ok(s) => s, - Err(_) => return false, - }; - match sig.recover_address_from_prehash(&digest.into()) { - Ok(addr) => addr == vote.voter, - Err(_) => false, - } - } - - // ─── Core Protocol ─────────────────────────────────────────────────── - - /// Called when the local node detects a proof failure. - /// - /// Resolves the accused address, caches the failure, extracts C3a/C3b - /// forwarding payload, then delegates to [`initiate_accusation`]. - fn on_local_proof_failure( - &mut self, - event: ProofVerificationFailed, - ec: &EventContext, - ctx: &mut Context, - ) { - if event.e3_id != self.e3_id { - return; - } - - let accused_address = if event.accused_address == Address::ZERO { - if let Some(&addr) = self.committee.get(event.accused_party_id as usize) { - warn!( - "Resolved Address::ZERO for party {} to committee address {}", - event.accused_party_id, addr - ); - addr - } else { - error!( - "Cannot resolve address for party {} (out of committee bounds) — dropping accusation", - event.accused_party_id - ); - return; - } - } else { - event.accused_address - }; - - if !self.committee.contains(&accused_address) { - warn!( - "Ignoring proof failure for {} — not on E3 {} committee", - accused_address, self.e3_id - ); - return; - } - - // Cache the failed verification result. - // Evidence preimage = `abi.encode(proof.data, public_signals)` — matches - // the on-chain `keccak256(evidence) == dataHash` check in SlashingManager. - let evidence = Bytes::from( - ( - Bytes::copy_from_slice(&event.signed_payload.payload.proof.data), - Bytes::copy_from_slice(&event.signed_payload.payload.proof.public_signals), - ) - .abi_encode(), - ); - self.received_data.insert( - (accused_address, event.proof_type), - ReceivedProofData { - data_hash: event.data_hash, - verification_passed: false, - evidence, - }, - ); - - // For C3a/C3b, include the signed payload so other nodes can re-verify - let forwarded_payload = match event.proof_type { - ProofType::C3aSkShareEncryption | ProofType::C3bESmShareEncryption => { - Some(event.signed_payload.clone()) - } - _ => None, - }; - - self.initiate_accusation( - accused_address, - event.accused_party_id, - event.proof_type, - event.data_hash, - forwarded_payload, - ec, - ctx, - ); - } - - /// Called when the `CommitmentConsistencyChecker` detects a cross-circuit - /// commitment mismatch for a party. - /// - /// Caches the failure and delegates to `initiate_accusation` — the same - /// quorum protocol as ZK proof failures. - fn on_consistency_violation( - &mut self, - data: CommitmentConsistencyViolation, - ec: &EventContext, - ctx: &mut Context, - ) { - if data.e3_id != self.e3_id { - return; - } - - if !self.committee.contains(&data.accused_address) { - warn!( - "Ignoring commitment violation for {} — not on E3 {} committee", - data.accused_address, self.e3_id - ); - return; - } - - // Cache as a failed verification for voting on future accusations. - // `data.evidence` carries the raw `abi.encode(proof.data, public_signals)` - // preimage of `data_hash`, populated by the consistency checker. Slashing - // via this path now binds voter signatures to evidence bytes on-chain - // just like the ProofVerificationFailed path. - self.received_data.insert( - (data.accused_address, data.proof_type), - ReceivedProofData { - data_hash: data.data_hash, - verification_passed: false, - evidence: data.evidence.clone(), - }, - ); - - self.initiate_accusation( - data.accused_address, - data.accused_party_id, - data.proof_type, - data.data_hash, - None, // No forwarding needed — violations are detected from public signals all nodes have - ec, - ctx, - ); - } - - /// Shared accusation creation and broadcast logic. - /// - /// Called by [`on_local_proof_failure`] (ZK verification failure) and - /// [`on_consistency_violation`] (commitment consistency mismatch). - /// Deduplicates, creates and signs a [`ProofFailureAccusation`], casts - /// the node's own vote, and begins vote collection with a timeout. - fn initiate_accusation( - &mut self, - accused_address: Address, - accused_party_id: u64, - proof_type: ProofType, - data_hash: [u8; 32], - forwarded_payload: Option, - ec: &EventContext, - ctx: &mut Context, - ) { - if !self.committee.contains(&accused_address) { - warn!( - "Refusing accusation against {} — not on E3 {} committee", - accused_address, self.e3_id - ); - return; - } - - let key = (accused_address, proof_type); - - // Dedup: don't create multiple accusations for the same (accused, proof_type) - if !self.accused_proofs.insert(key) { - info!( - "Already accused {:?} for {:?} — skipping duplicate", - accused_address, proof_type - ); - return; - } - - // Governance-disabled validity window means no accusation voting should - // be produced by this node. - if self.vote_validity_secs == 0 { - warn!( - "Refusing accusation initiation for {:?} on E3 {}: vote_validity_secs is 0", - accused_address, self.e3_id - ); - self.accused_proofs.remove(&key); - return; - } - - // Pick the on-chain validity deadline once per accusation. Every voter - // (including ourselves below) signs the same value; otherwise the - // aggregated evidence cannot be encoded as a single `deadline`. - let deadline = self.compute_deadline(); - - // Create the accusation - let mut accusation = ProofFailureAccusation { - e3_id: self.e3_id.clone(), - accuser: self.my_address, - accused: accused_address, - accused_party_id, - proof_type, - data_hash, - deadline, - signed_payload: forwarded_payload, - signature: ArcBytes::default(), - }; - match self.sign_accusation_digest(&accusation) { - Ok(sig) => accusation.signature = ArcBytes::from_bytes(&sig), - Err(err) => { - error!("Failed to sign ProofFailureAccusation: {err}"); - self.accused_proofs.remove(&key); - return; - } - } - - let accusation_id = Self::accusation_id(&accusation); - - info!( - "Broadcasting accusation against {} for {:?} failure", - accused_address, proof_type - ); - - // Broadcast accusation via gossip - if let Err(err) = self.bus.publish(accusation.clone(), ec.clone()) { - error!("Failed to broadcast ProofFailureAccusation: {err}"); - return; - } - - // Cast our own agreement vote (we just observed the failure locally). - let mut own_vote = AccusationVote { - e3_id: self.e3_id.clone(), - accusation_id, - voter: self.my_address, - data_hash, - deadline, - signature: ArcBytes::default(), - }; - match self.sign_vote_digest(&own_vote) { - Ok(sig) => own_vote.signature = ArcBytes::from_bytes(&sig), - Err(err) => { - error!("Failed to sign own AccusationVote: {err}"); - return; - } - } - - if let Err(err) = self.bus.publish(own_vote.clone(), ec.clone()) { - error!("Failed to broadcast own AccusationVote: {err}"); - } - - // Start timeout - let timeout_handle = ctx.run_later(self.vote_timeout, move |act, _ctx| { - act.on_vote_timeout(accusation_id); - }); - - // Store pending accusation with own vote - self.pending.insert( - accusation_id, - PendingAccusation { - accusation, - votes_for: vec![own_vote], - timeout_handle: Some(timeout_handle), - ec: ec.clone(), - }, - ); - - // Replay any votes that arrived before this accusation - if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { - for vote in buffered { - self.on_vote_received(vote, ec, ctx); - } - } - - // Check quorum immediately (in case threshold_m == 1) - self.check_quorum(accusation_id, ec, ctx); - } - - /// Called when we receive an accusation from another node via gossip. - /// - /// Validates the accuser, checks our own verification cache, and casts a vote. - fn on_accusation_received( - &mut self, - accusation: ProofFailureAccusation, - ec: &EventContext, - ctx: &mut Context, - ) { - // Ignore accusations for other E3s - if accusation.e3_id != self.e3_id { - return; - } - - let now = self.clock.unix_now_secs(); - if !Self::is_peer_deadline_acceptable( - accusation.deadline, - now, - self.vote_validity_secs, - self.accusation_deadline_skew_secs, - ) { - let max_deadline = now - .saturating_add(self.vote_validity_secs) - .saturating_add(self.accusation_deadline_skew_secs); - warn!( - "Ignoring accusation from {} — deadline {} outside local validity window \ - (now={}, vote_validity_secs={}, skew_secs={}, max_accepted_deadline={})", - accusation.accuser, - accusation.deadline, - now, - self.vote_validity_secs, - self.accusation_deadline_skew_secs, - max_deadline - ); - return; - } - - // Verify accuser is in committee - if !self.committee.contains(&accusation.accuser) { - warn!( - "Ignoring accusation from non-committee member {}", - accusation.accuser - ); - return; - } - - // Verify accused is a committee member (defense-in-depth) - if !self.committee.contains(&accusation.accused) { - warn!( - "Ignoring accusation against non-committee member {}", - accusation.accused - ); - return; - } - - // Ignore our own accusations (we already voted) - if accusation.accuser == self.my_address { - return; - } - - // Verify accuser's ECDSA signature - if !self.verify_accusation_signature(&accusation) { - warn!( - "Invalid signature on accusation from {} — ignoring", - accusation.accuser - ); - return; - } - - let accusation_id = Self::accusation_id(&accusation); - - // Don't process duplicate accusations - if self.pending.contains_key(&accusation_id) { - return; - } - - // Determine our position based on our local verification state. - // - // The gossip wire no longer carries disagreement: if our local check - // *passed*, we stay silent (no broadcast, no pending state). The - // accusation will then either reach quorum from other agreeing peers - // or time out as Inconclusive. Only the "we also saw it fail" branch - // and the "we don't have local data yet (C3a/C3b)" branch proceed - // below. - let key = (accusation.accused, accusation.proof_type); - let our_data_hash = if let Some(received) = self.received_data.get(&key) { - if received.verification_passed { - info!( - "Local verification of {:?} from {} passed — abstaining \ - (no disagreement vote on the wire)", - accusation.proof_type, accusation.accused - ); - return; - } - received.data_hash - } else if let Some(ref forwarded) = accusation.signed_payload { - // C3a/C3b case: we didn't receive this proof directly. - // Validate the forwarded payload's ECDSA, then dispatch async ZK re-verification. - let forwarded_valid = match forwarded.recover_address() { - Ok(addr) => { - if addr != accusation.accused { - warn!( - "Forwarded C3a/C3b payload signer {} != accused {} — cannot verify", - addr, accusation.accused - ); - false - } else if forwarded.payload.e3_id != self.e3_id { - warn!("Forwarded C3a/C3b payload e3_id mismatch — cannot verify"); - false - } else { - let expected = forwarded.payload.proof_type.circuit_names(); - expected.contains(&forwarded.payload.proof.circuit) - } - } - Err(e) => { - warn!("Forwarded C3a/C3b payload signature invalid: {e} — cannot verify"); - false - } - }; - - if !forwarded_valid { - // Can't trust the forwarded proof — abstain - return; - } - - let data_hash = Self::compute_payload_hash(forwarded); - let evidence: Bytes = ( - Bytes::copy_from_slice(&forwarded.payload.proof.data), - Bytes::copy_from_slice(&forwarded.payload.proof.public_signals), - ) - .abi_encode() - .into(); - let accused_party_id = accusation.accused_party_id; - let forwarded_clone = forwarded.clone(); - - // Create PendingAccusation without our vote — it arrives after ZK completes. - // - // NOTE (timeout race): If the async ZK re-verification takes longer than - // `vote_timeout` (default 5 min), the accusation will time out before this - // node casts its vote. This is an accepted trade-off: the node's contribution - // is lost, but the quorum can still be reached by other voters. In small - // committees near the threshold M, this could cause a valid accusation to - // become Inconclusive instead of AccusedFaulted. Operators should ensure ZK - // verification completes well within the vote timeout. - let timeout_handle = ctx.run_later(self.vote_timeout, move |act, _ctx| { - act.on_vote_timeout(accusation_id); - }); - self.pending.insert( - accusation_id, - PendingAccusation { - accusation, - votes_for: Vec::new(), - timeout_handle: Some(timeout_handle), - ec: ec.clone(), - }, - ); - - // Replay any buffered votes - if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { - for vote in buffered { - self.on_vote_received(vote, ec, ctx); - } - } - - // Dispatch ZK re-verification - let correlation_id = CorrelationId::new(); - self.pending_reverifications.insert( - correlation_id, - PendingReVerification { - accusation_id, - data_hash, - accused: key.0, - proof_type: key.1, - evidence, - }, - ); - - let party_proof = PartyProofsToVerify { - sender_party_id: accused_party_id, - signed_proofs: vec![forwarded_clone], - }; - let request = ComputeRequest::zk( - ZkRequest::VerifyShareProofs(VerifyShareProofsRequest { - party_proofs: vec![party_proof], - params_preset: self.params_preset, - }), - correlation_id, - self.e3_id.clone(), - ); - - if let Err(err) = self.bus.publish(request, ec.clone()) { - error!("Failed to dispatch C3a/C3b ZK re-verification: {err}"); - self.pending_reverifications.remove(&correlation_id); - } - - // Vote deferred — return without falling through to the normal vote path - return; - } else { - // We don't have the data and no payload was forwarded — abstain - info!( - "No local data for accused {} proof {:?} — abstaining from vote", - accusation.accused, accusation.proof_type - ); - return; - }; - - // We saw the proof fail locally — agree with the accusation. Adopt - // the accuser's deadline so every voter on this accusation signs the - // same on-chain validity window. - let mut vote = AccusationVote { - e3_id: self.e3_id.clone(), - accusation_id, - voter: self.my_address, - data_hash: our_data_hash, - deadline: accusation.deadline, - signature: ArcBytes::default(), - }; - match self.sign_vote_digest(&vote) { - Ok(sig) => vote.signature = ArcBytes::from_bytes(&sig), - Err(err) => { - error!("Failed to sign AccusationVote: {err}"); - return; - } - } - - info!( - "Agreeing with accusation against {} for {:?}", - accusation.accused, accusation.proof_type - ); - - // Broadcast vote via gossip - if let Err(err) = self.bus.publish(vote.clone(), ec.clone()) { - error!("Failed to broadcast AccusationVote: {err}"); - } - - // Start timeout for this accusation - let timeout_handle = ctx.run_later(self.vote_timeout, move |act, _ctx| { - act.on_vote_timeout(accusation_id); - }); - - // Record in pending - let pending = PendingAccusation { - accusation, - votes_for: vec![vote], - timeout_handle: Some(timeout_handle), - ec: ec.clone(), - }; - self.pending.insert(accusation_id, pending); - - // Replay any votes that arrived before this accusation - if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { - for vote in buffered { - self.on_vote_received(vote, ec, ctx); - } - } - - // Check quorum - self.check_quorum(accusation_id, ec, ctx); - } - - /// Called when we receive a vote from another node via gossip. - fn on_vote_received( - &mut self, - vote: AccusationVote, - ec: &EventContext, - ctx: &mut Context, - ) { - // Ignore votes for other E3s - if vote.e3_id != self.e3_id { - return; - } - - // Verify voter is in committee - if !self.committee.contains(&vote.voter) { - warn!("Ignoring vote from non-committee member {}", vote.voter); - return; - } - - // Ignore our own votes (already recorded) - if vote.voter == self.my_address { - return; - } - - // Verify voter's ECDSA signature - if !self.verify_vote_signature(&vote) { - warn!("Invalid signature on vote from {} — ignoring", vote.voter); - return; - } - - let vote_accusation_id = vote.accusation_id; - - // Find the pending accusation - let Some(pending) = self.pending.get_mut(&vote_accusation_id) else { - // Unknown accusation — buffer the vote for replay when the accusation arrives. - // Cap buffer size to prevent unbounded growth if the accusation never arrives. - let buf = self.buffered_votes.entry(vote_accusation_id).or_default(); - if buf.len() < self.committee.len() { - buf.push(vote); - } else { - warn!( - "Buffered votes for unknown accusation {:?} reached committee-size cap — dropping vote", - vote_accusation_id - ); - } - return; - }; - - // Reject votes whose deadline disagrees with the accusation's chosen - // deadline. All voters must sign the same deadline so the aggregated - // evidence carries a single value for `SlashingManager`'s - // `block.timestamp <= deadline` check. - if vote.deadline != pending.accusation.deadline { - warn!( - "Ignoring vote from {} — deadline {} does not match accusation deadline {}", - vote.voter, vote.deadline, pending.accusation.deadline - ); - return; - } - - // Reject votes from the accused party — they have a conflict of interest - if vote.voter == pending.accusation.accused { - warn!( - "Ignoring vote from accused party {} on their own accusation", - vote.voter - ); - return; - } - - // Dedup: don't count same voter twice - let already_voted = pending.votes_for.iter().any(|v| v.voter == vote.voter); - if already_voted { - return; - } - - // If the voter is the original accuser, their vote's data_hash must - // match the accusation's data_hash. A malicious accuser could otherwise - // send an accusation with one data_hash and a vote with a different one - // to create artificial data_hash diversity and trigger false equivocation. - if vote.voter == pending.accusation.accuser - && vote.data_hash != pending.accusation.data_hash - { - warn!( - "Accuser {} sent vote with data_hash inconsistent with their accusation — rejecting vote", - vote.voter - ); - return; - } - - // Every received `AccusationVote` is an agreement (the gossip wire - // carries no disagreement). Append to the agreeing pile and re-check - // quorum. - pending.votes_for.push(vote); - - self.check_quorum(vote_accusation_id, ec, ctx); - } - - /// Evaluate whether we have enough agreeing votes to decide. - /// - /// Quorum logic: - /// - `>= M` agreeing votes → `AccusedFaulted` (or `Equivocation` if those - /// votes disagree on `data_hash`, indicating the accused sent different - /// bytes to different peers). - /// - Otherwise → keep waiting; the timeout handler decides - /// `Inconclusive` if quorum never arrives. - /// - /// The gossip wire no longer carries disagreement, so there is no - /// fast-fail "quorum unreachable" branch — every silent peer might still - /// agree in flight. Silence beyond `vote_timeout` ⇒ `Inconclusive`. - fn check_quorum( - &mut self, - accusation_id: [u8; 32], - ec: &EventContext, - ctx: &mut Context, - ) { - let Some(pending) = self.pending.get(&accusation_id) else { - return; - }; - - let agree_count = pending.votes_for.len(); - if agree_count < self.threshold_m { - // Not yet at quorum — wait for more agreement votes or for the - // timeout to fire. - return; - } - - // Reached `M` — decide between AccusedFaulted and Equivocation by - // checking whether the agreeing voters all saw the same data_hash. - let agree_hashes: HashSet<[u8; 32]> = - pending.votes_for.iter().map(|v| v.data_hash).collect(); - if agree_hashes.len() > 1 { - info!( - "Equivocation detected at quorum: {} unique data hashes among {} agreeing voters for {} {:?}", - agree_hashes.len(), - agree_count, - pending.accusation.accused, - pending.accusation.proof_type - ); - self.emit_quorum_reached(accusation_id, AccusationOutcome::Equivocation, ec, ctx); - } else { - info!( - "Quorum reached: {} votes confirm {} sent bad {:?} proof — AccusedFaulted", - agree_count, pending.accusation.accused, pending.accusation.proof_type - ); - self.emit_quorum_reached(accusation_id, AccusationOutcome::AccusedFaulted, ec, ctx); - } - } - - /// Called when the vote timeout expires for an accusation. - fn on_vote_timeout(&mut self, accusation_id: [u8; 32]) { - let Some(pending) = self.pending.remove(&accusation_id) else { - return; // Already resolved - }; - - // All votes received are agreements (the wire carries no - // disagreement signal). At timeout, decide between AccusedFaulted, - // Equivocation, or Inconclusive purely from the agreeing pile. - let outcome = if pending.votes_for.len() >= self.threshold_m { - let agree_hashes: HashSet<[u8; 32]> = - pending.votes_for.iter().map(|v| v.data_hash).collect(); - if agree_hashes.len() > 1 { - AccusationOutcome::Equivocation - } else { - AccusationOutcome::AccusedFaulted - } - } else { - // Not enough agreements to convict and no signed disagreements - // exist; whether that's silence or active disagreement is - // indistinguishable on the wire. Report Inconclusive. - AccusationOutcome::Inconclusive - }; - - warn!( - "Accusation against {} for {:?} timed out with {} agreeing votes — outcome: {:?}", - pending.accusation.accused, - pending.accusation.proof_type, - pending.votes_for.len(), - outcome - ); - - let evidence = self - .received_data - .get(&(pending.accusation.accused, pending.accusation.proof_type)) - .map(|d| d.evidence.clone()) - .unwrap_or_default(); - if let Err(err) = self.bus.publish( - AccusationQuorumReached { - e3_id: self.e3_id.clone(), - accuser: pending.accusation.accuser, - accused: pending.accusation.accused, - proof_type: pending.accusation.proof_type, - votes_for: pending.votes_for, - outcome, - evidence, - }, - pending.ec, - ) { - error!("Failed to publish AccusationQuorumReached on timeout: {err}"); - } - } - - fn emit_quorum_reached( - &mut self, - accusation_id: [u8; 32], - outcome: AccusationOutcome, - ec: &EventContext, - ctx: &mut Context, - ) { - let Some(pending) = self.pending.remove(&accusation_id) else { - return; - }; - - // Cancel the timeout to avoid unnecessary timer fires - if let Some(handle) = pending.timeout_handle { - ctx.cancel_future(handle); - } - - info!( - "Accusation quorum reached for {} {:?}: {} agreeing votes — outcome: {}", - pending.accusation.accused, - pending.accusation.proof_type, - pending.votes_for.len(), - outcome - ); - - let evidence = self - .received_data - .get(&(pending.accusation.accused, pending.accusation.proof_type)) - .map(|d| d.evidence.clone()) - .unwrap_or_default(); - if let Err(err) = self.bus.publish( - AccusationQuorumReached { - e3_id: self.e3_id.clone(), - accuser: pending.accusation.accuser, - accused: pending.accusation.accused, - proof_type: pending.accusation.proof_type, - votes_for: pending.votes_for, - outcome, - evidence, - }, - ec.clone(), - ) { - error!("Failed to publish AccusationQuorumReached: {err}"); - } - } - - /// Handle an on-chain SlashExecuted event for this E3. - fn on_slash_executed(&mut self, data: SlashExecuted) { - if data.e3_id != self.e3_id { - return; - } - let prev_len = self.committee.len(); - self.committee.retain(|addr| *addr != data.operator); - if self.committee.len() < prev_len { - info!( - "Removed slashed operator {} from committee (now {} members)", - data.operator, - self.committee.len() - ); - - // Purge any votes from the expelled node in pending accusations - for pending in self.pending.values_mut() { - pending.votes_for.retain(|v| v.voter != data.operator); - } - - // Purge from buffered votes - for buf in self.buffered_votes.values_mut() { - buf.retain(|v| v.voter != data.operator); - } - } - } - - /// Cache a successful proof verification result for a specific (accused, proof_type). - /// This allows the node to vote on accusations from other nodes. - pub fn cache_verification_result( - &mut self, - accused: Address, - proof_type: ProofType, - data_hash: [u8; 32], - passed: bool, - evidence: Bytes, - ) { - self.received_data.insert( - (accused, proof_type), - ReceivedProofData { - data_hash, - verification_passed: passed, - evidence, - }, - ); - } - - /// Compute a keccak256 hash of a SignedProofPayload for data_hash comparison. - /// - /// `keccak256(abi.encode(zkProof, publicSignals))` - fn compute_payload_hash(payload: &SignedProofPayload) -> [u8; 32] { - let msg = ( - Bytes::copy_from_slice(&payload.payload.proof.data), - Bytes::copy_from_slice(&payload.payload.proof.public_signals), - ) - .abi_encode(); - keccak256(&msg).into() - } - - /// Handle ZK re-verification response for a forwarded C3a/C3b proof. - /// - /// Dispatched by `on_accusation_received` when the accused's forwarded proof - /// needs async ZK verification. Casts our vote based on the ZK result. - fn handle_reverification_response( - &mut self, - msg: TypedEvent, - ctx: &mut Context, - ) { - let (msg, _ec) = msg.into_components(); - - let correlation_id = msg.correlation_id; - let Some(reverif) = self.pending_reverifications.remove(&correlation_id) else { - return; // Not our correlation ID - }; - - let zk_passed = match msg.response { - ComputeResponseKind::Zk(ZkResponse::VerifyShareProofs(r)) => { - if r.party_results.is_empty() { - warn!("Empty ZK re-verification results — abstaining"); - return; - } - r.party_results.first().is_some_and(|r| r.all_verified) - } - _ => { - warn!("Unexpected ComputeResponse kind for C3a/C3b re-verification — abstaining"); - return; - } - }; - - // Cache the result for future accusations regardless of outcome. - self.cache_verification_result( - reverif.accused, - reverif.proof_type, - reverif.data_hash, - zk_passed, - reverif.evidence.clone(), - ); - - // ZK re-verification passed ⇒ the proof is actually valid ⇒ we - // disagree with the accusation. The gossip wire carries no - // disagreement signal, so just abstain (no broadcast, no pending - // mutation). Other agreeing peers will or won't reach quorum - // independently. - if zk_passed { - info!( - "C3a/C3b re-verification passed for {:?} — abstaining from vote", - reverif.proof_type - ); - return; - } - - // ZK re-verification failed ⇒ we agree with the accusation. - let (ec, deadline) = match self.pending.get(&reverif.accusation_id) { - Some(pending) => (pending.ec.clone(), pending.accusation.deadline), - None => { - // Accusation already resolved (timeout/quorum) before ZK finished - return; - } - }; - - let mut vote = AccusationVote { - e3_id: self.e3_id.clone(), - accusation_id: reverif.accusation_id, - voter: self.my_address, - data_hash: reverif.data_hash, - deadline, - signature: ArcBytes::default(), - }; - match self.sign_vote_digest(&vote) { - Ok(sig) => vote.signature = ArcBytes::from_bytes(&sig), - Err(err) => { - error!("Failed to sign C3a/C3b AccusationVote: {err}"); - return; - } - } - - info!( - "C3a/C3b re-verification confirmed failure for {:?} — agreeing with accusation", - reverif.proof_type - ); - - // Broadcast vote via gossip - if let Err(err) = self.bus.publish(vote.clone(), ec.clone()) { - error!("Failed to broadcast C3a/C3b AccusationVote: {err}"); - } - - // Record in pending - if let Some(pending) = self.pending.get_mut(&reverif.accusation_id) { - pending.votes_for.push(vote); - } - - // Check quorum - self.check_quorum(reverif.accusation_id, &ec, ctx); - } - - /// Handle ZK re-verification error for a forwarded C3a/C3b proof. - fn handle_reverification_error(&mut self, msg: TypedEvent) { - let (msg, _ec) = msg.into_components(); - - let correlation_id = msg.correlation_id(); - let Some(reverif) = self.pending_reverifications.remove(correlation_id) else { - return; // Not our correlation ID - }; - - error!( - "C3a/C3b ZK re-verification failed for {:?} — abstaining from vote", - reverif.proof_type - ); - // Don't vote — effectively abstain - } -} - -impl Actor for AccusationManager { - type Context = Context; -} - -impl Handler for AccusationManager { - type Result = (); - - fn handle(&mut self, msg: EnclaveEvent, ctx: &mut Self::Context) -> Self::Result { - let (msg, ec) = msg.into_components(); - match msg { - EnclaveEventData::ProofVerificationFailed(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::ProofVerificationPassed(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::ProofFailureAccusation(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::AccusationVote(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::ComputeResponse(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::ComputeRequestError(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::SlashExecuted(data) => { - self.on_slash_executed(data); - } - EnclaveEventData::CommitmentConsistencyViolation(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - _ => (), - } - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - ctx: &mut Self::Context, - ) -> Self::Result { - let (data, ec) = msg.into_components(); - self.on_local_proof_failure(data, &ec, ctx); - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - _ctx: &mut Self::Context, - ) -> Self::Result { - let (data, _ec) = msg.into_components(); - if data.e3_id != self.e3_id { - return; - } - if !self.committee.contains(&data.address) { - return; - } - // Cache successful verification for voting on future accusations. - // Evidence preimage = `abi.encode(proof.data, public_signals)`. - let evidence: Bytes = ( - Bytes::copy_from_slice(&data.proof_data), - Bytes::copy_from_slice(&data.public_signals), - ) - .abi_encode() - .into(); - self.received_data.insert( - (data.address, data.proof_type), - ReceivedProofData { - data_hash: data.data_hash, - verification_passed: true, - evidence, - }, - ); - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - ctx: &mut Self::Context, - ) -> Self::Result { - let (data, ec) = msg.into_components(); - self.on_accusation_received(data, &ec, ctx); - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle(&mut self, msg: TypedEvent, ctx: &mut Self::Context) -> Self::Result { - let (data, ec) = msg.into_components(); - self.on_vote_received(data, &ec, ctx); - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - ctx: &mut Self::Context, - ) -> Self::Result { - self.handle_reverification_response(msg, ctx); - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - _ctx: &mut Self::Context, - ) -> Self::Result { - self.handle_reverification_error(msg); - } -} - -impl Handler> for AccusationManager { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - ctx: &mut Self::Context, - ) -> Self::Result { - let (data, ec) = msg.into_components(); - self.on_consistency_violation(data, &ec, ctx); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Tests -// ════════════════════════════════════════════════════════════════════════════ -// -// These tests pin the actor's EIP-712 digest computation to the exact bytes -// that off-chain test helpers (and ultimately the on-chain -// `SlashingManager._verifyVotes`) expect. If anyone tweaks the typehash -// string, the domain name, or the struct field layout on EITHER side without -// updating the other, these tests fail before the broken signatures ever -// reach the chain. - -#[cfg(test)] -mod tests { - use super::*; - use alloy::primitives::FixedBytes; - use alloy::signers::SignerSync; - - /// Independent re-derivation of the EIP-712 vote digest, mirroring exactly - /// what `SlashingManager._verifyVotes` computes on chain. Kept here (and - /// not imported from a helper) so a regression in the actor's `vote_digest` - /// is caught by a byte-for-byte assertion against a hand-rolled reference. - fn reference_vote_digest( - chain_id: u64, - verifying_contract: Address, - e3_id: u64, - accusation_id: [u8; 32], - voter: Address, - data_hash: [u8; 32], - deadline: u64, - ) -> [u8; 32] { - let domain_typehash: [u8; 32] = keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", - ) - .into(); - let name_hash: [u8; 32] = keccak256(VOTE_DOMAIN_NAME).into(); - let version_hash: [u8; 32] = keccak256(VOTE_DOMAIN_VERSION).into(); - let domain_separator: [u8; 32] = keccak256( - &( - domain_typehash, - name_hash, - version_hash, - U256::from(chain_id), - verifying_contract, - ) - .abi_encode(), - ) - .into(); - - let typehash: [u8; 32] = keccak256(VOTE_TYPEHASH_STR).into(); - let struct_hash: [u8; 32] = keccak256( - &( - typehash, - U256::from(e3_id), - FixedBytes::<32>::from(accusation_id), - voter, - FixedBytes::<32>::from(data_hash), - U256::from(deadline), - ) - .abi_encode(), - ) - .into(); - - let mut buf = Vec::with_capacity(2 + 32 + 32); - buf.push(0x19); - buf.push(0x01); - buf.extend_from_slice(&domain_separator); - buf.extend_from_slice(&struct_hash); - keccak256(&buf).into() - } - - /// The actor's `vote_digest` must equal the reference digest byte-for-byte. - /// If this fails, the actor's typehash / domain / struct layout has drifted - /// from what the on-chain verifier expects (or from the constants in - /// `e3_events::accusation_vote`). - #[test] - fn vote_digest_matches_reference() { - let chain_id = 31337u64; - let verifying_contract: Address = "0x9999999999999999999999999999999999999999" - .parse() - .unwrap(); - let voter: Address = "0x2222222222222222222222222222222222222222" - .parse() - .unwrap(); - let accusation_id = [0xab; 32]; - let data_hash = [0xcd; 32]; - let deadline: u64 = 1_700_000_000; - - let vote = AccusationVote { - e3_id: E3id::new("42", chain_id), - accusation_id, - voter, - data_hash, - deadline, - signature: ArcBytes::default(), - }; - - let actor = AccusationManager::vote_digest(&vote, verifying_contract); - let reference = reference_vote_digest( - chain_id, - verifying_contract, - 42, - accusation_id, - voter, - data_hash, - deadline, - ); - - assert_eq!( - actor, reference, - "AccusationManager::vote_digest drifted from the reference EIP-712 \ - computation. Check VOTE_TYPEHASH_STR / VOTE_DOMAIN_NAME against \ - SlashingManager.sol — these MUST stay byte-equal across crates." - ); - } - - /// Sign-and-recover round-trip using the actor's digest. Since - /// `vote_digest_matches_reference` already pins the digest bytes, signing - /// that digest and recovering via `recover_address_from_prehash` must - /// return the voter — i.e. the actor's signatures will be accepted by the - /// on-chain `ECDSA.recover` step. - #[test] - fn actor_signature_recovers_to_voter() { - let signer: PrivateKeySigner = - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - .parse() - .unwrap(); - let voter = signer.address(); - let verifying_contract: Address = "0x5555555555555555555555555555555555555555" - .parse() - .unwrap(); - let chain_id = 31337u64; - - let vote = AccusationVote { - e3_id: E3id::new("12345", chain_id), - accusation_id: [0x07; 32], - voter, - data_hash: [0x08; 32], - deadline: 1_700_000_000, - signature: ArcBytes::default(), - }; - - let digest = AccusationManager::vote_digest(&vote, verifying_contract); - let sig = signer - .sign_hash_sync(&FixedBytes::<32>::from(digest)) - .unwrap(); - let recovered = sig - .recover_address_from_prehash(&FixedBytes::<32>::from(digest)) - .expect("recover"); - assert_eq!( - recovered, voter, - "signing the actor's digest and recovering must yield the voter" - ); - } - - /// The accusation digest must include `deadline`. A malicious peer could - /// otherwise rewrite the deadline in transit without invalidating the - /// accuser's signature. Guard: changing only `deadline` must change the - /// digest. - #[test] - fn accusation_digest_binds_deadline() { - let make = |deadline: u64| ProofFailureAccusation { - e3_id: E3id::new("9", 31337), - accuser: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .parse() - .unwrap(), - accused: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - .parse() - .unwrap(), - accused_party_id: 1, - proof_type: ProofType::C1PkGeneration, - data_hash: [0x42; 32], - deadline, - signed_payload: None, - signature: ArcBytes::default(), - }; - let a = AccusationManager::accusation_digest(&make(1_700_000_000)); - let b = AccusationManager::accusation_digest(&make(1_700_000_001)); - assert_ne!(a, b, "deadline must be part of the accusation digest"); - } - - #[test] - fn peer_deadline_acceptance_enforces_local_window() { - let now = 1_700_000_000u64; - let validity = 1_800u64; - let skew = DEFAULT_ACCUSATION_DEADLINE_SKEW_SECS; - let max_ok = now + validity + skew; - - assert!( - !AccusationManager::is_peer_deadline_acceptable(now, now, validity, skew), - "deadline equal to now must be rejected" - ); - assert!( - !AccusationManager::is_peer_deadline_acceptable(now - 1, now, validity, skew), - "expired deadline must be rejected" - ); - assert!( - AccusationManager::is_peer_deadline_acceptable(max_ok, now, validity, skew), - "deadline at upper bound must be accepted" - ); - assert!( - !AccusationManager::is_peer_deadline_acceptable(max_ok + 1, now, validity, skew), - "far-future deadline must be rejected" - ); - assert!( - !AccusationManager::is_peer_deadline_acceptable(now + 10, now, 0, skew), - "vote_validity_secs=0 must reject peer accusations" - ); - } -} +//! Re-exported from `e3-slashing` — the canonical implementation lives there. +pub use e3_slashing::accusation_manager::*; diff --git a/crates/zk-prover/src/actors/accusation_manager_ext.rs b/crates/zk-prover/src/actors/accusation_manager_ext.rs index 2e5c74568d..62831032e7 100644 --- a/crates/zk-prover/src/actors/accusation_manager_ext.rs +++ b/crates/zk-prover/src/actors/accusation_manager_ext.rs @@ -1,156 +1,8 @@ -// 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. - -//! E3Extension that wires up the [`AccusationManager`] per-E3 when the -//! committee is finalized. -//! -//! Listens for [`CommitteeFinalized`], reads `threshold_m` from [`E3Meta`], -//! parses committee addresses, and starts the actor with full context. - -use std::collections::HashMap; - -use crate::AccusationManager; -use alloy::primitives::Address; -use alloy::signers::local::PrivateKeySigner; -use anyhow::Result; -use async_trait::async_trait; -use e3_events::{BusHandle, CommitteeFinalized, EnclaveEvent, EnclaveEventData, Event}; -use e3_request::{E3Context, E3ContextSnapshot, E3Extension, META_KEY}; -use tracing::{error, info, warn}; - -pub struct AccusationManagerExtension { - bus: BusHandle, - signer: PrivateKeySigner, - /// On-chain `SlashingManager` address (EIP-712 `verifyingContract` for vote sigs). - slashing_manager: Address, - /// Per-chain off-chain freshness window (seconds), read from - /// `CiphernodeRegistry.accusationVoteValidity()` at process startup. - /// Looked up by `e3_id.chain_id()` when each per-E3 actor starts; - /// governance changes require a node restart to take effect (same lifecycle - /// contract as `slashing_manager`). - vote_validity_secs_by_chain: HashMap, - /// Clock-skew allowance for peer accusation deadlines. - accusation_deadline_skew_secs: u64, -} - -impl AccusationManagerExtension { - pub fn create( - bus: &BusHandle, - signer: PrivateKeySigner, - slashing_manager: Address, - vote_validity_secs_by_chain: HashMap, - accusation_deadline_skew_secs: u64, - ) -> Box { - Box::new(Self { - bus: bus.clone(), - signer: signer.clone(), - slashing_manager, - vote_validity_secs_by_chain, - accusation_deadline_skew_secs, - }) - } - - fn vote_validity_secs_for(&self, chain_id: u64) -> u64 { - match self.vote_validity_secs_by_chain.get(&chain_id) { - Some(&secs) => secs, - None => { - warn!( - chain_id, - "no accusationVoteValidity configured for chain; accusation votes will not be stamped" - ); - 0 - } - } - } -} - -#[async_trait] -impl E3Extension for AccusationManagerExtension { - fn on_event(&self, ctx: &mut E3Context, evt: &EnclaveEvent) { - let EnclaveEventData::CommitteeFinalized(data) = evt.get_data() else { - return; - }; - - // Don't start twice - if ctx.get_event_recipient("accusation_manager").is_some() { - return; - } - - let CommitteeFinalized { - e3_id, committee, .. - } = data.clone(); - - // Parse committee addresses — all must be valid or we cannot start - let mut committee_addresses: Vec
= Vec::with_capacity(committee.len()); - for s in committee.iter() { - match s.parse::
() { - Ok(addr) => committee_addresses.push(addr), - Err(e) => { - error!( - "Failed to parse committee address {} — cannot start AccusationManager: {}", - s, e - ); - return; - } - } - } - - if committee_addresses.is_empty() { - error!("No committee addresses — cannot start AccusationManager"); - return; - } - - // Get threshold from meta - let Some(meta) = ctx.get_dependency(META_KEY) else { - error!("E3Meta not available — cannot start AccusationManager"); - return; - }; - let threshold_m = meta.threshold_m; - - info!( - "Starting AccusationManager for E3 {} with {} committee members, threshold={}", - e3_id, - committee_addresses.len(), - threshold_m - ); - - let vote_validity_secs = self.vote_validity_secs_for(e3_id.chain_id()); - - let addr = AccusationManager::setup( - &self.bus, - e3_id, - self.signer.clone(), - self.slashing_manager, - committee_addresses, - threshold_m, - vote_validity_secs, - self.accusation_deadline_skew_secs, - meta.params_preset, - ); - - ctx.set_event_recipient("accusation_manager", Some(addr.into())); - } - - /// Re-hydrates the `AccusationManager` after a node restart. - /// - /// Intentionally a no-op — `AccusationManager` is **ephemeral by design**: - /// - /// - Each instance is scoped to one E3 (created by [`AccusationManagerExtension::handle`] - /// when `CommitteeFinalized` is received) and holds only transient in-memory state - /// (pending accusations, buffered votes, verification caches). - /// - On restart, all in-flight accusations are lost. This is an accepted trade-off: - /// every pending accusation has a finite vote timeout (default 5 min). If the node - /// restarts, the accusation would have timed out anyway. Other committee members - /// running their own independent `AccusationManager` instances will continue the - /// protocol unaffected. - /// - A malicious node cannot exploit restart-induced state loss to prevent slashing: - /// restarting only loses *this node's* pending state — all other honest nodes still - /// independently verify, vote, and reach quorum without this node's participation - /// (as long as enough honest nodes remain to meet threshold M). - async fn hydrate(&self, _ctx: &mut E3Context, _snapshot: &E3ContextSnapshot) -> Result<()> { - Ok(()) - } -} +// 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. + +//! Re-exported from `e3-slashing` — the canonical implementation lives there. +pub use e3_slashing::accusation_manager_ext::*; diff --git a/crates/zk-prover/src/actors/commitment_consistency_checker.rs b/crates/zk-prover/src/actors/commitment_consistency_checker.rs index 73a707acdc..812aba92ca 100644 --- a/crates/zk-prover/src/actors/commitment_consistency_checker.rs +++ b/crates/zk-prover/src/actors/commitment_consistency_checker.rs @@ -1,461 +1,3 @@ // 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. - -//! Actor that cross-checks commitment values across different circuit proofs. -//! -//! Has two roles: -//! -//! 1. **Pre-ZK gating** (request/response): Subscribes to -//! [`CommitmentConsistencyCheckRequested`] from [`ShareVerificationActor`], -//! caches each party's public signals, evaluates all registered -//! [`CommitmentLink`]s, and responds with -//! [`CommitmentConsistencyCheckComplete`]. Inconsistent parties are excluded -//! from ZK verification. -//! -//! 2. **Post-ZK cross-circuit checking**: Subscribes to -//! [`ProofVerificationPassed`] events and, for each registered link, -//! compares commitment values across different circuit proofs. On mismatch, -//! publishes [`CommitmentConsistencyViolation`] for the accusation pipeline. -//! -//! ## Architecture -//! -//! - Caches verified proof outputs keyed by `(Address, ProofType)`. -//! - On each new event, evaluates every registered link to see if both sides -//! (source and target) are now available. -//! - For **same-party** links, compares proofs from the same Ethereum address. -//! - For **cross-party** links (e.g. per-node C1 vs aggregator C5), checks all -//! cached source proofs against the newly arrived target (or vice versa). -//! - Logs warnings on mismatch. Future iterations may emit an accusation event. - -use super::commitment_links::{CommitmentLink, LinkScope}; -use actix::{Actor, Addr, Context, Handler}; -use alloy::primitives::Address; -use alloy::sol_types::SolValue; -use e3_events::{ - BusHandle, CommitmentConsistencyCheckComplete, CommitmentConsistencyCheckRequested, - CommitmentConsistencyViolation, E3id, EnclaveEvent, EnclaveEventData, EventContext, - EventPublisher, EventSubscriber, EventType, ProofType, ProofVerificationPassed, Sequenced, - TypedEvent, -}; -use e3_utils::utility_types::ArcBytes; -use e3_utils::NotifySync; -use std::collections::{BTreeSet, HashMap}; -use tracing::{error, info, warn}; - -/// Cached data from a verified proof. -struct VerifiedProofData { - party_id: u64, - address: Address, - public_signals: ArcBytes, - data_hash: [u8; 32], - /// Raw `proof.data` bytes. Together with `public_signals` they form the - /// preimage `abi.encode(proof.data, public_signals)` of `data_hash` — - /// forwarded to slashing so the on-chain contract can verify the dataHash - /// bound in voter signatures. - proof_data: ArcBytes, -} - -/// Describes a source entry whose commitments are inconsistent with a target. -struct Mismatch { - party_id: u64, - address: Address, - proof_type: ProofType, - data_hash: [u8; 32], - /// Same preimage as `VerifiedProofData.proof_data` paired with - /// `public_signals`. Carried from cache into the emitted violation so - /// downstream slashing can bind voter signatures to evidence bytes. - proof_data: ArcBytes, - public_signals: ArcBytes, -} - -/// Per-E3 actor that enforces cross-circuit commitment consistency. -pub struct CommitmentConsistencyChecker { - bus: BusHandle, - e3_id: E3id, - links: Vec>, - /// Verified proof outputs: `(address, proof_type) → data`. - /// Multiple proofs per key are supported (e.g. N-1 C3a proofs per sender). - verified: HashMap<(Address, ProofType), Vec>, -} - -impl CommitmentConsistencyChecker { - pub fn new(bus: &BusHandle, e3_id: E3id, links: Vec>) -> Self { - Self { - bus: bus.clone(), - e3_id, - links, - verified: HashMap::new(), - } - } - - /// 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(); - bus.subscribe( - EventType::CommitmentConsistencyCheckRequested, - addr.clone().into(), - ); - bus.subscribe(EventType::ProofVerificationPassed, addr.clone().into()); - addr - } - - /// Find all source entries whose commitments are inconsistent with cached - /// targets for a given link. - fn find_mismatches(&self, link: &dyn CommitmentLink) -> Vec { - let src_type = link.source_proof_type(); - let tgt_type = link.target_proof_type(); - - match link.scope() { - // 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, - src.party_id, - tgt.party_id, - ) { - mismatches.push(Mismatch { - party_id: src.party_id, - address: *addr, - proof_type: src_type, - data_hash: src.data_hash, - proof_data: src.proof_data.clone(), - public_signals: src.public_signals.clone(), - }); - 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 all_targets: Vec<&VerifiedProofData> = self - .verified - .iter() - .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() { - continue; - } - // Source must match AT LEAST ONE target. - let found = all_targets.iter().any(|tgt| { - link.check_consistency( - &vals, - &tgt.public_signals, - src.party_id, - tgt.party_id, - ) - }); - if !found { - mismatches.push(Mismatch { - party_id: src.party_id, - address: src.address, - proof_type: src_type, - data_hash: src.data_hash, - proof_data: src.proof_data.clone(), - public_signals: src.public_signals.clone(), - }); - } - } - } - 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 == 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() { - continue; - } - let found = all_targets.iter().any(|tgt| { - link.check_consistency( - &vals, - &tgt.public_signals, - src.party_id, - tgt.party_id, - ) - }); - if !found { - mismatches.push(Mismatch { - party_id: src.party_id, - address: src.address, - proof_type: src_type, - data_hash: src.data_hash, - proof_data: src.proof_data.clone(), - public_signals: src.public_signals.clone(), - }); - } - } - } - mismatches - } - } - } - - /// Post-ZK: evaluate links relevant to a newly arrived proof and emit - /// violations on mismatch. - fn check_links(&self, new_proof_type: ProofType, ec: &EventContext) { - for link in &self.links { - if new_proof_type != link.source_proof_type() - && new_proof_type != link.target_proof_type() - { - continue; - } - for m in self.find_mismatches(link.as_ref()) { - // Defense-in-depth: skip entries with unresolved data_hash - // (should not happen now that pre-ZK caching uses real hashes, - // but guards against future regressions). - if m.data_hash == [0u8; 32] { - warn!( - "[{}] Skipping mismatch with zero data_hash for party {} ({}) {:?}", - link.name(), - m.party_id, - m.address, - m.proof_type, - ); - continue; - } - warn!( - "[{}] Commitment mismatch for E3 {} — party {} ({}) {:?}", - link.name(), - self.e3_id, - m.party_id, - m.address, - m.proof_type, - ); - self.emit_violation(&m, ec); - } - } - } - - /// Publish a [`CommitmentConsistencyViolation`] for the accusation pipeline. - fn emit_violation(&self, m: &Mismatch, ec: &EventContext) { - // Evidence preimage = `abi.encode(proof.data, public_signals)`. The - // on-chain `SlashingManager.proposeSlash` recomputes `keccak256(evidence)` - // and requires it to equal each voter's signed `dataHash`. Without - // these bytes, slashing via the consistency-violation path would be - // gated by the evidence binding (safe but unable to slash). - let evidence = alloy::primitives::Bytes::from( - ( - alloy::primitives::Bytes::copy_from_slice(&m.proof_data), - alloy::primitives::Bytes::copy_from_slice(&m.public_signals), - ) - .abi_encode(), - ); - let violation = CommitmentConsistencyViolation { - e3_id: self.e3_id.clone(), - accused_party_id: m.party_id, - accused_address: m.address, - proof_type: m.proof_type, - data_hash: m.data_hash, - evidence, - }; - if let Err(err) = self.bus.publish(violation, ec.clone()) { - error!("Failed to publish CommitmentConsistencyViolation: {err}"); - } - } -} - -impl Actor for CommitmentConsistencyChecker { - type Context = Context; - - fn started(&mut self, _ctx: &mut Self::Context) { - info!( - "CommitmentConsistencyChecker started for E3 {} with {} link(s)", - self.e3_id, - self.links.len() - ); - } -} - -impl Handler for CommitmentConsistencyChecker { - type Result = (); - - fn handle(&mut self, msg: EnclaveEvent, ctx: &mut Self::Context) -> Self::Result { - let (msg, ec) = msg.into_components(); - match msg { - EnclaveEventData::CommitmentConsistencyCheckRequested(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - EnclaveEventData::ProofVerificationPassed(data) => { - self.notify_sync(ctx, TypedEvent::new(data, ec)) - } - _ => (), - } - } -} - -impl Handler> for CommitmentConsistencyChecker { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - _ctx: &mut Self::Context, - ) -> Self::Result { - let (data, ec) = msg.into_components(); - if data.e3_id != self.e3_id { - return; - } - - let proof_type = data.proof_type; - let address = data.address; - - self.insert_verified( - address, - proof_type, - VerifiedProofData { - party_id: data.party_id, - address, - public_signals: data.public_signals, - data_hash: data.data_hash, - proof_data: data.proof_data, - }, - ); - - self.check_links(proof_type, &ec); - } -} - -impl Handler> for CommitmentConsistencyChecker { - type Result = (); - - fn handle( - &mut self, - msg: TypedEvent, - _ctx: &mut Self::Context, - ) -> Self::Result { - let (data, ec) = msg.into_components(); - if data.e3_id != self.e3_id { - return; - } - - let mut inconsistent_parties = BTreeSet::new(); - - // Cache each party's proof data for link evaluation. - for party in &data.party_proofs { - for (proof_type, public_signals, data_hash, proof_data) in &party.proofs { - self.insert_verified( - party.address, - *proof_type, - VerifiedProofData { - party_id: party.party_id, - address: party.address, - public_signals: public_signals.clone(), - data_hash: *data_hash, - proof_data: proof_data.clone(), - }, - ); - } - } - - // Evaluate every link and collect inconsistent parties. - // Also emit violations so AccusationManager can initiate the quorum - // protocol — parties excluded pre-ZK would otherwise never trigger a - // post-ZK violation. - for link in &self.links { - for m in self.find_mismatches(link.as_ref()) { - warn!( - "[{}] Pre-ZK commitment mismatch for E3 {} — party {} ({})", - link.name(), - self.e3_id, - m.party_id, - m.address, - ); - inconsistent_parties.insert(m.party_id); - self.emit_violation(&m, &ec); - } - } - - // 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(|_, entries| { - entries.retain(|v| !inconsistent_parties.contains(&v.party_id)); - !entries.is_empty() - }); - } - - // Respond to ShareVerificationActor. - if let Err(err) = self.bus.publish( - CommitmentConsistencyCheckComplete { - e3_id: data.e3_id, - kind: data.kind, - correlation_id: data.correlation_id, - inconsistent_parties, - }, - ec, - ) { - error!("Failed to publish CommitmentConsistencyCheckComplete: {err}"); - } - } -} +// Re-exported from `e3-slashing` — the canonical implementation lives there. +pub use e3_slashing::commitment_consistency_checker::*; diff --git a/crates/zk-prover/src/actors/commitment_consistency_checker_ext.rs b/crates/zk-prover/src/actors/commitment_consistency_checker_ext.rs index 1550111507..9d31ab1581 100644 --- a/crates/zk-prover/src/actors/commitment_consistency_checker_ext.rs +++ b/crates/zk-prover/src/actors/commitment_consistency_checker_ext.rs @@ -1,67 +1,3 @@ // 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. - -//! E3Extension that wires up the [`CommitmentConsistencyChecker`] per-E3 -//! when the committee is finalized. -//! -//! Follows the same lifecycle pattern as [`AccusationManagerExtension`]: -//! listens for [`CommitteeFinalized`], creates the actor, and registers it -//! in the [`E3Context`] so it receives routed events. - -use super::commitment_consistency_checker::CommitmentConsistencyChecker; -use super::commitment_links; -use anyhow::Result; -use async_trait::async_trait; -use e3_events::{BusHandle, EnclaveEvent, EnclaveEventData, Event}; -use e3_request::{E3Context, E3ContextSnapshot, E3Extension, META_KEY}; -use tracing::{error, info}; - -pub struct CommitmentConsistencyCheckerExtension { - bus: BusHandle, -} - -impl CommitmentConsistencyCheckerExtension { - pub fn create(bus: &BusHandle) -> Box { - Box::new(Self { bus: bus.clone() }) - } -} - -#[async_trait] -impl E3Extension for CommitmentConsistencyCheckerExtension { - fn on_event(&self, ctx: &mut E3Context, evt: &EnclaveEvent) { - let EnclaveEventData::CommitteeFinalized(data) = evt.get_data() else { - return; - }; - - // Don't start twice - if ctx - .get_event_recipient("commitment_consistency_checker") - .is_some() - { - return; - } - - let e3_id = data.e3_id.clone(); - - let Some(meta) = ctx.get_dependency(META_KEY) else { - error!("E3Meta not available — cannot start CommitmentConsistencyChecker"); - return; - }; - - info!("Starting CommitmentConsistencyChecker for E3 {}", e3_id); - - let links = commitment_links::default_links(meta.params_preset); - let addr = CommitmentConsistencyChecker::setup(&self.bus, e3_id, links); - - ctx.set_event_recipient("commitment_consistency_checker", Some(addr.into())); - } - - /// Intentionally a no-op — the checker is ephemeral by design (same - /// reasoning as [`AccusationManagerExtension::hydrate`]). - async fn hydrate(&self, _ctx: &mut E3Context, _snapshot: &E3ContextSnapshot) -> Result<()> { - Ok(()) - } -} +// Re-exported from `e3-slashing` — the canonical implementation lives there. +pub use e3_slashing::commitment_consistency_checker_ext::*; diff --git a/crates/zk-prover/src/actors/commitment_links/mod.rs b/crates/zk-prover/src/actors/commitment_links/mod.rs index cfe978ecef..2a8d2c81ed 100644 --- a/crates/zk-prover/src/actors/commitment_links/mod.rs +++ b/crates/zk-prover/src/actors/commitment_links/mod.rs @@ -6,11 +6,8 @@ //! Cross-circuit commitment consistency links. //! -//! Each [`CommitmentLink`] defines a relationship between two proof types -//! where a commitment value produced by one circuit must match a value -//! consumed or produced by another circuit. The -//! [`CommitmentConsistencyChecker`](super::commitment_consistency_checker::CommitmentConsistencyChecker) -//! evaluates these links as verified proofs arrive. +//! Concrete implementations of [`CommitmentLink`](e3_events::CommitmentLink) +//! for each ZK proof pair. The trait and supporting types live in `e3-events`. pub mod c0_to_c3; pub mod c1_to_c2; @@ -21,78 +18,10 @@ pub mod c4a_to_c6; pub mod c4b_to_c6; pub mod c6_to_c7; -use e3_events::ProofType; +// Re-export the canonical trait and types from e3-events. +pub use e3_events::{CommitmentLink, FieldValue, LinkScope}; use e3_fhe_params::BfvPreset; -/// A 32-byte BN254 field element extracted from public signals. -pub type FieldValue = [u8; 32]; - -/// 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). 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. -/// -/// Implementations extract commitment values from the public signals of a -/// *source* proof type and verify they are consistent with the public signals -/// of a *target* proof type. -pub trait CommitmentLink: Send + Sync { - /// Human-readable name for logging. - fn name(&self) -> &'static str; - - /// The proof type that *produces* the commitment value. - fn source_proof_type(&self) -> ProofType; - - /// The proof type that *consumes* or must agree with the commitment value. - fn target_proof_type(&self) -> ProofType; - - /// Relationship scope between source and target parties. - fn scope(&self) -> LinkScope; - - /// Extract the commitment value(s) from the source proof's public signals. - fn extract_source_values(&self, public_signals: &[u8]) -> Vec; - - /// Return `true` when `source_values` are consistent with - /// `target_public_signals`, without party context. - /// - /// Most links implement this. Links that need positional party information - /// should override [`check_consistency`] instead and leave this unimplemented. - fn check_signals(&self, _source_values: &[FieldValue], _target_public_signals: &[u8]) -> bool { - unimplemented!("override check_signals or check_consistency") - } - - /// Return `true` when `source_values` are consistent with - /// `target_public_signals`. - /// - /// `src_party_id` and `tgt_party_id` are 0-based committee indices. - /// Defaults to ignoring party IDs and delegating to [`check_signals`]. - /// Override this only when positional party information is required. - fn check_consistency( - &self, - source_values: &[FieldValue], - target_public_signals: &[u8], - _src_party_id: u64, - _tgt_party_id: u64, - ) -> bool { - self.check_signals(source_values, target_public_signals) - } -} - /// Returns the default set of commitment links to register. /// /// C4→C6 links verify that C4's aggregated share commitment matches C6's diff --git a/crates/zk-prover/src/actors/mod.rs b/crates/zk-prover/src/actors/mod.rs index 92c2d3953c..654c8b5a2d 100644 --- a/crates/zk-prover/src/actors/mod.rs +++ b/crates/zk-prover/src/actors/mod.rs @@ -44,9 +44,10 @@ pub mod proof_verification; pub mod share_verification; pub mod zk_actor; -pub use accusation_manager::AccusationManager; -pub use accusation_manager_ext::AccusationManagerExtension; -pub use commitment_consistency_checker_ext::CommitmentConsistencyCheckerExtension; +// Re-export accusation types from their canonical home in e3-slashing. +pub use e3_slashing::AccusationManager; +pub use e3_slashing::AccusationManagerExtension; +pub use e3_slashing::CommitmentConsistencyCheckerExtension; pub use node_proof_aggregator::NodeProofAggregator; pub use proof_request::ProofRequestActor; pub use proof_verification::{ diff --git a/crates/zk-prover/src/circuits/aggregation/node_dkg_fold.rs b/crates/zk-prover/src/circuits/aggregation/node_dkg_fold.rs index 7c575d558e..1ea2888d19 100644 --- a/crates/zk-prover/src/circuits/aggregation/node_dkg_fold.rs +++ b/crates/zk-prover/src/circuits/aggregation/node_dkg_fold.rs @@ -422,7 +422,7 @@ pub fn prove_dkg_aggregation( .collect(); let (committee_hash_hi, committee_hash_lo) = - e3_utils::committee_hash::committee_hash_field_hex(input.committee_addresses); + e3_committee_hash::committee_hash_field_hex(input.committee_addresses); let committee_members: Vec = input .committee_addresses @@ -521,7 +521,7 @@ pub fn prove_decryption_aggregation_jobs( } let (committee_hash_hi, committee_hash_lo) = - e3_utils::committee_hash::committee_hash_field_hex(committee_addresses); + e3_committee_hash::committee_hash_field_hex(committee_addresses); let committee_members: Vec = committee_addresses .iter() diff --git a/crates/evm/src/dkg_attestation_bundle.rs b/crates/zk-prover/src/dkg_attestation_bundle.rs similarity index 100% rename from crates/evm/src/dkg_attestation_bundle.rs rename to crates/zk-prover/src/dkg_attestation_bundle.rs diff --git a/crates/zk-prover/src/lib.rs b/crates/zk-prover/src/lib.rs index 136eedd35c..fad55be784 100644 --- a/crates/zk-prover/src/lib.rs +++ b/crates/zk-prover/src/lib.rs @@ -8,6 +8,7 @@ mod actors; mod backend; mod circuits; mod config; +mod dkg_attestation_bundle; mod error; mod node_fold_public; mod prover; @@ -15,10 +16,11 @@ pub mod test_utils; mod traits; mod witness; +pub use actors::commitment_links::default_links; pub use actors::{ - setup_zk_actors, AccusationManager, AccusationManagerExtension, - CommitmentConsistencyCheckerExtension, ProofRequestActor, ProofVerificationActor, - ShareVerificationActor, ZkActors, ZkVerificationRequest, ZkVerificationResponse, + setup_zk_actors, CommitmentConsistencyCheckerExtension, ProofRequestActor, + ProofVerificationActor, ShareVerificationActor, ZkActors, ZkVerificationRequest, + ZkVerificationResponse, }; pub use backend::{SetupStatus, ZkBackend}; @@ -31,6 +33,7 @@ pub use circuits::aggregation::node_dkg_fold::{ }; pub use circuits::aggregation::nodes_fold_accumulator::generate_sequential_nodes_fold; pub use config::{verify_checksum, BbTarget, CircuitInfo, VersionInfo, ZkConfig}; +pub use dkg_attestation_bundle::encode_dkg_attestation_bundle; pub use e3_events::CircuitVariant; pub use e3_zk_helpers::circuits::dkg::pk::circuit::PkCircuit; pub use error::ZkError; diff --git a/crates/zk-prover/tests/slashing_integration_tests.rs b/crates/zk-prover/tests/slashing_integration_tests.rs index a47077df20..879fc866d3 100644 --- a/crates/zk-prover/tests/slashing_integration_tests.rs +++ b/crates/zk-prover/tests/slashing_integration_tests.rs @@ -1650,7 +1650,7 @@ async fn test_onchain_duplicate_evidence_reverts() { async fn test_onchain_actor_signed_vote_accepted() { use e3_events::{AccusationOutcome, AccusationQuorumReached, AccusationVote, ProofType}; use e3_evm::encode_attestation_evidence; - use e3_zk_prover::AccusationManager; + use e3_slashing::AccusationManager; if !find_anvil().await { println!("skipping: anvil not found on PATH"); diff --git a/examples/CRISP/Cargo.lock b/examples/CRISP/Cargo.lock index 23eb11e959..5d5632c58d 100644 --- a/examples/CRISP/Cargo.lock +++ b/examples/CRISP/Cargo.lock @@ -2366,6 +2366,14 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "e3-committee-hash" +version = "0.1.15" +dependencies = [ + "alloy", + "hex", +] + [[package]] name = "e3-compute-provider" version = "0.1.15" @@ -2521,6 +2529,7 @@ dependencies = [ "alloy", "anyhow", "derivative", + "e3-committee-hash", "e3-utils-derive", "hex", "rand 0.8.5",