From 43ae57b41c17f46d2156653ca60a27807609eeee Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Mon, 23 Feb 2026 14:50:36 +0500 Subject: [PATCH 01/21] feat: fault-attribution and slashing crates --- crates/aggregator/src/committee_finalizer.rs | 50 +- .../src/keyshare_created_filter_buffer.rs | 33 +- crates/aggregator/src/publickey_aggregator.rs | 89 +- .../src/ciphernode_builder.rs | 47 +- crates/config/src/contract.rs | 2 + crates/entrypoint/src/config/setup.rs | 5 + .../entrypoint/src/start/aggregator_start.rs | 1 + crates/entrypoint/src/start/start.rs | 1 + crates/events/src/committee.rs | 80 ++ crates/events/src/enclave_event/mod.rs | 8 + .../events/src/enclave_event/signed_proof.rs | 84 +- .../src/enclave_event/slash_executed.rs | 74 ++ crates/events/src/lib.rs | 2 + crates/events/src/ordered_set.rs | 4 + crates/evm/src/ciphernode_registry_sol.rs | 38 + crates/evm/src/enclave_sol_writer.rs | 11 +- crates/evm/src/lib.rs | 4 + crates/evm/src/slashing_manager_sol_reader.rs | 55 + crates/evm/src/slashing_manager_sol_writer.rs | 173 +++ .../keyshare/src/encryption_key_collector.rs | 69 +- crates/keyshare/src/lib.rs | 4 +- crates/keyshare/src/threshold_keyshare.rs | 78 +- .../keyshare/src/threshold_share_collector.rs | 67 +- crates/request/src/router.rs | 9 + crates/sortition/src/repo.rs | 6 +- crates/sortition/src/sortition.rs | 90 +- .../zk-helpers/src/ciphernodes_committee.rs | 22 +- .../src/actors/proof_verification.rs | 53 +- .../tests/slashing_integration_tests.rs | 981 ++++++++++++++++++ 29 files changed, 2014 insertions(+), 126 deletions(-) create mode 100644 crates/events/src/committee.rs create mode 100644 crates/events/src/enclave_event/slash_executed.rs create mode 100644 crates/evm/src/slashing_manager_sol_reader.rs create mode 100644 crates/evm/src/slashing_manager_sol_writer.rs create mode 100644 crates/zk-prover/tests/slashing_integration_tests.rs diff --git a/crates/aggregator/src/committee_finalizer.rs b/crates/aggregator/src/committee_finalizer.rs index a4618f16ee..dcfe621f44 100644 --- a/crates/aggregator/src/committee_finalizer.rs +++ b/crates/aggregator/src/committee_finalizer.rs @@ -6,8 +6,8 @@ use actix::prelude::*; use e3_events::{ - prelude::*, trap, BusHandle, CommitteeFinalizeRequested, CommitteeRequested, EType, - EnclaveEvent, EnclaveEventData, EventType, Shutdown, TypedEvent, + prelude::*, trap, BusHandle, CommitteeFinalizeRequested, CommitteeRequested, E3Failed, E3Stage, + E3StageChanged, EType, EnclaveEvent, EnclaveEventData, EventType, Shutdown, TypedEvent, }; use e3_utils::{NotifySync, MAILBOX_LIMIT}; use std::collections::HashMap; @@ -33,7 +33,12 @@ impl CommitteeFinalizer { let addr = CommitteeFinalizer::new(bus).start(); bus.subscribe_all( - &[EventType::CommitteeRequested, EventType::Shutdown], + &[ + EventType::CommitteeRequested, + EventType::Shutdown, + EventType::E3Failed, + EventType::E3StageChanged, + ], addr.clone().recipient(), ); @@ -57,6 +62,10 @@ impl Handler for CommitteeFinalizer { self.notify_sync(ctx, TypedEvent::new(data, ec)) } EnclaveEventData::Shutdown(data) => self.notify_sync(ctx, data), + EnclaveEventData::E3Failed(data) => self.notify_sync(ctx, TypedEvent::new(data, ec)), + EnclaveEventData::E3StageChanged(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } _ => (), } } @@ -166,3 +175,38 @@ impl Handler for CommitteeFinalizer { ctx.stop(); } } + +impl Handler> for CommitteeFinalizer { + type Result = (); + fn handle(&mut self, msg: TypedEvent, ctx: &mut Self::Context) -> Self::Result { + let e3_id_str = msg.e3_id.to_string(); + if let Some(handle) = self.pending_committees.remove(&e3_id_str) { + info!( + e3_id = %msg.e3_id, + reason = ?msg.reason, + "E3 failed — cancelling pending committee finalization timer" + ); + ctx.cancel_future(handle); + } + } +} + +impl Handler> for CommitteeFinalizer { + type Result = (); + fn handle(&mut self, msg: TypedEvent, ctx: &mut Self::Context) -> Self::Result { + match &msg.new_stage { + E3Stage::Complete | E3Stage::Failed => { + let e3_id_str = msg.e3_id.to_string(); + if let Some(handle) = self.pending_committees.remove(&e3_id_str) { + info!( + e3_id = %msg.e3_id, + stage = ?msg.new_stage, + "E3 reached terminal stage — cancelling pending committee finalization timer" + ); + ctx.cancel_future(handle); + } + } + _ => {} + } + } +} diff --git a/crates/aggregator/src/keyshare_created_filter_buffer.rs b/crates/aggregator/src/keyshare_created_filter_buffer.rs index eea8edd67c..8171f1599c 100644 --- a/crates/aggregator/src/keyshare_created_filter_buffer.rs +++ b/crates/aggregator/src/keyshare_created_filter_buffer.rs @@ -6,13 +6,14 @@ use actix::prelude::*; -use e3_events::{prelude::*, EnclaveEvent, EnclaveEventData}; +use e3_events::{prelude::*, Die, EnclaveEvent, EnclaveEventData}; use e3_utils::MAILBOX_LIMIT; use std::collections::HashSet; +use tracing::info; use crate::PublicKeyAggregator; -/// Buffer KeyshareCreated events until CommitteeFinalized has been published +/// Buffers `KeyshareCreated` events until `CommitteeFinalized` arrives. pub struct KeyshareCreatedFilterBuffer { dest: Addr, committee: Option>, @@ -65,14 +66,38 @@ impl Handler for KeyshareCreatedFilterBuffer { _ => {} }, EnclaveEventData::CommitteeFinalized(data) => { - self.dest.do_send(msg.clone()); // forward committee first + self.dest.do_send(msg.clone()); self.committee = Some(data.committee.iter().cloned().collect()); self.process_buffered_events(); } + EnclaveEventData::CommitteeMemberExpelled(data) => { + // Only process raw events from chain (party_id not yet resolved). + if data.party_id.is_some() { + return; + } + + // Remove expelled node so we don't forward late KeyshareCreated events from them + if let Some(ref mut committee) = self.committee { + let node_addr = data.node.to_string(); + info!( + "KeyshareCreatedFilterBuffer: removing expelled node {} from committee filter (e3_id={})", + node_addr, data.e3_id + ); + committee.remove(&node_addr); + } + // Forward to PublicKeyAggregator for threshold_n adjustment + self.dest.do_send(msg); + } _ => { - // forward all other events self.dest.do_send(msg); } } } } + +impl Handler for KeyshareCreatedFilterBuffer { + type Result = (); + fn handle(&mut self, _: Die, ctx: &mut Self::Context) -> Self::Result { + ctx.stop(); + } +} diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 1b36c91ad3..d8cf851573 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -137,6 +137,50 @@ impl PublicKeyAggregator { }) }) } + + pub fn handle_member_expelled( + &mut self, + node: &str, + ec: &EventContext, + ) -> Result<()> { + self.state.try_mutate(ec, |mut state| { + let PublicKeyAggregatorState::Collecting { + threshold_n, + keyshares, + nodes, + .. + } = &mut state + else { + return Ok(state); + }; + + // Remove the expelled node from the nodes set so it won't appear in + // PublicKeyAggregated.nodes (forwarded on-chain for reward distribution). + // Note: the corresponding keyshare cannot be removed because the + // keyshares OrderedSet is keyed by raw bytes with no node mapping. + // This is acceptable because BFV public key aggregation is additive + // and works correctly with any superset of valid keys. + nodes.remove(&node.to_string()); + + if *threshold_n > 0 { + *threshold_n -= 1; + info!( + "PublicKeyAggregator: reduced threshold_n to {} after expelling {}", + threshold_n, node + ); + } + + if keyshares.len() == *threshold_n && *threshold_n > 0 { + info!("PublicKeyAggregator: enough keyshares after expulsion, computing aggregate"); + return Ok(PublicKeyAggregatorState::Computing { + keyshares: std::mem::take(keyshares), + nodes: std::mem::take(nodes), + }); + } + + Ok(state) + }) + } } impl Actor for PublicKeyAggregator { @@ -155,6 +199,50 @@ impl Handler for PublicKeyAggregator { self.notify_sync(ctx, TypedEvent::new(data, ec)) } EnclaveEventData::E3RequestComplete(_) => self.notify_sync(ctx, Die), + EnclaveEventData::CommitteeMemberExpelled(data) => { + // Only process raw events from chain (party_id not yet resolved). + if data.party_id.is_some() { + return; + } + + let node_addr = data.node.to_string(); + + if data.e3_id != self.e3_id { + error!("Wrong e3_id sent to PublicKeyAggregator for expulsion. This should not happen."); + return; + } + + info!( + "PublicKeyAggregator: committee member expelled: {} for e3_id={}", + node_addr, data.e3_id + ); + trap(EType::PublickeyAggregation, &self.bus.with_ec(&ec), || { + let was_collecting = matches!( + self.state.get(), + Some(PublicKeyAggregatorState::Collecting { .. }) + ); + + self.handle_member_expelled(&node_addr, &ec)?; + + if was_collecting { + if let Some(PublicKeyAggregatorState::Computing { keyshares, .. }) = + &self.state.get() + { + self.notify_sync( + ctx, + TypedEvent::new( + ComputeAggregate { + keyshares: keyshares.clone(), + e3_id: data.e3_id, + }, + ec.clone(), + ), + ); + } + } + Ok(()) + }); + } _ => (), }; } @@ -217,7 +305,6 @@ impl Handler> for PublicKeyAggregator { self.fhe.params.moduli().to_vec(), )?; - // Update the local state self.set_pubkey(pubkey, &ec)?; if let Some(PublicKeyAggregatorState::Complete { diff --git a/crates/ciphernode-builder/src/ciphernode_builder.rs b/crates/ciphernode-builder/src/ciphernode_builder.rs index b44edddbbf..d1a0e67a15 100644 --- a/crates/ciphernode-builder/src/ciphernode_builder.rs +++ b/crates/ciphernode-builder/src/ciphernode_builder.rs @@ -18,7 +18,9 @@ use e3_events::{ AggregateConfig, AggregateId, BusHandle, EnclaveEvent, EventBus, EventBusConfig, EvmEventConfig, }; use e3_evm::{BondingRegistrySolReader, CiphernodeRegistrySolReader, EnclaveSolWriter}; -use e3_evm::{CiphernodeRegistrySol, EnclaveSolReader}; +use e3_evm::{ + CiphernodeRegistrySol, EnclaveSolReader, SlashingManagerSolReader, SlashingManagerSolWriter, +}; use e3_fhe::ext::FheExtension; use e3_fhe_params::BfvPreset; use e3_keyshare::ext::ThresholdKeyshareExtension; @@ -95,6 +97,7 @@ pub struct ContractComponents { enclave: bool, ciphernode_registry: bool, bonding_registry: bool, + slashing_manager: bool, } #[derive(Clone, Debug)] @@ -296,6 +299,13 @@ impl CiphernodeBuilder { self } + /// Setup a SlashingManager writer for submitting slash proposals on-chain. + /// Requires the `slashing_manager` contract address to be configured. + pub fn with_contract_slashing_manager(mut self) -> Self { + self.contract_components.slashing_manager = true; + self + } + /// Setup net package components. pub fn with_net(mut self, peers: Vec, quic_port: u16) -> Self { self.net_config = Some(NetConfig::new(peers, quic_port)); @@ -668,6 +678,41 @@ async fn setup_evm_system( ) } } + + if contract_components.slashing_manager { + if let Some(contract) = &chain.contracts.slashing_manager { + // Reader: read SlashExecuted events from chain + let contract_addr = contract.address()?; + system.with_contract(contract_addr, move |next| { + SlashingManagerSolReader::setup(&next).recipient() + }); + + // Writer: submit proposeSlash transactions + match provider_cache.ensure_write_provider(&chain).await { + Ok(write_provider) => { + match SlashingManagerSolWriter::attach( + &bus, + write_provider.clone(), + contract_addr, + ) + .await + { + Ok(_) => { + info!("SlashingManagerSolWriter attached for fault submission"); + } + Err(e) => { + error!("Failed to attach SlashingManagerSolWriter, skipping: {}", e) + } + } + } + Err(e) => error!( + "Failed to create write provider for SlashingManager, skipping: {}", + e + ), + } + } + } + system.build(); } diff --git a/crates/config/src/contract.rs b/crates/config/src/contract.rs index 420d3c953c..32c856d2ad 100644 --- a/crates/config/src/contract.rs +++ b/crates/config/src/contract.rs @@ -48,6 +48,7 @@ pub struct ContractAddresses { pub bonding_registry: Contract, pub e3_program: Option, pub fee_token: Option, + pub slashing_manager: Option, } impl ContractAddresses { @@ -58,6 +59,7 @@ impl ContractAddresses { Some(&self.bonding_registry), self.e3_program.as_ref(), self.fee_token.as_ref(), + self.slashing_manager.as_ref(), ] .into_iter() .flatten() diff --git a/crates/entrypoint/src/config/setup.rs b/crates/entrypoint/src/config/setup.rs index b8764ff05f..49a9d044a1 100644 --- a/crates/entrypoint/src/config/setup.rs +++ b/crates/entrypoint/src/config/setup.rs @@ -59,6 +59,9 @@ chains: bonding_registry: address: "{}" deploy_block: {} + slashing_manager: + address: "{}" + deploy_block: {} "#, rpc_url, get_contract_info("Enclave")?.address, @@ -67,6 +70,8 @@ chains: get_contract_info("CiphernodeRegistryOwnable")?.deploy_block, get_contract_info("BondingRegistry")?.address, get_contract_info("BondingRegistry")?.deploy_block, + get_contract_info("SlashingManager")?.address, + get_contract_info("SlashingManager")?.deploy_block, ); fs::write(config_path.clone(), config_content)?; diff --git a/crates/entrypoint/src/start/aggregator_start.rs b/crates/entrypoint/src/start/aggregator_start.rs index 1ad2275633..06e5918cc3 100644 --- a/crates/entrypoint/src/start/aggregator_start.rs +++ b/crates/entrypoint/src/start/aggregator_start.rs @@ -31,6 +31,7 @@ pub async fn execute( .with_contract_enclave_full() .with_contract_bonding_registry() .with_contract_ciphernode_registry() + .with_contract_slashing_manager() .with_max_threads() .with_pubkey_aggregation() .with_threshold_plaintext_aggregation() diff --git a/crates/entrypoint/src/start/start.rs b/crates/entrypoint/src/start/start.rs index 6a16d9d5cd..3c1ea3073d 100644 --- a/crates/entrypoint/src/start/start.rs +++ b/crates/entrypoint/src/start/start.rs @@ -35,6 +35,7 @@ pub async fn execute(config: &AppConfig) -> Result { .with_contract_bonding_registry() .with_max_threads() .with_contract_ciphernode_registry() + .with_contract_slashing_manager() .with_trbfv() .with_zkproof(backend) .with_net(config.peers(), config.quic_port()) diff --git a/crates/events/src/committee.rs b/crates/events/src/committee.rs new file mode 100644 index 0000000000..b55a2a04e9 --- /dev/null +++ b/crates/events/src/committee.rs @@ -0,0 +1,80 @@ +// 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. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Ordered list of committee members where index == party_id. +/// +/// Provides O(1) address→party_id lookups via an internal index. +/// The index is eagerly rebuilt when the struct is deserialized. +/// +/// `PartialEq` compares only the `members` vec (the canonical data); +/// the `index` is a derived cache. +#[derive(Clone, Debug, Serialize)] +pub struct Committee { + /// Ordered member list — index == party_id. + members: Vec, + /// Lowercased-address → party_id for O(1) lookup. + #[serde(skip)] + index: HashMap, +} + +impl PartialEq for Committee { + fn eq(&self, other: &Self) -> bool { + self.members == other.members + } +} + +impl Eq for Committee {} + +impl<'de> Deserialize<'de> for Committee { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Inner { + members: Vec, + } + let inner = Inner::deserialize(deserializer)?; + Ok(Committee::new(inner.members)) + } +} + +impl Committee { + pub fn new(members: Vec) -> Self { + let index = members + .iter() + .enumerate() + .map(|(i, addr)| (addr.to_lowercase(), i as u64)) + .collect(); + Self { members, index } + } + + /// Resolve an address to its party_id (position in the committee list). + pub fn party_id_for(&self, addr: &str) -> Option { + self.index.get(&addr.to_lowercase()).copied() + } + + /// Check if an address is a committee member. + pub fn contains(&self, addr: &str) -> bool { + self.party_id_for(addr).is_some() + } + + /// The ordered member list (index == party_id). + pub fn members(&self) -> &[String] { + &self.members + } + + pub fn len(&self) -> usize { + self.members.len() + } + + pub fn is_empty(&self) -> bool { + self.members.is_empty() + } +} diff --git a/crates/events/src/enclave_event/mod.rs b/crates/events/src/enclave_event/mod.rs index fba6dce82c..43ad475dea 100644 --- a/crates/events/src/enclave_event/mod.rs +++ b/crates/events/src/enclave_event/mod.rs @@ -38,6 +38,7 @@ mod publickey_aggregated; mod publish_document; mod shutdown; mod signed_proof; +mod slash_executed; mod sync_effect; mod sync_end; mod sync_start; @@ -85,6 +86,7 @@ pub use publickey_aggregated::*; pub use publish_document::*; pub use shutdown::*; pub use signed_proof::*; +pub use slash_executed::*; use strum::IntoStaticStr; pub use sync_effect::*; pub use sync_end::*; @@ -232,6 +234,8 @@ pub enum EnclaveEventData { ComputeResponse(ComputeResponse), // ComputeResponseReceived ComputeRequestError(ComputeRequestError), // ComputeRequestFailed SignedProofFailed(SignedProofFailed), + SlashExecuted(SlashExecuted), + CommitteeMemberExpelled(CommitteeMemberExpelled), OutgoingSyncRequested(OutgoingSyncRequested), NetSyncEventsReceived(NetSyncEventsReceived), HistoricalEvmSyncStart(HistoricalEvmSyncStart), @@ -484,6 +488,8 @@ impl EnclaveEventData { EnclaveEventData::ComputeResponse(ref data) => Some(data.e3_id.clone()), EnclaveEventData::TestEvent(ref data) => data.e3_id.clone(), EnclaveEventData::SignedProofFailed(ref data) => Some(data.e3_id.clone()), + EnclaveEventData::SlashExecuted(ref data) => Some(data.e3_id.clone()), + EnclaveEventData::CommitteeMemberExpelled(ref data) => Some(data.e3_id.clone()), EnclaveEventData::E3Failed(ref data) => Some(data.e3_id.clone()), EnclaveEventData::E3StageChanged(ref data) => Some(data.e3_id.clone()), _ => None, @@ -550,6 +556,8 @@ impl_event_types!( ComputeResponse, ComputeRequestError, SignedProofFailed, + SlashExecuted, + CommitteeMemberExpelled, OutgoingSyncRequested, NetSyncEventsReceived, HistoricalEvmSyncStart, diff --git a/crates/events/src/enclave_event/signed_proof.rs b/crates/events/src/enclave_event/signed_proof.rs index 4834144cb7..e26a4ba005 100644 --- a/crates/events/src/enclave_event/signed_proof.rs +++ b/crates/events/src/enclave_event/signed_proof.rs @@ -13,7 +13,7 @@ use crate::{CircuitName, E3id, Proof}; use actix::Message; -use alloy::primitives::{keccak256, Address, Bytes, Signature, U256}; +use alloy::primitives::{keccak256, Address, FixedBytes, Signature, U256}; use alloy::signers::{local::PrivateKeySigner, SignerSync}; use alloy::sol_types::SolValue; use anyhow::{anyhow, Result}; @@ -104,11 +104,36 @@ pub struct ProofPayload { } impl ProofPayload { + /// The typehash that domain-separates the signed message. + /// + /// Must match `PROOF_PAYLOAD_TYPEHASH` in `SlashingManager.sol`: + /// `keccak256("ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)")` + pub fn typehash() -> [u8; 32] { + keccak256( + "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + ) + .into() + } + /// Compute the keccak256 digest of the canonical encoding. /// - /// Uses standard ABI encoding (`abi.encode`) which includes offsets and - /// lengths for dynamic types, avoiding collision between the two - /// variable-length `Bytes` fields (`proof` and `publicSignals`). + /// Uses structured hashing with a typehash prefix for domain separation, + /// and keccak256-hashes the dynamic fields (`zkProof`, `publicSignals`) + /// for gas efficiency on the Solidity verification side. + /// + /// The encoding is: + /// ```text + /// keccak256(abi.encode( + /// PROOF_PAYLOAD_TYPEHASH, // bytes32 + /// chainId, // uint256 + /// e3Id, // uint256 + /// proofType, // uint256 + /// keccak256(zkProof), // bytes32 + /// keccak256(publicSignals) // bytes32 + /// )) + /// ``` + /// + /// This matches the reconstruction in `SlashingManager.proposeSlash()`. pub fn digest(&self) -> Result<[u8; 32]> { let e3_id_u256: U256 = self .e3_id @@ -116,13 +141,17 @@ impl ProofPayload { .try_into() .map_err(|_| anyhow!("E3id cannot be converted to U256"))?; - // keccak256(abi.encode(chainId, e3Id, proofType, proof, publicSignals)) + let typehash = Self::typehash(); + + // keccak256(abi.encode(typehash, chainId, e3Id, proofType, keccak256(proof), keccak256(publicSignals))) + // All fields are bytes32/uint256 → pure static ABI encoding (6 × 32 = 192 bytes) let encoded = ( + typehash, U256::from(self.e3_id.chain_id()), e3_id_u256, U256::from(self.proof_type as u8), - Bytes::copy_from_slice(&self.proof.data), - Bytes::copy_from_slice(&self.proof.public_signals), + keccak256(&*self.proof.data), + keccak256(&*self.proof.public_signals), ) .abi_encode(); @@ -206,6 +235,47 @@ impl Display for SignedProofFailed { } } +/// Encode a [`SignedProofFailed`] event into the ABI-encoded evidence bytes +/// expected by `SlashingManager.proposeSlash()`. +/// +/// Returns: `abi.encode(bytes zkProof, bytes32[] publicInputs, bytes signature, uint256 chainId, uint256 proofType, address verifier)` +/// +/// The `verifier` is the current on-chain verifier contract address for this +/// proof type's slash policy. The `FaultSubmitter` actor must look this up +/// before calling this function. +pub fn encode_fault_evidence(failed: &SignedProofFailed, verifier: Address) -> Vec { + use alloy::primitives::Bytes; + + let proof = &failed.signed_payload.payload.proof; + + // Convert raw public_signals bytes → Vec> (one per 32-byte field) + let public_inputs: Vec> = proof + .public_signals + .chunks(32) + .map(|chunk| { + let mut buf = [0u8; 32]; + buf[..chunk.len()].copy_from_slice(chunk); + FixedBytes::from(buf) + }) + .collect(); + + // Must match the decode in SlashingManager.proposeSlash(): + // (bytes zkProof, bytes32[] publicInputs, bytes signature, uint256 chainId, uint256 proofType, address verifier) + // + // IMPORTANT: Use abi_encode_params() (not abi_encode()) because abi_encode() + // wraps dynamic tuples in an outer offset word, but Solidity's abi.decode() + // expects flat parameter encoding — the same as abi.encode(a, b, c, ...). + ( + Bytes::copy_from_slice(&proof.data), + public_inputs, + Bytes::copy_from_slice(&failed.signed_payload.signature), + U256::from(failed.e3_id.chain_id()), + U256::from(failed.signed_payload.payload.proof_type as u8), + verifier, + ) + .abi_encode_params() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/events/src/enclave_event/slash_executed.rs b/crates/events/src/enclave_event/slash_executed.rs new file mode 100644 index 0000000000..824ae2b6a8 --- /dev/null +++ b/crates/events/src/enclave_event/slash_executed.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. + +use crate::E3id; +use actix::Message; +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// Emitted when a slash proposal is executed on-chain. +/// +/// This event is read from the SlashingManager contract logs. +/// The `CommitteeExpulsionHandler` reacts to this to update local committee state. +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct SlashExecuted { + /// The E3 computation this slash relates to. + pub e3_id: E3id, + /// On-chain proposal ID. + pub proposal_id: u128, + /// Address of the slashed operator. + pub operator: Address, + /// Hash of the slash reason. + pub reason: [u8; 32], + /// Amount of ticket collateral slashed. + pub ticket_amount: u128, + /// Amount of license bond slashed. + pub license_amount: u128, +} + +impl Display for SlashExecuted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "SlashExecuted {{ e3_id: {}, proposal_id: {}, operator: {} }}", + self.e3_id, self.proposal_id, self.operator + ) + } +} + +/// Emitted when a committee member is expelled from an E3 committee. +/// +/// Read from the CiphernodeRegistry contract logs after slashing triggers expulsion. +/// The `CommitteeExpulsionHandler` uses this to update the local committee view +/// and check viability (whether remaining active members >= threshold M). +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct CommitteeMemberExpelled { + /// The E3 computation from which the member was expelled. + pub e3_id: E3id, + /// Address of the expelled committee member. + pub node: Address, + /// Hash of the slash reason that caused the expulsion. + pub reason: [u8; 32], + /// Number of active committee members remaining after expulsion. + pub active_count_after: u64, + /// Party ID (position in the committee list) of the expelled member. + /// `None` when read from chain (not yet resolved); `Some(id)` after + /// enrichment by the Sortition actor. + pub party_id: Option, +} + +impl Display for CommitteeMemberExpelled { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "CommitteeMemberExpelled {{ e3_id: {}, node: {}, active_count_after: {}, party_id: {:?} }}", + self.e3_id, self.node, self.active_count_after, self.party_id + ) + } +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 2d0e7b9050..bd0fe091dc 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 committee; mod correlation_id; mod data_events; mod e3id; @@ -29,6 +30,7 @@ mod sync; mod traits; pub use bus_handle::*; +pub use committee::*; pub use correlation_id::*; pub use data_events::*; pub use e3id::*; diff --git a/crates/events/src/ordered_set.rs b/crates/events/src/ordered_set.rs index 13f08295aa..5c5fd0b18d 100644 --- a/crates/events/src/ordered_set.rs +++ b/crates/events/src/ordered_set.rs @@ -33,6 +33,10 @@ impl OrderedSet { self.0.is_empty() } + pub fn remove(&mut self, value: &T) -> bool { + self.0.remove(value) + } + pub fn iter(&self) -> impl Iterator { self.0.iter() } diff --git a/crates/evm/src/ciphernode_registry_sol.rs b/crates/evm/src/ciphernode_registry_sol.rs index d56a9e6c4f..6a1cfe3369 100644 --- a/crates/evm/src/ciphernode_registry_sol.rs +++ b/crates/evm/src/ciphernode_registry_sol.rs @@ -157,6 +157,30 @@ impl From for EnclaveEventData { } } +struct CommitteeMemberExpelledWithChainId( + pub ICiphernodeRegistry::CommitteeMemberExpelled, + pub u64, +); + +impl From for e3_events::CommitteeMemberExpelled { + fn from(value: CommitteeMemberExpelledWithChainId) -> Self { + e3_events::CommitteeMemberExpelled { + e3_id: E3id::new(value.0.e3Id.to_string(), value.1), + node: value.0.node, + reason: value.0.reason.into(), + active_count_after: value.0.activeCountAfter.to(), + party_id: None, + } + } +} + +impl From for EnclaveEventData { + fn from(value: CommitteeMemberExpelledWithChainId) -> Self { + let payload: e3_events::CommitteeMemberExpelled = value.into(); + EnclaveEventData::from(payload) + } +} + pub fn extractor(data: &LogData, topic: Option<&B256>, chain_id: u64) -> Option { match topic { Some(&ICiphernodeRegistry::CiphernodeAdded::SIGNATURE_HASH) => { @@ -204,6 +228,20 @@ pub fn extractor(data: &LogData, topic: Option<&B256>, chain_id: u64) -> Option< event, chain_id, ))) } + Some(&ICiphernodeRegistry::CommitteeMemberExpelled::SIGNATURE_HASH) => { + let Ok(event) = ICiphernodeRegistry::CommitteeMemberExpelled::decode_log_data(data) + else { + error!("Error parsing event CommitteeMemberExpelled after topic was matched!"); + return None; + }; + info!( + "CommitteeMemberExpelled event received: e3_id={}, node={}, reason={:?}, active_count_after={}", + event.e3Id, event.node, event.reason, event.activeCountAfter + ); + Some(EnclaveEventData::from(CommitteeMemberExpelledWithChainId( + event, chain_id, + ))) + } _topic => { trace!( topic=?_topic, diff --git a/crates/evm/src/enclave_sol_writer.rs b/crates/evm/src/enclave_sol_writer.rs index a95416dce0..21a5fd8a42 100644 --- a/crates/evm/src/enclave_sol_writer.rs +++ b/crates/evm/src/enclave_sol_writer.rs @@ -26,7 +26,7 @@ use e3_events::{run_once, EnclaveEvent}; use e3_events::{E3id, EType, PlaintextAggregated}; use e3_utils::NotifySync; use e3_utils::MAILBOX_LIMIT; -use tracing::info; +use tracing::{info, warn}; sol!( #[sol(rpc)] @@ -114,6 +114,15 @@ impl Handler 1 { + warn!( + "E3 {} has {} decrypted outputs but only the first is published on-chain. \ + Multi-output support is not yet implemented.", + e3_id, + decrypted_output.len() + ); + } let result = publish_plaintext_output( provider, contract_address, diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index cb6a4717dc..a7eb3533ad 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -18,6 +18,8 @@ mod fix_historical_order; pub mod helpers; mod log_fetcher; mod repo; +mod slashing_manager_sol_reader; +mod slashing_manager_sol_writer; mod sync_start_extractor; pub use bonding_registry_sol::BondingRegistrySolReader; @@ -35,4 +37,6 @@ pub use evm_router::*; pub use fix_historical_order::*; pub use helpers::*; pub use repo::*; +pub use slashing_manager_sol_reader::SlashingManagerSolReader; +pub use slashing_manager_sol_writer::SlashingManagerSolWriter; pub use sync_start_extractor::*; diff --git a/crates/evm/src/slashing_manager_sol_reader.rs b/crates/evm/src/slashing_manager_sol_reader.rs new file mode 100644 index 0000000000..cb341c95c8 --- /dev/null +++ b/crates/evm/src/slashing_manager_sol_reader.rs @@ -0,0 +1,55 @@ +// 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. + +use crate::{ + events::EvmEventProcessor, evm_parser::EvmParser, slashing_manager_sol_writer::ISlashingManager, +}; +use actix::{Actor, Addr}; +use alloy::{ + primitives::{LogData, B256}, + sol_types::SolEvent, +}; +use e3_events::{E3id, EnclaveEventData}; +use tracing::{error, info, trace}; + +pub fn extractor(data: &LogData, topic: Option<&B256>, chain_id: u64) -> Option { + match topic { + Some(&ISlashingManager::SlashExecuted::SIGNATURE_HASH) => { + let Ok(event) = ISlashingManager::SlashExecuted::decode_log_data(data) else { + error!("Error parsing event SlashExecuted after topic was matched!"); + return None; + }; + info!( + "SlashExecuted event received: proposal_id={}, e3_id={}, operator={}, reason={:?}, ticket={}, license={}", + event.proposalId, event.e3Id, event.operator, event.reason, event.ticketAmount, event.licenseAmount + ); + Some(EnclaveEventData::from(e3_events::SlashExecuted { + e3_id: E3id::new(event.e3Id.to_string(), chain_id), + proposal_id: event.proposalId.to::(), + operator: event.operator, + reason: event.reason.into(), + ticket_amount: event.ticketAmount.to::(), + license_amount: event.licenseAmount.to::(), + })) + } + _topic => { + trace!( + topic=?_topic, + "Unknown event was received by SlashingManager.sol parser but was ignored" + ); + None + } + } +} + +/// Connects to SlashingManager.sol converting EVM events to EnclaveEvents +pub struct SlashingManagerSolReader; + +impl SlashingManagerSolReader { + pub fn setup(next: &EvmEventProcessor) -> Addr { + EvmParser::new(next, extractor).start() + } +} diff --git a/crates/evm/src/slashing_manager_sol_writer.rs b/crates/evm/src/slashing_manager_sol_writer.rs new file mode 100644 index 0000000000..446f24041c --- /dev/null +++ b/crates/evm/src/slashing_manager_sol_writer.rs @@ -0,0 +1,173 @@ +// 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. + +//! Subscribes to `SignedProofFailed` events and submits `proposeSlash` +//! transactions on the SlashingManager contract. + +use crate::helpers::EthProvider; +use crate::send_tx_with_retry; +use actix::prelude::*; +use actix::Addr; +use alloy::{ + primitives::{keccak256, Address, Bytes, U256}, + providers::{Provider, WalletProvider}, + rpc::types::TransactionReceipt, + sol, +}; +use anyhow::Result; +use e3_events::prelude::*; +use e3_events::BusHandle; +use e3_events::EnclaveEvent; +use e3_events::EnclaveEventData; +use e3_events::EventType; +use e3_events::Shutdown; +use e3_events::{encode_fault_evidence, EType, SignedProofFailed}; +use e3_utils::NotifySync; +use tracing::info; + +sol!( + #[sol(rpc)] + ISlashingManager, + "../../packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json" +); + +/// Submits `SignedProofFailed` events as slash proposals on-chain. +pub struct SlashingManagerSolWriter

{ + provider: EthProvider

, + contract_address: Address, + bus: BusHandle, +} + +impl SlashingManagerSolWriter

{ + pub fn new( + bus: &BusHandle, + provider: EthProvider

, + contract_address: Address, + ) -> Result { + Ok(Self { + provider, + contract_address, + bus: bus.clone(), + }) + } + + pub async fn attach( + bus: &BusHandle, + provider: EthProvider

, + contract_address: Address, + ) -> Result>> { + let addr = SlashingManagerSolWriter::new(bus, provider, contract_address)?.start(); + bus.subscribe_all( + &[EventType::SignedProofFailed, EventType::Shutdown], + addr.clone().into(), + ); + Ok(addr) + } +} + +impl Actor for SlashingManagerSolWriter

{ + type Context = actix::Context; +} + +impl Handler + for SlashingManagerSolWriter

+{ + type Result = (); + + fn handle(&mut self, msg: EnclaveEvent, ctx: &mut Self::Context) -> Self::Result { + match msg.into_data() { + EnclaveEventData::SignedProofFailed(data) => { + if self.provider.chain_id() == data.e3_id.chain_id() { + ctx.notify(data); + } + } + EnclaveEventData::Shutdown(data) => self.notify_sync(ctx, data), + _ => (), + } + } +} + +impl Handler + for SlashingManagerSolWriter

+{ + type Result = ResponseFuture<()>; + + fn handle(&mut self, msg: SignedProofFailed, _: &mut Self::Context) -> Self::Result { + Box::pin({ + let contract_address = self.contract_address; + let provider = self.provider.clone(); + let bus = self.bus.clone(); + async move { + let result = submit_slash_proposal(provider, contract_address, msg).await; + match result { + Ok(receipt) => { + info!(tx=%receipt.transaction_hash, "Submitted slash proposal on-chain"); + } + Err(err) => { + bus.err( + EType::Evm, + anyhow::anyhow!("Error submitting slash proposal: {:?}", err), + ); + } + } + } + }) + } +} + +impl Handler + for SlashingManagerSolWriter

+{ + type Result = (); + + fn handle(&mut self, _: Shutdown, ctx: &mut Self::Context) -> Self::Result { + ctx.stop(); + } +} + +async fn submit_slash_proposal( + provider: EthProvider

, + contract_address: Address, + data: SignedProofFailed, +) -> Result { + let e3_id: U256 = data.e3_id.clone().try_into()?; + let operator = data.faulting_node; + let reason = keccak256(data.proof_type.slash_reason().as_bytes()); + + // Look up the verifier address from the on-chain slash policy. + // This is required to encode the full 6-tuple evidence that the contract expects: + // (bytes zkProof, bytes32[] publicInputs, bytes signature, uint256 chainId, uint256 proofType, address verifier) + let contract = ISlashingManager::new(contract_address, provider.provider()); + let policy = contract.getSlashPolicy(reason.into()).call().await?; + let verifier = policy.proofVerifier; + + let proof_data = encode_fault_evidence(&data, verifier); + + let from_address = provider.provider().default_signer_address(); + let current_nonce = provider + .provider() + .get_transaction_count(from_address) + .pending() + .await?; + + send_tx_with_retry("proposeSlash", &[], || { + info!( + "proposeSlash() e3_id={:?} operator={:?} reason={:?}", + e3_id, operator, reason + ); + let proof = Bytes::from(proof_data.clone()); + let contract = ISlashingManager::new(contract_address, provider.provider()); + + async move { + let builder = contract + .proposeSlash(e3_id, operator, reason.into(), proof) + .nonce(current_nonce); + let receipt = builder.send().await?.get_receipt().await?; + Ok(receipt) + } + }) + .await +} diff --git a/crates/keyshare/src/encryption_key_collector.rs b/crates/keyshare/src/encryption_key_collector.rs index 15d5ef5d81..bdfb86adc8 100644 --- a/crates/keyshare/src/encryption_key_collector.rs +++ b/crates/keyshare/src/encryption_key_collector.rs @@ -12,7 +12,8 @@ use std::{ use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, Message, SpawnHandle}; use e3_events::{ - E3id, EncryptionKey, EncryptionKeyCollectionFailed, EncryptionKeyCreated, TypedEvent, + E3id, EncryptionKey, EncryptionKeyCollectionFailed, EncryptionKeyCreated, EventContext, + Sequenced, TypedEvent, }; use e3_trbfv::PartyId; use e3_utils::MAILBOX_LIMIT; @@ -57,10 +58,21 @@ impl From>> for AllEncryptionKeysCollected { #[rtype(result = "()")] pub struct EncryptionKeyCollectionTimeout; +/// Removes this party from the `todo` set so the DKG can complete with +/// N-1 keys instead of waiting for a key that will never arrive. +#[derive(Message, Clone, Debug)] +#[rtype(result = "()")] +pub struct ExpelPartyFromKeyCollection { + pub party_id: PartyId, + pub ec: EventContext, +} + /// Actor that collects BFV encryption keys from all parties. /// /// Once all keys are collected, it sends `AllEncryptionKeysCollected` to the parent /// `ThresholdKeyshare` actor. If collection times out, it sends `EncryptionKeyCollectionFailed`. +/// If a party is expelled (slashed), it is removed from the expected set so the +/// collection can complete with N-1 parties. pub struct EncryptionKeyCollector { e3_id: E3id, todo: HashSet, @@ -194,7 +206,60 @@ impl Handler for EncryptionKeyCollector { missing_parties, }); - // Stop the actor ctx.stop(); } } + +impl Handler for EncryptionKeyCollector { + type Result = (); + fn handle( + &mut self, + msg: ExpelPartyFromKeyCollection, + ctx: &mut Self::Context, + ) -> Self::Result { + // Only handle if we're still collecting + if !matches!(self.state, CollectorState::Collecting) { + return; + } + + let party_id = msg.party_id; + + // Remove expelled party from the todo set + if !self.todo.remove(&party_id) { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} was not in todo set (already received or unknown)", + party_id + ); + return; + } + + info!( + e3_id = %self.e3_id, + party_id = party_id, + remaining = self.todo.len(), + "Removed expelled party {} from encryption key collection, {} remaining", + party_id, + self.todo.len() + ); + + // Check if all remaining keys have been collected + if self.todo.is_empty() { + info!( + e3_id = %self.e3_id, + "All remaining encryption keys collected after party expulsion!" + ); + self.state = CollectorState::Finished; + + // Cancel the timeout since we're done + if let Some(handle) = self.timeout_handle.take() { + ctx.cancel_future(handle); + } + + let event: TypedEvent = + TypedEvent::new(self.keys.clone().into(), msg.ec.clone()); + self.parent.do_send(event); + } + } +} diff --git a/crates/keyshare/src/lib.rs b/crates/keyshare/src/lib.rs index c9d9c80cd9..50bf3dac03 100644 --- a/crates/keyshare/src/lib.rs +++ b/crates/keyshare/src/lib.rs @@ -9,6 +9,8 @@ pub mod ext; mod repo; mod threshold_keyshare; mod threshold_share_collector; -pub use encryption_key_collector::{AllEncryptionKeysCollected, EncryptionKeyCollector}; +pub use encryption_key_collector::{ + AllEncryptionKeysCollected, EncryptionKeyCollector, ExpelPartyFromKeyCollection, +}; pub use repo::*; pub use threshold_keyshare::*; diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index b2a2e3a0f9..b27ba8f8c1 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -9,13 +9,13 @@ use anyhow::{anyhow, bail, Context, Result}; use e3_crypto::{Cipher, SensitiveBytes}; use e3_data::Persistable; use e3_events::{ - prelude::*, trap, BusHandle, CiphernodeSelected, CiphertextOutputPublished, ComputeRequest, - ComputeResponse, ComputeResponseKind, CorrelationId, DecryptionshareCreated, Die, - E3RequestComplete, E3id, EType, EnclaveEvent, EnclaveEventData, EncryptionKey, - EncryptionKeyCollectionFailed, EncryptionKeyCreated, EncryptionKeyPending, EventContext, - KeyshareCreated, PartyId, PkGenerationProofRequest, PkGenerationProofSigned, Sequenced, - SignedProofPayload, ThresholdShare, ThresholdShareCollectionFailed, ThresholdShareCreated, - ThresholdSharePending, TypedEvent, + prelude::*, trap, BusHandle, CiphernodeSelected, CiphertextOutputPublished, + CommitteeMemberExpelled, ComputeRequest, ComputeResponse, ComputeResponseKind, CorrelationId, + DecryptionshareCreated, Die, E3RequestComplete, E3id, EType, EnclaveEvent, EnclaveEventData, + EncryptionKey, EncryptionKeyCollectionFailed, EncryptionKeyCreated, EncryptionKeyPending, + EventContext, KeyshareCreated, PartyId, PkGenerationProofRequest, PkGenerationProofSigned, + Sequenced, SignedProofPayload, ThresholdShare, ThresholdShareCollectionFailed, + ThresholdShareCreated, ThresholdSharePending, TypedEvent, }; use e3_fhe_params::create_deterministic_crp_from_default_seed; use e3_fhe_params::{BfvParamSet, BfvPreset}; @@ -37,10 +37,12 @@ use fhe::bfv::{PublicKey, SecretKey}; use fhe_traits::{DeserializeParametrized, Serialize}; use rand::rngs::OsRng; use std::{collections::HashMap, mem, sync::Arc}; -use tracing::{info, trace, warn}; +use tracing::{error, info, trace, warn}; -use crate::encryption_key_collector::{AllEncryptionKeysCollected, EncryptionKeyCollector}; -use crate::threshold_share_collector::ThresholdShareCollector; +use crate::encryption_key_collector::{ + AllEncryptionKeysCollected, EncryptionKeyCollector, ExpelPartyFromKeyCollection, +}; +use crate::threshold_share_collector::{ExpelPartyFromShareCollection, ThresholdShareCollector}; #[derive(Message, Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[rtype(result = "()")] @@ -379,6 +381,36 @@ impl ThresholdKeyshare { Ok(addr.clone()) } + fn handle_committee_member_expelled( + &mut self, + data: CommitteeMemberExpelled, + ec: EventContext, + ) { + // Only process enriched events (party_id resolved by Sortition). + // Raw events from chain (party_id = None) are ignored here; + // Sortition will re-publish them with party_id set. + let Some(party_id) = data.party_id else { + return; + }; + + let node_addr = data.node.to_string(); + info!( + "CommitteeMemberExpelled received (enriched): node={}, party_id={}, e3_id={}, active_count_after={}", + node_addr, party_id, data.e3_id, data.active_count_after + ); + + if let Some(ref collector) = self.encryption_key_collector { + collector.do_send(ExpelPartyFromKeyCollection { + party_id, + ec: ec.clone(), + }); + } + + if let Some(ref collector) = self.decryption_key_collector { + collector.do_send(ExpelPartyFromShareCollection { party_id, ec }); + } + } + pub fn handle_threshold_share_created( &mut self, msg: TypedEvent, @@ -1047,32 +1079,12 @@ impl Handler for ThresholdKeyshare { let _ = self.handle_pk_generation_proof_signed(TypedEvent::new(data, ec)); } EnclaveEventData::E3RequestComplete(data) => self.notify_sync(ctx, data), - EnclaveEventData::E3Failed(data) => { - warn!( - "E3 failed: {:?}. Shutting down ThresholdKeyshare for e3_id={}", - data.reason, data.e3_id - ); - self.notify_sync(ctx, E3RequestComplete { e3_id: data.e3_id }); - } - EnclaveEventData::E3StageChanged(data) => { - use e3_events::E3Stage; - match &data.new_stage { - E3Stage::Complete | E3Stage::Failed => { - info!("E3 reached terminal stage {:?}. Shutting down ThresholdKeyshare for e3_id={}", data.new_stage, data.e3_id); - self.notify_sync(ctx, E3RequestComplete { e3_id: data.e3_id }); - } - _ => { - trace!( - "E3 stage changed to {:?} for e3_id={}", - data.new_stage, - data.e3_id - ); - } - } - } EnclaveEventData::ComputeResponse(data) => { self.notify_sync(ctx, TypedEvent::new(data, ec)) } + EnclaveEventData::CommitteeMemberExpelled(data) => { + self.handle_committee_member_expelled(data, ec); + } _ => (), } } diff --git a/crates/keyshare/src/threshold_share_collector.rs b/crates/keyshare/src/threshold_share_collector.rs index ae1167352f..0c4772eb05 100644 --- a/crates/keyshare/src/threshold_share_collector.rs +++ b/crates/keyshare/src/threshold_share_collector.rs @@ -12,7 +12,8 @@ use std::{ use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, Message, SpawnHandle}; use e3_events::{ - E3id, ThresholdShare, ThresholdShareCollectionFailed, ThresholdShareCreated, TypedEvent, + E3id, EventContext, Sequenced, ThresholdShare, ThresholdShareCollectionFailed, + ThresholdShareCreated, TypedEvent, }; use e3_trbfv::PartyId; use e3_utils::MAILBOX_LIMIT; @@ -33,6 +34,15 @@ pub(crate) enum CollectorState { #[rtype(result = "()")] pub struct ThresholdShareCollectionTimeout; +/// Removes this party from the `todo` set so the DKG can complete with +/// N-1 shares instead of waiting for a share that will never arrive. +#[derive(Message, Clone, Debug)] +#[rtype(result = "()")] +pub struct ExpelPartyFromShareCollection { + pub party_id: PartyId, + pub ec: EventContext, +} + pub struct ThresholdShareCollector { /// The E3id for the round e3_id: E3id, @@ -166,7 +176,60 @@ impl Handler for ThresholdShareCollector { missing_parties, }); - // Stop the actor ctx.stop(); } } + +impl Handler for ThresholdShareCollector { + type Result = (); + fn handle( + &mut self, + msg: ExpelPartyFromShareCollection, + ctx: &mut Self::Context, + ) -> Self::Result { + // Only handle if we're still collecting + if !matches!(self.state, CollectorState::Collecting) { + return; + } + + let party_id = msg.party_id; + + // Remove expelled party from the todo set + if !self.todo.remove(&party_id) { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} was not in share collection todo set (already received or unknown)", + party_id + ); + return; + } + + info!( + e3_id = %self.e3_id, + party_id = party_id, + remaining = self.todo.len(), + "Removed expelled party {} from threshold share collection, {} remaining", + party_id, + self.todo.len() + ); + + // Check if all remaining shares have been collected + if self.todo.is_empty() { + info!( + e3_id = %self.e3_id, + "All remaining threshold shares collected after party expulsion!" + ); + self.state = CollectorState::Finished; + + // Cancel the timeout since we're done + if let Some(handle) = self.timeout_handle.take() { + ctx.cancel_future(handle); + } + + let event: TypedEvent = + TypedEvent::new(self.shares.clone().into(), msg.ec); + self.parent.do_send(event); + } + } +} diff --git a/crates/request/src/router.rs b/crates/request/src/router.rs index d7a0a52b7e..93fea43614 100644 --- a/crates/request/src/router.rs +++ b/crates/request/src/router.rs @@ -23,6 +23,7 @@ use e3_events::prelude::*; use e3_events::trap; use e3_events::BusHandle; use e3_events::E3RequestComplete; +use e3_events::E3Stage; use e3_events::EType; use e3_events::EnclaveEventData; use e3_events::EventType; @@ -201,6 +202,14 @@ impl Handler for E3Router { // Send to bus so all other actors can react to a request being complete. self.bus.publish(event, ctx)?; } + EnclaveEventData::E3StageChanged(ref data) + if matches!(data.new_stage, E3Stage::Complete | E3Stage::Failed) => + { + let event = E3RequestComplete { + e3_id: e3_id.clone(), + }; + self.bus.publish(event, ctx)?; + } EnclaveEventData::E3RequestComplete(_) => { // Note this will be sent above to the children who can kill themselves based on // the event diff --git a/crates/sortition/src/repo.rs b/crates/sortition/src/repo.rs index ffa3b84cc9..0b25aad99b 100644 --- a/crates/sortition/src/repo.rs +++ b/crates/sortition/src/repo.rs @@ -7,7 +7,7 @@ use crate::backends::SortitionBackend; use crate::sortition::NodeStateStore; use e3_data::{Repositories, Repository}; -use e3_events::{E3id, StoreKeys}; +use e3_events::{Committee, E3id, StoreKeys}; use e3_request::E3Meta; use std::collections::HashMap; @@ -42,11 +42,11 @@ impl NodeStateRepositoryFactory for Repositories { } pub trait FinalizedCommitteesRepositoryFactory { - fn finalized_committees(&self) -> Repository>>; + fn finalized_committees(&self) -> Repository>; } impl FinalizedCommitteesRepositoryFactory for Repositories { - fn finalized_committees(&self) -> Repository>> { + fn finalized_committees(&self) -> Repository> { Repository::new(self.store.scope(StoreKeys::finalized_committees())) } } diff --git a/crates/sortition/src/sortition.rs b/crates/sortition/src/sortition.rs index a690a7f08c..ee6e37ba09 100644 --- a/crates/sortition/src/sortition.rs +++ b/crates/sortition/src/sortition.rs @@ -12,10 +12,11 @@ use alloy::primitives::U256; use anyhow::{anyhow, Result}; use e3_data::{AutoPersist, Persistable, Repository}; use e3_events::{ - prelude::*, trap, CiphernodeAdded, CiphernodeRemoved, CommitteeFinalized, CommitteePublished, - ConfigurationUpdated, E3Failed, E3Requested, E3Stage, E3StageChanged, EType, EnclaveEvent, - EventContext, EventType, OperatorActivationChanged, PlaintextOutputPublished, Seed, Sequenced, - TicketBalanceUpdated, TypedEvent, + prelude::*, trap, CiphernodeAdded, CiphernodeRemoved, Committee, CommitteeFinalized, + CommitteeMemberExpelled, CommitteePublished, ConfigurationUpdated, E3Failed, E3Requested, + E3Stage, E3StageChanged, EType, EnclaveEvent, EventContext, EventType, + OperatorActivationChanged, PlaintextOutputPublished, Seed, Sequenced, TicketBalanceUpdated, + TypedEvent, }; use e3_events::{BusHandle, E3id, EnclaveEventData}; use e3_utils::{NotifySync, MAILBOX_LIMIT}; @@ -212,7 +213,7 @@ pub struct Sortition { /// Event bus for error reporting and enclave event subscription. bus: BusHandle, /// Persistent map of finalized committees per E3 - finalized_committees: Persistable>>, + finalized_committees: Persistable>, /// Address for the CiphernodeSelector ciphernode_selector: Addr, /// Address for the current node @@ -229,7 +230,7 @@ pub struct SortitionParams { /// Node state store per chain pub node_state: Persistable>, /// Persistent map of finalized committees per E3 - pub finalized_committees: Persistable>>, + pub finalized_committees: Persistable>, /// Address for the CiphernodeSelector pub ciphernode_selector: Addr, /// Address for the current node @@ -253,7 +254,7 @@ impl Sortition { bus: &BusHandle, backends_store: Repository>, node_state_store: Repository>, - committees_store: Repository>>, + committees_store: Repository>, default_backend: SortitionBackend, ciphernode_selector: Addr, address: &str, @@ -289,6 +290,7 @@ impl Sortition { EventType::CommitteePublished, EventType::PlaintextOutputPublished, EventType::CommitteeFinalized, + EventType::CommitteeMemberExpelled, EventType::E3Failed, EventType::E3StageChanged, ], @@ -331,23 +333,21 @@ impl Sortition { }) } - fn get_committe(&self, e3_id: &E3id) -> Vec { + fn get_committee(&self, e3_id: &E3id) -> Option { self.finalized_committees .get() .and_then(|committees| committees.get(e3_id).cloned()) - .unwrap_or_else(|| Vec::new()) } fn committee_contains(&mut self, e3_id: E3id, node: String) -> bool { - let committee = self.get_committe(&e3_id); - - if committee.len() == 0 { + let Some(committee) = self.get_committee(&e3_id) else { // Non blocking error self.bus.err( EType::Sortition, anyhow!("No finalized committee found for E3 {}", e3_id), ); - } + return false; + }; committee.contains(&node) } @@ -438,6 +438,13 @@ impl Handler for Sortition { EnclaveEventData::CommitteeFinalized(data) => { self.notify_sync(ctx, TypedEvent::new(data, ec)) } + EnclaveEventData::CommitteeMemberExpelled(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } + EnclaveEventData::E3Failed(data) => self.notify_sync(ctx, TypedEvent::new(data, ec)), + EnclaveEventData::E3StageChanged(data) => { + self.notify_sync(ctx, TypedEvent::new(data, ec)) + } _ => (), } } @@ -784,9 +791,64 @@ impl Handler> for Sortition { ); self.finalized_committees.try_mutate(&ec, |mut committees| { - committees.insert(msg.e3_id.clone(), msg.committee.clone()); + committees.insert(msg.e3_id.clone(), Committee::new(msg.committee.clone())); Ok(committees) }) }) } } + +impl Handler> for Sortition { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + let (data, ec) = msg.into_components(); + + // Only process raw events from chain (party_id not yet resolved). + // Events we re-publish with party_id set will also arrive here; ignore them. + if data.party_id.is_some() { + return; + } + + trap(EType::Sortition, &self.bus.with_ec(&ec), || { + let node_addr = data.node.to_string(); + + let Some(committee) = self.get_committee(&data.e3_id) else { + warn!( + "CommitteeMemberExpelled for node {} but no finalized committee found for e3_id={}. \ + The committee should always be finalized before expulsions.", + node_addr, data.e3_id + ); + return Ok(()); + }; + + let Some(party_id) = committee.party_id_for(&node_addr) else { + warn!( + "Expelled node {} not found in committee for e3_id={}", + node_addr, data.e3_id + ); + return Ok(()); + }; + + info!( + "Sortition: resolved expelled node {} to party_id={} for e3_id={}, re-publishing enriched event", + node_addr, party_id, data.e3_id + ); + + // Re-publish the event with party_id set to downstream actors + self.bus.publish( + CommitteeMemberExpelled { + party_id: Some(party_id), + ..data + }, + ec, + )?; + + Ok(()) + }) + } +} diff --git a/crates/zk-helpers/src/ciphernodes_committee.rs b/crates/zk-helpers/src/ciphernodes_committee.rs index da5a706e55..f1a912296a 100644 --- a/crates/zk-helpers/src/ciphernodes_committee.rs +++ b/crates/zk-helpers/src/ciphernodes_committee.rs @@ -38,18 +38,16 @@ impl CiphernodesCommitteeSize { h: 5, threshold: 2, }, - _ => unreachable!(), + CiphernodesCommitteeSize::Medium => CiphernodesCommittee { + n: 10, + h: 8, + threshold: 4, + }, + CiphernodesCommitteeSize::Large => CiphernodesCommittee { + n: 20, + h: 15, + threshold: 7, + }, } - // @todo add the other committee sizes - // CiphernodesCommitteeSize::Medium => CiphernodesCommittee { - // n: 5, - // h: 5, - // threshold: 2, - // }, - // CiphernodesCommitteeSize::Large => CiphernodesCommittee { - // n: 5, - // h: 5, - // threshold: 2, - // }, } } diff --git a/crates/zk-prover/src/actors/proof_verification.rs b/crates/zk-prover/src/actors/proof_verification.rs index c0fe9ae3c4..ee3b7f8e10 100644 --- a/crates/zk-prover/src/actors/proof_verification.rs +++ b/crates/zk-prover/src/actors/proof_verification.rs @@ -4,20 +4,9 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -//! Core business logic actor for verifying received encryption keys. -//! -//! This actor verifies `EncryptionKeyReceived` events and converts them -//! to `EncryptionKeyCreated` events after validation. -//! -//! ## Signature Verification -//! -//! Every received key must carry a [`SignedProofPayload`]. This actor: -//! 1. Recovers the address from the ECDSA signature. -//! 2. Delegates the ZK proof to `ZkActor` for verification. -//! 3. On ZK failure, emits [`SignedProofFailed`] with the full evidence bundle -//! and [`E3Failed`] to stop the E3 computation. -//! -//! Keys without a signed proof are rejected outright. +//! Verifies `EncryptionKeyReceived` events: recovers ECDSA address, delegates +//! ZK proof to `ZkActor`, and on failure emits [`SignedProofFailed`] for +//! on-chain fault attribution. use std::collections::HashMap; use std::sync::Arc; @@ -25,14 +14,13 @@ use std::sync::Arc; use actix::{Actor, Addr, AsyncContext, Context, Handler, Message, Recipient}; use alloy::primitives::Address; use e3_events::{ - BusHandle, E3Failed, E3Stage, E3id, EnclaveEvent, EnclaveEventData, EncryptionKey, - EncryptionKeyCreated, EncryptionKeyReceived, EventContext, EventPublisher, EventSubscriber, - EventType, FailureReason, Proof, Sequenced, SignedProofFailed, SignedProofPayload, TypedEvent, + BusHandle, E3id, EnclaveEvent, EnclaveEventData, EncryptionKey, EncryptionKeyCreated, + EncryptionKeyReceived, EventContext, EventPublisher, EventSubscriber, EventType, Proof, + Sequenced, SignedProofFailed, SignedProofPayload, TypedEvent, }; use e3_utils::NotifySync; use tracing::{error, info, warn}; -/// Request to verify a ZK proof. #[derive(Debug, Message)] #[rtype(result = "()")] pub struct ZkVerificationRequest { @@ -42,7 +30,6 @@ pub struct ZkVerificationRequest { pub sender: Recipient>, } -/// Response from ZK proof verification with context. #[derive(Debug, Clone, Message)] #[rtype(result = "()")] pub struct ZkVerificationResponse { @@ -52,23 +39,15 @@ pub struct ZkVerificationResponse { pub key: Arc, } -/// Tracks a pending verification including the signed payload for fault evidence. #[derive(Clone, Debug)] struct PendingVerification { signed_payload: SignedProofPayload, recovered_signer: Address, } -/// Core actor that handles encryption key verification. -/// -/// Requires every received key to carry a [`SignedProofPayload`]. -/// On ZK verification failure, emits both [`SignedProofFailed`] (for fault -/// attribution) and [`E3Failed`] (to stop the E3 computation). pub struct ProofVerificationActor { bus: BusHandle, verifier: Recipient>, - /// Tracks signed payloads for keys currently being verified, - /// keyed by `(e3_id, party_id)`. pending: HashMap<(E3id, u64), PendingVerification>, } @@ -104,7 +83,6 @@ impl ProofVerificationActor { return; }; - // Signed proofs are mandatory — reject keys without a signed payload let signed = match &msg.key.signed_payload { Some(signed) => signed.clone(), None => { @@ -116,7 +94,6 @@ impl ProofVerificationActor { } }; - // Recover the address from the signature let recovered_address = match signed.recover_address() { Ok(addr) => { info!( @@ -134,7 +111,6 @@ impl ProofVerificationActor { } }; - // Store the signed payload so we can reference it in the verification response self.pending.insert( (msg.e3_id.clone(), msg.key.party_id), PendingVerification { @@ -230,7 +206,6 @@ impl Handler> for ProofVerificationActor { msg.key.party_id, error_msg ); - // Emit SignedProofFailed for fault attribution if let Some(PendingVerification { signed_payload, recovered_signer, @@ -253,17 +228,11 @@ impl Handler> for ProofVerificationActor { } } - // Stop the E3 computation — proof verification failure is fatal - if let Err(err) = self.bus.publish( - E3Failed { - e3_id: msg.e3_id, - failed_at_stage: E3Stage::CommitteeFinalized, - reason: FailureReason::VerificationFailed, - }, - ec, - ) { - error!("Failed to publish E3Failed: {err}"); - } + // NOTE: We do NOT emit E3Failed here. The on-chain SlashingManager + // will expel the faulting node and check if the committee drops below + // threshold. If it does, the contract emits E3Failed on-chain, which + // the EVM reader picks up and propagates to all actors. If the committee + // is still above threshold, the DKG continues with N-1 nodes. } } } diff --git a/crates/zk-prover/tests/slashing_integration_tests.rs b/crates/zk-prover/tests/slashing_integration_tests.rs new file mode 100644 index 0000000000..9ba8871bb4 --- /dev/null +++ b/crates/zk-prover/tests/slashing_integration_tests.rs @@ -0,0 +1,981 @@ +// 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. + +//! Slashing integration tests: complete flow from proof generation → +//! operator signing → evidence encoding → on-chain SlashingManager verification. +//! +//! ## What these tests prove +//! +//! 1. **Signing format alignment**: Rust `ProofPayload.digest()` produces the +//! exact structured hash that `SlashingManager.proposeSlash()` reconstructs. +//! 2. **Evidence encoding**: `encode_fault_evidence()` output is correctly +//! decoded by the Solidity `abi.decode` in `proposeSlash()`. +//! 3. **ECDSA recovery**: Signatures created with alloy's `sign_message_sync` +//! are correctly recovered on-chain via `ECDSA.recover(toEthSignedMessageHash(...))`. +//! 4. **Complete slashing flow**: Valid proofs revert with `ProofIsValid()`, +//! wrong signers revert with `SignerIsNotOperator()`, and invalid proofs +//! result in successful slash execution. +//! +//! ## Prerequisites +//! +//! On-chain tests require: +//! - `anvil` on PATH (from Foundry) +//! - Compiled Hardhat artifacts: `cd packages/enclave-contracts && npx hardhat compile` +//! +//! Run with: `cargo test -p e3-zk-prover --test slashing_integration_tests` + +mod common; + +use alloy::{ + network::TransactionBuilder, + primitives::{keccak256, Address, Bytes, FixedBytes, U256}, + providers::{Provider, ProviderBuilder}, + rpc::types::TransactionRequest, + signers::local::PrivateKeySigner, + sol, + sol_types::SolValue, +}; +use common::find_anvil; +use e3_events::{ + encode_fault_evidence, CircuitName, E3id, Proof, ProofPayload, ProofType, SignedProofFailed, + SignedProofPayload, +}; +use e3_utils::utility_types::ArcBytes; +use std::path::PathBuf; + +// ── Contract ABI definitions (bytecodes loaded from Hardhat artifacts at runtime) ── + +sol! { + #[sol(rpc)] + contract SlashingManager { + struct SlashPolicy { + uint256 ticketPenalty; + uint256 licensePenalty; + bool requiresProof; + address proofVerifier; + bool banNode; + uint256 appealWindow; + bool enabled; + bool affectsCommittee; + uint8 failureReason; + } + + function proposeSlash(uint256 e3Id, address operator, bytes32 reason, bytes calldata proof) external returns (uint256 proposalId); + function setSlashPolicy(bytes32 reason, SlashPolicy calldata policy) external; + function totalProposals() external view returns (uint256); + function isBanned(address node) external view returns (bool); + + error ProofIsValid(); + error SignerIsNotOperator(); + error OperatorNotInCommittee(); + error VerifierMismatch(); + } + + #[sol(rpc)] + contract MockCircuitVerifier { + function setReturnValue(bool _returnValue) external; + } + + #[sol(rpc)] + contract MockCiphernodeRegistry { + function setCommitteeNodes(uint256 e3Id, address[] calldata nodes) external; + } +} + +// ── Helpers ── + +/// No-op contract deployment bytecode. +/// +/// Deploys a contract whose runtime is a single STOP opcode. +/// All calls to this contract succeed with empty return data, making it +/// suitable as a mock for any interface that only has void-returning functions +/// (e.g., IBondingRegistry.slashTicketBalance, IEnclave.onE3Failed). +const NOOP_DEPLOY_BYTECODE: &[u8] = &[ + 0x60, 0x01, // PUSH1 0x01 (runtime size) + 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) + 0x60, 0x00, // PUSH1 0x00 (memory destination) + 0x39, // CODECOPY + 0x60, 0x01, // PUSH1 0x01 (return size) + 0x60, 0x00, // PUSH1 0x00 (return offset) + 0xf3, // RETURN + 0x00, // -- runtime: STOP -- +]; + +fn contracts_artifacts_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/enclave-contracts/artifacts/contracts") +} + +fn read_artifact_bytecode(subpath: &str) -> Option> { + let path = contracts_artifacts_dir().join(subpath); + let json_str = std::fs::read_to_string(&path).ok()?; + let json: serde_json::Value = serde_json::from_str(&json_str).ok()?; + let hex_str = json["bytecode"].as_str()?; + let clean = hex_str.strip_prefix("0x").unwrap_or(hex_str); + hex::decode(clean).ok() +} + +/// Load all three contract bytecodes, returning None if any are missing. +fn load_slashing_artifacts() -> Option<(Vec, Vec, Vec)> { + let sm = read_artifact_bytecode("slashing/SlashingManager.sol/SlashingManager.json")?; + let mv = read_artifact_bytecode("test/MockSlashingVerifier.sol/MockCircuitVerifier.json")?; + let mr = read_artifact_bytecode("test/MockCiphernodeRegistry.sol/MockCiphernodeRegistry.json")?; + Some((sm, mv, mr)) +} + +/// Deploy a contract on the connected provider. +/// `creation_bytecode` is the compiled init code; `constructor_args` is appended (ABI-encoded). +async fn deploy_contract( + provider: &impl Provider, + creation_bytecode: &[u8], + constructor_args: &[u8], +) -> Address { + let mut deploy_data = creation_bytecode.to_vec(); + deploy_data.extend_from_slice(constructor_args); + let tx = TransactionRequest::default().with_deploy_code(Bytes::from(deploy_data)); + let receipt = provider + .send_transaction(tx) + .await + .expect("failed to send deploy tx") + .get_receipt() + .await + .expect("failed to get deploy receipt"); + receipt + .contract_address + .expect("deploy receipt missing contract address") +} + +/// Create a test ProofPayload with the given parameters. +fn test_proof_payload(e3_id: u64, chain_id: u64) -> ProofPayload { + ProofPayload { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + proof_type: ProofType::T0PkBfv, + proof: Proof::new( + CircuitName::PkBfv, + ArcBytes::from_bytes(&[0xde, 0xad, 0xbe, 0xef]), + // One 32-byte public input (padded zero) + ArcBytes::from_bytes(&[0u8; 32]), + ), + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Pure Rust tests — no Anvil or artifacts required +// ════════════════════════════════════════════════════════════════════════════ + +/// Verifies the typehash constant matches the keccak256 of the type string. +#[test] +fn test_proof_payload_typehash() { + let expected: [u8; 32] = keccak256( + "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + ) + .into(); + assert_eq!( + ProofPayload::typehash(), + expected, + "typehash should match keccak256 of the type string" + ); +} + +/// Verifies that digest() uses the structured typehash format with hashed dynamic fields. +#[test] +fn test_proof_payload_digest_matches_manual_computation() { + let payload = test_proof_payload(1, 42); + let digest = payload.digest().expect("digest should succeed"); + + // Manually compute expected digest + let typehash = keccak256( + "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + ); + let expected_encoded = ( + typehash, + U256::from(42u64), // chainId + U256::from(1u64), // e3Id + U256::from(0u8), // proofType (T0PkBfv = 0) + keccak256(&[0xde, 0xad, 0xbe, 0xef]), // keccak256(zkProof) + keccak256(&[0u8; 32]), // keccak256(publicSignals) + ) + .abi_encode(); + let expected_digest: [u8; 32] = keccak256(&expected_encoded).into(); + + assert_eq!( + digest, expected_digest, + "digest should match manual computation" + ); +} + +/// Verifies sign → recover roundtrip with the structured digest format. +#[test] +fn test_signing_roundtrip_with_structured_digest() { + let signer: PrivateKeySigner = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .unwrap(); + + let payload = test_proof_payload(42, 31337); + let signed = SignedProofPayload::sign(payload, &signer).expect("signing should succeed"); + let recovered = signed.recover_address().expect("recovery should succeed"); + + assert_eq!( + recovered, + signer.address(), + "recovered address should match signer" + ); +} + +/// Verifies that different payloads produce different digests (no collisions). +#[test] +fn test_different_payloads_different_digests() { + let p1 = test_proof_payload(1, 42); + let p2 = test_proof_payload(2, 42); // different e3Id + let mut p3 = test_proof_payload(1, 42); + p3.proof_type = ProofType::T1PkGeneration; // different proofType + + let d1 = p1.digest().unwrap(); + let d2 = p2.digest().unwrap(); + let d3 = p3.digest().unwrap(); + + assert_ne!(d1, d2, "different e3Ids should produce different digests"); + assert_ne!( + d1, d3, + "different proofTypes should produce different digests" + ); +} + +/// Verifies that encode_fault_evidence() produces correctly structured ABI encoding. +#[test] +fn test_encode_fault_evidence_structure() { + let signer: PrivateKeySigner = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .unwrap(); + let verifier_addr: Address = "0x1234567890abcdef1234567890abcdef12345678" + .parse() + .unwrap(); + + let payload = test_proof_payload(42, 31337); + let signed = SignedProofPayload::sign(payload, &signer).expect("signing should succeed"); + + let failed = SignedProofFailed { + e3_id: E3id::new("42", 31337), + faulting_node: signer.address(), + proof_type: ProofType::T0PkBfv, + signed_payload: signed.clone(), + }; + + let evidence = encode_fault_evidence(&failed, verifier_addr); + + // Decode and verify structure: (bytes, bytes32[], bytes, uint256, uint256, address) + type EvidenceTuple = (Bytes, Vec>, Bytes, U256, U256, Address); + let decoded = EvidenceTuple::abi_decode_params(&evidence).expect("evidence should ABI-decode"); + + let (zk_proof, public_inputs, sig, chain_id, proof_type, verifier) = decoded; + + assert_eq!(&zk_proof[..], &[0xde, 0xad, 0xbe, 0xef], "zkProof mismatch"); + assert_eq!(public_inputs.len(), 1, "should have 1 public input"); + assert_eq!( + public_inputs[0], + FixedBytes::from([0u8; 32]), + "public input value mismatch" + ); + assert_eq!(&sig[..], &signed.signature[..], "signature bytes mismatch"); + assert_eq!(chain_id, U256::from(31337u64), "chainId mismatch"); + assert_eq!(proof_type, U256::from(0u8), "proofType mismatch"); + assert_eq!(verifier, verifier_addr, "verifier address mismatch"); +} + +/// Verifies that the digest format matches what Solidity would compute. +/// +/// This is the critical cross-language test: if this passes, then: +/// `keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, chainId, e3Id, proofType, keccak256(zkProof), keccak256(abi.encodePacked(publicInputs))))` +/// in Solidity produces the same bytes32 as `ProofPayload::digest()` in Rust. +#[test] +fn test_digest_matches_solidity_encoding() { + let payload = test_proof_payload(42, 31337); + let digest = payload.digest().expect("digest should succeed"); + + // Simulate what Solidity does step by step: + // + // bytes32 messageHash = keccak256(abi.encode( + // PROOF_PAYLOAD_TYPEHASH, // bytes32 + // chainId, // uint256 + // e3Id, // uint256 + // proofType, // uint256 + // keccak256(zkProof), // bytes32 + // keccak256(abi.encodePacked(publicInputs)) // bytes32 + // )); + // + // For publicInputs = [bytes32(0)]: + // abi.encodePacked(publicInputs) = 0x0000...0000 (32 bytes) + // which is the same as the raw publicSignals bytes + + let typehash = keccak256( + "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + ); + + // abi.encode of all-static types: each word is 32 bytes, no offsets + let mut solidity_encoded = Vec::with_capacity(192); + solidity_encoded.extend_from_slice(typehash.as_ref()); // bytes32 + solidity_encoded.extend_from_slice(&U256::from(31337u64).to_be_bytes::<32>()); // uint256 chainId + solidity_encoded.extend_from_slice(&U256::from(42u64).to_be_bytes::<32>()); // uint256 e3Id + solidity_encoded.extend_from_slice(&U256::from(0u8).to_be_bytes::<32>()); // uint256 proofType + solidity_encoded.extend_from_slice(keccak256(&[0xde, 0xad, 0xbe, 0xef]).as_ref()); // keccak256(zkProof) + + // For publicInputs = [bytes32(0)]: + // Solidity: keccak256(abi.encodePacked(publicInputs)) = keccak256(bytes32(0)) + // Rust: keccak256(public_signals) = keccak256([0u8; 32]) + // These must be the same! + let sol_public_inputs_hash = keccak256(&[0u8; 32]); + solidity_encoded.extend_from_slice(sol_public_inputs_hash.as_ref()); // keccak256(publicSignals) + + let solidity_digest: [u8; 32] = keccak256(&solidity_encoded).into(); + + assert_eq!( + digest, solidity_digest, + "Rust digest must exactly match Solidity messageHash reconstruction" + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// On-chain integration tests — require Anvil + compiled Hardhat artifacts +// ════════════════════════════════════════════════════════════════════════════ + +/// **Complete flow**: operator signs proof → evidence encoded → SlashingManager +/// reconstructs digest, recovers signer, verifies committee membership, and +/// checks ZK proof validity. +/// +/// With MockCircuitVerifier returning TRUE (proof is valid), the contract +/// reverts with `ProofIsValid()`. This proves the full Rust→Solidity signing +/// pipeline works correctly. +#[tokio::test] +async fn test_onchain_valid_proof_reverts_proof_is_valid() { + if !find_anvil().await { + println!("skipping: anvil not found on PATH"); + return; + } + + let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + Some(artifacts) => artifacts, + None => { + println!( + "skipping: contract artifacts not found \ + (run `npx hardhat compile` in packages/enclave-contracts)" + ); + return; + } + }; + + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + let chain_id = provider.get_chain_id().await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let admin = accounts[0]; + + // Operator uses a separate key (not an Anvil pre-funded account) + let operator_signer = PrivateKeySigner::random(); + let operator_addr = operator_signer.address(); + + // Deploy infrastructure contracts + let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; + let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + + // Deploy SlashingManager(admin, bondingRegistry, ciphernodeRegistry, enclave) + let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); + let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + + println!("deployed: SlashingManager={sm_addr}, MockVerifier={mock_verifier_addr}, MockRegistry={mock_registry_addr}"); + println!("operator: {operator_addr} (chain_id: {chain_id})"); + + // Bind contract instances + let slashing_mgr = SlashingManager::new(sm_addr, &provider); + let mock_verifier = MockCircuitVerifier::new(mock_verifier_addr, &provider); + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + + // ── Setup: slash policy + committee ── + + let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); + let e3_id: u64 = 42; + + slashing_mgr + .setSlashPolicy( + reason, + SlashingManager::SlashPolicy { + ticketPenalty: U256::from(50_000_000u64), + licensePenalty: U256::from(100_000_000_000_000_000_000u128), + requiresProof: true, + proofVerifier: mock_verifier_addr, + banNode: false, + appealWindow: U256::ZERO, + enabled: true, + affectsCommittee: false, + failureReason: 0u8, + }, + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + mock_registry + .setCommitteeNodes(U256::from(e3_id), vec![operator_addr]) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // MockCircuitVerifier returns TRUE → proof is valid → no fault + mock_verifier + .setReturnValue(true) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // ── Operator signs proof (Rust-side) ── + + let payload = ProofPayload { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + proof_type: ProofType::T0PkBfv, + proof: Proof::new( + CircuitName::PkBfv, + ArcBytes::from_bytes(&[0xde, 0xad, 0xbe, 0xef]), + ArcBytes::from_bytes(&[0u8; 32]), + ), + }; + + let signed = + SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); + + // ── FaultSubmitter encodes evidence (Rust-side) ── + + let failed = SignedProofFailed { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + faulting_node: operator_addr, + proof_type: ProofType::T0PkBfv, + signed_payload: signed, + }; + + let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + + // ── Submit to SlashingManager (on-chain) ── + + let result = slashing_mgr + .proposeSlash( + U256::from(e3_id), + operator_addr, + reason, + Bytes::from(evidence), + ) + .call() + .await; + + // Should revert with ProofIsValid — the proof is valid, so there's no fault + assert!( + result.is_err(), + "should revert because the proof is valid (no fault to slash)" + ); + + let err_string = format!("{:?}", result.unwrap_err()); + assert!( + err_string.contains("ProofIsValid") || err_string.contains("0x5b718c5b"), + "expected ProofIsValid revert, got: {err_string}" + ); + + println!("PASS: valid proof correctly reverts with ProofIsValid — Rust→Solidity signing alignment verified"); +} + +/// Tests that a wrong signer (attacker) cannot slash an arbitrary operator. +/// +/// The attacker signs the proof with their own key but submits it as evidence +/// against a different operator. The contract should reject because the +/// recovered signer doesn't match the target operator. +#[tokio::test] +async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { + if !find_anvil().await { + println!("skipping: anvil not found on PATH"); + return; + } + + let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + Some(artifacts) => artifacts, + None => { + println!("skipping: contract artifacts not found"); + return; + } + }; + + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + let chain_id = provider.get_chain_id().await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let admin = accounts[0]; + + let attacker_signer = PrivateKeySigner::random(); + let victim_addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + + let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; + let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + + let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); + let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + + let slashing_mgr = SlashingManager::new(sm_addr, &provider); + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + + let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); + let e3_id: u64 = 42; + + slashing_mgr + .setSlashPolicy( + reason, + SlashingManager::SlashPolicy { + ticketPenalty: U256::from(50_000_000u64), + licensePenalty: U256::from(100_000_000_000_000_000_000u128), + requiresProof: true, + proofVerifier: mock_verifier_addr, + banNode: false, + appealWindow: U256::ZERO, + enabled: true, + affectsCommittee: false, + failureReason: 0u8, + }, + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Add VICTIM to committee (not the attacker) + mock_registry + .setCommitteeNodes(U256::from(e3_id), vec![victim_addr]) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Attacker signs the proof with their own key + let payload = ProofPayload { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + proof_type: ProofType::T0PkBfv, + proof: Proof::new( + CircuitName::PkBfv, + ArcBytes::from_bytes(&[0xde, 0xad]), + ArcBytes::from_bytes(&[0u8; 32]), + ), + }; + let signed = + SignedProofPayload::sign(payload, &attacker_signer).expect("signing should succeed"); + + let failed = SignedProofFailed { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + faulting_node: attacker_signer.address(), + proof_type: ProofType::T0PkBfv, + signed_payload: signed, + }; + + let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + + // Submit evidence targeting the VICTIM, but signed by the ATTACKER + let result = slashing_mgr + .proposeSlash( + U256::from(e3_id), + victim_addr, // <-- target is victim, not the actual signer + reason, + Bytes::from(evidence), + ) + .call() + .await; + + assert!(result.is_err(), "should revert because signer != operator"); + + let err_string = format!("{:?}", result.unwrap_err()); + assert!( + err_string.contains("SignerIsNotOperator") || err_string.contains("0xcd659038"), + "expected SignerIsNotOperator revert, got: {err_string}" + ); + + println!("PASS: wrong signer correctly reverts — V-001 protection verified"); +} + +/// Tests that operators not in the committee cannot be slashed. +#[tokio::test] +async fn test_onchain_non_committee_member_reverts() { + if !find_anvil().await { + println!("skipping: anvil not found on PATH"); + return; + } + + let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + Some(artifacts) => artifacts, + None => { + println!("skipping: contract artifacts not found"); + return; + } + }; + + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + let chain_id = provider.get_chain_id().await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let admin = accounts[0]; + + let operator_signer = PrivateKeySigner::random(); + let operator_addr = operator_signer.address(); + + let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; + let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + + let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); + let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + + let slashing_mgr = SlashingManager::new(sm_addr, &provider); + + let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); + let e3_id: u64 = 42; + + slashing_mgr + .setSlashPolicy( + reason, + SlashingManager::SlashPolicy { + ticketPenalty: U256::from(50_000_000u64), + licensePenalty: U256::from(100_000_000_000_000_000_000u128), + requiresProof: true, + proofVerifier: mock_verifier_addr, + banNode: false, + appealWindow: U256::ZERO, + enabled: true, + affectsCommittee: false, + failureReason: 0u8, + }, + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // NOTE: We do NOT add the operator to the committee + + // Operator signs a proof + let payload = ProofPayload { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + proof_type: ProofType::T0PkBfv, + proof: Proof::new( + CircuitName::PkBfv, + ArcBytes::from_bytes(&[0xab, 0xcd]), + ArcBytes::from_bytes(&[0u8; 32]), + ), + }; + let signed = + SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); + + let failed = SignedProofFailed { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + faulting_node: operator_addr, + proof_type: ProofType::T0PkBfv, + signed_payload: signed, + }; + + let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + + let result = slashing_mgr + .proposeSlash( + U256::from(e3_id), + operator_addr, + reason, + Bytes::from(evidence), + ) + .call() + .await; + + assert!( + result.is_err(), + "should revert because operator is not in committee" + ); + + let err_string = format!("{:?}", result.unwrap_err()); + assert!( + err_string.contains("OperatorNotInCommittee") || err_string.contains("0x7353fac5"), + "expected OperatorNotInCommittee revert, got: {err_string}" + ); + + println!("PASS: non-committee member correctly reverts — committee check verified"); +} + +/// Tests the complete slash execution flow: invalid proof → fault confirmed → slash executed. +/// +/// Uses a NOOP contract as BondingRegistry so that `slashTicketBalance` and +/// `slashLicenseBond` calls succeed silently, allowing the full flow to complete. +#[tokio::test] +async fn test_onchain_invalid_proof_executes_slash() { + if !find_anvil().await { + println!("skipping: anvil not found on PATH"); + return; + } + + let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + Some(artifacts) => artifacts, + None => { + println!("skipping: contract artifacts not found"); + return; + } + }; + + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + let chain_id = provider.get_chain_id().await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let admin = accounts[0]; + + let operator_signer = PrivateKeySigner::random(); + let operator_addr = operator_signer.address(); + + let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; + let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + + // Use noop as both bondingRegistry and enclave + let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); + let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + + let slashing_mgr = SlashingManager::new(sm_addr, &provider); + let mock_verifier = MockCircuitVerifier::new(mock_verifier_addr, &provider); + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + + let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); + let e3_id: u64 = 42; + + slashing_mgr + .setSlashPolicy( + reason, + SlashingManager::SlashPolicy { + ticketPenalty: U256::from(50_000_000u64), + licensePenalty: U256::from(100_000_000_000_000_000_000u128), + requiresProof: true, + proofVerifier: mock_verifier_addr, + banNode: false, + appealWindow: U256::ZERO, + enabled: true, + affectsCommittee: false, + failureReason: 0u8, + }, + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + mock_registry + .setCommitteeNodes(U256::from(e3_id), vec![operator_addr]) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // MockCircuitVerifier returns FALSE → proof is invalid → fault confirmed + mock_verifier + .setReturnValue(false) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Operator signs a (bad) proof + let payload = ProofPayload { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + proof_type: ProofType::T0PkBfv, + proof: Proof::new( + CircuitName::PkBfv, + ArcBytes::from_bytes(&[0xba, 0xd0, 0xba, 0xd0]), + ArcBytes::from_bytes(&[0u8; 32]), + ), + }; + let signed = + SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); + + let failed = SignedProofFailed { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + faulting_node: operator_addr, + proof_type: ProofType::T0PkBfv, + signed_payload: signed, + }; + + let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + + // Verify proposal count before + let proposals_before = slashing_mgr + .totalProposals() + .call() + .await + .expect("totalProposals call failed"); + assert_eq!( + proposals_before, + U256::ZERO, + "should have 0 proposals before" + ); + + // Submit slash — should succeed (invalid proof = fault confirmed) + let receipt = slashing_mgr + .proposeSlash( + U256::from(e3_id), + operator_addr, + reason, + Bytes::from(evidence), + ) + .send() + .await + .expect("proposeSlash tx should not fail to send") + .get_receipt() + .await + .expect("proposeSlash receipt should be obtainable"); + + assert!( + receipt.status(), + "proposeSlash transaction should succeed (invalid proof = fault confirmed, slash executed)" + ); + + // Verify proposal was created and executed + let proposals_after = slashing_mgr + .totalProposals() + .call() + .await + .expect("totalProposals call failed"); + assert_eq!( + proposals_after, + U256::from(1u64), + "should have 1 proposal after slash" + ); + + println!("PASS: invalid proof correctly triggers slash execution — full flow verified"); +} + +/// Tests that verifier mismatch is detected (verifier-upgrade protection). +/// +/// If the evidence references an old verifier address but the policy has been +/// updated to a new verifier, proposeSlash should revert with VerifierMismatch. +#[tokio::test] +async fn test_onchain_verifier_mismatch_reverts() { + if !find_anvil().await { + println!("skipping: anvil not found on PATH"); + return; + } + + let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + Some(artifacts) => artifacts, + None => { + println!("skipping: contract artifacts not found"); + return; + } + }; + + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + let chain_id = provider.get_chain_id().await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let admin = accounts[0]; + + let operator_signer = PrivateKeySigner::random(); + let operator_addr = operator_signer.address(); + + let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; + let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + + let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); + let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + + let slashing_mgr = SlashingManager::new(sm_addr, &provider); + + let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); + let e3_id: u64 = 42; + + slashing_mgr + .setSlashPolicy( + reason, + SlashingManager::SlashPolicy { + ticketPenalty: U256::from(50_000_000u64), + licensePenalty: U256::from(100_000_000_000_000_000_000u128), + requiresProof: true, + proofVerifier: mock_verifier_addr, + banNode: false, + appealWindow: U256::ZERO, + enabled: true, + affectsCommittee: false, + failureReason: 0u8, + }, + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let payload = ProofPayload { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + proof_type: ProofType::T0PkBfv, + proof: Proof::new( + CircuitName::PkBfv, + ArcBytes::from_bytes(&[0xab]), + ArcBytes::from_bytes(&[0u8; 32]), + ), + }; + let signed = + SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); + + let failed = SignedProofFailed { + e3_id: E3id::new(&e3_id.to_string(), chain_id), + faulting_node: operator_addr, + proof_type: ProofType::T0PkBfv, + signed_payload: signed, + }; + + // Encode evidence pointing to a DIFFERENT verifier (simulating stale evidence) + let stale_verifier: Address = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .parse() + .unwrap(); + let evidence = encode_fault_evidence(&failed, stale_verifier); + + let result = slashing_mgr + .proposeSlash( + U256::from(e3_id), + operator_addr, + reason, + Bytes::from(evidence), + ) + .call() + .await; + + assert!( + result.is_err(), + "should revert because verifier in evidence doesn't match policy" + ); + + let err_string = format!("{:?}", result.unwrap_err()); + assert!( + err_string.contains("VerifierMismatch") || err_string.contains("0x1c485278"), + "expected VerifierMismatch revert, got: {err_string}" + ); + + println!("PASS: verifier mismatch correctly reverts — verifier-upgrade protection verified"); +} From bccf9735f3f23e3dbfa454a1b84585ed09d1b38c Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 27 Feb 2026 17:59:56 +0500 Subject: [PATCH 02/21] fix: resolve conflicts --- crates/keyshare/src/threshold_keyshare.rs | 2 +- crates/keyshare/src/threshold_share_collector.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 0502bf6a7c..76eaf5305f 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -50,7 +50,7 @@ use std::{ use tracing::{error, info, trace, warn}; use crate::encryption_key_collector::{ - AllEncryptionKeysCollected, EncryptionKeyCollector, ExpelPartyFromShareCollection, + AllEncryptionKeysCollected, EncryptionKeyCollector, ExpelPartyFromKeyCollection, }; use crate::threshold_share_collector::{ ExpelPartyFromShareCollection, ReceivedShareProofs, ThresholdShareCollector, diff --git a/crates/keyshare/src/threshold_share_collector.rs b/crates/keyshare/src/threshold_share_collector.rs index d57ba2f989..5ab945ae0c 100644 --- a/crates/keyshare/src/threshold_share_collector.rs +++ b/crates/keyshare/src/threshold_share_collector.rs @@ -255,8 +255,11 @@ impl Handler for ThresholdShareCollector { ctx.cancel_future(handle); } - let event: TypedEvent = - TypedEvent::new(self.shares.clone().into(), msg.ec); + let proofs = std::mem::take(&mut self.share_proofs); + let event: TypedEvent = TypedEvent::new( + AllThresholdSharesCollected::new(self.shares.clone(), proofs), + msg.ec, + ); self.parent.do_send(event); } } From a802ba5519b51c27be07586beb3dc711caa568e3 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Tue, 3 Mar 2026 05:22:30 +0500 Subject: [PATCH 03/21] fix: tests --- .../tests/slashing_integration_tests.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/zk-prover/tests/slashing_integration_tests.rs b/crates/zk-prover/tests/slashing_integration_tests.rs index 9ba8871bb4..259a57fd49 100644 --- a/crates/zk-prover/tests/slashing_integration_tests.rs +++ b/crates/zk-prover/tests/slashing_integration_tests.rs @@ -152,7 +152,7 @@ async fn deploy_contract( fn test_proof_payload(e3_id: u64, chain_id: u64) -> ProofPayload { ProofPayload { e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, proof: Proof::new( CircuitName::PkBfv, ArcBytes::from_bytes(&[0xde, 0xad, 0xbe, 0xef]), @@ -194,7 +194,7 @@ fn test_proof_payload_digest_matches_manual_computation() { typehash, U256::from(42u64), // chainId U256::from(1u64), // e3Id - U256::from(0u8), // proofType (T0PkBfv = 0) + U256::from(0u8), // proofType (C0PkBfv = 0) keccak256(&[0xde, 0xad, 0xbe, 0xef]), // keccak256(zkProof) keccak256(&[0u8; 32]), // keccak256(publicSignals) ) @@ -232,7 +232,7 @@ fn test_different_payloads_different_digests() { let p1 = test_proof_payload(1, 42); let p2 = test_proof_payload(2, 42); // different e3Id let mut p3 = test_proof_payload(1, 42); - p3.proof_type = ProofType::T1PkGeneration; // different proofType + p3.proof_type = ProofType::C1PkGeneration; // different proofType let d1 = p1.digest().unwrap(); let d2 = p2.digest().unwrap(); @@ -262,7 +262,7 @@ fn test_encode_fault_evidence_structure() { let failed = SignedProofFailed { e3_id: E3id::new("42", 31337), faulting_node: signer.address(), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, signed_payload: signed.clone(), }; @@ -444,7 +444,7 @@ async fn test_onchain_valid_proof_reverts_proof_is_valid() { let payload = ProofPayload { e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, proof: Proof::new( CircuitName::PkBfv, ArcBytes::from_bytes(&[0xde, 0xad, 0xbe, 0xef]), @@ -460,7 +460,7 @@ async fn test_onchain_valid_proof_reverts_proof_is_valid() { let failed = SignedProofFailed { e3_id: E3id::new(&e3_id.to_string(), chain_id), faulting_node: operator_addr, - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, signed_payload: signed, }; @@ -571,7 +571,7 @@ async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { // Attacker signs the proof with their own key let payload = ProofPayload { e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, proof: Proof::new( CircuitName::PkBfv, ArcBytes::from_bytes(&[0xde, 0xad]), @@ -584,7 +584,7 @@ async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { let failed = SignedProofFailed { e3_id: E3id::new(&e3_id.to_string(), chain_id), faulting_node: attacker_signer.address(), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, signed_payload: signed, }; @@ -675,7 +675,7 @@ async fn test_onchain_non_committee_member_reverts() { // Operator signs a proof let payload = ProofPayload { e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, proof: Proof::new( CircuitName::PkBfv, ArcBytes::from_bytes(&[0xab, 0xcd]), @@ -688,7 +688,7 @@ async fn test_onchain_non_committee_member_reverts() { let failed = SignedProofFailed { e3_id: E3id::new(&e3_id.to_string(), chain_id), faulting_node: operator_addr, - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, signed_payload: signed, }; @@ -804,7 +804,7 @@ async fn test_onchain_invalid_proof_executes_slash() { // Operator signs a (bad) proof let payload = ProofPayload { e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, proof: Proof::new( CircuitName::PkBfv, ArcBytes::from_bytes(&[0xba, 0xd0, 0xba, 0xd0]), @@ -817,7 +817,7 @@ async fn test_onchain_invalid_proof_executes_slash() { let failed = SignedProofFailed { e3_id: E3id::new(&e3_id.to_string(), chain_id), faulting_node: operator_addr, - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, signed_payload: signed, }; @@ -933,7 +933,7 @@ async fn test_onchain_verifier_mismatch_reverts() { let payload = ProofPayload { e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, proof: Proof::new( CircuitName::PkBfv, ArcBytes::from_bytes(&[0xab]), @@ -946,7 +946,7 @@ async fn test_onchain_verifier_mismatch_reverts() { let failed = SignedProofFailed { e3_id: E3id::new(&e3_id.to_string(), chain_id), faulting_node: operator_addr, - proof_type: ProofType::T0PkBfv, + proof_type: ProofType::C0PkBfv, signed_payload: signed, }; From 72f7fa97acb0276922f623a5981702c037434dca Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Tue, 3 Mar 2026 12:25:54 +0500 Subject: [PATCH 04/21] chore: comments --- crates/events/src/enclave_event/encryption_key_created.rs | 2 +- crates/events/src/enclave_event/signed_proof.rs | 2 +- crates/tests/tests/integration.rs | 2 +- crates/zk-prover/src/actors/proof_request.rs | 8 ++++---- crates/zk-prover/src/actors/proof_verification.rs | 6 +++--- crates/zk-prover/src/actors/share_verification.rs | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/events/src/enclave_event/encryption_key_created.rs b/crates/events/src/enclave_event/encryption_key_created.rs index 08793841b0..81b4aaf7a8 100644 --- a/crates/events/src/enclave_event/encryption_key_created.rs +++ b/crates/events/src/enclave_event/encryption_key_created.rs @@ -21,7 +21,7 @@ pub struct EncryptionKey { pub party_id: u64, #[derivative(Debug(format_with = "e3_utils::formatters::hexf"))] pub pk_bfv: ArcBytes, - /// Proof of correct BFV public key generation (T0 proof). + /// Proof of correct BFV public key generation (C0 proof). pub proof: Option, /// ECDSA-signed payload for fault attribution. /// Present when the node signs its proof before broadcasting. diff --git a/crates/events/src/enclave_event/signed_proof.rs b/crates/events/src/enclave_event/signed_proof.rs index baccc7dc18..3ca3b14f01 100644 --- a/crates/events/src/enclave_event/signed_proof.rs +++ b/crates/events/src/enclave_event/signed_proof.rs @@ -29,7 +29,7 @@ use std::fmt::{self, Display}; #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ProofType { - /// T0 — BFV public key proof (Proof 0). + /// C0 — BFV public key proof (Proof 0). C0PkBfv = 0, /// C1 — TrBFV public key generation proof (Proof 1). C1PkGeneration = 1, diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index eeb2f43cfe..8fce2355bb 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -102,7 +102,7 @@ async fn setup_test_zk_backend() -> (ZkBackend, tempfile::TempDir) { .join("circuits") .join("bin"); - // Copy T0 (pk) circuit + // Copy C0 (pk) circuit let pk_circuit_dir = circuits_dir.join("dkg").join("pk"); tokio::fs::create_dir_all(&pk_circuit_dir).await.unwrap(); let dkg_target = circuits_build_root.join("dkg").join("target"); diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index 4b9e07a003..fa28352fbc 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -219,7 +219,7 @@ impl ProofRequestActor { msg.e3_id, ); - info!("Requesting T0 proof generation"); + info!("Requesting C0 proof generation"); if let Err(err) = self.bus.publish(request, ec) { error!("Failed to publish ZK proof request: {err}"); self.pending.remove(&correlation_id); @@ -843,14 +843,14 @@ impl ProofRequestActor { match SignedProofPayload::sign(payload, &self.signer) { Ok(signed) => { info!( - "Signed T0 proof for party {} (signer: {})", + "Signed C0 proof for party {} (signer: {})", key.party_id, self.signer.address() ); key.signed_payload = Some(signed); } Err(err) => { - error!("Failed to sign T0 proof payload: {err} — proof will not be published"); + error!("Failed to sign C0 proof payload: {err} — proof will not be published"); return; } } @@ -874,7 +874,7 @@ impl ProofRequestActor { if let Some(pending) = self.pending.remove(msg.correlation_id()) { error!( - "T0 proof request failed for E3 {}: {err} — key will not be published without proof", + "C0 proof request failed for E3 {}: {err} — key will not be published without proof", pending.e3_id ); } diff --git a/crates/zk-prover/src/actors/proof_verification.rs b/crates/zk-prover/src/actors/proof_verification.rs index 7fb54c550c..f66acdd2f3 100644 --- a/crates/zk-prover/src/actors/proof_verification.rs +++ b/crates/zk-prover/src/actors/proof_verification.rs @@ -77,7 +77,7 @@ impl ProofVerificationActor { let (msg, ec) = msg.into_components(); let Some(ref proof) = msg.key.proof else { error!( - "External key from party {} is missing T0 proof - rejecting", + "External key from party {} is missing C0 proof - rejecting", msg.key.party_id ); return; @@ -206,14 +206,14 @@ impl Handler> for ProofVerificationActor { if msg.verified { info!( - "T0 proof verified for party {} - accepting key", + "C0 proof verified for party {} - accepting key", msg.key.party_id ); self.publish_key_created(msg.e3_id, msg.key, ec.clone()); } else { let error_msg = msg.error.unwrap_or_else(|| "unknown error".to_string()); error!( - "T0 proof verification FAILED for party {} - rejecting key and stopping E3: {}", + "C0 proof verification FAILED for party {} - rejecting key and stopping E3: {}", msg.key.party_id, error_msg ); diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index 439122f6ef..72cc23cda1 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -6,7 +6,7 @@ //! Actor for C2/C3/C4 share proof verification. //! -//! Follows the same pattern as [`ProofVerificationActor`] (for C0/T0) — sits +//! Follows the same pattern as [`ProofVerificationActor`] (for C0) — sits //! between the raw proof data and the verified result, handling ECDSA validation //! and ZK verification orchestration. //! From e97108c7bac434166b7e2101b007a023440ccb2f Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 01:01:04 +0500 Subject: [PATCH 05/21] fix: conflicts --- crates/aggregator/src/publickey_aggregator.rs | 36 +++++++++++-------- crates/keyshare/src/threshold_keyshare.rs | 10 +++--- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 7e6505744c..e72578a202 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -385,6 +385,7 @@ impl PublicKeyAggregator { self.state.try_mutate(ec, |mut state| { let PublicKeyAggregatorState::Collecting { threshold_n, + threshold_m, keyshares, nodes, .. @@ -410,10 +411,13 @@ impl PublicKeyAggregator { } if keyshares.len() == *threshold_n && *threshold_n > 0 { - info!("PublicKeyAggregator: enough keyshares after expulsion, computing aggregate"); - return Ok(PublicKeyAggregatorState::Computing { + let m = *threshold_m; + info!("PublicKeyAggregator: enough keyshares after expulsion, transitioning to VerifyingC1"); + return Ok(PublicKeyAggregatorState::VerifyingC1 { keyshares: std::mem::take(keyshares), nodes: std::mem::take(nodes), + threshold_m: m, + no_proof_parties: Vec::new(), }); } @@ -470,22 +474,24 @@ impl Handler for PublicKeyAggregator { Some(PublicKeyAggregatorState::Collecting { .. }) ); + // Snapshot c1_proofs before expulsion mutates state + let c1_proofs_snapshot = match self.state.get() { + Some(PublicKeyAggregatorState::Collecting { c1_proofs, .. }) => { + Some(c1_proofs.clone()) + } + _ => None, + }; + self.handle_member_expelled(&node_addr, &ec)?; if was_collecting { - if let Some(PublicKeyAggregatorState::Computing { keyshares, .. }) = - &self.state.get() - { - self.notify_sync( - ctx, - TypedEvent::new( - ComputeAggregate { - keyshares: keyshares.clone(), - e3_id: data.e3_id, - }, - ec.clone(), - ), - ); + if matches!( + self.state.get(), + Some(PublicKeyAggregatorState::VerifyingC1 { .. }) + ) { + if let Some(c1_proofs) = c1_proofs_snapshot { + self.dispatch_c1_verification(&c1_proofs, ec.clone())?; + } } } Ok(()) diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 91699ec410..c0d047f062 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -9,11 +9,11 @@ use anyhow::{anyhow, bail, Context, Result}; use e3_crypto::{Cipher, SensitiveBytes}; use e3_data::Persistable; use e3_events::{ - prelude::*, trap, BusHandle, CiphernodeSelected, CiphertextOutputPublished, ComputeRequest, - ComputeResponse, ComputeResponseKind, CorrelationId, DecryptionKeyShared, - DecryptionShareProofSigned, DecryptionShareProofsPending, Die, DkgProofSigned,CommitteeMemberExpelled, - DkgShareDecryptionProofRequest, E3Failed, E3RequestComplete, E3Stage, E3id, EType, - EnclaveEvent, EnclaveEventData, EncryptionKey, EncryptionKeyCollectionFailed, + prelude::*, trap, BusHandle, CiphernodeSelected, CiphertextOutputPublished, + CommitteeMemberExpelled, ComputeRequest, ComputeResponse, ComputeResponseKind, CorrelationId, + DecryptionKeyShared, DecryptionShareProofSigned, DecryptionShareProofsPending, Die, + DkgProofSigned, DkgShareDecryptionProofRequest, E3Failed, E3RequestComplete, E3Stage, E3id, + EType, EnclaveEvent, EnclaveEventData, EncryptionKey, EncryptionKeyCollectionFailed, EncryptionKeyCreated, EncryptionKeyPending, EventContext, FailureReason, KeyshareCreated, PartyId, PartyProofsToVerify, PartyShareDecryptionProofsToVerify, PkGenerationProofRequest, PkGenerationProofSigned, ProofType, Sequenced, ShareComputationProofRequest, From a702472fe2307d1b7ffc766f605ae35c824c64b0 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 18:28:56 +0500 Subject: [PATCH 06/21] feat: committee attestation slashing --- .../src/ciphernode_builder.rs | 9 +- .../accusation_quorum_reached.rs | 67 ++ .../src/enclave_event/accusation_vote.rs | 41 + crates/events/src/enclave_event/mod.rs | 25 + .../enclave_event/proof_failure_accusation.rs | 50 + .../proof_verification_failed.rs | 45 + .../proof_verification_passed.rs | 43 + crates/evm/src/slashing_manager_sol_writer.rs | 63 +- .../src/decryption_key_shared_collector.rs | 66 +- crates/keyshare/src/threshold_keyshare.rs | 11 +- crates/net/src/net_event_translator.rs | 2 + crates/request/src/context.rs | 1 + .../src/actors/accusation_manager.rs | 1040 +++++++++++++++++ .../src/actors/accusation_manager_ext.rs | 101 ++ crates/zk-prover/src/actors/mod.rs | 4 + .../src/actors/proof_verification.rs | 61 +- .../src/actors/share_verification.rs | 114 +- crates/zk-prover/src/lib.rs | 5 +- .../packages/crisp-sdk/tests/vote.test.ts | 3 +- .../IBondingRegistry.json | 2 +- .../ICiphernodeRegistry.json | 2 +- .../interfaces/IEnclave.sol/IEnclave.json | 2 +- .../ISlashingManager.json | 22 +- .../contracts/interfaces/ISlashingManager.sol | 38 +- .../contracts/slashing/SlashingManager.sol | 150 ++- .../contracts/test/MockCiphernodeRegistry.sol | 16 +- .../test/E3Lifecycle/E3Integration.spec.ts | 96 +- .../test/Slashing/CommitteeExpulsion.spec.ts | 235 ++-- .../test/Slashing/SlashingManager.spec.ts | 533 ++++++--- 29 files changed, 2405 insertions(+), 442 deletions(-) create mode 100644 crates/events/src/enclave_event/accusation_quorum_reached.rs create mode 100644 crates/events/src/enclave_event/accusation_vote.rs create mode 100644 crates/events/src/enclave_event/proof_failure_accusation.rs create mode 100644 crates/events/src/enclave_event/proof_verification_failed.rs create mode 100644 crates/events/src/enclave_event/proof_verification_passed.rs create mode 100644 crates/zk-prover/src/actors/accusation_manager.rs create mode 100644 crates/zk-prover/src/actors/accusation_manager_ext.rs diff --git a/crates/ciphernode-builder/src/ciphernode_builder.rs b/crates/ciphernode-builder/src/ciphernode_builder.rs index b4e2c8712c..6290e5ed5d 100644 --- a/crates/ciphernode-builder/src/ciphernode_builder.rs +++ b/crates/ciphernode-builder/src/ciphernode_builder.rs @@ -34,7 +34,7 @@ use e3_sortition::{ }; use e3_sync::sync; use e3_utils::SharedRng; -use e3_zk_prover::{setup_zk_actors, ZkBackend}; +use e3_zk_prover::{setup_zk_actors, AccusationManagerExtension, ZkBackend}; use std::time::Duration; use std::{collections::HashMap, path::PathBuf, sync::Arc}; use tracing::{error, info}; @@ -507,6 +507,13 @@ impl CiphernodeBuilder { )) } + // AccusationManager extension — per-E3 fault attribution quorum + { + let signer = provider_cache.ensure_signer().await?; + info!("Setting up AccusationManagerExtension"); + e3_builder = e3_builder.with(AccusationManagerExtension::create(&bus, signer)); + } + info!("building..."); e3_builder.build().await?; diff --git a/crates/events/src/enclave_event/accusation_quorum_reached.rs b/crates/events/src/enclave_event/accusation_quorum_reached.rs new file mode 100644 index 0000000000..c4911859ca --- /dev/null +++ b/crates/events/src/enclave_event/accusation_quorum_reached.rs @@ -0,0 +1,67 @@ +// 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. + +use crate::{AccusationVote, E3id, ProofType}; +use actix::Message; +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// The outcome of an accusation quorum vote. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AccusationOutcome { + /// >= M nodes agree the proof is bad → slash the accused. + AccusedFaulted, + /// Only the accuser says bad, same data_hash as others → accuser lied. + AccuserLied, + /// data_hashes differ between voters → accused sent different data to different nodes. + Equivocation, + /// Vote timeout expired or not enough votes → proceed with E3 timeout. + Inconclusive, +} + +impl Display for AccusationOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AccusationOutcome::AccusedFaulted => write!(f, "AccusedFaulted"), + AccusationOutcome::AccuserLied => write!(f, "AccuserLied"), + AccusationOutcome::Equivocation => write!(f, "Equivocation"), + AccusationOutcome::Inconclusive => write!(f, "Inconclusive"), + } + } +} + +/// Emitted locally when the accusation quorum protocol reaches a decision. +/// +/// Consumed by aggregator actors to exclude faulted nodes and by the on-chain +/// submission logic to submit `E3FaultEvidence`. +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct AccusationQuorumReached { + pub e3_id: E3id, + /// Address of the node that originally accused. + pub accuser: Address, + /// Address of the accused node. + pub accused: Address, + /// Which proof type was disputed. + pub proof_type: ProofType, + /// Votes from nodes that agreed the proof is bad. + pub votes_for: Vec, + /// Votes from nodes that said the proof is fine. + pub votes_against: Vec, + /// The quorum decision. + pub outcome: AccusationOutcome, +} + +impl Display for AccusationQuorumReached { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AccusationQuorumReached {{ e3_id: {}, accused: {}, proof_type: {:?}, outcome: {} }}", + self.e3_id, self.accused, self.proof_type, self.outcome + ) + } +} diff --git a/crates/events/src/enclave_event/accusation_vote.rs b/crates/events/src/enclave_event/accusation_vote.rs new file mode 100644 index 0000000000..e2ff7fba6b --- /dev/null +++ b/crates/events/src/enclave_event/accusation_vote.rs @@ -0,0 +1,41 @@ +// 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. + +use crate::E3id; +use actix::Message; +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// Broadcast via gossip: a committee member's vote on an accusation. +/// +/// Each committee member independently checks whether the accused's proof +/// failed verification from their perspective, and broadcasts this vote. +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct AccusationVote { + pub e3_id: E3id, + /// keccak256 of the `ProofFailureAccusation` this vote responds to. + pub accusation_id: [u8; 32], + /// Ethereum address of the voter. + pub voter: Address, + /// `true` if this node also saw the proof fail verification. + pub agrees: bool, + /// keccak256 hash of the data as this node received it — for equivocation detection. + pub data_hash: [u8; 32], + /// ECDSA signature of the voter over the vote fields. + pub signature: Vec, +} + +impl Display for AccusationVote { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AccusationVote {{ e3_id: {}, voter: {}, agrees: {} }}", + self.e3_id, self.voter, self.agrees + ) + } +} diff --git a/crates/events/src/enclave_event/mod.rs b/crates/events/src/enclave_event/mod.rs index 080a2d5dfd..9201ed42b1 100644 --- a/crates/events/src/enclave_event/mod.rs +++ b/crates/events/src/enclave_event/mod.rs @@ -4,6 +4,8 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +mod accusation_quorum_reached; +mod accusation_vote; mod aggregation_proof_pending; mod aggregation_proof_signed; mod ciphernode_added; @@ -41,6 +43,9 @@ mod pk_generation_proof_signed; mod plaintext_aggregated; mod plaintext_output_published; mod proof; +mod proof_failure_accusation; +mod proof_verification_failed; +mod proof_verification_passed; mod publickey_aggregated; mod publish_document; mod share_computation_proof_signed; @@ -61,6 +66,8 @@ mod ticket_generated; mod ticket_submitted; mod typed_event; +pub use accusation_quorum_reached::*; +pub use accusation_vote::*; pub use aggregation_proof_pending::*; pub use aggregation_proof_signed::*; pub use ciphernode_added::*; @@ -99,6 +106,9 @@ pub use pk_generation_proof_signed::*; pub use plaintext_aggregated::*; pub use plaintext_output_published::*; pub use proof::*; +pub use proof_failure_accusation::*; +pub use proof_verification_failed::*; +pub use proof_verification_passed::*; pub use publickey_aggregated::*; pub use publish_document::*; pub use share_computation_proof_signed::*; @@ -216,6 +226,11 @@ macro_rules! impl_event_types { #[derive(Clone, Debug, PartialEq, Eq, Hash, IntoStaticStr, Serialize, Deserialize)] pub enum EnclaveEventData { + AccusationQuorumReached(AccusationQuorumReached), + AccusationVote(AccusationVote), + ProofFailureAccusation(ProofFailureAccusation), + ProofVerificationFailed(ProofVerificationFailed), + ProofVerificationPassed(ProofVerificationPassed), KeyshareCreated(KeyshareCreated), E3Requested(E3Requested), PublicKeyAggregated(PublicKeyAggregated), @@ -496,6 +511,11 @@ impl From<&EnclaveEvent> for EventId { impl EnclaveEventData { pub fn get_e3_id(&self) -> Option { match self { + EnclaveEventData::AccusationQuorumReached(ref data) => Some(data.e3_id.clone()), + EnclaveEventData::AccusationVote(ref data) => Some(data.e3_id.clone()), + EnclaveEventData::ProofFailureAccusation(ref data) => Some(data.e3_id.clone()), + EnclaveEventData::ProofVerificationFailed(ref data) => Some(data.e3_id.clone()), + EnclaveEventData::ProofVerificationPassed(ref data) => Some(data.e3_id.clone()), EnclaveEventData::KeyshareCreated(ref data) => Some(data.e3_id.clone()), EnclaveEventData::E3Requested(ref data) => Some(data.e3_id.clone()), EnclaveEventData::PublicKeyAggregated(ref data) => Some(data.e3_id.clone()), @@ -559,6 +579,11 @@ impl WithAggregateId for EnclaveEvent { } impl_event_types!( + AccusationQuorumReached, + AccusationVote, + ProofFailureAccusation, + ProofVerificationFailed, + ProofVerificationPassed, KeyshareCreated, E3Requested, PublicKeyAggregated, diff --git a/crates/events/src/enclave_event/proof_failure_accusation.rs b/crates/events/src/enclave_event/proof_failure_accusation.rs new file mode 100644 index 0000000000..6871e4e15b --- /dev/null +++ b/crates/events/src/enclave_event/proof_failure_accusation.rs @@ -0,0 +1,50 @@ +// 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. + +use crate::{E3id, ProofType, SignedProofPayload}; +use actix::Message; +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// Broadcast via gossip: a committee member claims another node's proof failed verification. +/// +/// This is the accusation that starts the off-chain quorum protocol. Other committee +/// members receive this, independently check their own verification result for the same +/// proof, and respond with an [`AccusationVote`]. +/// +/// For C3a/C3b proofs (per-recipient encryption), the accuser includes the +/// [`SignedProofPayload`] so other nodes can re-verify a proof they never received directly. +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct ProofFailureAccusation { + pub e3_id: E3id, + /// Ethereum address of the accusing node. + pub accuser: Address, + /// Ethereum address of the accused node. + pub accused: Address, + /// Party ID of the accused node. + pub accused_party_id: u64, + /// Which proof type allegedly failed. + pub proof_type: ProofType, + /// keccak256 hash of (data + proof) as received by the accuser. + pub data_hash: [u8; 32], + /// For C3a/C3b: the signed proof payload so other nodes can re-verify. + /// `None` for proofs that all nodes already received. + pub signed_payload: Option, + /// ECDSA signature of the accuser over the accusation fields. + pub signature: Vec, +} + +impl Display for ProofFailureAccusation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ProofFailureAccusation {{ e3_id: {}, accuser: {}, accused: {}, proof_type: {:?} }}", + self.e3_id, self.accuser, self.accused, self.proof_type + ) + } +} diff --git a/crates/events/src/enclave_event/proof_verification_failed.rs b/crates/events/src/enclave_event/proof_verification_failed.rs new file mode 100644 index 0000000000..822a20cc7d --- /dev/null +++ b/crates/events/src/enclave_event/proof_verification_failed.rs @@ -0,0 +1,45 @@ +// 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. + +use crate::{E3id, ProofType, SignedProofPayload}; +use actix::Message; +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// Emitted locally when a node detects that another node's ZK proof failed verification. +/// +/// This triggers the [`AccusationManager`] to broadcast a [`ProofFailureAccusation`] +/// and start the off-chain quorum protocol. +/// +/// Emitted by: +/// - [`ProofVerificationActor`] — for C0 (BFV public key) failures +/// - [`ShareVerificationActor`] — for C1–C4/C6 failures +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct ProofVerificationFailed { + pub e3_id: E3id, + /// Party ID of the node whose proof failed. + pub accused_party_id: u64, + /// Recovered Ethereum address of the accused node. + pub accused_address: Address, + /// Which proof type failed. + pub proof_type: ProofType, + /// keccak256 hash of the received data + proof bytes — used for equivocation detection. + pub data_hash: [u8; 32], + /// The signed proof payload that failed — preserved for C3a/C3b forwarding. + pub signed_payload: SignedProofPayload, +} + +impl Display for ProofVerificationFailed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ProofVerificationFailed {{ e3_id: {}, accused: {}, proof_type: {:?} }}", + self.e3_id, self.accused_address, self.proof_type + ) + } +} diff --git a/crates/events/src/enclave_event/proof_verification_passed.rs b/crates/events/src/enclave_event/proof_verification_passed.rs new file mode 100644 index 0000000000..b44a212741 --- /dev/null +++ b/crates/events/src/enclave_event/proof_verification_passed.rs @@ -0,0 +1,43 @@ +// 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. + +use crate::{E3id, ProofType}; +use actix::Message; +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// Emitted locally when a node successfully verifies another node's ZK proof. +/// +/// This allows the [`AccusationManager`] to cache successful verification results +/// so it can vote DISAGREE on false accusations from other nodes. +/// +/// Emitted by: +/// - [`ProofVerificationActor`] — for C0 (BFV public key) successes +/// - [`ShareVerificationActor`] — for C1–C4/C6 successes +#[derive(Message, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct ProofVerificationPassed { + pub e3_id: E3id, + /// Party ID of the node whose proof passed. + pub party_id: u64, + /// Recovered Ethereum address of the verified node. + pub address: Address, + /// Which proof type passed. + pub proof_type: ProofType, + /// keccak256 hash of the received data + proof bytes — for equivocation detection. + pub data_hash: [u8; 32], +} + +impl Display for ProofVerificationPassed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ProofVerificationPassed {{ e3_id: {}, party: {}, address: {}, proof_type: {:?} }}", + self.e3_id, self.party_id, self.address, self.proof_type + ) + } +} diff --git a/crates/evm/src/slashing_manager_sol_writer.rs b/crates/evm/src/slashing_manager_sol_writer.rs index 446f24041c..029dfcd0c6 100644 --- a/crates/evm/src/slashing_manager_sol_writer.rs +++ b/crates/evm/src/slashing_manager_sol_writer.rs @@ -4,8 +4,8 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -//! Subscribes to `SignedProofFailed` events and submits `proposeSlash` -//! transactions on the SlashingManager contract. +//! Subscribes to `AccusationQuorumReached` events and submits `proposeSlash` +//! transactions on the SlashingManager contract with committee attestation evidence. use crate::helpers::EthProvider; use crate::send_tx_with_retry; @@ -16,6 +16,7 @@ use alloy::{ providers::{Provider, WalletProvider}, rpc::types::TransactionReceipt, sol, + sol_types::SolValue, }; use anyhow::Result; use e3_events::prelude::*; @@ -24,7 +25,7 @@ use e3_events::EnclaveEvent; use e3_events::EnclaveEventData; use e3_events::EventType; use e3_events::Shutdown; -use e3_events::{encode_fault_evidence, EType, SignedProofFailed}; +use e3_events::{AccusationOutcome, AccusationQuorumReached, EType}; use e3_utils::NotifySync; use tracing::info; @@ -34,7 +35,7 @@ sol!( "../../packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json" ); -/// Submits `SignedProofFailed` events as slash proposals on-chain. +/// Submits `AccusationQuorumReached` events as slash proposals on-chain. pub struct SlashingManagerSolWriter

{ provider: EthProvider

, contract_address: Address, @@ -61,7 +62,7 @@ impl SlashingManagerSolWriter

) -> Result>> { let addr = SlashingManagerSolWriter::new(bus, provider, contract_address)?.start(); bus.subscribe_all( - &[EventType::SignedProofFailed, EventType::Shutdown], + &[EventType::AccusationQuorumReached, EventType::Shutdown], addr.clone().into(), ); Ok(addr) @@ -79,8 +80,15 @@ impl Handler fn handle(&mut self, msg: EnclaveEvent, ctx: &mut Self::Context) -> Self::Result { match msg.into_data() { - EnclaveEventData::SignedProofFailed(data) => { - if self.provider.chain_id() == data.e3_id.chain_id() { + EnclaveEventData::AccusationQuorumReached(data) => { + // Only submit if: + // 1. This is the right chain + // 2. The quorum decided the accused is at fault + // 3. This node is the accuser (only one node should submit) + if self.provider.chain_id() == data.e3_id.chain_id() + && data.outcome == AccusationOutcome::AccusedFaulted + && data.accuser == self.provider.provider().default_signer_address() + { ctx.notify(data); } } @@ -90,12 +98,12 @@ impl Handler } } -impl Handler +impl Handler for SlashingManagerSolWriter

{ type Result = ResponseFuture<()>; - fn handle(&mut self, msg: SignedProofFailed, _: &mut Self::Context) -> Self::Result { + fn handle(&mut self, msg: AccusationQuorumReached, _: &mut Self::Context) -> Self::Result { Box::pin({ let contract_address = self.contract_address; let provider = self.provider.clone(); @@ -104,7 +112,7 @@ impl Handler let result = submit_slash_proposal(provider, contract_address, msg).await; match result { Ok(receipt) => { - info!(tx=%receipt.transaction_hash, "Submitted slash proposal on-chain"); + info!(tx=%receipt.transaction_hash, "Submitted attestation-based slash proposal on-chain"); } Err(err) => { bus.err( @@ -128,23 +136,38 @@ impl Handler } } +/// Encode `AccusationQuorumReached` into the attestation evidence format expected +/// by `SlashingManager.proposeSlash()`: +/// `abi.encode(uint256 proofType, address[] voters, bool[] agrees, bytes32[] dataHashes, bytes[] signatures)` +/// +/// Voters are sorted ascending by address to satisfy the contract's duplicate-prevention check. +fn encode_attestation_evidence(data: &AccusationQuorumReached) -> Vec { + // Collect and sort votes by voter address (ascending) + let mut votes = data.votes_for.clone(); + votes.sort_by_key(|v| v.voter); + + let proof_type = U256::from(data.proof_type as u8); + let voters: Vec

= votes.iter().map(|v| v.voter).collect(); + let agrees: Vec = votes.iter().map(|v| v.agrees).collect(); + let data_hashes: Vec<[u8; 32]> = votes.iter().map(|v| v.data_hash).collect(); + let signatures: Vec = votes + .iter() + .map(|v| Bytes::from(v.signature.clone())) + .collect(); + + (proof_type, voters, agrees, data_hashes, signatures).abi_encode() +} + async fn submit_slash_proposal( provider: EthProvider

, contract_address: Address, - data: SignedProofFailed, + data: AccusationQuorumReached, ) -> Result { let e3_id: U256 = data.e3_id.clone().try_into()?; - let operator = data.faulting_node; + let operator = data.accused; let reason = keccak256(data.proof_type.slash_reason().as_bytes()); - // Look up the verifier address from the on-chain slash policy. - // This is required to encode the full 6-tuple evidence that the contract expects: - // (bytes zkProof, bytes32[] publicInputs, bytes signature, uint256 chainId, uint256 proofType, address verifier) - let contract = ISlashingManager::new(contract_address, provider.provider()); - let policy = contract.getSlashPolicy(reason.into()).call().await?; - let verifier = policy.proofVerifier; - - let proof_data = encode_fault_evidence(&data, verifier); + let proof_data = encode_attestation_evidence(&data); let from_address = provider.provider().default_signer_address(); let current_nonce = provider diff --git a/crates/keyshare/src/decryption_key_shared_collector.rs b/crates/keyshare/src/decryption_key_shared_collector.rs index 0795b4124c..adadc9617f 100644 --- a/crates/keyshare/src/decryption_key_shared_collector.rs +++ b/crates/keyshare/src/decryption_key_shared_collector.rs @@ -10,7 +10,7 @@ use std::{ }; use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, Message, SpawnHandle}; -use e3_events::{DecryptionKeyShared, E3id, TypedEvent}; +use e3_events::{DecryptionKeyShared, E3id, EventContext, Sequenced, TypedEvent}; use e3_utils::MAILBOX_LIMIT; use tracing::{info, warn}; @@ -45,6 +45,15 @@ pub struct DecryptionKeySharedCollectionFailed { pub missing_parties: Vec, } +/// Removes this party from the `expected` set so decryption can proceed with +/// N-1 shares instead of waiting for a share that will never arrive. +#[derive(Message, Clone, Debug)] +#[rtype(result = "()")] +pub struct ExpelPartyFromDecryptionKeySharedCollection { + pub party_id: u64, + pub ec: EventContext, +} + /// Collects `DecryptionKeyShared` events from expected parties in H (Exchange #3). /// /// Once all expected events are collected, sends `AllDecryptionKeySharesCollected` @@ -184,3 +193,58 @@ impl Handler for DecryptionKeySharedCollec ctx.stop(); } } + +impl Handler for DecryptionKeySharedCollector { + type Result = (); + fn handle( + &mut self, + msg: ExpelPartyFromDecryptionKeySharedCollection, + ctx: &mut Self::Context, + ) -> Self::Result { + if !matches!(self.state, CollectorState::Collecting) { + return; + } + + let party_id = msg.party_id; + + if !self.expected.remove(&party_id) { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} was not in expected set (already received or unknown)", + party_id + ); + return; + } + + info!( + e3_id = %self.e3_id, + party_id = party_id, + remaining = self.expected.len(), + "Removed expelled party {} from decryption key shared collection, {} remaining", + party_id, + self.expected.len() + ); + + if self.expected.is_empty() { + info!( + e3_id = %self.e3_id, + "All remaining decryption key shares collected after party expulsion!" + ); + self.state = CollectorState::Finished; + + if let Some(handle) = self.timeout_handle.take() { + ctx.cancel_future(handle); + } + + let event: TypedEvent = TypedEvent::new( + AllDecryptionKeySharesCollected { + shares: std::mem::take(&mut self.shares), + }, + msg.ec.clone(), + ); + self.parent.do_send(event); + ctx.stop(); + } + } +} diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index c0d047f062..5498e55c7e 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -51,7 +51,7 @@ use tracing::{error, info, trace, warn}; use crate::decryption_key_shared_collector::{ AllDecryptionKeySharesCollected, DecryptionKeySharedCollectionFailed, - DecryptionKeySharedCollector, + DecryptionKeySharedCollector, ExpelPartyFromDecryptionKeySharedCollection, }; use crate::encryption_key_collector::{ AllEncryptionKeysCollected, EncryptionKeyCollector, ExpelPartyFromKeyCollection, @@ -535,7 +535,14 @@ impl ThresholdKeyshare { } if let Some(ref collector) = self.decryption_key_collector { - collector.do_send(ExpelPartyFromShareCollection { party_id, ec }); + collector.do_send(ExpelPartyFromShareCollection { + party_id, + ec: ec.clone(), + }); + } + + if let Some(ref collector) = self.decryption_key_shared_collector { + collector.do_send(ExpelPartyFromDecryptionKeySharedCollection { party_id, ec }); } } diff --git a/crates/net/src/net_event_translator.rs b/crates/net/src/net_event_translator.rs index 709d68e410..e48d365bb0 100644 --- a/crates/net/src/net_event_translator.rs +++ b/crates/net/src/net_event_translator.rs @@ -95,6 +95,8 @@ impl NetEventTranslator { EnclaveEventData::KeyshareCreated(_) => true, EnclaveEventData::PlaintextAggregated(_) => true, EnclaveEventData::PublicKeyAggregated(_) => true, + EnclaveEventData::ProofFailureAccusation(_) => true, + EnclaveEventData::AccusationVote(_) => true, _ => false, } } diff --git a/crates/request/src/context.rs b/crates/request/src/context.rs index 8a9053ad05..31a36a619c 100644 --- a/crates/request/src/context.rs +++ b/crates/request/src/context.rs @@ -24,6 +24,7 @@ fn init_recipients() -> HashMap>> { ("threshold_keyshare".to_owned(), None), ("plaintext".to_owned(), None), ("publickey".to_owned(), None), + ("accusation_manager".to_owned(), None), ]) } diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs new file mode 100644 index 0000000000..3eb8c37b2b --- /dev/null +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -0,0 +1,1040 @@ +// 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::time::Duration; + +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, ComputeRequest, + ComputeRequestError, ComputeResponse, ComputeResponseKind, CorrelationId, E3id, EnclaveEvent, + EnclaveEventData, EventContext, EventPublisher, EventSubscriber, EventType, + PartyProofsToVerify, ProofFailureAccusation, ProofType, ProofVerificationFailed, + ProofVerificationPassed, Sequenced, SignedProofPayload, TypedEvent, VerifyShareProofsRequest, + ZkRequest, ZkResponse, +}; +use e3_utils::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 + +/// An active accusation awaiting votes from committee members. +struct PendingAccusation { + accusation: ProofFailureAccusation, + votes_for: Vec, + votes_against: 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, +} + +/// 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, +} + +/// Manages the off-chain accusation quorum protocol. +/// +/// Subscribes to: +/// - [`ProofVerificationFailed`] — local proof failure detection +/// - [`ProofFailureAccusation`] — incoming accusations from other nodes via gossip +/// - [`AccusationVote`] — incoming votes from other nodes via gossip +/// +/// 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, + + /// 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, +} + +impl AccusationManager { + pub fn new( + bus: &BusHandle, + e3_id: E3id, + signer: PrivateKeySigner, + committee: Vec
, + threshold_m: usize, + ) -> Self { + let my_address = signer.address(); + Self { + bus: bus.clone(), + e3_id, + my_address, + signer, + 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, + } + } + + pub fn setup( + bus: &BusHandle, + e3_id: E3id, + signer: PrivateKeySigner, + committee: Vec
, + threshold_m: usize, + ) -> Addr { + let addr = Self::new(bus, e3_id, signer, committee, threshold_m).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()); + addr + } + + // ─── 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) -> Vec { + let digest = Self::accusation_digest(accusation); + self.signer + .sign_message_sync(&digest) + .map(|sig| sig.as_bytes().to_vec()) + .unwrap_or_default() + } + + /// Structured digest for ECDSA signing of accusations. + /// + /// Uses a typehash + `abi.encode` pattern matching `ProofPayload::digest()`: + /// ```text + /// keccak256(abi.encode( + /// ACCUSATION_TYPEHASH, + /// chainId, e3Id, accuser, accused, proofType, + /// dataHash + /// )) + /// ``` + 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)" + ).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, + ) + .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.as_slice()) { + Ok(s) => s, + Err(_) => return false, + }; + match sig.recover_address_from_msg(&digest) { + Ok(addr) => addr == accusation.accuser, + Err(_) => false, + } + } + + fn sign_vote_digest(&self, vote: &AccusationVote) -> Vec { + let digest = Self::vote_digest(vote); + self.signer + .sign_message_sync(&digest) + .map(|sig| sig.as_bytes().to_vec()) + .unwrap_or_default() + } + + /// Structured digest for ECDSA signing of votes. + /// + /// ```text + /// keccak256(abi.encode( + /// VOTE_TYPEHASH, + /// chainId, e3Id, accusationId, voter, agrees, + /// dataHash + /// )) + /// ``` + fn vote_digest(vote: &AccusationVote) -> [u8; 32] { + let e3_id_u256: U256 = vote + .e3_id + .clone() + .try_into() + .expect("E3id should be valid U256"); + let typehash: [u8; 32] = keccak256( + "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)" + ).into(); + let encoded = ( + typehash, + U256::from(vote.e3_id.chain_id()), + e3_id_u256, + vote.accusation_id, + vote.voter, + vote.agrees, + vote.data_hash, + ) + .abi_encode(); + keccak256(&encoded).into() + } + + fn verify_vote_signature(&self, vote: &AccusationVote) -> bool { + let digest = Self::vote_digest(vote); + let sig = match alloy::primitives::Signature::try_from(vote.signature.as_slice()) { + Ok(s) => s, + Err(_) => return false, + }; + match sig.recover_address_from_msg(&digest) { + Ok(addr) => addr == vote.voter, + Err(_) => false, + } + } + + // ─── Core Protocol ─────────────────────────────────────────────────── + + /// Called when the local node detects a proof failure. + /// + /// Creates and broadcasts a `ProofFailureAccusation`, casts own vote, + /// and begins vote collection with a timeout. + fn on_local_proof_failure( + &mut self, + event: ProofVerificationFailed, + ec: &EventContext, + ctx: &mut Context, + ) { + let key = (event.accused_address, event.proof_type); + + // Cache the failed verification result + self.received_data.insert( + key, + ReceivedProofData { + data_hash: event.data_hash, + verification_passed: false, + }, + ); + + // Dedup: don't create multiple accusations for the same (accused, proof_type) + if !self.accused_proofs.insert(key) { + info!( + "Already accused {:?} for {:?} — skipping duplicate", + event.accused_address, event.proof_type + ); + return; + } + + // 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, + }; + + // Create the accusation + let mut accusation = ProofFailureAccusation { + e3_id: self.e3_id.clone(), + accuser: self.my_address, + accused: event.accused_address, + accused_party_id: event.accused_party_id, + proof_type: event.proof_type, + data_hash: event.data_hash, + signed_payload: forwarded_payload, + signature: Vec::new(), + }; + accusation.signature = self.sign_accusation_digest(&accusation); + + let accusation_id = Self::accusation_id(&accusation); + + info!( + "Broadcasting accusation against {} for {:?} proof failure", + event.accused_address, event.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 own vote (agrees: true) + let mut own_vote = AccusationVote { + e3_id: self.e3_id.clone(), + accusation_id, + voter: self.my_address, + agrees: true, + data_hash: event.data_hash, + signature: Vec::new(), + }; + own_vote.signature = self.sign_vote_digest(&own_vote); + + 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], + votes_against: Vec::new(), + 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); + } + } + + // Check quorum immediately (in case threshold_m == 1) + self.check_quorum(accusation_id, ec); + } + + /// 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; + } + + // 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 vote based on our local verification state + let key = (accusation.accused, accusation.proof_type); + let (agrees, our_data_hash) = if let Some(received) = self.received_data.get(&key) { + // We have the data — did our verification also fail? + (!received.verification_passed, 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 accused_party_id = accusation.accused_party_id; + let forwarded_clone = forwarded.clone(); + + // Create PendingAccusation without our vote — it arrives after ZK completes + 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(), + votes_against: 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); + } + } + + // 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, + }, + ); + + 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], + }), + 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; + }; + + // Cast vote + let mut vote = AccusationVote { + e3_id: self.e3_id.clone(), + accusation_id, + voter: self.my_address, + agrees, + data_hash: our_data_hash, + signature: Vec::new(), + }; + vote.signature = self.sign_vote_digest(&vote); + + info!( + "Voting {} on accusation against {} for {:?}", + if agrees { "AGREE" } else { "DISAGREE" }, + 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: if agrees { + vec![vote.clone()] + } else { + Vec::new() + }, + votes_against: if agrees { Vec::new() } else { 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); + } + } + + // Check quorum + self.check_quorum(accusation_id, ec); + } + + /// Called when we receive a vote from another node via gossip. + fn on_vote_received(&mut self, vote: AccusationVote, ec: &EventContext) { + // 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 + self.buffered_votes + .entry(vote_accusation_id) + .or_default() + .push(vote); + return; + }; + + // Dedup: don't count same voter twice + let already_voted = pending + .votes_for + .iter() + .chain(pending.votes_against.iter()) + .any(|v| v.voter == vote.voter); + if already_voted { + return; + } + + if vote.agrees { + pending.votes_for.push(vote); + } else { + pending.votes_against.push(vote); + } + + // Check if quorum reached + self.check_quorum(vote_accusation_id, ec); + } + + /// Evaluate whether we have enough votes to decide. + /// + /// Quorum logic: + /// - Need >= M agreeing votes → AccusedFaulted + /// - If impossible to reach M even with remaining voters → early exit + /// - data_hash comparison detects equivocation vs false accusation + fn check_quorum(&mut self, accusation_id: [u8; 32], ec: &EventContext) { + let Some(pending) = self.pending.get(&accusation_id) else { + return; + }; + + let agree_count = pending.votes_for.len(); + let disagree_count = pending.votes_against.len(); + let total_votes = agree_count + disagree_count; + + // CASE A: Majority says proof is bad → accused is at fault + if agree_count >= self.threshold_m { + 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); + return; + } + + // Check if quorum is still possible + let remaining = self.committee.len().saturating_sub(total_votes); + if agree_count + remaining < self.threshold_m { + // Even if all remaining voters agree, can't reach quorum. + // Collect all unique data hashes (from all votes + the accusation itself) + let all_hashes: HashSet<[u8; 32]> = pending + .votes_for + .iter() + .chain(pending.votes_against.iter()) + .map(|v| v.data_hash) + .chain(std::iter::once(pending.accusation.data_hash)) + .collect(); + + if all_hashes.len() > 1 { + // Different nodes received different data → equivocation by the accused + info!( + "Equivocation detected: {} unique data hashes for {} {:?}", + all_hashes.len(), + pending.accusation.accused, + pending.accusation.proof_type + ); + self.emit_quorum_reached(accusation_id, AccusationOutcome::Equivocation, ec); + } else if agree_count <= 1 && disagree_count > 0 { + // Same data, only accuser says bad, others say good → AccuserLied + info!( + "Accuser {} appears to have lied about {} {:?}", + pending.accusation.accuser, + pending.accusation.accused, + pending.accusation.proof_type + ); + self.emit_quorum_reached(accusation_id, AccusationOutcome::AccuserLied, ec); + } else { + self.emit_quorum_reached(accusation_id, AccusationOutcome::Inconclusive, ec); + } + } + // Otherwise: still waiting for more votes — timeout will handle it + } + + /// 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 + }; + + let outcome = if pending.votes_for.len() >= self.threshold_m { + AccusationOutcome::AccusedFaulted + } else { + AccusationOutcome::Inconclusive + }; + + warn!( + "Accusation against {} for {:?} timed out with {} for / {} against — outcome: {:?}", + pending.accusation.accused, + pending.accusation.proof_type, + pending.votes_for.len(), + pending.votes_against.len(), + outcome + ); + + 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, + votes_against: pending.votes_against, + outcome, + }, + pending.ec, + ) { + error!("Failed to publish AccusationQuorumReached on timeout: {err}"); + } + } + + fn emit_quorum_reached( + &mut self, + accusation_id: [u8; 32], + outcome: AccusationOutcome, + ec: &EventContext, + ) { + let Some(pending) = self.pending.remove(&accusation_id) else { + return; + }; + + // Cancel the timeout if it exists + // (SpawnHandle can't be cancelled directly in actix without ctx, + // but removing from pending prevents the timeout handler from acting) + + info!( + "Accusation quorum reached for {} {:?}: {} for, {} against — outcome: {}", + pending.accusation.accused, + pending.accusation.proof_type, + pending.votes_for.len(), + pending.votes_against.len(), + outcome + ); + + 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, + votes_against: pending.votes_against, + outcome, + }, + ec.clone(), + ) { + error!("Failed to publish AccusationQuorumReached: {err}"); + } + } + + /// 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, + ) { + self.received_data.insert( + (accused, proof_type), + ReceivedProofData { + data_hash, + verification_passed: passed, + }, + ); + } + + /// Compute a keccak256 hash of a SignedProofPayload for data_hash comparison. + /// + /// `keccak256(abi.encodePacked(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_packed(); + 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) { + 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)) => { + r.party_results.first().is_some_and(|r| r.all_verified) + } + _ => { + warn!("Unexpected ComputeResponse kind for C3a/C3b re-verification — abstaining"); + return; + } + }; + + let agrees = !zk_passed; // ZK failed → proof is bad → agree with accusation + + // Cache the result for future accusations + self.cache_verification_result( + reverif.accused, + reverif.proof_type, + reverif.data_hash, + zk_passed, + ); + + // Get ec from the PendingAccusation + let ec = match self.pending.get(&reverif.accusation_id) { + Some(pending) => pending.ec.clone(), + None => { + // Accusation already resolved (timeout/quorum) before ZK finished + return; + } + }; + + // Cast vote + let mut vote = AccusationVote { + e3_id: self.e3_id.clone(), + accusation_id: reverif.accusation_id, + voter: self.my_address, + agrees, + data_hash: reverif.data_hash, + signature: Vec::new(), + }; + vote.signature = self.sign_vote_digest(&vote); + + info!( + "C3a/C3b re-verification complete — voting {} on accusation against {:?}", + if agrees { "AGREE" } else { "DISAGREE" }, + 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) { + if agrees { + pending.votes_for.push(vote); + } else { + pending.votes_against.push(vote); + } + } + + // Check quorum + self.check_quorum(reverif.accusation_id, &ec); + } + + /// 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)) + } + _ => (), + } + } +} + +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(); + // Cache successful verification for voting on future accusations + self.received_data.insert( + (data.address, data.proof_type), + ReceivedProofData { + data_hash: data.data_hash, + verification_passed: true, + }, + ); + } +} + +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); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + self.handle_reverification_response(msg); + } +} + +impl Handler> for AccusationManager { + type Result = (); + + fn handle( + &mut self, + msg: TypedEvent, + _ctx: &mut Self::Context, + ) -> Self::Result { + self.handle_reverification_error(msg); + } +} diff --git a/crates/zk-prover/src/actors/accusation_manager_ext.rs b/crates/zk-prover/src/actors/accusation_manager_ext.rs new file mode 100644 index 0000000000..4d471e6983 --- /dev/null +++ b/crates/zk-prover/src/actors/accusation_manager_ext.rs @@ -0,0 +1,101 @@ +// 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 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, +} + +impl AccusationManagerExtension { + pub fn create(bus: &BusHandle, signer: PrivateKeySigner) -> Box { + Box::new(Self { + bus: bus.clone(), + signer: signer.clone(), + }) + } +} + +#[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 + let committee_addresses: Vec
= committee + .iter() + .filter_map(|s| match s.parse::
() { + Ok(addr) => Some(addr), + Err(e) => { + warn!("Failed to parse committee address {}: {}", s, e); + None + } + }) + .collect(); + + if committee_addresses.is_empty() { + error!("No valid 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 addr = AccusationManager::setup( + &self.bus, + e3_id, + self.signer.clone(), + committee_addresses, + threshold_m, + ); + + ctx.set_event_recipient("accusation_manager", Some(addr.into())); + } + + async fn hydrate(&self, _ctx: &mut E3Context, _snapshot: &E3ContextSnapshot) -> Result<()> { + // AccusationManager is ephemeral — no state to hydrate. + // On restart, in-flight accusations are lost (acceptable: they would + // have timed out anyway). The actor will be re-created on the next + // CommitteeFinalized. + Ok(()) + } +} diff --git a/crates/zk-prover/src/actors/mod.rs b/crates/zk-prover/src/actors/mod.rs index e9cf0d71d5..ec09c73964 100644 --- a/crates/zk-prover/src/actors/mod.rs +++ b/crates/zk-prover/src/actors/mod.rs @@ -33,11 +33,15 @@ //! setup_zk_actors(&bus, &backend, signer); //! ``` +pub mod accusation_manager; +pub mod accusation_manager_ext; pub mod proof_request; 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 proof_request::ProofRequestActor; pub use proof_verification::{ ProofVerificationActor, ZkVerificationRequest, ZkVerificationResponse, diff --git a/crates/zk-prover/src/actors/proof_verification.rs b/crates/zk-prover/src/actors/proof_verification.rs index f66acdd2f3..5f6ffb447a 100644 --- a/crates/zk-prover/src/actors/proof_verification.rs +++ b/crates/zk-prover/src/actors/proof_verification.rs @@ -12,11 +12,13 @@ use std::collections::HashMap; use std::sync::Arc; use actix::{Actor, Addr, AsyncContext, Context, Handler, Message, Recipient}; -use alloy::primitives::Address; +use alloy::primitives::{keccak256, Address, Bytes}; +use alloy::sol_types::SolValue; use e3_events::{ BusHandle, E3id, EnclaveEvent, EnclaveEventData, EncryptionKey, EncryptionKeyCreated, EncryptionKeyReceived, EventContext, EventPublisher, EventSubscriber, EventType, Proof, - Sequenced, SignedProofFailed, SignedProofPayload, TypedEvent, + ProofType, ProofVerificationFailed, ProofVerificationPassed, Sequenced, SignedProofFailed, + SignedProofPayload, TypedEvent, }; use e3_utils::NotifySync; use tracing::{error, info, warn}; @@ -209,7 +211,37 @@ impl Handler> for ProofVerificationActor { "C0 proof verified for party {} - accepting key", msg.key.party_id ); + let party_id = msg.key.party_id; + let e3_id = msg.e3_id.clone(); self.publish_key_created(msg.e3_id, msg.key, ec.clone()); + + // Emit ProofVerificationPassed so AccusationManager can cache success + if let Some(PendingVerification { + signed_payload, + recovered_signer, + }) = pending + { + let data_hash: [u8; 32] = { + let msg = ( + Bytes::copy_from_slice(&signed_payload.payload.proof.data), + Bytes::copy_from_slice(&signed_payload.payload.proof.public_signals), + ) + .abi_encode_packed(); + keccak256(&msg).into() + }; + if let Err(err) = self.bus.publish( + ProofVerificationPassed { + e3_id, + party_id, + address: recovered_signer, + proof_type: ProofType::C0PkBfv, + data_hash, + }, + ec, + ) { + error!("Failed to publish ProofVerificationPassed: {err}"); + } + } } else { let error_msg = msg.error.unwrap_or_else(|| "unknown error".to_string()); error!( @@ -231,12 +263,35 @@ impl Handler> for ProofVerificationActor { e3_id: msg.e3_id.clone(), faulting_node: recovered_signer, proof_type: signed_payload.payload.proof_type, - signed_payload, + signed_payload: signed_payload.clone(), }, ec.clone(), ) { error!("Failed to publish SignedProofFailed: {err}"); } + + // Emit ProofVerificationFailed for AccusationManager + let data_hash: [u8; 32] = { + let msg = ( + Bytes::copy_from_slice(&signed_payload.payload.proof.data), + Bytes::copy_from_slice(&signed_payload.payload.proof.public_signals), + ) + .abi_encode_packed(); + keccak256(&msg).into() + }; + if let Err(err) = self.bus.publish( + ProofVerificationFailed { + e3_id: msg.e3_id.clone(), + accused_party_id: msg.key.party_id, + accused_address: recovered_signer, + proof_type: ProofType::C0PkBfv, + data_hash, + signed_payload, + }, + ec.clone(), + ) { + error!("Failed to publish ProofVerificationFailed: {err}"); + } } // NOTE: We do NOT emit E3Failed here. The on-chain SlashingManager diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index 4addfcc17e..17f0718d82 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -24,14 +24,16 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use actix::{Actor, Addr, Context, Handler}; -use alloy::primitives::Address; +use alloy::primitives::{keccak256, Address, Bytes}; +use alloy::sol_types::SolValue; use e3_events::{ BusHandle, ComputeRequest, ComputeRequestError, ComputeResponse, ComputeResponseKind, CorrelationId, E3id, EnclaveEvent, EnclaveEventData, EventContext, EventPublisher, EventSubscriber, EventType, PartyProofsToVerify, PartyShareDecryptionProofsToVerify, - PartyVerificationResult, Sequenced, ShareVerificationComplete, ShareVerificationDispatched, - SignedProofFailed, SignedProofPayload, TypedEvent, VerificationKind, - VerifyShareDecryptionProofsRequest, VerifyShareProofsRequest, ZkRequest, ZkResponse, + PartyVerificationResult, ProofType, ProofVerificationFailed, ProofVerificationPassed, + Sequenced, ShareVerificationComplete, ShareVerificationDispatched, SignedProofFailed, + SignedProofPayload, TypedEvent, VerificationKind, VerifyShareDecryptionProofsRequest, + VerifyShareProofsRequest, ZkRequest, ZkResponse, }; use e3_utils::NotifySync; use tracing::{error, info, warn}; @@ -56,6 +58,8 @@ struct PendingVerification { dispatched_party_ids: HashSet, /// Recovered address for each party (from ECDSA step). party_addresses: HashMap, + /// Cached (proof_type, data_hash) per party — for emitting ProofVerificationPassed. + party_proof_hashes: HashMap>, } /// Actor that handles C2/C3/C4 share proof verification. @@ -142,7 +146,7 @@ impl ShareVerificationActor { } else { ecdsa_dishonest.insert(party.sender_party_id); if let Some((ref signed, addr)) = result.failed_payload { - self.emit_signed_proof_failed(&e3_id, signed, addr, &ec); + self.emit_signed_proof_failed(&e3_id, signed, addr, party.sender_party_id, &ec); } } } @@ -172,6 +176,25 @@ impl ShareVerificationActor { .iter() .map(|p| p.sender_party_id) .collect(); + + // Compute proof hashes for ECDSA-passed parties (for ProofVerificationPassed on success) + let mut party_proof_hashes: HashMap> = HashMap::new(); + for party in &ecdsa_passed_parties { + let hashes: Vec<(ProofType, [u8; 32])> = party + .signed_proofs + .iter() + .map(|signed| { + let msg = ( + Bytes::copy_from_slice(&signed.payload.proof.data), + Bytes::copy_from_slice(&signed.payload.proof.public_signals), + ) + .abi_encode_packed(); + (signed.payload.proof_type, keccak256(&msg).into()) + }) + .collect(); + party_proof_hashes.insert(party.sender_party_id, hashes); + } + self.pending.insert( correlation_id, PendingVerification { @@ -182,6 +205,7 @@ impl ShareVerificationActor { pre_dishonest, dispatched_party_ids, party_addresses, + party_proof_hashes, }, ); @@ -239,7 +263,7 @@ impl ShareVerificationActor { } else { ecdsa_dishonest.insert(party.sender_party_id); if let Some((ref signed, addr)) = result.failed_payload { - self.emit_signed_proof_failed(&e3_id, signed, addr, &ec); + self.emit_signed_proof_failed(&e3_id, signed, addr, party.sender_party_id, &ec); } } } @@ -265,6 +289,28 @@ impl ShareVerificationActor { .iter() .map(|p| p.sender_party_id) .collect(); + + // Compute proof hashes for ECDSA-passed parties (for ProofVerificationPassed on success) + let mut party_proof_hashes: HashMap> = HashMap::new(); + for party in &ecdsa_passed_parties { + let all_signed: Vec<&SignedProofPayload> = + std::iter::once(&party.signed_sk_decryption_proof) + .chain(party.signed_esm_decryption_proofs.iter()) + .collect(); + let hashes: Vec<(ProofType, [u8; 32])> = all_signed + .iter() + .map(|signed| { + let msg = ( + Bytes::copy_from_slice(&signed.payload.proof.data), + Bytes::copy_from_slice(&signed.payload.proof.public_signals), + ) + .abi_encode_packed(); + (signed.payload.proof_type, keccak256(&msg).into()) + }) + .collect(); + party_proof_hashes.insert(party.sender_party_id, hashes); + } + self.pending.insert( correlation_id, PendingVerification { @@ -275,6 +321,7 @@ impl ShareVerificationActor { pre_dishonest, dispatched_party_ids, party_addresses, + party_proof_hashes, }, ); @@ -444,7 +491,36 @@ impl ShareVerificationActor { .party_addresses .get(&result.sender_party_id) .copied(); - self.emit_signed_proof_failed(&pending.e3_id, signed, addr, &pending.ec); + self.emit_signed_proof_failed( + &pending.e3_id, + signed, + addr, + result.sender_party_id, + &pending.ec, + ); + } + } else { + // Emit ProofVerificationPassed for each proof type from this party + if let Some(hashes) = pending.party_proof_hashes.get(&result.sender_party_id) { + let addr = pending + .party_addresses + .get(&result.sender_party_id) + .copied() + .unwrap_or_default(); + for &(proof_type, data_hash) in hashes { + if let Err(err) = self.bus.publish( + ProofVerificationPassed { + e3_id: pending.e3_id.clone(), + party_id: result.sender_party_id, + address: addr, + proof_type, + data_hash, + }, + pending.ec.clone(), + ) { + error!("Failed to publish ProofVerificationPassed: {err}"); + } + } } } } @@ -457,6 +533,7 @@ impl ShareVerificationActor { e3_id: &E3id, signed_payload: &SignedProofPayload, recovered_addr: Option
, + party_id: u64, ec: &EventContext, ) { let faulting_node = match recovered_addr { @@ -481,6 +558,29 @@ impl ShareVerificationActor { ) { error!("Failed to publish SignedProofFailed: {err}"); } + + // Also emit ProofVerificationFailed for AccusationManager + let data_hash: [u8; 32] = { + let msg = ( + Bytes::copy_from_slice(&signed_payload.payload.proof.data), + Bytes::copy_from_slice(&signed_payload.payload.proof.public_signals), + ) + .abi_encode_packed(); + keccak256(&msg).into() + }; + if let Err(err) = self.bus.publish( + ProofVerificationFailed { + e3_id: e3_id.clone(), + accused_party_id: party_id, + accused_address: faulting_node, + proof_type: signed_payload.payload.proof_type, + data_hash, + signed_payload: signed_payload.clone(), + }, + ec.clone(), + ) { + error!("Failed to publish ProofVerificationFailed: {err}"); + } } /// Handle computation error from multithread — clean up pending state and diff --git a/crates/zk-prover/src/lib.rs b/crates/zk-prover/src/lib.rs index f2c4e54306..04c8dbec96 100644 --- a/crates/zk-prover/src/lib.rs +++ b/crates/zk-prover/src/lib.rs @@ -15,8 +15,9 @@ mod traits; mod witness; pub use actors::{ - setup_zk_actors, ProofRequestActor, ProofVerificationActor, ShareVerificationActor, ZkActors, - ZkVerificationRequest, ZkVerificationResponse, + setup_zk_actors, AccusationManager, AccusationManagerExtension, ProofRequestActor, + ProofVerificationActor, ShareVerificationActor, ZkActors, ZkVerificationRequest, + ZkVerificationResponse, }; pub use backend::{SetupStatus, ZkBackend}; diff --git a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts index 9b7c2fdb1c..52544a8bd9 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts @@ -35,8 +35,7 @@ describe('Vote', () => { json: async () => ({ ciphertext: previousCiphertext }), }) as Response - const mockPreviousCiphertextNotFoundResponse = () => - ({ ok: false, status: 404 }) as Response + const mockPreviousCiphertextNotFoundResponse = () => ({ ok: false, status: 404 }) as Response beforeEach(() => { vi.clearAllMocks() diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json b/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json index ec8df1ccb1..b9cff5d9eb 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json @@ -940,5 +940,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IBondingRegistry.sol", - "buildInfoId": "solc-0_8_28-16cefaab22a5b0c22d849bfd93e340ea85b6ef79" + "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" } \ No newline at end of file diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json b/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json index 1daf6d2fcb..6d93ec6a5f 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json @@ -782,5 +782,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ICiphernodeRegistry.sol", - "buildInfoId": "solc-0_8_28-16cefaab22a5b0c22d849bfd93e340ea85b6ef79" + "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" } \ No newline at end of file diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json b/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json index 530a8092f0..9bd81933c0 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json @@ -1263,5 +1263,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IEnclave.sol", - "buildInfoId": "solc-0_8_28-16cefaab22a5b0c22d849bfd93e340ea85b6ef79" + "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" } \ No newline at end of file diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index c64048d8df..2587be67b1 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -53,6 +53,16 @@ "name": "DuplicateEvidence", "type": "error" }, + { + "inputs": [], + "name": "DuplicateVoter", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientAttestations", + "type": "error" + }, { "inputs": [], "name": "InvalidPolicy", @@ -68,6 +78,11 @@ "name": "InvalidProposal", "type": "error" }, + { + "inputs": [], + "name": "InvalidVoteSignature", + "type": "error" + }, { "inputs": [], "name": "OperatorNotInCommittee", @@ -118,6 +133,11 @@ "name": "VerifierNotSet", "type": "error" }, + { + "inputs": [], + "name": "VoterNotInCommittee", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", @@ -934,5 +954,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-16cefaab22a5b0c22d849bfd93e340ea85b6ef79" + "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" } \ No newline at end of file diff --git a/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol b/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol index daecf707ce..6015275f97 100644 --- a/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol +++ b/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol @@ -163,6 +163,18 @@ interface ISlashingManager { /// @notice Thrown when the chainId in the signed proof payload does not match the current chain error ChainIdMismatch(); + /// @notice Thrown when the number of attestation votes is below the committee threshold M + error InsufficientAttestations(); + + /// @notice Thrown when the attestation voters array contains duplicate addresses (must be sorted ascending) + error DuplicateVoter(); + + /// @notice Thrown when an attestation voter is not a member of the committee for this E3 + error VoterNotInCommittee(); + + /// @notice Thrown when an attestation vote signature does not recover to the declared voter + error InvalidVoteSignature(); + // ====================== // Events // ====================== @@ -373,23 +385,25 @@ interface ISlashingManager { // ====================== /** - * @notice Creates a new slash proposal with cryptographic proof (Lane A - permissionless) - * @dev Anyone can call this for proof-based slashes. Requires the operator's ECDSA signature - * over the proof payload to prevent arbitrary slashing. + * @notice Creates a new slash proposal with committee attestation (Lane A - permissionless) + * @dev Anyone can call this for attestation-based slashes. Requires a quorum of committee + * members to have signed votes attesting that the operator submitted a bad proof. * Evidence format: - * abi.encode(bytes zkProof, bytes32[] publicInputs, - * bytes signature, uint256 chainId, uint256 proofType, address verifier) - * The operator must have signed: keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, chainId, e3Id, - * proofType, keccak256(zkProof), keccak256(abi.encodePacked(publicInputs)))) + * abi.encode(uint256 proofType, + * address[] voters, bool[] agrees, bytes32[] dataHashes, bytes[] signatures) + * Each voter must have signed: personal_sign(keccak256(abi.encode(VOTE_TYPEHASH, + * block.chainid, e3Id, accusationId, voter, agrees, dataHash))) + * where accusationId = keccak256(abi.encodePacked(block.chainid, e3Id, operator, proofType)) * Verifications performed: - * 1. Verifier address in evidence matches the policy's current proofVerifier - * 2. Signature recovery confirms the operator authored the bad proof - * 3. Committee membership check confirms the operator was in the E3's committee - * 4. ZK proof re-verification confirms the proof is indeed invalid (fault) + * 1. Number of votes >= committee threshold M + * 2. Voters are sorted ascending (prevents duplicates) + * 3. Each voter is a committee member for this E3 + * 4. Each vote signature recovers to the declared voter + * 5. All votes agree the proof is invalid (agrees == true) * @param e3Id ID of the E3 computation this slash relates to * @param operator Address of the ciphernode operator to slash (must be non-zero) * @param reason Hash of the slash reason (must have an enabled proof-required policy) - * @param proof Evidence data: abi.encode(zkProof, publicInputs, signature, chainId, proofType, verifier) + * @param proof Attestation evidence: abi.encode(proofType, voters, agrees, dataHashes, signatures) * @return proposalId Sequential ID of the created proposal */ function proposeSlash( diff --git a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol index 18f5d4113f..583a2ae6c0 100644 --- a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol +++ b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol @@ -18,7 +18,6 @@ import { IBondingRegistry } from "../interfaces/IBondingRegistry.sol"; import { ICiphernodeRegistry } from "../interfaces/ICiphernodeRegistry.sol"; import { IEnclave } from "../interfaces/IEnclave.sol"; import { IE3RefundManager } from "../interfaces/IE3RefundManager.sol"; -import { ICircuitVerifier } from "../interfaces/ICircuitVerifier.sol"; /** * @title SlashingManager @@ -84,6 +83,14 @@ contract SlashingManager is ISlashingManager, AccessControl { "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)" ); + /// @notice EIP-712 style typehash for committee attestation votes. + /// @dev Must match `AccusationManager::vote_digest()` in `crates/zk-prover/src/actors/accusation_manager.rs`. + /// Includes chainId to prevent cross-chain replay and dataHash for equivocation detection. + bytes32 public constant VOTE_TYPEHASH = + keccak256( + "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)" + ); + // ====================== // Modifiers // ====================== @@ -155,7 +162,7 @@ contract SlashingManager is ISlashingManager, AccessControl { ); if (policy.requiresProof) { - require(policy.proofVerifier != address(0), VerifierNotSet()); + // Attestation-based slashes: no proofVerifier needed, no appeal window require(policy.appealWindow == 0, InvalidPolicy()); } else { require(policy.appealWindow > 0, InvalidPolicy()); @@ -217,17 +224,15 @@ contract SlashingManager is ISlashingManager, AccessControl { // ====================== /// @inheritdoc ISlashingManager - /// @dev Lane A: Permissionless proof-based slash. Anyone can call. - /// Atomically proposes, verifies operator signature + ZK proof, and executes slash. + /// @dev Lane A: Permissionless committee attestation-based slash. Anyone can call. + /// Atomically proposes, verifies committee vote signatures, and executes slash. /// Evidence format: - /// `abi.encode(bytes zkProof, bytes32[] publicInputs, - /// bytes signature, - /// uint256 chainId, - /// uint256 proofType, - /// address verifier)` - /// Operator must sign the EIP-191 prefixed payload hash via `personal_sign`/`signMessage()` - /// (NOT raw `eth_signHash`): `personal_sign(keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, - /// chainId, e3Id, proofType, keccak256(zkProof), keccak256(abi.encodePacked(publicInputs)))))` + /// `abi.encode(uint256 proofType, + /// address[] voters, bool[] agrees, bytes32[] dataHashes, bytes[] signatures)` + /// Each voter must sign via `personal_sign`/`signMessage()` (EIP-191 prefixed): + /// `personal_sign(keccak256(abi.encode(VOTE_TYPEHASH, + /// block.chainid, e3Id, accusationId, voter, agrees, dataHash)))` + /// where `accusationId = keccak256(abi.encodePacked(block.chainid, e3Id, operator, proofType))` function proposeSlash( uint256 e3Id, address operator, @@ -248,8 +253,8 @@ contract SlashingManager is ISlashingManager, AccessControl { require(!evidenceConsumed[evidenceKey], DuplicateEvidence()); evidenceConsumed[evidenceKey] = true; - // Verify evidence: signature, committee membership, and ZK proof - _verifyProofEvidence(proof, e3Id, operator, policy.proofVerifier); + // Verify committee attestation: vote signatures and quorum + _verifyAttestationEvidence(proof, e3Id, operator); // Create proposal proposalId = totalProposals; @@ -361,66 +366,83 @@ contract SlashingManager is ISlashingManager, AccessControl { // Internal Execution // ====================== - /// @dev Decodes and verifies: verifier match, chainId, operator EIP-191 signature, committee - /// membership, and that the ZK proof is invalid (fault confirmed). Evidence encoding - /// matches proposeSlash — see that function's dev note for the abi.encode layout. - function _verifyProofEvidence( + /// @dev Verifies committee attestation evidence for Lane A slashes. + /// Decodes the attestation, checks quorum (>= threshold M), verifies each voter's + /// ECDSA signature against the VOTE_TYPEHASH-structured digest, and confirms each + /// voter is a committee member. Voters must be sorted ascending to prevent duplicates. + function _verifyAttestationEvidence( bytes calldata proof, uint256 e3Id, - address operator, - address policyVerifier + address operator ) internal view { ( - bytes memory zkProof, - bytes32[] memory publicInputs, - bytes memory signature, - uint256 chainId, uint256 proofType, - address signedVerifier - ) = abi.decode( - proof, - (bytes, bytes32[], bytes, uint256, uint256, address) - ); + address[] memory voters, + bool[] memory agrees, + bytes32[] memory dataHashes, + bytes[] memory signatures + ) = abi.decode(proof, (uint256, address[], bool[], bytes32[], bytes[])); - // 1. Verify verifier in evidence matches policy's current verifier. - require(signedVerifier == policyVerifier, VerifierMismatch()); - - // 1b. Verify chainId matches current chain to prevent cross-chain replay. - require(chainId == block.chainid, ChainIdMismatch()); - - // 2. Verify the operator signed this exact proof payload. - bytes32 messageHash = keccak256( - abi.encode( - PROOF_PAYLOAD_TYPEHASH, - chainId, - e3Id, - proofType, - keccak256(zkProof), - keccak256(abi.encodePacked(publicInputs)) - ) - ); - bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash( - messageHash - ); - address recoveredSigner = ECDSA.recover(ethSignedHash, signature); - require(recoveredSigner == operator, SignerIsNotOperator()); - - // 3. Verify the operator was ever a committee member for this E3. + uint256 numVotes = voters.length; require( - ciphernodeRegistry.isCommitteeMember(e3Id, operator), - OperatorNotInCommittee() + numVotes == agrees.length && + numVotes == dataHashes.length && + numVotes == signatures.length, + InvalidProof() ); - // 4. Re-verify the ZK proof on-chain (INVERTED: must FAIL to confirm fault). - // The staticcall MUST succeed — if the verifier reverts or doesn't exist, - // we cannot determine fault and must not slash. - (bool callSuccess, bytes memory returnData) = policyVerifier.staticcall( - abi.encodeCall(ICircuitVerifier.verify, (zkProof, publicInputs)) + // Compute accusation ID matching AccusationManager::accusation_id() on the Rust side + bytes32 accusationId = keccak256( + abi.encodePacked(block.chainid, e3Id, operator, proofType) ); - require(callSuccess, VerifierCallFailed()); - require(returnData.length >= 32, VerifierCallFailed()); - bool proofValid = abi.decode(returnData, (bool)); - require(!proofValid, ProofIsValid()); + + // Get committee threshold — need at least M agreeing votes + { + (, uint32 thresholdM, , ) = ciphernodeRegistry + .getCommitteeViability(e3Id); + require(numVotes >= thresholdM, InsufficientAttestations()); + } + + // Verify each vote signature and membership + address prevVoter = address(0); + for (uint256 i = 0; i < numVotes; i++) { + address voter = voters[i]; + + // Sorted ascending order prevents duplicate voters + require(voter > prevVoter, DuplicateVoter()); + prevVoter = voter; + + // All votes must agree the proof is bad (fault confirmed) + require(agrees[i], InvalidProof()); + + // Verify voter was a committee member for this E3 + require( + ciphernodeRegistry.isCommitteeMember(e3Id, voter), + VoterNotInCommittee() + ); + + // Reconstruct vote digest and verify signature in a scoped block + // to avoid stack-too-deep + { + bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encode( + VOTE_TYPEHASH, + block.chainid, + e3Id, + accusationId, + voter, + agrees[i], + dataHashes[i] + ) + ) + ); + require( + ECDSA.recover(ethSignedHash, signatures[i]) == voter, + InvalidVoteSignature() + ); + } + } } /// @dev Executes a slash: applies financial penalties, optional ban, and committee expulsion. diff --git a/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol b/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol index 8ddece9568..a34d493db9 100644 --- a/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol +++ b/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol @@ -13,6 +13,9 @@ contract MockCiphernodeRegistry is ICiphernodeRegistry { /// @notice Configurable committee members per E3 for testing mapping(uint256 e3Id => address[] nodes) private _committeeNodes; + /// @notice Configurable threshold M per E3 for testing + mapping(uint256 e3Id => uint32) private _thresholdM; + /// @notice Set committee members for an E3 (test helper) function setCommitteeNodes( uint256 e3Id, @@ -24,6 +27,11 @@ contract MockCiphernodeRegistry is ICiphernodeRegistry { } } + /// @notice Set the threshold M for an E3 (test helper) + function setThreshold(uint256 e3Id, uint32 m) external { + _thresholdM[e3Id] = m; + } + function requestCommittee( uint256, uint256, @@ -146,9 +154,11 @@ contract MockCiphernodeRegistry is ICiphernodeRegistry { } function getCommitteeViability( - uint256 - ) external pure returns (uint256, uint32, uint32, bool) { - return (0, 0, 0, false); + uint256 e3Id + ) external view returns (uint256, uint32, uint32, bool) { + uint32 m = _thresholdM[e3Id]; + uint32 n = uint32(_committeeNodes[e3Id].length); + return (n, m, n, n >= m && m > 0); } } diff --git a/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts b/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts index 41bbf67edb..12e9bf202b 100644 --- a/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts +++ b/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts @@ -76,43 +76,69 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { // Slash-related constants for E2E tests const REASON_BAD_PROOF = ethers.keccak256(ethers.toUtf8Bytes("E3_BAD_PROOF")); - const PROOF_PAYLOAD_TYPEHASH = ethers.keccak256( + const VOTE_TYPEHASH = ethers.keccak256( ethers.toUtf8Bytes( - "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)", ), ); /** - * Helper to create a signed proof evidence bundle for proposeSlash. + * Helper to create a committee-attestation evidence bundle for proposeSlash. + * Voters sign an AccusationVote digest via personal_sign (EIP-191). */ - async function signAndEncodeProof( - signer: Signer, + async function signAndEncodeAttestation( + voters: Signer[], e3Id: number, - verifierAddress: string, - zkProof: string = "0x1234", - publicInputs: string[] = [ethers.ZeroHash], - chainId: number = 31337, + operator: string, proofType: number = 0, + chainId: number = 31337, + dataHash: string = ethers.ZeroHash, ): Promise { - const messageHash = ethers.keccak256( - abiCoder.encode( - ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32"], - [ - PROOF_PAYLOAD_TYPEHASH, - chainId, - e3Id, - proofType, - ethers.keccak256(zkProof), - ethers.keccak256( - ethers.solidityPacked(["bytes32[]"], [publicInputs]), - ), - ], + const accusationId = ethers.keccak256( + ethers.solidityPacked( + ["uint256", "uint256", "address", "uint256"], + [chainId, e3Id, operator, proofType], ), ); - const signature = await signer.signMessage(ethers.getBytes(messageHash)); + + // Sort voters by address ascending + const voterData = await Promise.all( + voters.map(async (v) => ({ signer: v, address: await v.getAddress() })), + ); + voterData.sort((a, b) => + a.address.toLowerCase().localeCompare(b.address.toLowerCase()), + ); + + const sortedAddresses: string[] = []; + const agrees: boolean[] = []; + const dataHashes: string[] = []; + const signatures: string[] = []; + + for (const { signer, address } of voterData) { + const digest = ethers.keccak256( + abiCoder.encode( + [ + "bytes32", + "uint256", + "uint256", + "bytes32", + "address", + "bool", + "bytes32", + ], + [VOTE_TYPEHASH, chainId, e3Id, accusationId, address, true, dataHash], + ), + ); + const sig = await signer.signMessage(ethers.getBytes(digest)); + sortedAddresses.push(address); + agrees.push(true); + dataHashes.push(dataHash); + signatures.push(sig); + } + return abiCoder.encode( - ["bytes", "bytes32[]", "bytes", "uint256", "uint256", "address"], - [zkProof, publicInputs, signature, chainId, proofType, verifierAddress], + ["uint256", "address[]", "bool[]", "bytes32[]", "bytes[]"], + [proofType, sortedAddresses, agrees, dataHashes, signatures], ); } @@ -304,7 +330,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: await circuitVerifier.getAddress(), + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -762,10 +788,10 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { // 4. Slash operator1 via proposeSlash (Lane A) — real on-chain flow // This triggers: _executeSlash → slashTicketBalance → redirectSlashedTicketFunds // → ticketToken.payout(refundManager, amount) → enclave.escrowSlashedFunds → e3RefundManager.escrowSlashedFunds - const proof = await signAndEncodeProof( - operator1, + const proof = await signAndEncodeAttestation( + [operator1, operator2], 0, - await circuitVerifier.getAddress(), + await operator1.getAddress(), ); await slashingManager.proposeSlash( @@ -854,10 +880,10 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const honestNodeAmountBefore = distributionBefore.honestNodeAmount; // 4. Slash operator1 — this routes funds into the refund pool - const proof = await signAndEncodeProof( - operator1, + const proof = await signAndEncodeAttestation( + [operator1, operator2], 0, - await circuitVerifier.getAddress(), + await operator1.getAddress(), ); await slashingManager.proposeSlash( 0, @@ -1400,10 +1426,10 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const refundBalanceBefore = await usdcToken.balanceOf(refundManagerAddress); - const proof = await signAndEncodeProof( - operator1, + const proof = await signAndEncodeAttestation( + [operator1, operator2], 0, - await circuitVerifier.getAddress(), + await operator1.getAddress(), ); await slashingManager.proposeSlash( 0, diff --git a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts index 6d6fa8f895..afe7deeffc 100644 --- a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts +++ b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts @@ -79,46 +79,86 @@ describe("Committee Expulsion & Fault Tolerance", function () { decryptionWindow: ONE_DAY, }; - // Must match the PROOF_PAYLOAD_TYPEHASH in SlashingManager.sol - const PROOF_PAYLOAD_TYPEHASH = ethers.keccak256( + // Must match the VOTE_TYPEHASH in SlashingManager.sol + const VOTE_TYPEHASH = ethers.keccak256( ethers.toUtf8Bytes( - "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)", ), ); /** - * Helper to create a signed proof evidence bundle. - * The operator signs the proof payload (matching SlashingManager._verifyProofEvidence), - * then the evidence is encoded in the 6-field format expected by proposeSlash(). + * Helper to create signed committee attestation evidence for Lane A. + * Voters (other committee members) sign votes confirming the accused is faulty. + * Returns abi.encode(proofType, voters, agrees, dataHashes, signatures) */ - async function signAndEncodeProof( - signer: Signer, + async function signAndEncodeAttestation( + voterSigners: Signer[], e3Id: number, - verifierAddress: string, - zkProof: string = "0x1234", - publicInputs: string[] = [ethers.ZeroHash], - chainId: number = 31337, + operator: string, proofType: number = 0, + chainId: number = 31337, + dataHash: string = ethers.ZeroHash, ): Promise { - const messageHash = ethers.keccak256( - abiCoder.encode( - ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32"], - [ - PROOF_PAYLOAD_TYPEHASH, - chainId, - e3Id, - proofType, - ethers.keccak256(zkProof), - ethers.keccak256( - ethers.solidityPacked(["bytes32[]"], [publicInputs]), - ), - ], + const accusationId = ethers.keccak256( + ethers.solidityPacked( + ["uint256", "uint256", "address", "uint256"], + [chainId, e3Id, operator, proofType], ), ); - const signature = await signer.signMessage(ethers.getBytes(messageHash)); + + const signersWithAddrs = await Promise.all( + voterSigners.map(async (s) => ({ + signer: s, + address: await s.getAddress(), + })), + ); + signersWithAddrs.sort((a, b) => + a.address.toLowerCase() < b.address.toLowerCase() + ? -1 + : a.address.toLowerCase() > b.address.toLowerCase() + ? 1 + : 0, + ); + + const voters: string[] = []; + const agrees: boolean[] = []; + const dataHashes: string[] = []; + const signatures: string[] = []; + + for (const { signer, address: voterAddress } of signersWithAddrs) { + voters.push(voterAddress); + agrees.push(true); + dataHashes.push(dataHash); + + const messageHash = ethers.keccak256( + abiCoder.encode( + [ + "bytes32", + "uint256", + "uint256", + "bytes32", + "address", + "bool", + "bytes32", + ], + [ + VOTE_TYPEHASH, + chainId, + e3Id, + accusationId, + voterAddress, + true, + dataHash, + ], + ), + ); + const signature = await signer.signMessage(ethers.getBytes(messageHash)); + signatures.push(signature); + } + return abiCoder.encode( - ["bytes", "bytes32[]", "bytes", "uint256", "uint256", "address"], - [zkProof, publicInputs, signature, chainId, proofType, verifierAddress], + ["uint256", "address[]", "bool[]", "bytes32[]", "bytes[]"], + [proofType, voters, agrees, dataHashes, signatures], ); } const setup = async () => { @@ -300,7 +340,7 @@ describe("Committee Expulsion & Fault Tolerance", function () { ticketPenalty: ethers.parseUnits("10", 6), licensePenalty: ethers.parseEther("50"), requiresProof: true, - proofVerifier: await mockVerifier.getAddress(), + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -408,7 +448,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { const { registry, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -435,12 +474,11 @@ describe("Committee Expulsion & Fault Tolerance", function () { expect(await registry.isCommitteeMemberActive(0, op1Address)).to.be.true; expect((await registry.getCommitteeViability(0)).activeCount).to.equal(3); - // Submit slash proposal — MockCircuitVerifier returns false by default - // so fault is confirmed and slash is auto-executed - const proof = await signAndEncodeProof( - operator1, + // Committee members attest that operator1 is faulty + const proof = await signAndEncodeAttestation( + [operator2, operator3], 0, - await mockVerifier.getAddress(), + op1Address, ); const tx = await slashingManager.proposeSlash( 0, @@ -469,7 +507,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { enclave, registry, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -490,10 +527,10 @@ describe("Committee Expulsion & Fault Tolerance", function () { ]); // Slash one member — 3 active → 2 active, threshold is 2, still viable - const proof = await signAndEncodeProof( - operator1, + const proof = await signAndEncodeAttestation( + [operator2, operator3], 0, - await mockVerifier.getAddress(), + await operator1.getAddress(), ); await slashingManager.proposeSlash( 0, @@ -518,7 +555,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { const { enclave, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -539,11 +575,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { ]); // Slash first member — 3 → 2 active, still >= 2 - const proof1 = await signAndEncodeProof( - operator1, + const proof1 = await signAndEncodeAttestation( + [operator2, operator3], 0, - await mockVerifier.getAddress(), - "0x1111", + await operator1.getAddress(), + 0, + 31337, + ethers.keccak256(ethers.toUtf8Bytes("data1")), ); await slashingManager.proposeSlash( 0, @@ -556,11 +594,14 @@ describe("Committee Expulsion & Fault Tolerance", function () { expect(stage).to.not.equal(6); // Not failed yet // Slash second member — 2 → 1 active, below threshold M=2 - const proof2 = await signAndEncodeProof( - operator2, + // Use operator1 and operator3 as voters (operator1's vote still valid per isCommitteeMember) + const proof2 = await signAndEncodeAttestation( + [operator1, operator3], 0, - await mockVerifier.getAddress(), - "0x2222", + await operator2.getAddress(), + 0, + 31337, + ethers.keccak256(ethers.toUtf8Bytes("data2")), ); const tx = await slashingManager.proposeSlash( 0, @@ -585,7 +626,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { const { registry, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -606,11 +646,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { ]); // Slash operator1 once - const proof1 = await signAndEncodeProof( - operator1, + const proof1 = await signAndEncodeAttestation( + [operator2, operator3], + 0, + await operator1.getAddress(), 0, - await mockVerifier.getAddress(), - "0xaaaa", + 31337, + ethers.keccak256(ethers.toUtf8Bytes("first")), ); await slashingManager.proposeSlash( 0, @@ -620,12 +662,14 @@ describe("Committee Expulsion & Fault Tolerance", function () { ); expect((await registry.getCommitteeViability(0)).activeCount).to.equal(2); - // Slash operator1 again with different proof (different evidence key) - const proof2 = await signAndEncodeProof( - operator1, + // Slash operator1 again with different evidence (different dataHash) + const proof2 = await signAndEncodeAttestation( + [operator2, operator3], + 0, + await operator1.getAddress(), 0, - await mockVerifier.getAddress(), - "0xbbbb", + 31337, + ethers.keccak256(ethers.toUtf8Bytes("second")), ); await slashingManager.proposeSlash( 0, @@ -642,7 +686,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { const { registry, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -668,10 +711,10 @@ describe("Committee Expulsion & Fault Tolerance", function () { expect(nodesBefore).to.include(await operator1.getAddress()); // Expel operator1 - const proof = await signAndEncodeProof( - operator1, + const proof = await signAndEncodeAttestation( + [operator2, operator3], 0, - await mockVerifier.getAddress(), + await operator1.getAddress(), ); await slashingManager.proposeSlash( 0, @@ -695,7 +738,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { enclave, registry, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -721,11 +763,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { expect((await registry.getCommitteeViability(0)).activeCount).to.equal(4); // Expel 2 out of 4 — still have 2 >= M=2 - const proof1 = await signAndEncodeProof( - operator1, + const proof1 = await signAndEncodeAttestation( + [operator2, operator3], 0, - await mockVerifier.getAddress(), - "0x1111", + await operator1.getAddress(), + 0, + 31337, + ethers.keccak256(ethers.toUtf8Bytes("expel1")), ); await slashingManager.proposeSlash( 0, @@ -735,11 +779,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { ); expect((await registry.getCommitteeViability(0)).activeCount).to.equal(3); - const proof2 = await signAndEncodeProof( - operator2, + const proof2 = await signAndEncodeAttestation( + [operator3, operator4], 0, - await mockVerifier.getAddress(), - "0x2222", + await operator2.getAddress(), + 0, + 31337, + ethers.keccak256(ethers.toUtf8Bytes("expel2")), ); await slashingManager.proposeSlash( 0, @@ -761,7 +807,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { enclave, registry, slashingManager, - mockVerifier, operator1, operator2, setupOperator, @@ -776,10 +821,11 @@ describe("Committee Expulsion & Fault Tolerance", function () { await finalizeCommitteeWithOperators(0, [operator1, operator2]); // Expel one member: 2 → 1 < M=2 → E3 fails immediately - const proof = await signAndEncodeProof( - operator1, + // Both committee members vote (including the accused — contract allows it) + const proof = await signAndEncodeAttestation( + [operator1, operator2], 0, - await mockVerifier.getAddress(), + await operator1.getAddress(), ); const tx = await slashingManager.proposeSlash( 0, @@ -803,7 +849,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { const { enclave, slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -824,11 +869,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { ]); // Expel operator1 — still viable (2 >= 2) - const proof1 = await signAndEncodeProof( - operator1, + const proof1 = await signAndEncodeAttestation( + [operator2, operator3], + 0, + await operator1.getAddress(), 0, - await mockVerifier.getAddress(), - "0x1111", + 31337, + ethers.keccak256(ethers.toUtf8Bytes("expel-op1")), ); await slashingManager.proposeSlash( 0, @@ -838,11 +885,14 @@ describe("Committee Expulsion & Fault Tolerance", function () { ); // Expel operator2 — now below threshold (1 < 2), E3 fails - const proof2 = await signAndEncodeProof( - operator2, + // operator1 is expelled but still isCommitteeMember, so can vote + const proof2 = await signAndEncodeAttestation( + [operator1, operator3], 0, - await mockVerifier.getAddress(), - "0x2222", + await operator2.getAddress(), + 0, + 31337, + ethers.keccak256(ethers.toUtf8Bytes("expel-op2")), ); await slashingManager.proposeSlash( 0, @@ -857,11 +907,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { // Try to expel operator3 — E3 already failed, but onE3Failed is wrapped // in try-catch so financial penalties are still applied - const proof3 = await signAndEncodeProof( - operator3, + const proof3 = await signAndEncodeAttestation( + [operator1, operator2], + 0, + await operator3.getAddress(), 0, - await mockVerifier.getAddress(), - "0x3333", + 31337, + ethers.keccak256(ethers.toUtf8Bytes("expel-op3")), ); // The third slash should succeed — penalties are applied even though E3 is already Failed. @@ -885,7 +937,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { it("should emit SlashExecuted on proof-based committee slash", async function () { const { slashingManager, - mockVerifier, operator1, operator2, operator3, @@ -905,10 +956,10 @@ describe("Committee Expulsion & Fault Tolerance", function () { operator3, ]); - const proof = await signAndEncodeProof( - operator1, + const proof = await signAndEncodeAttestation( + [operator2, operator3], 0, - await mockVerifier.getAddress(), + await operator1.getAddress(), ); const op1Addr = await operator1.getAddress(); const tx = await slashingManager.proposeSlash( diff --git a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts index c7ae3e9051..136c820221 100644 --- a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts +++ b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts @@ -46,77 +46,118 @@ describe("SlashingManager", function () { const abiCoder = ethers.AbiCoder.defaultAbiCoder(); - // Must match the PROOF_PAYLOAD_TYPEHASH in SlashingManager.sol - const PROOF_PAYLOAD_TYPEHASH = ethers.keccak256( + // Must match the VOTE_TYPEHASH in SlashingManager.sol + const VOTE_TYPEHASH = ethers.keccak256( ethers.toUtf8Bytes( - "ProofPayload(uint256 chainId,uint256 e3Id,uint256 proofType,bytes zkProof,bytes publicSignals)", + "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)", ), ); /** - * Helper to create a signed proof evidence bundle. - * The operator signs the proof payload (matching Rust ProofPayload.digest()), - * then the evidence is encoded in the format expected by proposeSlash(). - * Returns abi.encode(zkProof, publicInputs, signature, chainId, proofType, verifier) + * Helper to create signed committee attestation evidence for Lane A. + * Each voter signs a VOTE_TYPEHASH-structured digest via personal_sign (EIP-191). + * Returns abi.encode(proofType, voters, agrees, dataHashes, signatures) + * with voters sorted ascending by address. */ - async function signAndEncodeProof( - signer: any, + async function signAndEncodeAttestation( + voterSigners: any[], e3Id: number, - reason: string, - verifierAddress: string, - zkProof: string = "0x1234", - publicInputs: string[] = [ethers.ZeroHash], - chainId: number = 31337, // Hardhat default chain ID - proofType: number = 0, // T0PkBfv + operator: string, + proofType: number = 0, + chainId: number = 31337, + dataHash: string = ethers.ZeroHash, + agreesOverride?: boolean[], ): Promise { - // Operator signs: keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, chainId, e3Id, proofType, keccak256(zkProof), keccak256(publicSignals))) - const messageHash = ethers.keccak256( - abiCoder.encode( - ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32"], - [ - PROOF_PAYLOAD_TYPEHASH, - chainId, - e3Id, - proofType, - ethers.keccak256(zkProof), - ethers.keccak256( - ethers.solidityPacked(["bytes32[]"], [publicInputs]), - ), - ], + // Compute accusationId matching AccusationManager::accusation_id() on Rust side + const accusationId = ethers.keccak256( + ethers.solidityPacked( + ["uint256", "uint256", "address", "uint256"], + [chainId, e3Id, operator, proofType], ), ); - const signature = await signer.signMessage(ethers.getBytes(messageHash)); - // Evidence format: abi.encode(zkProof, publicInputs, signature, chainId, proofType, verifier) + + // Sort voters by address ascending (required by contract to prevent duplicates) + const signersWithAddrs = await Promise.all( + voterSigners.map(async (s) => ({ + signer: s, + address: await s.getAddress(), + })), + ); + signersWithAddrs.sort((a, b) => + a.address.toLowerCase() < b.address.toLowerCase() + ? -1 + : a.address.toLowerCase() > b.address.toLowerCase() + ? 1 + : 0, + ); + + const voters: string[] = []; + const agrees: boolean[] = []; + const dataHashes: string[] = []; + const signatures: string[] = []; + + for (let i = 0; i < signersWithAddrs.length; i++) { + const { signer, address: voterAddress } = signersWithAddrs[i]; + const voteAgrees = + agreesOverride !== undefined ? agreesOverride[i] : true; + + voters.push(voterAddress); + agrees.push(voteAgrees); + dataHashes.push(dataHash); + + // Reconstruct vote digest matching _verifyAttestationEvidence + const messageHash = ethers.keccak256( + abiCoder.encode( + [ + "bytes32", + "uint256", + "uint256", + "bytes32", + "address", + "bool", + "bytes32", + ], + [ + VOTE_TYPEHASH, + chainId, + e3Id, + accusationId, + voterAddress, + voteAgrees, + dataHash, + ], + ), + ); + const signature = await signer.signMessage(ethers.getBytes(messageHash)); + signatures.push(signature); + } + return abiCoder.encode( - ["bytes", "bytes32[]", "bytes", "uint256", "uint256", "address"], - [zkProof, publicInputs, signature, chainId, proofType, verifierAddress], + ["uint256", "address[]", "bool[]", "bytes32[]", "bytes[]"], + [proofType, voters, agrees, dataHashes, signatures], ); } /** - * Legacy helper for tests that check early failures (before abi.decode). - * This encodes a minimal 6-tuple with dummy values for basic validation tests. + * Encodes a minimal attestation evidence for tests that check early + * failures (before abi.decode is reached). */ - function encodeDummyProof( - zkProof: string = "0x1234", - publicInputs: string[] = [ethers.ZeroHash], - verifierAddress: string = ethers.ZeroAddress, - ): string { + function encodeDummyAttestation(proofType: number = 0): string { return abiCoder.encode( - ["bytes", "bytes32[]", "bytes", "uint256", "uint256", "address"], - [zkProof, publicInputs, "0x00", 31337, 0, verifierAddress], + ["uint256", "address[]", "bool[]", "bytes32[]", "bytes[]"], + [proofType, [], [], [], []], ); } async function setupPolicies( slashingManager: SlashingManager, - mockVerifier: MockCircuitVerifier, + _mockVerifier?: MockCircuitVerifier, ) { const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: await mockVerifier.getAddress(), + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -140,7 +181,7 @@ describe("SlashingManager", function () { ticketPenalty: ethers.parseUnits("100", 6), licensePenalty: ethers.parseEther("500"), requiresProof: true, - proofVerifier: await mockVerifier.getAddress(), + proofVerifier: ethers.ZeroAddress, banNode: true, appealWindow: 0, enabled: true, @@ -155,8 +196,16 @@ describe("SlashingManager", function () { async function setup() { // ── Signers ──────────────────────────────────────────────────────────────── - const [owner, slasher, proposer, operator, notTheOwner] = - await ethers.getSigners(); + const [ + owner, + slasher, + proposer, + operator, + notTheOwner, + voter1, + voter2, + voter3, + ] = await ethers.getSigners(); const ownerAddress = await owner.getAddress(); const operatorAddress = await operator.getAddress(); @@ -281,6 +330,9 @@ describe("SlashingManager", function () { operator, operatorAddress, notTheOwner, + voter1, + voter2, + voter3, slashingManager, bondingRegistry, enclaveToken, @@ -466,7 +518,7 @@ describe("SlashingManager", function () { ).to.be.revertedWithCustomError(slashingManager, "InvalidPolicy"); }); - it("should revert if proof required but no verifier set", async function () { + it("should allow proof-based policy without verifier (attestation model)", async function () { const { slashingManager } = await loadFixture(setup); const policy = { @@ -481,9 +533,9 @@ describe("SlashingManager", function () { failureReason: 0, }; - await expect( - slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy), - ).to.be.revertedWithCustomError(slashingManager, "VerifierNotSet"); + await expect(slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy)) + .to.emit(slashingManager, "SlashPolicyUpdated") + .withArgs(REASON_MISBEHAVIOR, Object.values(policy)); }); it("should revert if proof required but appeal window set", async function () { @@ -571,23 +623,21 @@ describe("SlashingManager", function () { }); describe("proposeSlash() — Lane A (proof-based, permissionless)", function () { - it("should propose and auto-execute slash with signed proof from operator", async function () { + it("should propose and auto-execute slash with committee attestation", async function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - // MockCircuitVerifier default returnValue=false → proof invalid → fault confirmed - const verifierAddress = await mockVerifier.getAddress(); const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: verifierAddress, + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -596,19 +646,24 @@ describe("SlashingManager", function () { }; await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); - // Set up committee membership for operator + // Set up committee membership for voters (not the operator — voters attest the operator is faulty) const e3Id = 0; - await mockCiphernodeRegistry.setCommitteeNodes(e3Id, [operatorAddress]); - - // Operator signs the bad proof - const proof = await signAndEncodeProof( - operator, + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(e3Id, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(e3Id, 2); + + // Committee members sign attestation votes + const proof = await signAndEncodeAttestation( + [voter1, voter2], e3Id, - REASON_MISBEHAVIOR, - verifierAddress, + operatorAddress, ); - // Anyone can submit the signed evidence (permissionless for Lane A) + // Anyone can submit the signed attestation evidence (permissionless for Lane A) await expect( slashingManager .connect(proposer) @@ -624,22 +679,21 @@ describe("SlashingManager", function () { expect(proposal.proposer).to.equal(await proposer.getAddress()); }); - it("should revert if circuit verifier says proof is valid (no fault)", async function () { + it("should revert if committee attestation has insufficient votes", async function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - const verifierAddress = await mockVerifier.getAddress(); const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: verifierAddress, + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -648,39 +702,46 @@ describe("SlashingManager", function () { }; await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); - // Set mock verifier to return true → proof is valid → NOT a fault - await mockVerifier.setReturnValue(true); - - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); - - const proof = await signAndEncodeProof( - operator, + // Threshold is 2 but only 1 vote provided + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); + + const proof = await signAndEncodeAttestation( + [voter1], // only 1 voter, need 2 0, - REASON_MISBEHAVIOR, - verifierAddress, + operatorAddress, ); await expect( slashingManager .connect(proposer) .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), - ).to.be.revertedWithCustomError(slashingManager, "ProofIsValid"); + ).to.be.revertedWithCustomError( + slashingManager, + "InsufficientAttestations", + ); }); - it("should revert if signer is not the operator (V-001 fix)", async function () { + it("should revert if vote signature is invalid", async function () { const { slashingManager, proposer, operatorAddress, - mockVerifier, + voter1, + voter2, + notTheOwner, mockCiphernodeRegistry, } = await loadFixture(setup); - const verifierAddress = await mockVerifier.getAddress(); const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: verifierAddress, + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -689,37 +750,100 @@ describe("SlashingManager", function () { }; await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); + + // Build attestation manually with voter2's address but notTheOwner's signature + const chainId = 31337; + const accusationId = ethers.keccak256( + ethers.solidityPacked( + ["uint256", "uint256", "address", "uint256"], + [chainId, 0, operatorAddress, 0], + ), + ); - // Proposer signs the proof (NOT the operator) — should be rejected - const proof = await signAndEncodeProof( - proposer, - 0, - REASON_MISBEHAVIOR, - verifierAddress, + // Sort voters ascending + const sortedVoters = [voter1Addr, voter2Addr].sort((a, b) => + a.toLowerCase() < b.toLowerCase() ? -1 : 1, + ); + const sortedSigners = sortedVoters.map((addr) => + addr.toLowerCase() === voter1Addr.toLowerCase() ? voter1 : voter2, + ); + + const voters: string[] = []; + const agrees: boolean[] = []; + const dataHashes: string[] = []; + const signatures: string[] = []; + + for (let i = 0; i < sortedVoters.length; i++) { + const voterAddr = sortedVoters[i]; + voters.push(voterAddr); + agrees.push(true); + dataHashes.push(ethers.ZeroHash); + + // For the second voter, use notTheOwner to sign (wrong signer) + const signerToUse = + i === sortedVoters.length - 1 ? notTheOwner : sortedSigners[i]; + const messageHash = ethers.keccak256( + abiCoder.encode( + [ + "bytes32", + "uint256", + "uint256", + "bytes32", + "address", + "bool", + "bytes32", + ], + [ + VOTE_TYPEHASH, + chainId, + 0, + accusationId, + voterAddr, + true, + ethers.ZeroHash, + ], + ), + ); + const signature = await signerToUse.signMessage( + ethers.getBytes(messageHash), + ); + signatures.push(signature); + } + + const proof = abiCoder.encode( + ["uint256", "address[]", "bool[]", "bytes32[]", "bytes[]"], + [0, voters, agrees, dataHashes, signatures], ); + await expect( slashingManager .connect(proposer) .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), - ).to.be.revertedWithCustomError(slashingManager, "SignerIsNotOperator"); + ).to.be.revertedWithCustomError(slashingManager, "InvalidVoteSignature"); }); - it("should revert if operator is not in committee (V-001 fix)", async function () { + it("should revert if voter is not in committee", async function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, + mockCiphernodeRegistry, } = await loadFixture(setup); - const verifierAddress = await mockVerifier.getAddress(); const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: verifierAddress, + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -728,32 +852,29 @@ describe("SlashingManager", function () { }; await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); - // Do NOT add operator to committee — empty committee for this E3 + // Only voter1 is a committee member, but voter2 also signs + const voter1Addr = await voter1.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [voter1Addr]); + await mockCiphernodeRegistry.setThreshold(0, 1); - const proof = await signAndEncodeProof( - operator, + const proof = await signAndEncodeAttestation( + [voter1, voter2], // voter2 is NOT in committee 0, - REASON_MISBEHAVIOR, - verifierAddress, + operatorAddress, ); await expect( slashingManager .connect(proposer) .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), - ).to.be.revertedWithCustomError( - slashingManager, - "OperatorNotInCommittee", - ); + ).to.be.revertedWithCustomError(slashingManager, "VoterNotInCommittee"); }); it("should revert if operator is zero address", async function () { - const { slashingManager, proposer, mockVerifier } = - await loadFixture(setup); + const { slashingManager, proposer } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); - // Any non-empty proof triggers ZeroAddress check before decode - const proof = encodeDummyProof(); + const proof = encodeDummyAttestation(); await expect( slashingManager @@ -766,7 +887,7 @@ describe("SlashingManager", function () { const { slashingManager, proposer, operatorAddress } = await loadFixture(setup); - const proof = encodeDummyProof(); + const proof = encodeDummyAttestation(); await expect( slashingManager @@ -776,14 +897,14 @@ describe("SlashingManager", function () { }); it("should revert if proof is empty", async function () { - const { slashingManager, proposer, operatorAddress, mockVerifier } = + const { slashingManager, proposer, operatorAddress } = await loadFixture(setup); const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: await mockVerifier.getAddress(), + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -803,18 +924,17 @@ describe("SlashingManager", function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - const verifierAddress = await mockVerifier.getAddress(); const proofPolicy = { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, - proofVerifier: verifierAddress, + proofVerifier: ethers.ZeroAddress, banNode: false, appealWindow: 0, enabled: true, @@ -822,13 +942,18 @@ describe("SlashingManager", function () { failureReason: 0, }; await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); - - const proof = await signAndEncodeProof( - operator, + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); + + const proof = await signAndEncodeAttestation( + [voter1, voter2], 0, - REASON_MISBEHAVIOR, - verifierAddress, + operatorAddress, ); await slashingManager .connect(proposer) @@ -846,26 +971,33 @@ describe("SlashingManager", function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); - - const verifierAddress = await mockVerifier.getAddress(); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); - await mockCiphernodeRegistry.setCommitteeNodes(1, [operatorAddress]); + await setupPolicies(slashingManager); + + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); + await mockCiphernodeRegistry.setCommitteeNodes(1, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(1, 2); expect(await slashingManager.totalProposals()).to.equal(0); - const proof1 = await signAndEncodeProof( - operator, + const proof1 = await signAndEncodeAttestation( + [voter1, voter2], 0, - REASON_MISBEHAVIOR, - verifierAddress, - "0x1111", + operatorAddress, ); await slashingManager .connect(proposer) @@ -873,12 +1005,10 @@ describe("SlashingManager", function () { expect(await slashingManager.totalProposals()).to.equal(1); - const proof2 = await signAndEncodeProof( - operator, + const proof2 = await signAndEncodeAttestation( + [voter1, voter2], 1, - REASON_MISBEHAVIOR, - verifierAddress, - "0x2222", + operatorAddress, ); await slashingManager .connect(proposer) @@ -891,25 +1021,28 @@ describe("SlashingManager", function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); - const verifierAddress = await mockVerifier.getAddress(); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); expect(await slashingManager.isBanned(operatorAddress)).to.be.false; - const proof = await signAndEncodeProof( - operator, + const proof = await signAndEncodeAttestation( + [voter1, voter2], 0, - REASON_DOUBLE_SIGN, - verifierAddress, - "0x3333", + operatorAddress, ); await slashingManager .connect(proposer) @@ -925,7 +1058,7 @@ describe("SlashingManager", function () { const { slashingManager, slasher, operatorAddress, mockVerifier } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); const evidence = ethers.toUtf8Bytes("operator was inactive during E3"); const e3Id = 0; @@ -954,7 +1087,7 @@ describe("SlashingManager", function () { const { slashingManager, notTheOwner, operatorAddress, mockVerifier } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); const evidence = ethers.toUtf8Bytes("evidence"); @@ -974,7 +1107,7 @@ describe("SlashingManager", function () { const { slashingManager, slasher, mockVerifier } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await expect( slashingManager @@ -994,7 +1127,7 @@ describe("SlashingManager", function () { const { slashingManager, slasher, operatorAddress, mockVerifier } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1026,22 +1159,26 @@ describe("SlashingManager", function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); - const verifierAddress = await mockVerifier.getAddress(); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); + await setupPolicies(slashingManager); + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); // Proof-based slash auto-executes in proposeSlash - const proof = await signAndEncodeProof( - operator, + const proof = await signAndEncodeAttestation( + [voter1, voter2], 0, - REASON_MISBEHAVIOR, - verifierAddress, + operatorAddress, ); await slashingManager .connect(proposer) @@ -1065,7 +1202,7 @@ describe("SlashingManager", function () { const { slashingManager, slasher, operatorAddress, mockVerifier } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1095,7 +1232,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1125,7 +1262,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1150,7 +1287,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1177,7 +1314,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1201,20 +1338,25 @@ describe("SlashingManager", function () { proposer, operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); - const verifierAddress = await mockVerifier.getAddress(); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); + await setupPolicies(slashingManager); + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); // Proof-based slash auto-executes with proofVerified=true - const proof = await signAndEncodeProof( - operator, + const proof = await signAndEncodeAttestation( + [voter1, voter2], 0, - REASON_MISBEHAVIOR, - verifierAddress, + operatorAddress, ); await slashingManager .connect(proposer) @@ -1236,7 +1378,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1277,7 +1419,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1307,7 +1449,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1336,7 +1478,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1366,7 +1508,7 @@ describe("SlashingManager", function () { mockVerifier, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); + await setupPolicies(slashingManager); await slashingManager .connect(slasher) @@ -1490,22 +1632,25 @@ describe("SlashingManager", function () { const { slashingManager, proposer, - operator, operatorAddress, - mockVerifier, + voter1, + voter2, mockCiphernodeRegistry, } = await loadFixture(setup); - await setupPolicies(slashingManager, mockVerifier); - const verifierAddress = await mockVerifier.getAddress(); - await mockCiphernodeRegistry.setCommitteeNodes(0, [operatorAddress]); - - const proof = await signAndEncodeProof( - operator, + await setupPolicies(slashingManager); + const voter1Addr = await voter1.getAddress(); + const voter2Addr = await voter2.getAddress(); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + voter1Addr, + voter2Addr, + ]); + await mockCiphernodeRegistry.setThreshold(0, 2); + + const proof = await signAndEncodeAttestation( + [voter1, voter2], 0, - REASON_MISBEHAVIOR, - verifierAddress, - "0x4444", + operatorAddress, ); await slashingManager .connect(proposer) From 1b588365b4faef19c178d066f26f7c92a9e1ec65 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 19:02:37 +0500 Subject: [PATCH 07/21] fix: lint errors --- crates/tests/tests/integration.rs | 11 +++++++++-- .../contracts/registry/CiphernodeRegistryOwnable.sol | 4 ++-- .../contracts/slashing/SlashingManager.sol | 6 +++++- .../contracts/test/MockCiphernodeRegistry.sol | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 53606ffda3..3571caa29a 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -666,6 +666,7 @@ async fn test_trbfv_actor() -> Result<()> { // - ShareVerificationDispatched (C1 proof verification dispatched by PublicKeyAggregator) // - ComputeRequest (C1 ZK verification) // - ComputeResponse (C1 ZK verification result) + // - ProofVerificationPassed × 5 (one per party's C1 proof) // - ShareVerificationComplete (C1 verification done) // - PkAggregationProofPending (C5 proof requested by PublicKeyAggregator) // - ComputeRequest (C5 proof generation) @@ -682,6 +683,11 @@ async fn test_trbfv_actor() -> Result<()> { "ShareVerificationDispatched", "ComputeRequest", "ComputeResponse", + "ProofVerificationPassed", + "ProofVerificationPassed", + "ProofVerificationPassed", + "ProofVerificationPassed", + "ProofVerificationPassed", "ShareVerificationComplete", "PkAggregationProofPending", "ComputeRequest", @@ -766,6 +772,7 @@ async fn test_trbfv_actor() -> Result<()> { // - 1 ShareVerificationDispatched (C6 verification dispatched by ThresholdPlaintextAggregator) // - 1 ComputeRequest (C6 ZK verification) // - 1 ComputeResponse (C6 ZK verification result) + // - 5 ProofVerificationPassed (one per party's C6 proof) // - 1 ShareVerificationComplete (C6 verification done) // - 1 ComputeRequest (TrBFV CalculateThresholdDecryption) // - 1 ComputeResponse (TrBFV CalculateThresholdDecryption) @@ -776,8 +783,8 @@ async fn test_trbfv_actor() -> Result<()> { // - 1 PlaintextAggregated (with C7 proofs) // Internal events from committee nodes (ComputeRequest/Response for CalculateDecryptionShare) // stay on their local buses. - // Total: 1 + 5 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 1 = 17 events - let expected_count = 1 + 5 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 1; + // Total: 1 + 5 + 1 + 2 + 5 + 1 + 2 + 1 + 2 + 1 + 1 = 22 events + let expected_count = 1 + 5 + 1 + 2 + 5 + 1 + 2 + 1 + 2 + 1 + 1; let h = nodes .take_history_with_timeout(0, expected_count, Duration::from_secs(1000)) diff --git a/packages/enclave-contracts/contracts/registry/CiphernodeRegistryOwnable.sol b/packages/enclave-contracts/contracts/registry/CiphernodeRegistryOwnable.sol index 365b97ed0e..0903f0e7fc 100644 --- a/packages/enclave-contracts/contracts/registry/CiphernodeRegistryOwnable.sol +++ b/packages/enclave-contracts/contracts/registry/CiphernodeRegistryOwnable.sol @@ -62,10 +62,10 @@ contract CiphernodeRegistryOwnable is ICiphernodeRegistry, OwnableUpgradeable { LazyIMTData public ciphernodes; /// @notice Tracks whether a ciphernode is enabled in the registry - mapping(address => bool) public ciphernodeEnabled; + mapping(address node => bool enabled) public ciphernodeEnabled; /// @notice Tracks the tree leaf index for each ciphernode - mapping(address => uint40) public ciphernodeTreeIndex; + mapping(address node => uint40 index) public ciphernodeTreeIndex; /// @notice Maps E3 ID to the IMT root at the time of committee request mapping(uint256 e3Id => uint256 root) public roots; diff --git a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol index 583a2ae6c0..e2ddec6293 100644 --- a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol +++ b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol @@ -88,7 +88,9 @@ contract SlashingManager is ISlashingManager, AccessControl { /// Includes chainId to prevent cross-chain replay and dataHash for equivocation detection. bytes32 public constant VOTE_TYPEHASH = keccak256( - "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)" + "AccusationVote(uint256 chainId,uint256 e3Id," + "bytes32 accusationId,address voter," + "bool agrees,bytes32 dataHash)" ); // ====================== @@ -486,6 +488,7 @@ contract SlashingManager is ISlashingManager, AccessControl { // If active count drops below M, fail the E3 if (activeCount < thresholdM && p.failureReason > 0) { // NOTE: catch block must not be empty (solc optimizer bug, see below) + // solhint-disable-next-line no-empty-blocks try enclave.onE3Failed(p.e3Id, p.failureReason) { // Side effects occur in the external call } catch { @@ -499,6 +502,7 @@ contract SlashingManager is ISlashingManager, AccessControl { // Self-call for try/catch atomicity — on failure, funds stay in BondingRegistry. if (actualTicketSlashed > 0) { // NOTE: catch must not be empty — solc >=0.8.28 optimizer bug. + // solhint-disable-next-line no-empty-blocks try this.escrowSlashedFundsToRefund(p.e3Id, actualTicketSlashed) {} catch { diff --git a/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol b/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol index a34d493db9..1901d54855 100644 --- a/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol +++ b/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol @@ -14,7 +14,7 @@ contract MockCiphernodeRegistry is ICiphernodeRegistry { mapping(uint256 e3Id => address[] nodes) private _committeeNodes; /// @notice Configurable threshold M per E3 for testing - mapping(uint256 e3Id => uint32) private _thresholdM; + mapping(uint256 e3Id => uint32 threshold) private _thresholdM; /// @notice Set committee members for an E3 (test helper) function setCommitteeNodes( From a8c322947634dbcb9312ced384ac7e528f142e29 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 21:54:14 +0500 Subject: [PATCH 08/21] fix: lint errors --- crates/tests/tests/integration.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 3571caa29a..4718d3258a 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -772,7 +772,7 @@ async fn test_trbfv_actor() -> Result<()> { // - 1 ShareVerificationDispatched (C6 verification dispatched by ThresholdPlaintextAggregator) // - 1 ComputeRequest (C6 ZK verification) // - 1 ComputeResponse (C6 ZK verification result) - // - 5 ProofVerificationPassed (one per party's C6 proof) + // - 15 ProofVerificationPassed (5 parties × 3 C6 proofs per ciphertext) // - 1 ShareVerificationComplete (C6 verification done) // - 1 ComputeRequest (TrBFV CalculateThresholdDecryption) // - 1 ComputeResponse (TrBFV CalculateThresholdDecryption) @@ -783,8 +783,8 @@ async fn test_trbfv_actor() -> Result<()> { // - 1 PlaintextAggregated (with C7 proofs) // Internal events from committee nodes (ComputeRequest/Response for CalculateDecryptionShare) // stay on their local buses. - // Total: 1 + 5 + 1 + 2 + 5 + 1 + 2 + 1 + 2 + 1 + 1 = 22 events - let expected_count = 1 + 5 + 1 + 2 + 5 + 1 + 2 + 1 + 2 + 1 + 1; + // Total: 1 + 5 + 1 + 2 + 15 + 1 + 2 + 1 + 2 + 1 + 1 = 32 events + let expected_count = 1 + 5 + 1 + 2 + 15 + 1 + 2 + 1 + 2 + 1 + 1; let h = nodes .take_history_with_timeout(0, expected_count, Duration::from_secs(1000)) From 34983fb2bca9e998513d1c2e272e953249a01b49 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 22:47:55 +0500 Subject: [PATCH 09/21] fix: lint errors --- .../src/ciphernode_builder.rs | 59 ++++++++++--------- .../events/src/enclave_event/signed_proof.rs | 8 ++- crates/evm/src/enclave_sol_writer.rs | 18 +++--- crates/evm/src/slashing_manager_sol_writer.rs | 16 ++--- .../src/decryption_key_shared_collector.rs | 22 +++++-- .../keyshare/src/encryption_key_collector.rs | 22 +++++-- .../keyshare/src/threshold_share_collector.rs | 24 ++++++-- crates/request/src/router.rs | 5 +- .../src/actors/accusation_manager.rs | 33 ++++++++--- .../src/actors/accusation_manager_ext.rs | 25 ++++---- .../src/actors/proof_verification.rs | 20 +++++-- .../src/actors/share_verification.rs | 13 ++-- .../test/Slashing/SlashingManager.spec.ts | 11 +++- 13 files changed, 182 insertions(+), 94 deletions(-) diff --git a/crates/ciphernode-builder/src/ciphernode_builder.rs b/crates/ciphernode-builder/src/ciphernode_builder.rs index 6290e5ed5d..fb29dc05e1 100644 --- a/crates/ciphernode-builder/src/ciphernode_builder.rs +++ b/crates/ciphernode-builder/src/ciphernode_builder.rs @@ -720,36 +720,41 @@ async fn setup_evm_system( } if contract_components.slashing_manager { - if let Some(contract) = &chain.contracts.slashing_manager { - // Reader: read SlashExecuted events from chain - let contract_addr = contract.address()?; - system.with_contract(contract_addr, move |next| { - SlashingManagerSolReader::setup(&next).recipient() - }); - - // Writer: submit proposeSlash transactions - match provider_cache.ensure_write_provider(&chain).await { - Ok(write_provider) => { - match SlashingManagerSolWriter::attach( - &bus, - write_provider.clone(), - contract_addr, - ) - .await - { - Ok(_) => { - info!("SlashingManagerSolWriter attached for fault submission"); - } - Err(e) => { - error!("Failed to attach SlashingManagerSolWriter, skipping: {}", e) - } + let contract = chain.contracts.slashing_manager.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Slashing manager is enabled but no contract address configured for chain {}", + chain.name + ) + })?; + + // Reader: read SlashExecuted events from chain + let contract_addr = contract.address()?; + system.with_contract(contract_addr, move |next| { + SlashingManagerSolReader::setup(&next).recipient() + }); + + // Writer: submit proposeSlash transactions + match provider_cache.ensure_write_provider(&chain).await { + Ok(write_provider) => { + match SlashingManagerSolWriter::attach( + &bus, + write_provider.clone(), + contract_addr, + ) + .await + { + Ok(_) => { + info!("SlashingManagerSolWriter attached for fault submission"); + } + Err(e) => { + error!("Failed to attach SlashingManagerSolWriter, skipping: {}", e) } } - Err(e) => error!( - "Failed to create write provider for SlashingManager, skipping: {}", - e - ), } + Err(e) => error!( + "Failed to create write provider for SlashingManager, skipping: {}", + e + ), } } diff --git a/crates/events/src/enclave_event/signed_proof.rs b/crates/events/src/enclave_event/signed_proof.rs index 0a2841bcf5..5a63f4178f 100644 --- a/crates/events/src/enclave_event/signed_proof.rs +++ b/crates/events/src/enclave_event/signed_proof.rs @@ -242,7 +242,13 @@ impl Display for SignedProofFailed { } /// Encode a [`SignedProofFailed`] event into the ABI-encoded evidence bytes -/// expected by `SlashingManager.proposeSlash()`. +/// expected by `SlashingManager.proposeSlash()` for **Lane B** (evidence-based, +/// SLASHER_ROLE) slashing. +/// +/// **Not used in production.** The current production flow uses Lane A +/// (attestation-based) via `encode_attestation_evidence()` in +/// `slashing_manager_sol_writer.rs`. This function is retained for Lane B +/// integration tests and may be activated when Lane B slashing is implemented. /// /// Returns: `abi.encode(bytes zkProof, bytes32[] publicInputs, bytes signature, uint256 chainId, uint256 proofType, address verifier)` /// diff --git a/crates/evm/src/enclave_sol_writer.rs b/crates/evm/src/enclave_sol_writer.rs index 21a5fd8a42..92ffae8639 100644 --- a/crates/evm/src/enclave_sol_writer.rs +++ b/crates/evm/src/enclave_sol_writer.rs @@ -26,7 +26,7 @@ use e3_events::{run_once, EnclaveEvent}; use e3_events::{E3id, EType, PlaintextAggregated}; use e3_utils::NotifySync; use e3_utils::MAILBOX_LIMIT; -use tracing::{info, warn}; +use tracing::info; sol!( #[sol(rpc)] @@ -114,14 +114,18 @@ impl Handler 1 { - warn!( - "E3 {} has {} decrypted outputs but only the first is published on-chain. \ - Multi-output support is not yet implemented.", - e3_id, - decrypted_output.len() + bus.err( + EType::Evm, + anyhow::anyhow!( + "E3 {} has {} decrypted outputs but only single-output is supported. \ + Refusing partial on-chain write.", + e3_id, + decrypted_output.len() + ), ); + return; } let result = publish_plaintext_output( provider, diff --git a/crates/evm/src/slashing_manager_sol_writer.rs b/crates/evm/src/slashing_manager_sol_writer.rs index 029dfcd0c6..75c514b097 100644 --- a/crates/evm/src/slashing_manager_sol_writer.rs +++ b/crates/evm/src/slashing_manager_sol_writer.rs @@ -169,22 +169,22 @@ async fn submit_slash_proposal( let proof_data = encode_attestation_evidence(&data); - let from_address = provider.provider().default_signer_address(); - let current_nonce = provider - .provider() - .get_transaction_count(from_address) - .pending() - .await?; - send_tx_with_retry("proposeSlash", &[], || { info!( "proposeSlash() e3_id={:?} operator={:?} reason={:?}", e3_id, operator, reason ); let proof = Bytes::from(proof_data.clone()); - let contract = ISlashingManager::new(contract_address, provider.provider()); + let provider = provider.clone(); async move { + let from_address = provider.provider().default_signer_address(); + let current_nonce = provider + .provider() + .get_transaction_count(from_address) + .pending() + .await?; + let contract = ISlashingManager::new(contract_address, provider.provider()); let builder = contract .proposeSlash(e3_id, operator, reason.into(), proof) .nonce(current_nonce); diff --git a/crates/keyshare/src/decryption_key_shared_collector.rs b/crates/keyshare/src/decryption_key_shared_collector.rs index adadc9617f..9148ba50a8 100644 --- a/crates/keyshare/src/decryption_key_shared_collector.rs +++ b/crates/keyshare/src/decryption_key_shared_collector.rs @@ -208,12 +208,22 @@ impl Handler for DecryptionKeyShare let party_id = msg.party_id; if !self.expected.remove(&party_id) { - info!( - e3_id = %self.e3_id, - party_id = party_id, - "Expelled party {} was not in expected set (already received or unknown)", - party_id - ); + // Party already delivered their share — remove from collected data + if self.shares.remove(&party_id).is_some() { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} already delivered decryption key share — removed from collected data", + party_id + ); + } else { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} was not in expected set and had no collected data", + party_id + ); + } return; } diff --git a/crates/keyshare/src/encryption_key_collector.rs b/crates/keyshare/src/encryption_key_collector.rs index bdfb86adc8..f110acdf58 100644 --- a/crates/keyshare/src/encryption_key_collector.rs +++ b/crates/keyshare/src/encryption_key_collector.rs @@ -226,12 +226,22 @@ impl Handler for EncryptionKeyCollector { // Remove expelled party from the todo set if !self.todo.remove(&party_id) { - info!( - e3_id = %self.e3_id, - party_id = party_id, - "Expelled party {} was not in todo set (already received or unknown)", - party_id - ); + // Party already delivered their key — remove it from collected keys + if self.keys.remove(&party_id).is_some() { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} already delivered key — removed from collected keys", + party_id + ); + } else { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} was not in todo set and had no collected key", + party_id + ); + } return; } diff --git a/crates/keyshare/src/threshold_share_collector.rs b/crates/keyshare/src/threshold_share_collector.rs index ffb6efe0e7..74c31df3f5 100644 --- a/crates/keyshare/src/threshold_share_collector.rs +++ b/crates/keyshare/src/threshold_share_collector.rs @@ -224,12 +224,24 @@ impl Handler for ThresholdShareCollector { // Remove expelled party from the todo set if !self.todo.remove(&party_id) { - info!( - e3_id = %self.e3_id, - party_id = party_id, - "Expelled party {} was not in share collection todo set (already received or unknown)", - party_id - ); + // Party already delivered their share — remove from collected data + let had_share = self.shares.remove(&party_id).is_some(); + let had_proofs = self.share_proofs.remove(&party_id).is_some(); + if had_share || had_proofs { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} already delivered share — removed from collected data", + party_id + ); + } else { + info!( + e3_id = %self.e3_id, + party_id = party_id, + "Expelled party {} was not in share collection todo set and had no collected data", + party_id + ); + } return; } diff --git a/crates/request/src/router.rs b/crates/request/src/router.rs index 93fea43614..d3148962eb 100644 --- a/crates/request/src/router.rs +++ b/crates/request/src/router.rs @@ -203,13 +203,16 @@ impl Handler for E3Router { self.bus.publish(event, ctx)?; } EnclaveEventData::E3StageChanged(ref data) - if matches!(data.new_stage, E3Stage::Complete | E3Stage::Failed) => + if matches!(data.new_stage, E3Stage::Complete) => { let event = E3RequestComplete { e3_id: e3_id.clone(), }; self.bus.publish(event, ctx)?; } + // NOTE: E3Stage::Failed does NOT trigger E3RequestComplete. + // Failed rounds need the accusation/slashing lifecycle to + // complete before the context is torn down. EnclaveEventData::E3RequestComplete(_) => { // Note this will be sent above to the children who can kill themselves based on // the event diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs index 3eb8c37b2b..598123d46a 100644 --- a/crates/zk-prover/src/actors/accusation_manager.rs +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -677,12 +677,27 @@ impl AccusationManager { let total_votes = agree_count + disagree_count; // CASE A: Majority says proof is bad → accused is at fault + // But first check for equivocation: if agreeing voters saw different data, + // the accused sent different payloads to different nodes. if agree_count >= self.threshold_m { - 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); + 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); + } 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); + } return; } @@ -820,13 +835,13 @@ impl AccusationManager { /// Compute a keccak256 hash of a SignedProofPayload for data_hash comparison. /// - /// `keccak256(abi.encodePacked(zkProof, publicSignals))` + /// `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_packed(); + .abi_encode(); keccak256(&msg).into() } @@ -844,6 +859,10 @@ impl AccusationManager { 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) } _ => { diff --git a/crates/zk-prover/src/actors/accusation_manager_ext.rs b/crates/zk-prover/src/actors/accusation_manager_ext.rs index 4d471e6983..3700a8df3d 100644 --- a/crates/zk-prover/src/actors/accusation_manager_ext.rs +++ b/crates/zk-prover/src/actors/accusation_manager_ext.rs @@ -17,7 +17,7 @@ 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}; +use tracing::{error, info}; pub struct AccusationManagerExtension { bus: BusHandle, @@ -49,20 +49,23 @@ impl E3Extension for AccusationManagerExtension { e3_id, committee, .. } = data.clone(); - // Parse committee addresses - let committee_addresses: Vec
= committee - .iter() - .filter_map(|s| match s.parse::
() { - Ok(addr) => Some(addr), + // 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) => { - warn!("Failed to parse committee address {}: {}", s, e); - None + error!( + "Failed to parse committee address {} — cannot start AccusationManager: {}", + s, e + ); + return; } - }) - .collect(); + } + } if committee_addresses.is_empty() { - error!("No valid committee addresses — cannot start AccusationManager"); + error!("No committee addresses — cannot start AccusationManager"); return; } diff --git a/crates/zk-prover/src/actors/proof_verification.rs b/crates/zk-prover/src/actors/proof_verification.rs index 5f6ffb447a..1b87fb9d63 100644 --- a/crates/zk-prover/src/actors/proof_verification.rs +++ b/crates/zk-prover/src/actors/proof_verification.rs @@ -207,6 +207,18 @@ impl Handler> for ProofVerificationActor { let pending = self.pending.remove(&pending_key); if msg.verified { + let Some(PendingVerification { + signed_payload, + recovered_signer, + }) = pending + else { + warn!( + "No pending verification for verified party {} — ignoring duplicate response", + msg.key.party_id + ); + return; + }; + info!( "C0 proof verified for party {} - accepting key", msg.key.party_id @@ -216,17 +228,13 @@ impl Handler> for ProofVerificationActor { self.publish_key_created(msg.e3_id, msg.key, ec.clone()); // Emit ProofVerificationPassed so AccusationManager can cache success - if let Some(PendingVerification { - signed_payload, - recovered_signer, - }) = pending { let data_hash: [u8; 32] = { let msg = ( Bytes::copy_from_slice(&signed_payload.payload.proof.data), Bytes::copy_from_slice(&signed_payload.payload.proof.public_signals), ) - .abi_encode_packed(); + .abi_encode(); keccak256(&msg).into() }; if let Err(err) = self.bus.publish( @@ -276,7 +284,7 @@ impl Handler> for ProofVerificationActor { Bytes::copy_from_slice(&signed_payload.payload.proof.data), Bytes::copy_from_slice(&signed_payload.payload.proof.public_signals), ) - .abi_encode_packed(); + .abi_encode(); keccak256(&msg).into() }; if let Err(err) = self.bus.publish( diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index 17f0718d82..75d3a89a30 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -188,7 +188,7 @@ impl ShareVerificationActor { Bytes::copy_from_slice(&signed.payload.proof.data), Bytes::copy_from_slice(&signed.payload.proof.public_signals), ) - .abi_encode_packed(); + .abi_encode(); (signed.payload.proof_type, keccak256(&msg).into()) }) .collect(); @@ -304,7 +304,7 @@ impl ShareVerificationActor { Bytes::copy_from_slice(&signed.payload.proof.data), Bytes::copy_from_slice(&signed.payload.proof.public_signals), ) - .abi_encode_packed(); + .abi_encode(); (signed.payload.proof_type, keccak256(&msg).into()) }) .collect(); @@ -541,8 +541,11 @@ impl ShareVerificationActor { None => match signed_payload.recover_address() { Ok(addr) => addr, Err(err) => { - warn!("Cannot attribute fault — signature recovery failed: {err}"); - return; + warn!( + "Signature recovery failed for party {} — using zero address for fault attribution: {err}", + party_id + ); + Address::ZERO } }, }; @@ -565,7 +568,7 @@ impl ShareVerificationActor { Bytes::copy_from_slice(&signed_payload.payload.proof.data), Bytes::copy_from_slice(&signed_payload.payload.proof.public_signals), ) - .abi_encode_packed(); + .abi_encode(); keccak256(&msg).into() }; if let Err(err) = self.bus.publish( diff --git a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts index 136c820221..4357b0d439 100644 --- a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts +++ b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts @@ -78,9 +78,10 @@ describe("SlashingManager", function () { // Sort voters by address ascending (required by contract to prevent duplicates) const signersWithAddrs = await Promise.all( - voterSigners.map(async (s) => ({ + voterSigners.map(async (s, idx) => ({ signer: s, address: await s.getAddress(), + originalIndex: idx, })), ); signersWithAddrs.sort((a, b) => @@ -97,9 +98,13 @@ describe("SlashingManager", function () { const signatures: string[] = []; for (let i = 0; i < signersWithAddrs.length; i++) { - const { signer, address: voterAddress } = signersWithAddrs[i]; + const { + signer, + address: voterAddress, + originalIndex, + } = signersWithAddrs[i]; const voteAgrees = - agreesOverride !== undefined ? agreesOverride[i] : true; + agreesOverride !== undefined ? agreesOverride[originalIndex] : true; voters.push(voterAddress); agrees.push(voteAgrees); From 56e3dfd6861beb3e435a6a380ba3856594357e34 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 23:52:33 +0500 Subject: [PATCH 10/21] fix: review comments --- .../src/keyshare_created_filter_buffer.rs | 18 ++- crates/aggregator/src/publickey_aggregator.rs | 103 ++++++++---------- crates/evm/src/slashing_manager_sol_reader.rs | 44 +++++++- crates/keyshare/src/threshold_keyshare.rs | 11 ++ .../contracts/test/MockCiphernodeRegistry.sol | 18 ++- 5 files changed, 127 insertions(+), 67 deletions(-) diff --git a/crates/aggregator/src/keyshare_created_filter_buffer.rs b/crates/aggregator/src/keyshare_created_filter_buffer.rs index 8171f1599c..283f5112b1 100644 --- a/crates/aggregator/src/keyshare_created_filter_buffer.rs +++ b/crates/aggregator/src/keyshare_created_filter_buffer.rs @@ -18,6 +18,9 @@ pub struct KeyshareCreatedFilterBuffer { dest: Addr, committee: Option>, buffer: Vec, + /// Nodes expelled before CommitteeFinalized arrived. + /// Tracked separately so they are filtered during `process_buffered_events()`. + expelled_before_finalization: HashSet, } impl KeyshareCreatedFilterBuffer { @@ -26,6 +29,7 @@ impl KeyshareCreatedFilterBuffer { dest, committee: None, buffer: Vec::new(), + expelled_before_finalization: HashSet::new(), } } @@ -33,7 +37,9 @@ impl KeyshareCreatedFilterBuffer { if let Some(ref committee) = self.committee { for event in self.buffer.drain(..) { if let EnclaveEventData::KeyshareCreated(data) = event.get_data() { - if committee.contains(&data.node) { + if committee.contains(&data.node) + && !self.expelled_before_finalization.contains(&data.node) + { self.dest.do_send(event); } } @@ -76,14 +82,22 @@ impl Handler for KeyshareCreatedFilterBuffer { return; } + let node_addr = data.node.to_string(); + // Remove expelled node so we don't forward late KeyshareCreated events from them if let Some(ref mut committee) = self.committee { - let node_addr = data.node.to_string(); info!( "KeyshareCreatedFilterBuffer: removing expelled node {} from committee filter (e3_id={})", node_addr, data.e3_id ); committee.remove(&node_addr); + } else { + // Committee not yet finalized — track for filtering during process_buffered_events + info!( + "KeyshareCreatedFilterBuffer: tracking expelled node {} before finalization (e3_id={})", + node_addr, data.e3_id + ); + self.expelled_before_finalization.insert(node_addr); } // Forward to PublicKeyAggregator for threshold_n adjustment self.dest.do_send(msg); diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index e72578a202..1c3ab7c872 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -33,11 +33,18 @@ pub enum PublicKeyAggregatorState { c1_proofs: Vec>, seed: Seed, nodes: OrderedSet, + /// Insertion-ordered (node, keyshare) pairs. + /// Index matches `c1_proofs`, giving the party ID for verification. + #[serde(default)] + submission_order: Vec<(String, ArcBytes)>, }, VerifyingC1 { - keyshares: OrderedSet, - nodes: OrderedSet, + /// Insertion-ordered (node, keyshare) pairs from Collecting. + /// Index matches `c1_proofs`, giving the party ID used in verification. + submission_order: Vec<(String, ArcBytes)>, threshold_m: usize, + /// C1 proofs in the same insertion order as `submission_order`. + c1_proofs: Vec>, /// Party indices that submitted no C1 proof — treated as dishonest. no_proof_parties: Vec, }, @@ -63,6 +70,7 @@ impl PublicKeyAggregatorState { c1_proofs: Vec::new(), seed, nodes: OrderedSet::new(), + submission_order: Vec::new(), } } } @@ -113,15 +121,17 @@ impl PublicKeyAggregator { keyshares, c1_proofs, nodes, + submission_order, .. } = &mut state else { return Err(anyhow::anyhow!("Can only add keyshare in Collecting state")); }; - keyshares.insert(keyshare); + keyshares.insert(keyshare.clone()); c1_proofs.push(c1_proof); - nodes.insert(node); + nodes.insert(node.clone()); + submission_order.push((node, keyshare)); let n = *threshold_n; let m = *threshold_m; info!( @@ -132,9 +142,9 @@ impl PublicKeyAggregator { if keyshares.len() == n { info!("All keyshares collected, transitioning to VerifyingC1..."); return Ok(PublicKeyAggregatorState::VerifyingC1 { - keyshares: std::mem::take(keyshares), - nodes: std::mem::take(nodes), + submission_order: std::mem::take(submission_order), threshold_m: m, + c1_proofs: std::mem::take(c1_proofs), no_proof_parties: Vec::new(), }); } @@ -223,8 +233,7 @@ impl PublicKeyAggregator { } let PublicKeyAggregatorState::VerifyingC1 { - keyshares, - nodes, + submission_order, threshold_m, .. } = self @@ -239,16 +248,13 @@ impl PublicKeyAggregator { let dishonest_parties = &msg.dishonest_parties; - // Filter out dishonest parties from keyshares and nodes - let keyshares_vec: Vec = keyshares.into_iter().collect(); - let nodes_vec: Vec = nodes.into_iter().collect(); - - let (honest_keyshares, honest_nodes): (Vec, Vec) = keyshares_vec + // Filter out dishonest parties using submission_order (insertion-order indexed, + // matching the party IDs sent to dispatch_c1_verification). + let (honest_keyshares, honest_nodes): (Vec, Vec) = submission_order .into_iter() - .zip(nodes_vec.into_iter()) .enumerate() .filter(|(idx, _)| !dishonest_parties.contains(&(*idx as u64))) - .map(|(_, (ks, node))| (ks, node)) + .map(|(_, (node, ks))| (ks, node)) .unzip(); if !dishonest_parties.is_empty() { @@ -387,20 +393,26 @@ impl PublicKeyAggregator { threshold_n, threshold_m, keyshares, + c1_proofs, nodes, + submission_order, .. } = &mut state else { return Ok(state); }; - // Remove the expelled node from the nodes set so it won't appear in - // PublicKeyAggregated.nodes (forwarded on-chain for reward distribution). - // Note: the corresponding keyshare cannot be removed because the - // keyshares OrderedSet is keyed by raw bytes with no node mapping. - // This is acceptable because BFV public key aggregation is additive - // and works correctly with any superset of valid keys. - nodes.remove(&node.to_string()); + let node_str = node.to_string(); + + // Find the expelled node's index in submission_order and remove from + // all parallel collections so they stay aligned. + if let Some(idx) = submission_order.iter().position(|(n, _)| n == &node_str) { + let (_, expelled_keyshare) = submission_order.remove(idx); + keyshares.remove(&expelled_keyshare); + c1_proofs.remove(idx); + } + + nodes.remove(&node_str); if *threshold_n > 0 { *threshold_n -= 1; @@ -414,9 +426,9 @@ impl PublicKeyAggregator { let m = *threshold_m; info!("PublicKeyAggregator: enough keyshares after expulsion, transitioning to VerifyingC1"); return Ok(PublicKeyAggregatorState::VerifyingC1 { - keyshares: std::mem::take(keyshares), - nodes: std::mem::take(nodes), + submission_order: std::mem::take(submission_order), threshold_m: m, + c1_proofs: std::mem::take(c1_proofs), no_proof_parties: Vec::new(), }); } @@ -474,24 +486,16 @@ impl Handler for PublicKeyAggregator { Some(PublicKeyAggregatorState::Collecting { .. }) ); - // Snapshot c1_proofs before expulsion mutates state - let c1_proofs_snapshot = match self.state.get() { - Some(PublicKeyAggregatorState::Collecting { c1_proofs, .. }) => { - Some(c1_proofs.clone()) - } - _ => None, - }; - self.handle_member_expelled(&node_addr, &ec)?; + // If we just transitioned to VerifyingC1, dispatch C1 verification + // using the c1_proofs now stored in the VerifyingC1 state (already + // cleaned of the expelled node's entry). if was_collecting { - if matches!( - self.state.get(), - Some(PublicKeyAggregatorState::VerifyingC1 { .. }) - ) { - if let Some(c1_proofs) = c1_proofs_snapshot { - self.dispatch_c1_verification(&c1_proofs, ec.clone())?; - } + if let Some(PublicKeyAggregatorState::VerifyingC1 { c1_proofs, .. }) = + self.state.get() + { + self.dispatch_c1_verification(&c1_proofs, ec.clone())?; } } Ok(()) @@ -522,26 +526,13 @@ impl Handler> for PublicKeyAggregator { return Ok(()); } - // Extract c1_proofs before state mutation - let c1_proofs_snapshot = match self.state.get() { - Some(PublicKeyAggregatorState::Collecting { c1_proofs, .. }) => { - let mut proofs = c1_proofs.clone(); - proofs.push(c1_proof.clone()); - Some(proofs) - } - _ => None, - }; - self.add_keyshare(pubkey, node, c1_proof, &ec)?; // If we just transitioned to VerifyingC1, dispatch verification - if matches!( - self.state.get(), - Some(PublicKeyAggregatorState::VerifyingC1 { .. }) - ) { - if let Some(c1_proofs) = c1_proofs_snapshot { - self.dispatch_c1_verification(&c1_proofs, ec)?; - } + // using c1_proofs stored in the new state. + if let Some(PublicKeyAggregatorState::VerifyingC1 { c1_proofs, .. }) = self.state.get() + { + self.dispatch_c1_verification(&c1_proofs, ec)?; } Ok(()) diff --git a/crates/evm/src/slashing_manager_sol_reader.rs b/crates/evm/src/slashing_manager_sol_reader.rs index cb341c95c8..d504c5c16a 100644 --- a/crates/evm/src/slashing_manager_sol_reader.rs +++ b/crates/evm/src/slashing_manager_sol_reader.rs @@ -9,12 +9,21 @@ use crate::{ }; use actix::{Actor, Addr}; use alloy::{ - primitives::{LogData, B256}, + primitives::{LogData, B256, U256}, sol_types::SolEvent, }; use e3_events::{E3id, EnclaveEventData}; use tracing::{error, info, trace}; +/// Convert a U256 to u128, returning None if the value overflows. +fn safe_u256_to_u128(val: U256) -> Option { + if val > U256::from(u128::MAX) { + None + } else { + Some(val.to::()) + } +} + pub fn extractor(data: &LogData, topic: Option<&B256>, chain_id: u64) -> Option { match topic { Some(&ISlashingManager::SlashExecuted::SIGNATURE_HASH) => { @@ -28,11 +37,38 @@ pub fn extractor(data: &LogData, topic: Option<&B256>, chain_id: u64) -> Option< ); Some(EnclaveEventData::from(e3_events::SlashExecuted { e3_id: E3id::new(event.e3Id.to_string(), chain_id), - proposal_id: event.proposalId.to::(), + proposal_id: match safe_u256_to_u128(event.proposalId) { + Some(v) => v, + None => { + error!( + "SlashExecuted proposalId overflows u128: {}", + event.proposalId + ); + return None; + } + }, operator: event.operator, reason: event.reason.into(), - ticket_amount: event.ticketAmount.to::(), - license_amount: event.licenseAmount.to::(), + ticket_amount: match safe_u256_to_u128(event.ticketAmount) { + Some(v) => v, + None => { + error!( + "SlashExecuted ticketAmount overflows u128: {}", + event.ticketAmount + ); + return None; + } + }, + license_amount: match safe_u256_to_u128(event.licenseAmount) { + Some(v) => v, + None => { + error!( + "SlashExecuted licenseAmount overflows u128: {}", + event.licenseAmount + ); + return None; + } + }, })) } _topic => { diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 5498e55c7e..222a016b9d 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -527,6 +527,17 @@ impl ThresholdKeyshare { node_addr, party_id, data.e3_id, data.active_count_after ); + // Clean transient coordination state for the expelled party + self.pending_shares.retain(|s| s.party_id != party_id); + + if let Some(ref mut honest) = self.honest_parties { + honest.remove(&party_id); + } + + if let Some(ref mut pending_c4) = self.pending_c4_verification_shares { + pending_c4.remove(&party_id); + } + if let Some(ref collector) = self.encryption_key_collector { collector.do_send(ExpelPartyFromKeyCollection { party_id, diff --git a/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol b/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol index 1901d54855..8eb1b17c5b 100644 --- a/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol +++ b/packages/enclave-contracts/contracts/test/MockCiphernodeRegistry.sol @@ -116,13 +116,21 @@ contract MockCiphernodeRegistry is ICiphernodeRegistry { return false; } - // solhint-disable-next-line no-empty-blocks function expelCommitteeMember( - uint256, - address, + uint256 e3Id, + address member, bytes32 - ) external pure returns (uint256, uint32) { - return (0, 0); + ) external returns (uint256, uint32) { + address[] storage nodes = _committeeNodes[e3Id]; + for (uint256 i = 0; i < nodes.length; i++) { + if (nodes[i] == member) { + nodes[i] = nodes[nodes.length - 1]; + nodes.pop(); + break; + } + } + uint32 m = _thresholdM[e3Id]; + return (nodes.length, m); } function isCommitteeMemberActive( From 468424cea62e090190f8b5e6d33168b0075b1b1d Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Fri, 6 Mar 2026 23:53:49 +0500 Subject: [PATCH 11/21] fix: review comments --- templates/default/enclave.config.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/default/enclave.config.yaml b/templates/default/enclave.config.yaml index 80234fb404..2c2657fe95 100644 --- a/templates/default/enclave.config.yaml +++ b/templates/default/enclave.config.yaml @@ -11,6 +11,9 @@ chains: bonding_registry: address: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318" deploy_block: 13 + slashing_manager: + address: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" + deploy_block: 13 fee_token: address: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" deploy_block: 7 From 5ca6631c1c25ac683bb1cf69369650012326760f Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Sat, 7 Mar 2026 02:01:29 +0500 Subject: [PATCH 12/21] fix: review comments --- crates/keyshare/src/threshold_keyshare.rs | 136 ++++++++++++++-------- 1 file changed, 87 insertions(+), 49 deletions(-) diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 222a016b9d..885073076e 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -256,9 +256,9 @@ pub struct ThresholdKeyshareState { pub threshold_n: u64, pub params: ArcBytes, /// Aggregated public key bytes, captured from PublicKeyAggregated event for C6 proof. - /// Defaults to None for backward compatibility with existing persisted state. - #[serde(default)] pub aggregated_pk: Option, + pub expelled_parties: HashSet, + pub honest_parties: Option>, } impl ThresholdKeyshareState { @@ -280,6 +280,8 @@ impl ThresholdKeyshareState { threshold_n, params, aggregated_pk: None, + expelled_parties: HashSet::new(), + honest_parties: None, } } @@ -407,12 +409,8 @@ pub struct ThresholdKeyshare { DkgShareDecryptionProofRequest, Vec, )>, - /// Honest party IDs determined by C2/C3 verification, narrowed by C4. - honest_parties: Option>, /// Temporarily stores DecryptionKeyShared while C4 verification is in flight. pending_c4_verification_shares: Option>, - /// Aggregated public key bytes, captured from PublicKeyAggregated event for C6 proof. - aggregated_pk: Option, } impl ThresholdKeyshare { @@ -427,9 +425,7 @@ impl ThresholdKeyshare { share_enc_preset: params.share_enc_preset, pending_shares: Vec::new(), pending_share_decryption_data: None, - honest_parties: None, pending_c4_verification_shares: None, - aggregated_pk: None, } } } @@ -483,7 +479,7 @@ impl ThresholdKeyshare { } /// Create or return the DecryptionKeySharedCollector. - /// Uses honest_parties from the struct. + /// Uses honest_parties from persisted state. pub fn ensure_decryption_key_shared_collector( &mut self, self_addr: Addr, @@ -491,7 +487,7 @@ impl ThresholdKeyshare { let state = self.state.try_get()?; let my_party_id = state.party_id; - let honest = self + let honest = state .honest_parties .as_ref() .ok_or_else(|| anyhow!("honest_parties not set when creating collector"))?; @@ -527,13 +523,20 @@ impl ThresholdKeyshare { node_addr, party_id, data.e3_id, data.active_count_after ); + // Record permanently so late-arriving data is rejected even if + // collectors haven't been created or have already completed. + // Also clean honest_parties set for the expelled party. + let _ = self.state.try_mutate(&ec, |mut s| { + s.expelled_parties.insert(party_id); + if let Some(ref mut honest) = s.honest_parties { + honest.remove(&party_id); + } + Ok(s) + }); + // Clean transient coordination state for the expelled party self.pending_shares.retain(|s| s.party_id != party_id); - if let Some(ref mut honest) = self.honest_parties { - honest.remove(&party_id); - } - if let Some(ref mut pending_c4) = self.pending_c4_verification_shares { pending_c4.remove(&party_id); } @@ -570,6 +573,15 @@ impl ThresholdKeyshare { return Ok(()); } + // Reject shares from expelled parties + if state.expelled_parties.contains(&msg.share.party_id) { + info!( + "Dropping ThresholdShareCreated from expelled party {} for us (party {})", + msg.share.party_id, my_party_id + ); + return Ok(()); + } + info!( "Received ThresholdShareCreated from party {} for us (party {}), forwarding to collector!", msg.share.party_id, my_party_id @@ -585,6 +597,15 @@ impl ThresholdKeyshare { msg: TypedEvent, self_addr: Addr, ) -> Result<()> { + let state = self.state.try_get()?; + // Reject keys from expelled parties + if state.expelled_parties.contains(&msg.key.party_id) { + info!( + "Dropping EncryptionKeyCreated from expelled party {}", + msg.key.party_id + ); + return Ok(()); + } info!("Received EncryptionKeyCreated forwarding to encryption key collector!"); let collector = self.ensure_encryption_key_collector(self_addr)?; collector.do_send(msg); @@ -759,7 +780,18 @@ impl ThresholdKeyshare { msg.keys.len() ); - let current: CollectingEncryptionKeysData = self.state.try_get()?.try_into()?; + let state = self.state.try_get()?; + let current: CollectingEncryptionKeysData = state.clone().try_into()?; + + // Filter out any keys from parties expelled after collection started + let filtered_keys: Vec<_> = if state.expelled_parties.is_empty() { + msg.keys + } else { + msg.keys + .into_iter() + .filter(|k| !state.expelled_parties.contains(&k.party_id)) + .collect() + }; self.state.try_mutate(&ec, |s| { s.new_state(KeyshareState::GeneratingThresholdShare( @@ -770,7 +802,7 @@ impl ThresholdKeyshare { e_sm_raw: None, sk_bfv: current.sk_bfv, pk_bfv: current.pk_bfv, - collected_encryption_keys: msg.keys, + collected_encryption_keys: filtered_keys, ciphernode_selected: Some(current.ciphernode_selected.clone()), proof_request_data: None, }, @@ -1254,24 +1286,7 @@ impl ThresholdKeyshare { // Store shares on the actor for use after verification completes (keep Arc to avoid deep clone) self.pending_shares = msg.shares.iter().cloned().collect(); - // Backward compat: only when ALL non-self parties have zero proofs - // AND none have incomplete proofs (incomplete proofs are always dishonest) - if party_proofs_to_verify.is_empty() && incomplete_proof_parties.is_empty() { - if no_proof_parties.is_empty() { - info!( - "No C2/C3 proofs to verify for E3 {} — proceeding with all parties", - e3_id - ); - return self.proceed_with_decryption_key_calculation(None, ec); - } - info!( - "No C2/C3 proofs from any party for E3 {} — proceeding with all parties (backward compat)", - e3_id - ); - return self.proceed_with_decryption_key_calculation(None, ec); - } - - // Merge no-proof and incomplete-proof parties — both are pre-dishonest + // Merge no-proof and incomplete-proof parties — both are dishonest let mut pre_dishonest: BTreeSet = BTreeSet::new(); pre_dishonest.extend(incomplete_proof_parties); pre_dishonest.extend(no_proof_parties); @@ -1388,12 +1403,16 @@ impl ThresholdKeyshare { VerificationKind::DecryptionProofs => { // C4 verification complete — update honest set and publish KeyshareCreated if !msg.dishonest_parties.is_empty() { - if let Some(ref mut honest) = self.honest_parties { - honest.retain(|pid| !msg.dishonest_parties.contains(pid)); - } + self.state.try_mutate(&ec, |mut s| { + if let Some(ref mut honest) = s.honest_parties { + honest.retain(|pid| !msg.dishonest_parties.contains(pid)); + } + Ok(s) + })?; + let state = self.state.try_get()?; let threshold = state.threshold_m; - let honest_count = self + let honest_count = state .honest_parties .as_ref() .map(|h| h.len() as u64) @@ -1714,7 +1733,10 @@ impl ThresholdKeyshare { .collect(); // Store honest parties and C4 data on the actor (transient coordination) - self.honest_parties = Some(honest_party_ids); + self.state.try_mutate(&ec, |mut s| { + s.honest_parties = Some(honest_party_ids); + Ok(s) + })?; self.pending_share_decryption_data = Some((sk_request, esm_requests)); Ok(()) @@ -1808,7 +1830,7 @@ impl ThresholdKeyshare { // Create collector and replay any early-arriving DecryptionKeyShared events let state = self.state.try_get()?; let my_party_id = state.party_id; - let honest = self.honest_parties.as_ref().cloned().unwrap_or_default(); + let honest = state.honest_parties.as_ref().cloned().unwrap_or_default(); let expected: HashSet = honest .iter() .filter(|&&pid| pid != my_party_id) @@ -1833,6 +1855,14 @@ impl ThresholdKeyshare { _ec: EventContext, ) -> Result<()> { let party_id = data.party_id; + let state = self.state.try_get()?; + if state.expelled_parties.contains(&party_id) { + info!( + "Dropping early DecryptionKeyShared from expelled party {}", + party_id + ); + return Ok(()); + } info!( "Storing early DecryptionKeyShared from party {} (state: AggregatingDecryptionKey)", party_id @@ -1889,15 +1919,19 @@ impl ThresholdKeyshare { // Evict pre-dishonest parties (wrong ESM count) from honest set if !c4_count_dishonest.is_empty() { - if let Some(ref mut honest) = self.honest_parties { - honest.retain(|pid| !c4_count_dishonest.contains(pid)); - } + self.state.try_mutate(&ec, |mut s| { + if let Some(ref mut honest) = s.honest_parties { + honest.retain(|pid| !c4_count_dishonest.contains(pid)); + } + Ok(s) + })?; } if party_proofs.is_empty() { // Check threshold viability after removing pre-dishonest parties + let state = self.state.try_get()?; let threshold = state.threshold_m; - let honest_count = self + let honest_count = state .honest_parties .as_ref() .map(|h| h.len() as u64) @@ -2031,10 +2065,9 @@ impl ThresholdKeyshare { let decrypting: Decrypting = state.clone().try_into()?; let d_share_poly = msg.d_share_poly; - let aggregated_pk_bytes = self + let aggregated_pk_bytes = state .aggregated_pk .clone() - .or_else(|| state.aggregated_pk.clone()) .ok_or_else(|| anyhow!("Aggregated public key not available for C6 proof"))?; let threshold_preset = self @@ -2125,7 +2158,6 @@ impl Handler for ThresholdKeyshare { } EnclaveEventData::PublicKeyAggregated(data) => { let pk = ArcBytes::from_bytes(&data.pubkey); - self.aggregated_pk = Some(pk.clone()); let _ = self.state.try_mutate(&ec, |mut s| { s.aggregated_pk = Some(pk); Ok(s) @@ -2173,6 +2205,13 @@ impl Handler for ThresholdKeyshare { if data.external { // Route based on current state if let Some(state) = self.state.get() { + if state.expelled_parties.contains(&data.party_id) { + info!( + "Dropping DecryptionKeyShared from expelled party {}", + data.party_id + ); + return; + } let result = match &state.state { KeyshareState::AggregatingDecryptionKey(_) => { self.handle_early_decryption_key_share(data, ec) @@ -2209,7 +2248,7 @@ impl Handler for ThresholdKeyshare { if let Some(state) = self.state.get() { if data.party_id == state.party_id { if let KeyshareState::ReadyForDecryption(_) = state.state { - let others = self + let others = state .honest_parties .as_ref() .map(|h| h.iter().filter(|&&pid| pid != state.party_id).count()) @@ -2461,7 +2500,6 @@ impl Handler for ThresholdKeyshare { self.decryption_key_shared_collector = None; self.pending_shares.clear(); self.pending_share_decryption_data = None; - self.honest_parties = None; self.pending_c4_verification_shares = None; self.notify_sync(ctx, Die); } From 347cffe5969dab53c814d9ea12f282d66e37e001 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Sat, 7 Mar 2026 05:55:36 +0500 Subject: [PATCH 13/21] fix: issues --- .../enclave_event/threshold_share_pending.rs | 4 + crates/evm/src/enclave_sol_writer.rs | 71 +++++++++- crates/evm/src/slashing_manager_sol_writer.rs | 22 +++- crates/keyshare/src/threshold_keyshare.rs | 4 + .../src/actors/accusation_manager.rs | 121 +++++++++++++----- crates/zk-prover/src/actors/proof_request.rs | 29 +++-- .../IBondingRegistry.json | 2 +- .../ICiphernodeRegistry.json | 2 +- .../interfaces/IEnclave.sol/IEnclave.json | 15 ++- .../ISlashingManager.json | 2 +- .../contracts/E3RefundManager.sol | 45 ++++++- .../contracts/interfaces/IE3RefundManager.sol | 2 + .../contracts/interfaces/IEnclave.sol | 5 + 13 files changed, 274 insertions(+), 50 deletions(-) diff --git a/crates/events/src/enclave_event/threshold_share_pending.rs b/crates/events/src/enclave_event/threshold_share_pending.rs index e4df46e541..a5b0247193 100644 --- a/crates/events/src/enclave_event/threshold_share_pending.rs +++ b/crates/events/src/enclave_event/threshold_share_pending.rs @@ -29,6 +29,10 @@ pub struct ThresholdSharePending { pub sk_share_encryption_requests: Vec, /// C3b: E_SM share encryption proof requests (per ESI, per recipient, per modulus row) pub e_sm_share_encryption_requests: Vec, + /// Maps positional index (used by extract_for_party) to real party_id. + /// Required because collected_encryption_keys may be filtered for expulsions, + /// making positional indices diverge from actual party IDs. + pub recipient_party_ids: Vec, } impl Display for ThresholdSharePending { diff --git a/crates/evm/src/enclave_sol_writer.rs b/crates/evm/src/enclave_sol_writer.rs index 92ffae8639..1ac9c73af2 100644 --- a/crates/evm/src/enclave_sol_writer.rs +++ b/crates/evm/src/enclave_sol_writer.rs @@ -23,6 +23,7 @@ use e3_events::EventType; use e3_events::Shutdown; use e3_events::{prelude::*, EffectsEnabled}; use e3_events::{run_once, EnclaveEvent}; +use e3_events::{E3Stage, E3StageChanged}; use e3_events::{E3id, EType, PlaintextAggregated}; use e3_utils::NotifySync; use e3_utils::MAILBOX_LIMIT; @@ -60,7 +61,11 @@ impl EnclaveSolWriter

{ move |_| { let addr = EnclaveSolWriter::new(&bus, provider, contract_address)?.start(); bus.subscribe_all( - &[EventType::PlaintextAggregated, EventType::Shutdown], + &[ + EventType::PlaintextAggregated, + EventType::E3StageChanged, + EventType::Shutdown, + ], addr.clone().into(), ); Ok(()) @@ -89,6 +94,13 @@ impl Handler for E ctx.notify(data); } } + EnclaveEventData::E3StageChanged(data) => { + if data.new_stage == E3Stage::Failed + && self.provider.chain_id() == data.e3_id.chain_id() + { + ctx.notify(data); + } + } EnclaveEventData::Shutdown(data) => self.notify_sync(ctx, data), _ => (), } @@ -158,6 +170,36 @@ impl Handler for Encla } } +impl Handler + for EnclaveSolWriter

+{ + type Result = ResponseFuture<()>; + + fn handle(&mut self, msg: E3StageChanged, _: &mut Self::Context) -> Self::Result { + Box::pin({ + let contract_address = self.contract_address; + let provider = self.provider.clone(); + let bus = self.bus.clone(); + async move { + let result = + process_e3_failure(provider, contract_address, msg.e3_id.clone()).await; + match result { + Ok(receipt) => { + info!(tx=%receipt.transaction_hash, "Called processE3Failure for E3 {}", msg.e3_id); + } + Err(err) => { + // Non-fatal: may revert if already processed or no payment + info!( + "processE3Failure for E3 {} did not succeed (may already be processed): {:?}", + msg.e3_id, err + ); + } + } + } + }) + } +} + async fn publish_plaintext_output( provider: EthProvider

, contract_address: Address, @@ -190,3 +232,30 @@ async fn publish_plaintext_output( }) .await } + +async fn process_e3_failure( + provider: EthProvider

, + contract_address: Address, + e3_id: E3id, +) -> Result { + let e3_id: U256 = e3_id.try_into()?; + + let from_address = provider.provider().default_signer_address(); + let current_nonce = provider + .provider() + .get_transaction_count(from_address) + .pending() + .await?; + + send_tx_with_retry("processE3Failure", &[], || { + info!("processE3Failure() e3_id={:?}", e3_id); + let contract = IEnclave::new(contract_address, provider.provider()); + + async move { + let builder = contract.processE3Failure(e3_id).nonce(current_nonce); + let receipt = builder.send().await?.get_receipt().await?; + Ok(receipt) + } + }) + .await +} diff --git a/crates/evm/src/slashing_manager_sol_writer.rs b/crates/evm/src/slashing_manager_sol_writer.rs index 75c514b097..b4173a6a4d 100644 --- a/crates/evm/src/slashing_manager_sol_writer.rs +++ b/crates/evm/src/slashing_manager_sol_writer.rs @@ -83,11 +83,25 @@ impl Handler EnclaveEventData::AccusationQuorumReached(data) => { // Only submit if: // 1. This is the right chain - // 2. The quorum decided the accused is at fault - // 3. This node is the accuser (only one node should submit) + // 2. The quorum decided the accused is at fault OR equivocated + // 3. This node is the designated submitter (lowest-address agreeing + // voter). This is deterministic so exactly one node submits, + // and decouples submission from the accuser to avoid single-point + // failures if the accuser's node goes down after quorum. + let my_addr = self.provider.provider().default_signer_address(); + let is_designated_submitter = data + .votes_for + .iter() + .map(|v| v.voter) + .min() + .map_or(false, |min_voter| min_voter == my_addr); + if self.provider.chain_id() == data.e3_id.chain_id() - && data.outcome == AccusationOutcome::AccusedFaulted - && data.accuser == self.provider.provider().default_signer_address() + && matches!( + data.outcome, + AccusationOutcome::AccusedFaulted | AccusationOutcome::Equivocation + ) + && is_designated_submitter { ctx.notify(data); } diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 885073076e..fad4b39bc9 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -1179,6 +1179,9 @@ impl ThresholdKeyshare { e_sm_share_encryption_requests.len() ); + // Collect real party IDs in positional order (indices match encrypt_all_extended output) + let recipient_party_ids: Vec = encryption_keys.iter().map(|k| k.party_id).collect(); + // Publish ThresholdSharePending - ProofRequestActor will generate proof, sign, and publish ThresholdShareCreated self.bus.publish( ThresholdSharePending { @@ -1189,6 +1192,7 @@ impl ThresholdKeyshare { e_sm_share_computation_request, sk_share_encryption_requests, e_sm_share_encryption_requests, + recipient_party_ids, }, ec.clone(), )?; diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs index 598123d46a..e54a182074 100644 --- a/crates/zk-prover/src/actors/accusation_manager.rs +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -301,7 +301,25 @@ impl AccusationManager { ec: &EventContext, ctx: &mut Context, ) { - let key = (event.accused_address, event.proof_type); + 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 + }; + + let key = (accused_address, event.proof_type); // Cache the failed verification result self.received_data.insert( @@ -316,7 +334,7 @@ impl AccusationManager { if !self.accused_proofs.insert(key) { info!( "Already accused {:?} for {:?} — skipping duplicate", - event.accused_address, event.proof_type + accused_address, event.proof_type ); return; } @@ -333,7 +351,7 @@ impl AccusationManager { let mut accusation = ProofFailureAccusation { e3_id: self.e3_id.clone(), accuser: self.my_address, - accused: event.accused_address, + accused: accused_address, accused_party_id: event.accused_party_id, proof_type: event.proof_type, data_hash: event.data_hash, @@ -346,7 +364,7 @@ impl AccusationManager { info!( "Broadcasting accusation against {} for {:?} proof failure", - event.accused_address, event.proof_type + accused_address, event.proof_type ); // Broadcast accusation via gossip @@ -390,12 +408,12 @@ impl AccusationManager { // 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); + self.on_vote_received(vote, ec, ctx); } } // Check quorum immediately (in case threshold_m == 1) - self.check_quorum(accusation_id, ec); + self.check_quorum(accusation_id, ec, ctx); } /// Called when we receive an accusation from another node via gossip. @@ -508,7 +526,7 @@ impl AccusationManager { // Replay any buffered votes if let Some(buffered) = self.buffered_votes.remove(&accusation_id) { for vote in buffered { - self.on_vote_received(vote, ec); + self.on_vote_received(vote, ec, ctx); } } @@ -597,16 +615,21 @@ impl AccusationManager { // 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); + self.on_vote_received(vote, ec, ctx); } } // Check quorum - self.check_quorum(accusation_id, ec); + 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) { + 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; @@ -641,6 +664,15 @@ impl AccusationManager { 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 @@ -658,7 +690,7 @@ impl AccusationManager { } // Check if quorum reached - self.check_quorum(vote_accusation_id, ec); + self.check_quorum(vote_accusation_id, ec, ctx); } /// Evaluate whether we have enough votes to decide. @@ -667,7 +699,12 @@ impl AccusationManager { /// - Need >= M agreeing votes → AccusedFaulted /// - If impossible to reach M even with remaining voters → early exit /// - data_hash comparison detects equivocation vs false accusation - fn check_quorum(&mut self, accusation_id: [u8; 32], ec: &EventContext) { + fn check_quorum( + &mut self, + accusation_id: [u8; 32], + ec: &EventContext, + ctx: &mut Context, + ) { let Some(pending) = self.pending.get(&accusation_id) else { return; }; @@ -690,13 +727,13 @@ impl AccusationManager { pending.accusation.accused, pending.accusation.proof_type ); - self.emit_quorum_reached(accusation_id, AccusationOutcome::Equivocation, ec); + 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); + self.emit_quorum_reached(accusation_id, AccusationOutcome::AccusedFaulted, ec, ctx); } return; } @@ -722,7 +759,7 @@ impl AccusationManager { pending.accusation.accused, pending.accusation.proof_type ); - self.emit_quorum_reached(accusation_id, AccusationOutcome::Equivocation, ec); + self.emit_quorum_reached(accusation_id, AccusationOutcome::Equivocation, ec, ctx); } else if agree_count <= 1 && disagree_count > 0 { // Same data, only accuser says bad, others say good → AccuserLied info!( @@ -731,9 +768,9 @@ impl AccusationManager { pending.accusation.accused, pending.accusation.proof_type ); - self.emit_quorum_reached(accusation_id, AccusationOutcome::AccuserLied, ec); + self.emit_quorum_reached(accusation_id, AccusationOutcome::AccuserLied, ec, ctx); } else { - self.emit_quorum_reached(accusation_id, AccusationOutcome::Inconclusive, ec); + self.emit_quorum_reached(accusation_id, AccusationOutcome::Inconclusive, ec, ctx); } } // Otherwise: still waiting for more votes — timeout will handle it @@ -745,8 +782,28 @@ impl AccusationManager { return; // Already resolved }; + // Check for equivocation: if voters saw different data hashes, + // the accused sent different payloads to different nodes. + let all_hashes: HashSet<[u8; 32]> = pending + .votes_for + .iter() + .chain(pending.votes_against.iter()) + .map(|v| v.data_hash) + .chain(std::iter::once(pending.accusation.data_hash)) + .collect(); + let outcome = if pending.votes_for.len() >= self.threshold_m { - AccusationOutcome::AccusedFaulted + // Check among agreeing voters first + 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 if all_hashes.len() > 1 { + // Not enough votes to convict, but divergent data → equivocation + AccusationOutcome::Equivocation } else { AccusationOutcome::Inconclusive }; @@ -781,14 +838,16 @@ impl AccusationManager { accusation_id: [u8; 32], outcome: AccusationOutcome, ec: &EventContext, + ctx: &mut Context, ) { let Some(pending) = self.pending.remove(&accusation_id) else { return; }; - // Cancel the timeout if it exists - // (SpawnHandle can't be cancelled directly in actix without ctx, - // but removing from pending prevents the timeout handler from acting) + // Cancel the timeout to avoid unnecessary timer fires + if let Some(handle) = pending.timeout_handle { + ctx.cancel_future(handle); + } info!( "Accusation quorum reached for {} {:?}: {} for, {} against — outcome: {}", @@ -849,7 +908,11 @@ impl AccusationManager { /// /// 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) { + fn handle_reverification_response( + &mut self, + msg: TypedEvent, + ctx: &mut Context, + ) { let (msg, _ec) = msg.into_components(); let correlation_id = msg.correlation_id; @@ -922,7 +985,7 @@ impl AccusationManager { } // Check quorum - self.check_quorum(reverif.accusation_id, &ec); + self.check_quorum(reverif.accusation_id, &ec, ctx); } /// Handle ZK re-verification error for a forwarded C3a/C3b proof. @@ -1024,13 +1087,9 @@ impl Handler> for AccusationManager { impl Handler> for AccusationManager { type Result = (); - fn handle( - &mut self, - msg: TypedEvent, - _ctx: &mut Self::Context, - ) -> Self::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); + self.on_vote_received(data, &ec, ctx); } } @@ -1040,9 +1099,9 @@ impl Handler> for AccusationManager { fn handle( &mut self, msg: TypedEvent, - _ctx: &mut Self::Context, + ctx: &mut Self::Context, ) -> Self::Result { - self.handle_reverification_response(msg); + self.handle_reverification_response(msg, ctx); } } diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index a3010c2203..7e4c6f3500 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -60,6 +60,8 @@ struct PendingThresholdProofs { /// C3b proofs: keyed by (esi_index, recipient_party_id, row_index) e_sm_share_encryption_proofs: HashMap<(usize, usize, usize), Proof>, expected_e_sm_enc_count: usize, + /// Maps positional index to real party_id (from ThresholdSharePending). + recipient_party_ids: Vec, } impl PendingThresholdProofs { @@ -69,6 +71,7 @@ impl PendingThresholdProofs { ec: EventContext, expected_sk_enc_count: usize, expected_e_sm_enc_count: usize, + recipient_party_ids: Vec, ) -> Self { Self { e3_id, @@ -81,6 +84,7 @@ impl PendingThresholdProofs { expected_sk_enc_count, e_sm_share_encryption_proofs: HashMap::new(), expected_e_sm_enc_count, + recipient_party_ids, } } @@ -285,6 +289,7 @@ impl ProofRequestActor { ec.clone(), sk_enc_count, e_sm_enc_count, + msg.recipient_party_ids, ), ); @@ -1114,22 +1119,30 @@ impl ProofRequestActor { e3_id, num_parties ); - for recipient_party_id in 0..num_parties { - if let Some(party_share) = share.extract_for_party(recipient_party_id) { + for positional_idx in 0..num_parties { + if let Some(party_share) = share.extract_for_party(positional_idx) { let c3a_proofs = signed_c3a_map - .get(&recipient_party_id) + .get(&positional_idx) .cloned() .unwrap_or_default(); let c3b_proofs = signed_c3b_map - .get(&recipient_party_id) + .get(&positional_idx) .cloned() .unwrap_or_default(); + // Use real party_id from the mapping (positional index may differ + // from party_id when expelled members cause gaps) + let real_party_id = pending + .recipient_party_ids + .get(positional_idx) + .copied() + .unwrap_or(positional_idx as u64); + if let Err(err) = self.bus.publish( ThresholdShareCreated { e3_id: e3_id.clone(), share: Arc::new(party_share), - target_party_id: recipient_party_id as u64, + target_party_id: real_party_id, external: false, signed_c2a_proof: Some(signed_c2a.clone()), signed_c2b_proof: Some(signed_c2b.clone()), @@ -1139,12 +1152,12 @@ impl ProofRequestActor { ec.clone(), ) { error!( - "Failed to publish ThresholdShareCreated for party {}: {err}", - recipient_party_id + "Failed to publish ThresholdShareCreated for party {} (idx {}): {err}", + real_party_id, positional_idx ); } } else { - error!("Failed to extract share for party {}", recipient_party_id); + error!("Failed to extract share for index {}", positional_idx); } } } diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json b/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json index b9cff5d9eb..c3e3f47993 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json @@ -940,5 +940,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IBondingRegistry.sol", - "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" + "buildInfoId": "solc-0_8_28-b29e2e20df477e5829743192d3e53aa86d40cfe2" } \ No newline at end of file diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json b/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json index 6d93ec6a5f..50ecc0c4f9 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json @@ -782,5 +782,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ICiphernodeRegistry.sol", - "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" + "buildInfoId": "solc-0_8_28-b29e2e20df477e5829743192d3e53aa86d40cfe2" } \ No newline at end of file diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json b/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json index 9bd81933c0..980c2adfba 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json @@ -949,6 +949,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "e3Id", + "type": "uint256" + } + ], + "name": "processE3Failure", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1263,5 +1276,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IEnclave.sol", - "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" + "buildInfoId": "solc-0_8_28-b29e2e20df477e5829743192d3e53aa86d40cfe2" } \ No newline at end of file diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index 2587be67b1..5bd09e7db2 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -954,5 +954,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" + "buildInfoId": "solc-0_8_28-b29e2e20df477e5829743192d3e53aa86d40cfe2" } \ No newline at end of file diff --git a/packages/enclave-contracts/contracts/E3RefundManager.sol b/packages/enclave-contracts/contracts/E3RefundManager.sol index 1e216be75d..c01360da7c 100644 --- a/packages/enclave-contracts/contracts/E3RefundManager.sol +++ b/packages/enclave-contracts/contracts/E3RefundManager.sol @@ -334,8 +334,11 @@ contract E3RefundManager is IE3RefundManager, OwnableUpgradeable { RefundDistribution storage dist = _distributions[e3Id]; if (dist.calculated) { - require(_claimCount[e3Id] == 0, "Claims already started"); - _applySlashedFunds(e3Id, amount); + if (_claimCount[e3Id] == 0) { + _applySlashedFunds(e3Id, amount); + } else { + _pendingSlashedFunds[e3Id] += amount; + } } else { _pendingSlashedFunds[e3Id] += amount; } @@ -471,4 +474,42 @@ contract E3RefundManager is IE3RefundManager, OwnableUpgradeable { require(_treasury != address(0), "Invalid treasury"); treasury = _treasury; } + + /// @notice Recover orphaned slashed funds for an E3 that has already completed + /// or whose failure was already fully processed. + /// @dev When a slash executes after an E3 has completed (or after failure claims + /// have started), funds accumulate in `_pendingSlashedFunds` with no drain + /// path. This function allows the owner to redirect them to the treasury. + /// Only callable when the E3 is in a terminal state (Complete or Failed) + /// and the funds cannot be distributed through normal channels. + /// @param e3Id The E3 ID + /// @param paymentToken The token the slashed funds are denominated in + function withdrawOrphanedSlashedFunds( + uint256 e3Id, + IERC20 paymentToken + ) external onlyOwner { + uint256 amount = _pendingSlashedFunds[e3Id]; + require(amount > 0, "No orphaned funds"); + + // Only allow withdrawal when E3 is in a terminal state + IEnclave.E3Stage stage = enclave.getE3Stage(e3Id); + require( + stage == IEnclave.E3Stage.Complete || + stage == IEnclave.E3Stage.Failed, + "E3 not in terminal state" + ); + + // If E3 is Failed and distribution hasn't been calculated yet, + // funds should flow through the normal processE3Failure path + if (stage == IEnclave.E3Stage.Failed) { + RefundDistribution storage dist = _distributions[e3Id]; + require(dist.calculated, "Use processE3Failure first"); + } + + _pendingSlashedFunds[e3Id] = 0; + require(address(paymentToken) != address(0), "Invalid fee token"); + paymentToken.safeTransfer(treasury, amount); + + emit OrphanedSlashedFundsWithdrawn(e3Id, amount); + } } diff --git a/packages/enclave-contracts/contracts/interfaces/IE3RefundManager.sol b/packages/enclave-contracts/contracts/interfaces/IE3RefundManager.sol index 1e83751616..05396b249c 100644 --- a/packages/enclave-contracts/contracts/interfaces/IE3RefundManager.sol +++ b/packages/enclave-contracts/contracts/interfaces/IE3RefundManager.sol @@ -73,6 +73,8 @@ interface IE3RefundManager { ); /// @notice Emitted when work allocation is updated event WorkAllocationUpdated(WorkValueAllocation allocation); + /// @notice Emitted when orphaned slashed funds are withdrawn to treasury + event OrphanedSlashedFundsWithdrawn(uint256 indexed e3Id, uint256 amount); //////////////////////////////////////////////////////////// // // // Errors // diff --git a/packages/enclave-contracts/contracts/interfaces/IEnclave.sol b/packages/enclave-contracts/contracts/interfaces/IEnclave.sol index 533027674f..2a87c8a3a5 100644 --- a/packages/enclave-contracts/contracts/interfaces/IEnclave.sol +++ b/packages/enclave-contracts/contracts/interfaces/IEnclave.sol @@ -394,6 +394,11 @@ interface IEnclave { /// @return reason The failure reason function markE3Failed(uint256 e3Id) external returns (FailureReason reason); + /// @notice Process a failed E3: transfer payment to E3RefundManager and calculate refunds. + /// @dev Permissionless. Requires E3 to be in Failed stage. + /// @param e3Id The E3 ID + function processE3Failure(uint256 e3Id) external; + /// @notice Check if E3 can be marked as failed /// @param e3Id The E3 ID /// @return canFail Whether failure condition is met From 7d9cbfddc44718475a4945f1a6856d0ec592e2eb Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Sat, 7 Mar 2026 06:27:23 +0500 Subject: [PATCH 14/21] fix: issues --- crates/evm/src/enclave_sol_writer.rs | 18 +++++++------ crates/keyshare/src/threshold_keyshare.rs | 26 ++++++++++++++++--- .../src/actors/accusation_manager.rs | 16 ++++++++---- crates/zk-prover/src/actors/proof_request.rs | 15 +++++++---- .../interfaces/IEnclave.sol/IEnclave.json | 26 +++++++++---------- 5 files changed, 66 insertions(+), 35 deletions(-) diff --git a/crates/evm/src/enclave_sol_writer.rs b/crates/evm/src/enclave_sol_writer.rs index 1ac9c73af2..815a1c72a2 100644 --- a/crates/evm/src/enclave_sol_writer.rs +++ b/crates/evm/src/enclave_sol_writer.rs @@ -95,6 +95,8 @@ impl Handler for E } } EnclaveEventData::E3StageChanged(data) => { + // When an E3 transitions to Failed on-chain, call processE3Failure + // to finalize refund distribution automatically. if data.new_stage == E3Stage::Failed && self.provider.chain_id() == data.e3_id.chain_id() { @@ -240,18 +242,18 @@ async fn process_e3_failure( ) -> Result { let e3_id: U256 = e3_id.try_into()?; - let from_address = provider.provider().default_signer_address(); - let current_nonce = provider - .provider() - .get_transaction_count(from_address) - .pending() - .await?; - send_tx_with_retry("processE3Failure", &[], || { info!("processE3Failure() e3_id={:?}", e3_id); - let contract = IEnclave::new(contract_address, provider.provider()); + let provider = provider.clone(); async move { + let from_address = provider.provider().default_signer_address(); + let current_nonce = provider + .provider() + .get_transaction_count(from_address) + .pending() + .await?; + let contract = IEnclave::new(contract_address, provider.provider()); let builder = contract.processE3Failure(e3_id).nonce(current_nonce); let receipt = builder.send().await?.get_receipt().await?; Ok(receipt) diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index fad4b39bc9..c1282dd1ce 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -1211,11 +1211,29 @@ impl ThresholdKeyshare { let e3_id = state.get_e3_id(); let own_party_id = state.party_id; + // Filter out expelled parties before any processing. The collector may + // have accepted shares before the expulsion arrived, so we scrub here. + let expelled = &state.expelled_parties; + let (shares, share_proofs): (Vec<_>, Vec<_>) = if expelled.is_empty() { + (msg.shares, msg.share_proofs) + } else { + warn!( + "Filtering {} expelled parties from AllThresholdSharesCollected for E3 {}: {:?}", + expelled.len(), + e3_id, + expelled + ); + msg.shares + .into_iter() + .zip(msg.share_proofs.into_iter()) + .filter(|(s, _)| !expelled.contains(&s.party_id)) + .unzip() + }; + // Derive expected proof counts from our own share (trusted source). // All parties use the same BFV params, so moduli counts are identical. // Using the sender's share would let a malicious party manipulate expected counts. - let own_share = msg - .shares + let own_share = shares .iter() .find(|s| s.party_id == own_party_id) .ok_or_else(|| anyhow!("Own share not found in AllThresholdSharesCollected"))?; @@ -1235,7 +1253,7 @@ impl ThresholdKeyshare { let mut party_proofs_to_verify: Vec = Vec::new(); let mut no_proof_parties: HashSet = HashSet::new(); let mut incomplete_proof_parties: HashSet = HashSet::new(); - for (share, proofs) in msg.shares.iter().zip(msg.share_proofs.iter()) { + for (share, proofs) in shares.iter().zip(share_proofs.iter()) { if share.party_id == own_party_id { continue; } @@ -1288,7 +1306,7 @@ impl ThresholdKeyshare { } // Store shares on the actor for use after verification completes (keep Arc to avoid deep clone) - self.pending_shares = msg.shares.iter().cloned().collect(); + self.pending_shares = shares.iter().cloned().collect(); // Merge no-proof and incomplete-proof parties — both are dishonest let mut pre_dishonest: BTreeSet = BTreeSet::new(); diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs index e54a182074..e0827280ad 100644 --- a/crates/zk-prover/src/actors/accusation_manager.rs +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -656,11 +656,17 @@ impl AccusationManager { // 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 - self.buffered_votes - .entry(vote_accusation_id) - .or_default() - .push(vote); + // 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; }; diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index 7e4c6f3500..0b1ec60f48 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -1132,11 +1132,16 @@ impl ProofRequestActor { // Use real party_id from the mapping (positional index may differ // from party_id when expelled members cause gaps) - let real_party_id = pending - .recipient_party_ids - .get(positional_idx) - .copied() - .unwrap_or(positional_idx as u64); + let real_party_id = match pending.recipient_party_ids.get(positional_idx) { + Some(&id) => id, + None => { + warn!( + "recipient_party_ids has no entry for positional index {} (len={}), falling back to index as party_id", + positional_idx, pending.recipient_party_ids.len() + ); + positional_idx as u64 + } + }; if let Err(err) = self.bus.publish( ThresholdShareCreated { diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json b/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json index 980c2adfba..7cd80998ad 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/IEnclave.sol/IEnclave.json @@ -908,7 +908,7 @@ "type": "uint256" } ], - "name": "onCommitteeFinalized", + "name": "processE3Failure", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -919,14 +919,9 @@ "internalType": "uint256", "name": "e3Id", "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "committeePublicKeyHash", - "type": "bytes32" } ], - "name": "onCommitteePublished", + "name": "onCommitteeFinalized", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -939,12 +934,12 @@ "type": "uint256" }, { - "internalType": "uint8", - "name": "reason", - "type": "uint8" + "internalType": "bytes32", + "name": "committeePublicKeyHash", + "type": "bytes32" } ], - "name": "onE3Failed", + "name": "onCommitteePublished", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -955,9 +950,14 @@ "internalType": "uint256", "name": "e3Id", "type": "uint256" + }, + { + "internalType": "uint8", + "name": "reason", + "type": "uint8" } ], - "name": "processE3Failure", + "name": "onE3Failed", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -1276,5 +1276,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IEnclave.sol", - "buildInfoId": "solc-0_8_28-b29e2e20df477e5829743192d3e53aa86d40cfe2" + "buildInfoId": "solc-0_8_28-aabfcbf9eb9905bf16af29953ad33064524f63a7" } \ No newline at end of file From 4aa5e9036bb459960fff473fd34b7fed177a5bee Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Sat, 7 Mar 2026 18:42:05 +0500 Subject: [PATCH 15/21] fix: issues --- crates/zk-prover/src/actors/proof_request.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index 0b1ec60f48..818ee6b5c1 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -1119,7 +1119,7 @@ impl ProofRequestActor { e3_id, num_parties ); - for positional_idx in 0..num_parties { + for (positional_idx, &real_party_id) in pending.recipient_party_ids.iter().enumerate() { if let Some(party_share) = share.extract_for_party(positional_idx) { let c3a_proofs = signed_c3a_map .get(&positional_idx) @@ -1130,19 +1130,6 @@ impl ProofRequestActor { .cloned() .unwrap_or_default(); - // Use real party_id from the mapping (positional index may differ - // from party_id when expelled members cause gaps) - let real_party_id = match pending.recipient_party_ids.get(positional_idx) { - Some(&id) => id, - None => { - warn!( - "recipient_party_ids has no entry for positional index {} (len={}), falling back to index as party_id", - positional_idx, pending.recipient_party_ids.len() - ); - positional_idx as u64 - } - }; - if let Err(err) = self.bus.publish( ThresholdShareCreated { e3_id: e3_id.clone(), From f62e4976f0c7fe15c751b102893f372357002c9b Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Sun, 8 Mar 2026 21:56:05 +0500 Subject: [PATCH 16/21] fix: slashing integration test --- .../tests/slashing_integration_tests.rs | 1077 ++++++++++++----- 1 file changed, 762 insertions(+), 315 deletions(-) diff --git a/crates/zk-prover/tests/slashing_integration_tests.rs b/crates/zk-prover/tests/slashing_integration_tests.rs index 259a57fd49..3d84c94d15 100644 --- a/crates/zk-prover/tests/slashing_integration_tests.rs +++ b/crates/zk-prover/tests/slashing_integration_tests.rs @@ -4,20 +4,27 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -//! Slashing integration tests: complete flow from proof generation → -//! operator signing → evidence encoding → on-chain SlashingManager verification. +//! Slashing integration tests: off-chain proof signing + on-chain attestation-based slashing. //! //! ## What these tests prove //! -//! 1. **Signing format alignment**: Rust `ProofPayload.digest()` produces the -//! exact structured hash that `SlashingManager.proposeSlash()` reconstructs. -//! 2. **Evidence encoding**: `encode_fault_evidence()` output is correctly -//! decoded by the Solidity `abi.decode` in `proposeSlash()`. -//! 3. **ECDSA recovery**: Signatures created with alloy's `sign_message_sync` -//! are correctly recovered on-chain via `ECDSA.recover(toEthSignedMessageHash(...))`. -//! 4. **Complete slashing flow**: Valid proofs revert with `ProofIsValid()`, -//! wrong signers revert with `SignerIsNotOperator()`, and invalid proofs -//! result in successful slash execution. +//! ### Pure Rust (no Anvil) +//! 1. **ProofPayload signing**: `ProofPayload.digest()` produces the correct +//! structured hash for off-chain proof signing (PROOF_PAYLOAD_TYPEHASH). +//! 2. **ECDSA roundtrip**: `sign_message_sync` → `recover_address` for ProofPayload. +//! 3. **Evidence encoding**: `encode_fault_evidence()` produces valid ABI-encoded +//! data (retained for Lane B tests). +//! 4. **Vote typehash**: VOTE_TYPEHASH matches the Solidity constant. +//! 5. **Attestation evidence**: vote signatures are correctly constructed and +//! ABI-encoded for `proposeSlash()`. +//! +//! ### On-chain integration (Anvil + Hardhat artifacts) +//! 6. **Valid attestation quorum** → slash executes successfully. +//! 7. **Insufficient attestations** → reverts `InsufficientAttestations`. +//! 8. **Voter not in committee** → reverts `VoterNotInCommittee`. +//! 9. **Invalid vote signature** → reverts `InvalidVoteSignature`. +//! 10. **Duplicate voter** → reverts `DuplicateVoter`. +//! 11. **Duplicate evidence replay** → reverts `DuplicateEvidence`. //! //! ## Prerequisites //! @@ -34,7 +41,7 @@ use alloy::{ primitives::{keccak256, Address, Bytes, FixedBytes, U256}, providers::{Provider, ProviderBuilder}, rpc::types::TransactionRequest, - signers::local::PrivateKeySigner, + signers::{local::PrivateKeySigner, SignerSync}, sol, sol_types::SolValue, }; @@ -65,23 +72,24 @@ sol! { function proposeSlash(uint256 e3Id, address operator, bytes32 reason, bytes calldata proof) external returns (uint256 proposalId); function setSlashPolicy(bytes32 reason, SlashPolicy calldata policy) external; + function setBondingRegistry(address newBondingRegistry) external; + function setCiphernodeRegistry(address newCiphernodeRegistry) external; + function setEnclave(address newEnclave) external; function totalProposals() external view returns (uint256); function isBanned(address node) external view returns (bool); - error ProofIsValid(); - error SignerIsNotOperator(); - error OperatorNotInCommittee(); - error VerifierMismatch(); - } - - #[sol(rpc)] - contract MockCircuitVerifier { - function setReturnValue(bool _returnValue) external; + error InsufficientAttestations(); + error DuplicateVoter(); + error VoterNotInCommittee(); + error InvalidVoteSignature(); + error InvalidProof(); + error DuplicateEvidence(); } #[sol(rpc)] contract MockCiphernodeRegistry { function setCommitteeNodes(uint256 e3Id, address[] calldata nodes) external; + function setThreshold(uint256 e3Id, uint32 m) external; } } @@ -92,7 +100,7 @@ sol! { /// Deploys a contract whose runtime is a single STOP opcode. /// All calls to this contract succeed with empty return data, making it /// suitable as a mock for any interface that only has void-returning functions -/// (e.g., IBondingRegistry.slashTicketBalance, IEnclave.onE3Failed). +/// (e.g., IEnclave.onE3Failed). const NOOP_DEPLOY_BYTECODE: &[u8] = &[ 0x60, 0x01, // PUSH1 0x01 (runtime size) 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) @@ -104,6 +112,25 @@ const NOOP_DEPLOY_BYTECODE: &[u8] = &[ 0x00, // -- runtime: STOP -- ]; +/// Mock contract that returns 32 zero bytes for any call. +/// +/// EVM memory is zero-initialized, so `RETURN(0x00, 0x20)` returns 32 zero bytes. +/// Suitable as a mock for interfaces that return a single `uint256` +/// (e.g., `IBondingRegistry.slashTicketBalance` returns `uint256`). +const RETURNER_DEPLOY_BYTECODE: &[u8] = &[ + 0x60, 0x05, // PUSH1 0x05 (runtime size) + 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) + 0x60, 0x00, // PUSH1 0x00 (memory destination) + 0x39, // CODECOPY + 0x60, 0x05, // PUSH1 0x05 (return size) + 0x60, 0x00, // PUSH1 0x00 (return offset) + 0xf3, // RETURN + // -- runtime: return 32 zero bytes -- + 0x60, 0x20, // PUSH1 0x20 + 0x60, 0x00, // PUSH1 0x00 + 0xf3, // RETURN +]; + fn contracts_artifacts_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../packages/enclave-contracts/artifacts/contracts") @@ -118,12 +145,11 @@ fn read_artifact_bytecode(subpath: &str) -> Option> { hex::decode(clean).ok() } -/// Load all three contract bytecodes, returning None if any are missing. -fn load_slashing_artifacts() -> Option<(Vec, Vec, Vec)> { +/// Load contract bytecodes, returning None if any are missing. +fn load_slashing_artifacts() -> Option<(Vec, Vec)> { let sm = read_artifact_bytecode("slashing/SlashingManager.sol/SlashingManager.json")?; - let mv = read_artifact_bytecode("test/MockSlashingVerifier.sol/MockCircuitVerifier.json")?; let mr = read_artifact_bytecode("test/MockCiphernodeRegistry.sol/MockCiphernodeRegistry.json")?; - Some((sm, mv, mr)) + Some((sm, mr)) } /// Deploy a contract on the connected provider. @@ -339,25 +365,299 @@ fn test_digest_matches_solidity_encoding() { ); } +// ════════════════════════════════════════════════════════════════════════════ +// Attestation vote helpers — used by both pure Rust and on-chain tests +// ════════════════════════════════════════════════════════════════════════════ + +const VOTE_TYPEHASH_STR: &str = + "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)"; + +/// Compute `accusationId = keccak256(abi.encodePacked(chainId, e3Id, operator, proofType))` +/// matching `AccusationManager::accusation_id()` and `SlashingManager._verifyAttestationEvidence()`. +fn compute_accusation_id( + chain_id: u64, + e3_id: u64, + operator: Address, + proof_type: u8, +) -> FixedBytes<32> { + keccak256( + &( + U256::from(chain_id), + U256::from(e3_id), + operator, + U256::from(proof_type), + ) + .abi_encode_packed(), + ) +} + +/// Compute the structured vote digest matching `AccusationManager::vote_digest()`. +fn compute_vote_digest( + chain_id: u64, + e3_id: u64, + accusation_id: FixedBytes<32>, + voter: Address, + agrees: bool, + data_hash: FixedBytes<32>, +) -> FixedBytes<32> { + let typehash = keccak256(VOTE_TYPEHASH_STR); + keccak256( + &( + typehash, + U256::from(chain_id), + U256::from(e3_id), + accusation_id, + voter, + agrees, + data_hash, + ) + .abi_encode(), + ) +} + +/// Sign a vote and return `(voter_address, signature_bytes)`. +fn sign_vote( + signer: &PrivateKeySigner, + chain_id: u64, + e3_id: u64, + accusation_id: FixedBytes<32>, + agrees: bool, + data_hash: FixedBytes<32>, +) -> (Address, Bytes) { + let voter = signer.address(); + let digest = compute_vote_digest(chain_id, e3_id, accusation_id, voter, agrees, data_hash); + let sig = signer + .sign_message_sync(digest.as_ref()) + .expect("vote signing should succeed"); + (voter, Bytes::from(sig.as_bytes().to_vec())) +} + +/// Encode attestation evidence for `proposeSlash()`. +/// +/// Format: `abi.encode(uint256 proofType, address[] voters, bool[] agrees, bytes32[] dataHashes, bytes[] signatures)` +/// Voters are sorted ascending by address (contract requires strict ascending order). +fn encode_attestation_evidence( + proof_type: u8, + mut votes: Vec<(Address, bool, FixedBytes<32>, Bytes)>, +) -> Bytes { + votes.sort_by_key(|(addr, _, _, _)| *addr); + + let voters: Vec

= votes.iter().map(|(a, _, _, _)| *a).collect(); + let agrees: Vec = votes.iter().map(|(_, a, _, _)| *a).collect(); + let data_hashes: Vec> = votes.iter().map(|(_, _, d, _)| *d).collect(); + let sigs: Vec = votes.iter().map(|(_, _, _, s)| s.clone()).collect(); + + Bytes::from((U256::from(proof_type), voters, agrees, data_hashes, sigs).abi_encode()) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Pure Rust attestation tests — no Anvil required +// ════════════════════════════════════════════════════════════════════════════ + +/// Verifies the VOTE_TYPEHASH constant matches the keccak256 of the vote type string. +#[test] +fn test_vote_typehash() { + let expected: [u8; 32] = keccak256(VOTE_TYPEHASH_STR).into(); + // Cross-check with the exact string the Solidity contract uses: + let sol_str = "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)"; + let sol_hash: [u8; 32] = keccak256(sol_str).into(); + assert_eq!( + expected, sol_hash, + "VOTE_TYPEHASH must match the Solidity constant" + ); +} + +/// Verifies vote digest computation matches manual abi.encode + keccak256. +#[test] +fn test_vote_digest_manual_computation() { + let chain_id = 31337u64; + let e3_id = 42u64; + let operator: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let voter: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + let proof_type = 0u8; // C0PkBfv + let data_hash = FixedBytes::from([0xab; 32]); + + let accusation_id = compute_accusation_id(chain_id, e3_id, operator, proof_type); + let digest = compute_vote_digest(chain_id, e3_id, accusation_id, voter, true, data_hash); + + // Manual computation + let typehash = keccak256(VOTE_TYPEHASH_STR); + let encoded = ( + typehash, + U256::from(chain_id), + U256::from(e3_id), + accusation_id, + voter, + true, + data_hash, + ) + .abi_encode(); + let expected: FixedBytes<32> = keccak256(&encoded); + + assert_eq!( + digest, expected, + "vote digest should match manual computation" + ); +} + +/// Verifies vote sign/recover roundtrip. +#[test] +fn test_vote_signing_roundtrip() { + let signer: PrivateKeySigner = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .unwrap(); + let chain_id = 31337u64; + let e3_id = 42u64; + let operator: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let proof_type = 0u8; + let data_hash = FixedBytes::from([0xab; 32]); + + let accusation_id = compute_accusation_id(chain_id, e3_id, operator, proof_type); + let (voter, sig_bytes) = sign_vote(&signer, chain_id, e3_id, accusation_id, true, data_hash); + + assert_eq!( + voter, + signer.address(), + "voter should be the signer address" + ); + + // Verify recover + let digest = compute_vote_digest(chain_id, e3_id, accusation_id, voter, true, data_hash); + let sig = + alloy::primitives::Signature::try_from(sig_bytes.as_ref()).expect("signature should parse"); + let recovered = sig + .recover_address_from_msg(digest.as_slice()) + .expect("recovery should succeed"); + assert_eq!( + recovered, + signer.address(), + "recovered address should match signer" + ); +} + +/// Verifies attestation evidence encoding structure. +#[test] +fn test_attestation_evidence_encoding() { + let signer1: PrivateKeySigner = PrivateKeySigner::random(); + let signer2: PrivateKeySigner = PrivateKeySigner::random(); + + let chain_id = 31337u64; + let e3_id = 1u64; + let operator: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let proof_type = 0u8; + let data_hash = FixedBytes::from([0xcc; 32]); + + let accusation_id = compute_accusation_id(chain_id, e3_id, operator, proof_type); + + let (voter1, sig1) = sign_vote(&signer1, chain_id, e3_id, accusation_id, true, data_hash); + let (voter2, sig2) = sign_vote(&signer2, chain_id, e3_id, accusation_id, true, data_hash); + + let evidence = encode_attestation_evidence( + proof_type, + vec![ + (voter1, true, data_hash, sig1), + (voter2, true, data_hash, sig2), + ], + ); + + // Decode and verify structure: (uint256, address[], bool[], bytes32[], bytes[]) + type AttestationTuple = ( + U256, + Vec
, + Vec, + Vec>, + Vec, + ); + let decoded = + AttestationTuple::abi_decode_params(&evidence).expect("evidence should ABI-decode"); + + let (dec_proof_type, dec_voters, dec_agrees, dec_hashes, dec_sigs) = decoded; + assert_eq!(dec_proof_type, U256::from(proof_type), "proofType mismatch"); + assert_eq!(dec_voters.len(), 2, "should have 2 voters"); + assert!( + dec_voters[0] < dec_voters[1], + "voters should be sorted ascending" + ); + assert!(dec_agrees.iter().all(|a| *a), "all votes should agree"); + assert_eq!(dec_hashes.len(), 2, "should have 2 data hashes"); + assert_eq!(dec_sigs.len(), 2, "should have 2 signatures"); +} + // ════════════════════════════════════════════════════════════════════════════ // On-chain integration tests — require Anvil + compiled Hardhat artifacts // ════════════════════════════════════════════════════════════════════════════ -/// **Complete flow**: operator signs proof → evidence encoded → SlashingManager -/// reconstructs digest, recovers signer, verifies committee membership, and -/// checks ZK proof validity. +/// Deploy SlashingManager and configure dependencies. +/// Returns (SlashingManager contract instance, admin address). +async fn deploy_and_configure( + provider: &impl Provider, + sm_bytecode: &[u8], + mock_registry_addr: Address, +) -> (Address, Address) { + let accounts = provider.get_accounts().await.unwrap(); + let admin = accounts[0]; + + // Deploy noop for enclave (void functions) + let noop_addr = deploy_contract(provider, NOOP_DEPLOY_BYTECODE, &[]).await; + // Deploy returner for bondingRegistry (slashTicketBalance returns uint256) + let returner_addr = deploy_contract(provider, RETURNER_DEPLOY_BYTECODE, &[]).await; + + // Deploy SlashingManager(admin) — constructor only takes admin address + let sm_args = admin.abi_encode(); + let sm_addr = deploy_contract(provider, sm_bytecode, &sm_args).await; + + // Configure dependencies via admin functions + let slashing_mgr = SlashingManager::new(sm_addr, provider); + slashing_mgr + .setBondingRegistry(returner_addr) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + slashing_mgr + .setCiphernodeRegistry(mock_registry_addr) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + slashing_mgr + .setEnclave(noop_addr) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + (sm_addr, admin) +} + +/// **Lane A attestation flow**: 3 committee members vote on a fault, quorum +/// is reached (M=2), and the slash executes atomically. /// -/// With MockCircuitVerifier returning TRUE (proof is valid), the contract -/// reverts with `ProofIsValid()`. This proves the full Rust→Solidity signing -/// pipeline works correctly. +/// Proves the complete Rust→Solidity attestation signing pipeline works: +/// vote_digest → sign_message_sync → abi.encode evidence → proposeSlash → _verifyAttestationEvidence #[tokio::test] -async fn test_onchain_valid_proof_reverts_proof_is_valid() { +async fn test_onchain_valid_attestation_executes_slash() { if !find_anvil().await { println!("skipping: anvil not found on PATH"); return; } - let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { Some(artifacts) => artifacts, None => { println!( @@ -370,35 +670,29 @@ async fn test_onchain_valid_proof_reverts_proof_is_valid() { let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); - let accounts = provider.get_accounts().await.unwrap(); - let admin = accounts[0]; - - // Operator uses a separate key (not an Anvil pre-funded account) - let operator_signer = PrivateKeySigner::random(); - let operator_addr = operator_signer.address(); - // Deploy infrastructure contracts - let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; - let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; - let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + // Three committee member signers + let voter_signer1 = PrivateKeySigner::random(); + let voter_signer2 = PrivateKeySigner::random(); + let voter_signer3 = PrivateKeySigner::random(); - // Deploy SlashingManager(admin, bondingRegistry, ciphernodeRegistry, enclave) - let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); - let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; - - println!("deployed: SlashingManager={sm_addr}, MockVerifier={mock_verifier_addr}, MockRegistry={mock_registry_addr}"); - println!("operator: {operator_addr} (chain_id: {chain_id})"); + let operator_addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); - // Bind contract instances - let slashing_mgr = SlashingManager::new(sm_addr, &provider); - let mock_verifier = MockCircuitVerifier::new(mock_verifier_addr, &provider); + // Deploy mock registry + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); - // ── Setup: slash policy + committee ── + // Deploy and configure SlashingManager + let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; + let slashing_mgr = SlashingManager::new(sm_addr, &provider); let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); let e3_id: u64 = 42; + let proof_type = 0u8; // C0PkBfv + // Set slash policy (attestation-based: requiresProof=true, appealWindow=0) slashing_mgr .setSlashPolicy( reason, @@ -406,7 +700,7 @@ async fn test_onchain_valid_proof_reverts_proof_is_valid() { ticketPenalty: U256::from(50_000_000u64), licensePenalty: U256::from(100_000_000_000_000_000_000u128), requiresProof: true, - proofVerifier: mock_verifier_addr, + proofVerifier: Address::ZERO, banNode: false, appealWindow: U256::ZERO, enabled: true, @@ -421,18 +715,22 @@ async fn test_onchain_valid_proof_reverts_proof_is_valid() { .await .unwrap(); + // Set committee: 3 voters, threshold M=2 + let committee = vec![ + voter_signer1.address(), + voter_signer2.address(), + voter_signer3.address(), + ]; mock_registry - .setCommitteeNodes(U256::from(e3_id), vec![operator_addr]) + .setCommitteeNodes(U256::from(e3_id), committee) .send() .await .unwrap() .get_receipt() .await .unwrap(); - - // MockCircuitVerifier returns TRUE → proof is valid → no fault - mock_verifier - .setReturnValue(true) + mock_registry + .setThreshold(U256::from(e3_id), 2u32) .send() .await .unwrap() @@ -440,72 +738,215 @@ async fn test_onchain_valid_proof_reverts_proof_is_valid() { .await .unwrap(); - // ── Operator signs proof (Rust-side) ── + // All 3 voters sign accusation votes (agrees=true) + let accusation_id = compute_accusation_id(chain_id, e3_id, operator_addr, proof_type); + let data_hash = FixedBytes::from([0xaa; 32]); + + let (v1, s1) = sign_vote( + &voter_signer1, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); + let (v2, s2) = sign_vote( + &voter_signer2, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); + let (v3, s3) = sign_vote( + &voter_signer3, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); - let payload = ProofPayload { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::C0PkBfv, - proof: Proof::new( - CircuitName::PkBfv, - ArcBytes::from_bytes(&[0xde, 0xad, 0xbe, 0xef]), - ArcBytes::from_bytes(&[0u8; 32]), - ), - }; + let evidence = encode_attestation_evidence( + proof_type, + vec![ + (v1, true, data_hash, s1), + (v2, true, data_hash, s2), + (v3, true, data_hash, s3), + ], + ); - let signed = - SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); + // Verify proposal count before + let proposals_before = slashing_mgr + .totalProposals() + .call() + .await + .expect("totalProposals call failed"); + assert_eq!( + proposals_before, + U256::ZERO, + "should have 0 proposals before" + ); - // ── FaultSubmitter encodes evidence (Rust-side) ── + // Submit slash — should succeed (3 valid votes, threshold M=2) + let receipt = slashing_mgr + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence) + .send() + .await + .expect("proposeSlash tx should not fail to send") + .get_receipt() + .await + .expect("proposeSlash receipt should be obtainable"); - let failed = SignedProofFailed { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - faulting_node: operator_addr, - proof_type: ProofType::C0PkBfv, - signed_payload: signed, + assert!( + receipt.status(), + "proposeSlash should succeed with valid attestation quorum" + ); + + // Verify proposal was created and executed + let proposals_after = slashing_mgr + .totalProposals() + .call() + .await + .expect("totalProposals call failed"); + assert_eq!( + proposals_after, + U256::from(1u64), + "should have 1 proposal after slash" + ); + + println!( + "PASS: valid attestation quorum → slash executed — attestation signing pipeline verified" + ); +} + +/// Tests that insufficient attestations (below threshold M) cause revert. +#[tokio::test] +async fn test_onchain_insufficient_attestations_reverts() { + if !find_anvil().await { + println!("skipping: anvil not found on PATH"); + return; + } + + let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { + Some(artifacts) => artifacts, + None => { + println!("skipping: contract artifacts not found"); + return; + } }; - let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + let chain_id = provider.get_chain_id().await.unwrap(); - // ── Submit to SlashingManager (on-chain) ── + let voter_signer1 = PrivateKeySigner::random(); + let voter_signer2 = PrivateKeySigner::random(); + let voter_signer3 = PrivateKeySigner::random(); - let result = slashing_mgr - .proposeSlash( - U256::from(e3_id), - operator_addr, + let operator_addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + + let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; + let slashing_mgr = SlashingManager::new(sm_addr, &provider); + + let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); + let e3_id: u64 = 42; + let proof_type = 0u8; + + slashing_mgr + .setSlashPolicy( reason, - Bytes::from(evidence), + SlashingManager::SlashPolicy { + ticketPenalty: U256::from(50_000_000u64), + licensePenalty: U256::from(100_000_000_000_000_000_000u128), + requiresProof: true, + proofVerifier: Address::ZERO, + banNode: false, + appealWindow: U256::ZERO, + enabled: true, + affectsCommittee: false, + failureReason: 0u8, + }, + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Committee: 3 voters, threshold M=2 + mock_registry + .setCommitteeNodes( + U256::from(e3_id), + vec![ + voter_signer1.address(), + voter_signer2.address(), + voter_signer3.address(), + ], ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + mock_registry + .setThreshold(U256::from(e3_id), 2u32) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Only 1 vote (below threshold M=2) + let accusation_id = compute_accusation_id(chain_id, e3_id, operator_addr, proof_type); + let data_hash = FixedBytes::from([0xbb; 32]); + + let (v1, s1) = sign_vote( + &voter_signer1, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); + + let evidence = encode_attestation_evidence(proof_type, vec![(v1, true, data_hash, s1)]); + + let result = slashing_mgr + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence) .call() .await; - // Should revert with ProofIsValid — the proof is valid, so there's no fault assert!( result.is_err(), - "should revert because the proof is valid (no fault to slash)" + "should revert because only 1 vote < threshold M=2" ); let err_string = format!("{:?}", result.unwrap_err()); assert!( - err_string.contains("ProofIsValid") || err_string.contains("0x5b718c5b"), - "expected ProofIsValid revert, got: {err_string}" + err_string.contains("InsufficientAttestations"), + "expected InsufficientAttestations revert, got: {err_string}" ); - println!("PASS: valid proof correctly reverts with ProofIsValid — Rust→Solidity signing alignment verified"); + println!("PASS: insufficient attestations correctly reverts"); } -/// Tests that a wrong signer (attacker) cannot slash an arbitrary operator. -/// -/// The attacker signs the proof with their own key but submits it as evidence -/// against a different operator. The contract should reject because the -/// recovered signer doesn't match the target operator. +/// Tests that a voter not in the committee causes revert. #[tokio::test] -async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { +async fn test_onchain_voter_not_in_committee_reverts() { if !find_anvil().await { println!("skipping: anvil not found on PATH"); return; } - let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { Some(artifacts) => artifacts, None => { println!("skipping: contract artifacts not found"); @@ -515,26 +956,23 @@ async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); - let accounts = provider.get_accounts().await.unwrap(); - let admin = accounts[0]; - let attacker_signer = PrivateKeySigner::random(); - let victim_addr: Address = "0x1111111111111111111111111111111111111111" + let committee_signer = PrivateKeySigner::random(); + let outsider_signer = PrivateKeySigner::random(); + + let operator_addr: Address = "0x1111111111111111111111111111111111111111" .parse() .unwrap(); - let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; - let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); - let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); - let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; - + let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; let slashing_mgr = SlashingManager::new(sm_addr, &provider); - let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); let e3_id: u64 = 42; + let proof_type = 0u8; slashing_mgr .setSlashPolicy( @@ -543,7 +981,7 @@ async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { ticketPenalty: U256::from(50_000_000u64), licensePenalty: U256::from(100_000_000_000_000_000_000u128), requiresProof: true, - proofVerifier: mock_verifier_addr, + proofVerifier: Address::ZERO, banNode: false, appealWindow: U256::ZERO, enabled: true, @@ -558,9 +996,17 @@ async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { .await .unwrap(); - // Add VICTIM to committee (not the attacker) + // Committee only contains committee_signer, NOT outsider_signer mock_registry - .setCommitteeNodes(U256::from(e3_id), vec![victim_addr]) + .setCommitteeNodes(U256::from(e3_id), vec![committee_signer.address()]) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + mock_registry + .setThreshold(U256::from(e3_id), 1u32) .send() .await .unwrap() @@ -568,59 +1014,49 @@ async fn test_onchain_wrong_signer_reverts_signer_is_not_operator() { .await .unwrap(); - // Attacker signs the proof with their own key - let payload = ProofPayload { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::C0PkBfv, - proof: Proof::new( - CircuitName::PkBfv, - ArcBytes::from_bytes(&[0xde, 0xad]), - ArcBytes::from_bytes(&[0u8; 32]), - ), - }; - let signed = - SignedProofPayload::sign(payload, &attacker_signer).expect("signing should succeed"); - - let failed = SignedProofFailed { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - faulting_node: attacker_signer.address(), - proof_type: ProofType::C0PkBfv, - signed_payload: signed, - }; + // Outsider signs a vote (valid signature, but not a committee member) + let accusation_id = compute_accusation_id(chain_id, e3_id, operator_addr, proof_type); + let data_hash = FixedBytes::from([0xcc; 32]); + + let (v_out, s_out) = sign_vote( + &outsider_signer, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); - let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + let evidence = encode_attestation_evidence(proof_type, vec![(v_out, true, data_hash, s_out)]); - // Submit evidence targeting the VICTIM, but signed by the ATTACKER let result = slashing_mgr - .proposeSlash( - U256::from(e3_id), - victim_addr, // <-- target is victim, not the actual signer - reason, - Bytes::from(evidence), - ) + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence) .call() .await; - assert!(result.is_err(), "should revert because signer != operator"); + assert!( + result.is_err(), + "should revert because voter is not a committee member" + ); let err_string = format!("{:?}", result.unwrap_err()); assert!( - err_string.contains("SignerIsNotOperator") || err_string.contains("0xcd659038"), - "expected SignerIsNotOperator revert, got: {err_string}" + err_string.contains("VoterNotInCommittee"), + "expected VoterNotInCommittee revert, got: {err_string}" ); - println!("PASS: wrong signer correctly reverts — V-001 protection verified"); + println!("PASS: non-committee voter correctly reverts — committee check verified"); } -/// Tests that operators not in the committee cannot be slashed. +/// Tests that an invalid vote signature (signed by wrong key) causes revert. #[tokio::test] -async fn test_onchain_non_committee_member_reverts() { +async fn test_onchain_invalid_vote_signature_reverts() { if !find_anvil().await { println!("skipping: anvil not found on PATH"); return; } - let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { Some(artifacts) => artifacts, None => { println!("skipping: contract artifacts not found"); @@ -630,23 +1066,23 @@ async fn test_onchain_non_committee_member_reverts() { let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); - let accounts = provider.get_accounts().await.unwrap(); - let admin = accounts[0]; - let operator_signer = PrivateKeySigner::random(); - let operator_addr = operator_signer.address(); + let victim_signer = PrivateKeySigner::random(); + let impersonator_signer = PrivateKeySigner::random(); - let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; - let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; - let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let operator_addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); - let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); - let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; let slashing_mgr = SlashingManager::new(sm_addr, &provider); let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); let e3_id: u64 = 42; + let proof_type = 0u8; slashing_mgr .setSlashPolicy( @@ -655,7 +1091,7 @@ async fn test_onchain_non_committee_member_reverts() { ticketPenalty: U256::from(50_000_000u64), licensePenalty: U256::from(100_000_000_000_000_000_000u128), requiresProof: true, - proofVerifier: mock_verifier_addr, + proofVerifier: Address::ZERO, banNode: false, appealWindow: U256::ZERO, enabled: true, @@ -670,66 +1106,84 @@ async fn test_onchain_non_committee_member_reverts() { .await .unwrap(); - // NOTE: We do NOT add the operator to the committee - - // Operator signs a proof - let payload = ProofPayload { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::C0PkBfv, - proof: Proof::new( - CircuitName::PkBfv, - ArcBytes::from_bytes(&[0xab, 0xcd]), - ArcBytes::from_bytes(&[0u8; 32]), - ), - }; - let signed = - SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); - - let failed = SignedProofFailed { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - faulting_node: operator_addr, - proof_type: ProofType::C0PkBfv, - signed_payload: signed, - }; + // victim_signer is in the committee + mock_registry + .setCommitteeNodes(U256::from(e3_id), vec![victim_signer.address()]) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + mock_registry + .setThreshold(U256::from(e3_id), 1u32) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); - let evidence = encode_fault_evidence(&failed, mock_verifier_addr); + // Impersonator signs the vote with their key, but we claim it's from victim_signer + let accusation_id = compute_accusation_id(chain_id, e3_id, operator_addr, proof_type); + let data_hash = FixedBytes::from([0xdd; 32]); + + // Sign using impersonator's key but construct the digest for victim_signer's address + let digest = compute_vote_digest( + chain_id, + e3_id, + accusation_id, + victim_signer.address(), + true, + data_hash, + ); + let bad_sig = impersonator_signer + .sign_message_sync(digest.as_ref()) + .expect("signing should succeed"); + + // Build evidence claiming the vote is from victim_signer but signed by impersonator + let evidence = Bytes::from( + ( + U256::from(proof_type), + vec![victim_signer.address()], + vec![true], + vec![data_hash], + vec![Bytes::from(bad_sig.as_bytes().to_vec())], + ) + .abi_encode(), + ); let result = slashing_mgr - .proposeSlash( - U256::from(e3_id), - operator_addr, - reason, - Bytes::from(evidence), - ) + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence) .call() .await; assert!( result.is_err(), - "should revert because operator is not in committee" + "should revert because signature doesn't match claimed voter" ); let err_string = format!("{:?}", result.unwrap_err()); assert!( - err_string.contains("OperatorNotInCommittee") || err_string.contains("0x7353fac5"), - "expected OperatorNotInCommittee revert, got: {err_string}" + err_string.contains("InvalidVoteSignature"), + "expected InvalidVoteSignature revert, got: {err_string}" ); - println!("PASS: non-committee member correctly reverts — committee check verified"); + println!("PASS: invalid vote signature correctly reverts — signature verification verified"); } -/// Tests the complete slash execution flow: invalid proof → fault confirmed → slash executed. +/// Tests that duplicate voters (non-ascending order) cause revert. /// -/// Uses a NOOP contract as BondingRegistry so that `slashTicketBalance` and -/// `slashLicenseBond` calls succeed silently, allowing the full flow to complete. +/// The contract requires voters in strictly ascending address order to prevent +/// the same voter from being counted twice. #[tokio::test] -async fn test_onchain_invalid_proof_executes_slash() { +async fn test_onchain_duplicate_voter_reverts() { if !find_anvil().await { println!("skipping: anvil not found on PATH"); return; } - let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { Some(artifacts) => artifacts, None => { println!("skipping: contract artifacts not found"); @@ -739,26 +1193,22 @@ async fn test_onchain_invalid_proof_executes_slash() { let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); - let accounts = provider.get_accounts().await.unwrap(); - let admin = accounts[0]; - let operator_signer = PrivateKeySigner::random(); - let operator_addr = operator_signer.address(); + let voter_signer = PrivateKeySigner::random(); - let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; - let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; - let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let operator_addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); - // Use noop as both bondingRegistry and enclave - let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); - let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; let slashing_mgr = SlashingManager::new(sm_addr, &provider); - let mock_verifier = MockCircuitVerifier::new(mock_verifier_addr, &provider); - let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); let e3_id: u64 = 42; + let proof_type = 0u8; slashing_mgr .setSlashPolicy( @@ -767,7 +1217,7 @@ async fn test_onchain_invalid_proof_executes_slash() { ticketPenalty: U256::from(50_000_000u64), licensePenalty: U256::from(100_000_000_000_000_000_000u128), requiresProof: true, - proofVerifier: mock_verifier_addr, + proofVerifier: Address::ZERO, banNode: false, appealWindow: U256::ZERO, enabled: true, @@ -783,17 +1233,15 @@ async fn test_onchain_invalid_proof_executes_slash() { .unwrap(); mock_registry - .setCommitteeNodes(U256::from(e3_id), vec![operator_addr]) + .setCommitteeNodes(U256::from(e3_id), vec![voter_signer.address()]) .send() .await .unwrap() .get_receipt() .await .unwrap(); - - // MockCircuitVerifier returns FALSE → proof is invalid → fault confirmed - mock_verifier - .setReturnValue(false) + mock_registry + .setThreshold(U256::from(e3_id), 1u32) .send() .await .unwrap() @@ -801,87 +1249,60 @@ async fn test_onchain_invalid_proof_executes_slash() { .await .unwrap(); - // Operator signs a (bad) proof - let payload = ProofPayload { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::C0PkBfv, - proof: Proof::new( - CircuitName::PkBfv, - ArcBytes::from_bytes(&[0xba, 0xd0, 0xba, 0xd0]), - ArcBytes::from_bytes(&[0u8; 32]), - ), - }; - let signed = - SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); - - let failed = SignedProofFailed { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - faulting_node: operator_addr, - proof_type: ProofType::C0PkBfv, - signed_payload: signed, - }; - - let evidence = encode_fault_evidence(&failed, mock_verifier_addr); - - // Verify proposal count before - let proposals_before = slashing_mgr - .totalProposals() - .call() - .await - .expect("totalProposals call failed"); - assert_eq!( - proposals_before, - U256::ZERO, - "should have 0 proposals before" + // Create TWO votes from the same voter (duplicate addresses) + let accusation_id = compute_accusation_id(chain_id, e3_id, operator_addr, proof_type); + let data_hash = FixedBytes::from([0xee; 32]); + + let (voter, sig) = sign_vote( + &voter_signer, + chain_id, + e3_id, + accusation_id, + true, + data_hash, ); - // Submit slash — should succeed (invalid proof = fault confirmed) - let receipt = slashing_mgr - .proposeSlash( - U256::from(e3_id), - operator_addr, - reason, - Bytes::from(evidence), + // Submit evidence with duplicate voter entries (bypassing encode_attestation_evidence + // which would deduplicate — construct manually to have same address appear twice) + let evidence = Bytes::from( + ( + U256::from(proof_type), + vec![voter, voter], // duplicate! + vec![true, true], + vec![data_hash, data_hash], + vec![sig.clone(), sig], ) - .send() - .await - .expect("proposeSlash tx should not fail to send") - .get_receipt() - .await - .expect("proposeSlash receipt should be obtainable"); + .abi_encode(), + ); + + let result = slashing_mgr + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence) + .call() + .await; assert!( - receipt.status(), - "proposeSlash transaction should succeed (invalid proof = fault confirmed, slash executed)" + result.is_err(), + "should revert because of duplicate voter addresses" ); - // Verify proposal was created and executed - let proposals_after = slashing_mgr - .totalProposals() - .call() - .await - .expect("totalProposals call failed"); - assert_eq!( - proposals_after, - U256::from(1u64), - "should have 1 proposal after slash" + let err_string = format!("{:?}", result.unwrap_err()); + assert!( + err_string.contains("DuplicateVoter"), + "expected DuplicateVoter revert, got: {err_string}" ); - println!("PASS: invalid proof correctly triggers slash execution — full flow verified"); + println!("PASS: duplicate voter correctly reverts — sorted-order dedup verified"); } -/// Tests that verifier mismatch is detected (verifier-upgrade protection). -/// -/// If the evidence references an old verifier address but the policy has been -/// updated to a new verifier, proposeSlash should revert with VerifierMismatch. +/// Tests that replaying the same evidence causes revert. #[tokio::test] -async fn test_onchain_verifier_mismatch_reverts() { +async fn test_onchain_duplicate_evidence_reverts() { if !find_anvil().await { println!("skipping: anvil not found on PATH"); return; } - let (sm_bytecode, mv_bytecode, mr_bytecode) = match load_slashing_artifacts() { + let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { Some(artifacts) => artifacts, None => { println!("skipping: contract artifacts not found"); @@ -891,23 +1312,23 @@ async fn test_onchain_verifier_mismatch_reverts() { let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); - let accounts = provider.get_accounts().await.unwrap(); - let admin = accounts[0]; - let operator_signer = PrivateKeySigner::random(); - let operator_addr = operator_signer.address(); + let voter_signer1 = PrivateKeySigner::random(); + let voter_signer2 = PrivateKeySigner::random(); - let noop_addr = deploy_contract(&provider, NOOP_DEPLOY_BYTECODE, &[]).await; - let mock_verifier_addr = deploy_contract(&provider, &mv_bytecode, &[]).await; - let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let operator_addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); - let sm_args = (admin, noop_addr, mock_registry_addr, noop_addr).abi_encode(); - let sm_addr = deploy_contract(&provider, &sm_bytecode, &sm_args).await; + let mock_registry_addr = deploy_contract(&provider, &mr_bytecode, &[]).await; + let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); + let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; let slashing_mgr = SlashingManager::new(sm_addr, &provider); let reason: FixedBytes<32> = keccak256("E3_BAD_DKG_PROOF"); let e3_id: u64 = 42; + let proof_type = 0u8; slashing_mgr .setSlashPolicy( @@ -916,7 +1337,7 @@ async fn test_onchain_verifier_mismatch_reverts() { ticketPenalty: U256::from(50_000_000u64), licensePenalty: U256::from(100_000_000_000_000_000_000u128), requiresProof: true, - proofVerifier: mock_verifier_addr, + proofVerifier: Address::ZERO, banNode: false, appealWindow: U256::ZERO, enabled: true, @@ -931,51 +1352,77 @@ async fn test_onchain_verifier_mismatch_reverts() { .await .unwrap(); - let payload = ProofPayload { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - proof_type: ProofType::C0PkBfv, - proof: Proof::new( - CircuitName::PkBfv, - ArcBytes::from_bytes(&[0xab]), - ArcBytes::from_bytes(&[0u8; 32]), - ), - }; - let signed = - SignedProofPayload::sign(payload, &operator_signer).expect("signing should succeed"); + mock_registry + .setCommitteeNodes( + U256::from(e3_id), + vec![voter_signer1.address(), voter_signer2.address()], + ) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + mock_registry + .setThreshold(U256::from(e3_id), 2u32) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); - let failed = SignedProofFailed { - e3_id: E3id::new(&e3_id.to_string(), chain_id), - faulting_node: operator_addr, - proof_type: ProofType::C0PkBfv, - signed_payload: signed, - }; + let accusation_id = compute_accusation_id(chain_id, e3_id, operator_addr, proof_type); + let data_hash = FixedBytes::from([0xff; 32]); - // Encode evidence pointing to a DIFFERENT verifier (simulating stale evidence) - let stale_verifier: Address = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - .parse() - .unwrap(); - let evidence = encode_fault_evidence(&failed, stale_verifier); + let (v1, s1) = sign_vote( + &voter_signer1, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); + let (v2, s2) = sign_vote( + &voter_signer2, + chain_id, + e3_id, + accusation_id, + true, + data_hash, + ); + + let evidence = encode_attestation_evidence( + proof_type, + vec![(v1, true, data_hash, s1), (v2, true, data_hash, s2)], + ); + // First submission should succeed + slashing_mgr + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence.clone()) + .send() + .await + .expect("first proposeSlash should succeed") + .get_receipt() + .await + .expect("first proposeSlash receipt should be obtainable"); + + // Second submission with same evidence should revert let result = slashing_mgr - .proposeSlash( - U256::from(e3_id), - operator_addr, - reason, - Bytes::from(evidence), - ) + .proposeSlash(U256::from(e3_id), operator_addr, reason, evidence) .call() .await; assert!( result.is_err(), - "should revert because verifier in evidence doesn't match policy" + "should revert because the same evidence was already consumed" ); let err_string = format!("{:?}", result.unwrap_err()); assert!( - err_string.contains("VerifierMismatch") || err_string.contains("0x1c485278"), - "expected VerifierMismatch revert, got: {err_string}" + err_string.contains("DuplicateEvidence"), + "expected DuplicateEvidence revert, got: {err_string}" ); - println!("PASS: verifier mismatch correctly reverts — verifier-upgrade protection verified"); + println!("PASS: duplicate evidence correctly reverts — replay protection verified"); } From f7e71b97a5401eb1ff70b78da6441a874ff60b85 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Tue, 10 Mar 2026 22:10:50 +0500 Subject: [PATCH 17/21] fix: use active committee check for voters --- .../contracts/slashing/SlashingManager.sol | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol index e2ddec6293..8cfe155f0f 100644 --- a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol +++ b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol @@ -247,6 +247,10 @@ contract SlashingManager is ISlashingManager, AccessControl { require(policy.enabled, SlashReasonDisabled()); require(policy.requiresProof, InvalidPolicy()); require(proof.length != 0, ProofRequired()); + require( + ciphernodeRegistry.isCommitteeMember(e3Id, operator), + OperatorNotInCommittee() + ); // Evidence replay protection — reason-independent to prevent cross-reason replay bytes32 evidenceKey = keccak256( @@ -402,6 +406,7 @@ contract SlashingManager is ISlashingManager, AccessControl { { (, uint32 thresholdM, , ) = ciphernodeRegistry .getCommitteeViability(e3Id); + require(thresholdM > 0, InvalidProposal()); require(numVotes >= thresholdM, InsufficientAttestations()); } @@ -417,9 +422,9 @@ contract SlashingManager is ISlashingManager, AccessControl { // All votes must agree the proof is bad (fault confirmed) require(agrees[i], InvalidProof()); - // Verify voter was a committee member for this E3 + // Verify voter is an active committee member for this E3 require( - ciphernodeRegistry.isCommitteeMember(e3Id, voter), + ciphernodeRegistry.isCommitteeMemberActive(e3Id, voter), VoterNotInCommittee() ); From f748d959a98bb6c1fc1131ccb98717f3a0386e25 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Tue, 10 Mar 2026 23:27:38 +0500 Subject: [PATCH 18/21] fix: accused cannot be a voter --- .../ISlashingManager.json | 7 +- .../contracts/interfaces/ISlashingManager.sol | 3 + .../contracts/slashing/SlashingManager.sol | 3 + .../test/E3Lifecycle/E3Integration.spec.ts | 48 ++++-- .../test/Slashing/CommitteeExpulsion.spec.ts | 149 ++++++++++++------ .../test/Slashing/SlashingManager.spec.ts | 17 +- 6 files changed, 162 insertions(+), 65 deletions(-) diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index 5bd09e7db2..9abc6ba2bc 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -133,6 +133,11 @@ "name": "VerifierNotSet", "type": "error" }, + { + "inputs": [], + "name": "VoterIsAccused", + "type": "error" + }, { "inputs": [], "name": "VoterNotInCommittee", @@ -954,5 +959,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-b29e2e20df477e5829743192d3e53aa86d40cfe2" + "buildInfoId": "solc-0_8_28-6bc8af1be4d8089e346f35a0a250902383427ca2" } \ No newline at end of file diff --git a/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol b/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol index 6015275f97..bb2f6af08a 100644 --- a/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol +++ b/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol @@ -175,6 +175,9 @@ interface ISlashingManager { /// @notice Thrown when an attestation vote signature does not recover to the declared voter error InvalidVoteSignature(); + /// @notice Thrown when the accused operator is included as a voter in the attestation + error VoterIsAccused(); + // ====================== // Events // ====================== diff --git a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol index 8cfe155f0f..07ac1984a9 100644 --- a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol +++ b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol @@ -419,6 +419,9 @@ contract SlashingManager is ISlashingManager, AccessControl { require(voter > prevVoter, DuplicateVoter()); prevVoter = voter; + // The accused cannot vote on their own accusation (conflict of interest) + require(voter != operator, VoterIsAccused()); + // All votes must agree the proof is bad (fault confirmed) require(agrees[i], InvalidProof()); diff --git a/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts b/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts index 12e9bf202b..de5a89d13b 100644 --- a/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts +++ b/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts @@ -144,8 +144,15 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const setup = async () => { // ── Signers ──────────────────────────────────────────────────────────────── - const [owner, requester, treasury, operator1, operator2, computeProvider] = - await ethers.getSigners(); + const [ + owner, + requester, + treasury, + operator1, + operator2, + computeProvider, + operator3, + ] = await ethers.getSigners(); const ownerAddress = await owner.getAddress(); const treasuryAddress = await treasury.getAddress(); @@ -345,11 +352,12 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { // ── Helpers ──────────────────────────────────────────────────────────────── const makeRequest = async ( signer: Signer = requester, + threshold: [number, number] = [2, 2], ): Promise<{ e3Id: number }> => { const startTime = (await time.latest()) + 100; const requestParams = { - threshold: [2, 2] as [number, number], + threshold, inputWindow: [startTime + 100, startTime + ONE_DAY] as [number, number], e3Program: await e3Program.getAddress(), e3ProgramParams: encodedE3ProgramParams, @@ -414,6 +422,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { treasury, operator1, operator2, + operator3, computeProvider, makeRequest, setupOperator, @@ -744,22 +753,26 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { requester, operator1, operator2, + operator3, setupOperator, } = await loadFixture(setup); await setupOperator(operator1); await setupOperator(operator2); + await setupOperator(operator3); // 1. Request E3, form committee, publish key - await makeRequest(); + await makeRequest(requester, [2, 3]); await registry.connect(operator1).submitTicket(0, 1); await registry.connect(operator2).submitTicket(0, 1); + await registry.connect(operator3).submitTicket(0, 1); await time.increase(SORTITION_SUBMISSION_WINDOW + 1); await registry.finalizeCommittee(0); const nodes = [ await operator1.getAddress(), await operator2.getAddress(), + await operator3.getAddress(), ]; const publicKey = "0x1234567890abcdef1234567890abcdef"; const publicKeyHash = ethers.keccak256(publicKey); @@ -789,7 +802,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { // This triggers: _executeSlash → slashTicketBalance → redirectSlashedTicketFunds // → ticketToken.payout(refundManager, amount) → enclave.escrowSlashedFunds → e3RefundManager.escrowSlashedFunds const proof = await signAndEncodeAttestation( - [operator1, operator2], + [operator2, operator3], 0, await operator1.getAddress(), ); @@ -846,22 +859,26 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { makeRequest, operator1, operator2, + operator3, setupOperator, } = await loadFixture(setup); await setupOperator(operator1); await setupOperator(operator2); + await setupOperator(operator3); // 1. Request E3, form committee, publish key - await makeRequest(); + await makeRequest(undefined, [2, 3]); await registry.connect(operator1).submitTicket(0, 1); await registry.connect(operator2).submitTicket(0, 1); + await registry.connect(operator3).submitTicket(0, 1); await time.increase(SORTITION_SUBMISSION_WINDOW + 1); await registry.finalizeCommittee(0); const nodes = [ await operator1.getAddress(), await operator2.getAddress(), + await operator3.getAddress(), ]; const publicKey = "0x1234567890abcdef1234567890abcdef"; const publicKeyHash = ethers.keccak256(publicKey); @@ -881,7 +898,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { // 4. Slash operator1 — this routes funds into the refund pool const proof = await signAndEncodeAttestation( - [operator1, operator2], + [operator2, operator3], 0, await operator1.getAddress(), ); @@ -1396,23 +1413,27 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { makeRequest, operator1, operator2, + operator3, treasury, setupOperator, } = await loadFixture(setup); await setupOperator(operator1); await setupOperator(operator2); + await setupOperator(operator3); // 1. Request E3, form committee, publish key - await makeRequest(); + await makeRequest(undefined, [2, 3]); await registry.connect(operator1).submitTicket(0, 1); await registry.connect(operator2).submitTicket(0, 1); + await registry.connect(operator3).submitTicket(0, 1); await time.increase(SORTITION_SUBMISSION_WINDOW + 1); await registry.finalizeCommittee(0); const nodes = [ await operator1.getAddress(), await operator2.getAddress(), + await operator3.getAddress(), ]; const publicKey = "0x1234567890abcdef1234567890abcdef"; const publicKeyHash = ethers.keccak256(publicKey); @@ -1427,7 +1448,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { await usdcToken.balanceOf(refundManagerAddress); const proof = await signAndEncodeAttestation( - [operator1, operator2], + [operator2, operator3], 0, await operator1.getAddress(), ); @@ -1465,6 +1486,9 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const op2BalanceBefore = await usdcToken.balanceOf( await operator2.getAddress(), ); + const op3BalanceBefore = await usdcToken.balanceOf( + await operator3.getAddress(), + ); const plaintextOutput = "0x" + "cd".repeat(100); await enclave.publishPlaintextOutput(0, plaintextOutput, proofBytes); @@ -1494,10 +1518,14 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const op2BalanceAfter = await usdcToken.balanceOf( await operator2.getAddress(), ); + const op3BalanceAfter = await usdcToken.balanceOf( + await operator3.getAddress(), + ); const nodesReceivedTotal = op1BalanceAfter - op1BalanceBefore + - (op2BalanceAfter - op2BalanceBefore); + (op2BalanceAfter - op2BalanceBefore) + + (op3BalanceAfter - op3BalanceBefore); expect(nodesReceivedTotal).to.equal(e3Payment + expectedSlashedToNodes); // Verify refund manager escrowed balance was drained diff --git a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts index afe7deeffc..2954e02922 100644 --- a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts +++ b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts @@ -555,6 +555,7 @@ describe("Committee Expulsion & Fault Tolerance", function () { const { enclave, slashingManager, + owner, operator1, operator2, operator3, @@ -567,6 +568,26 @@ describe("Committee Expulsion & Fault Tolerance", function () { await setupOperator(operator2); await setupOperator(operator3); + // Add an evidence-based slash policy (Lane B) with no appeal window + const REASON_EVIDENCE = ethers.keccak256( + ethers.toUtf8Bytes("E3_EVIDENCE_SLASH"), + ); + await slashingManager.setSlashPolicy(REASON_EVIDENCE, { + ticketPenalty: ethers.parseUnits("10", 6), + licensePenalty: ethers.parseEther("50"), + requiresProof: false, + proofVerifier: ethers.ZeroAddress, + banNode: false, + appealWindow: 1, // Minimum appeal window (1 second) + enabled: true, + affectsCommittee: true, + failureReason: 4, // FailureReason.DKGInvalidShares + }); + + // Grant SLASHER_ROLE to owner for Lane B + const SLASHER_ROLE = await slashingManager.SLASHER_ROLE(); + await slashingManager.grantRole(SLASHER_ROLE, await owner.getAddress()); + await makeRequest([2, 3]); // M=2, N=3 await finalizeCommitteeWithOperators(0, [ operator1, @@ -574,8 +595,8 @@ describe("Committee Expulsion & Fault Tolerance", function () { operator3, ]); - // Slash first member — 3 → 2 active, still >= 2 - const proof1 = await signAndEncodeAttestation( + // Lane A: Slash op1 with attestation from [op2, op3] — active 3→2, still >= M=2 + const proof = await signAndEncodeAttestation( [operator2, operator3], 0, await operator1.getAddress(), @@ -587,29 +608,28 @@ describe("Committee Expulsion & Fault Tolerance", function () { 0, await operator1.getAddress(), REASON_BAD_DKG, - proof1, + proof, ); let stage = await enclave.getE3Stage(0); expect(stage).to.not.equal(6); // Not failed yet - // Slash second member — 2 → 1 active, below threshold M=2 - // Use operator1 and operator3 as voters (operator1's vote still valid per isCommitteeMember) - const proof2 = await signAndEncodeAttestation( - [operator1, operator3], + // Lane B: Evidence-based slash of op2 (no attestation needed) — active 2→1 < M=2 + // Lane A can't trigger E3 failure alone because you always need M active + // non-accused voters, but after the slash active must drop below M — a contradiction. + // Lane B (SLASHER_ROLE) bypasses attestation requirements for this final slash. + const nextProposalId = await slashingManager.totalProposals(); + await slashingManager.proposeSlashEvidence( 0, await operator2.getAddress(), - 0, - 31337, - ethers.keccak256(ethers.toUtf8Bytes("data2")), - ); - const tx = await slashingManager.proposeSlash( - 0, - await operator2.getAddress(), - REASON_BAD_DKG, - proof2, + REASON_EVIDENCE, + ethers.toUtf8Bytes("evidence-data"), ); + // Wait for appeal window to pass, then execute + await time.increase(2); + const tx = await slashingManager.executeSlash(nextProposalId); + // Should emit E3Failed event await expect(tx).to.emit(enclave, "E3Failed"); @@ -802,11 +822,12 @@ describe("Committee Expulsion & Fault Tolerance", function () { }); describe("E3 fails below threshold", function () { - it("should fail E3 exactly at the threshold breach", async function () { + it("should fail E3 exactly at the threshold breach via Lane B", async function () { const { enclave, registry, slashingManager, + owner, operator1, operator2, setupOperator, @@ -817,23 +838,43 @@ describe("Committee Expulsion & Fault Tolerance", function () { await setupOperator(operator1); await setupOperator(operator2); + // Lane B evidence-based policy with no appeal window + const REASON_EVIDENCE = ethers.keccak256( + ethers.toUtf8Bytes("E3_EVIDENCE_SLASH"), + ); + await slashingManager.setSlashPolicy(REASON_EVIDENCE, { + ticketPenalty: ethers.parseUnits("10", 6), + licensePenalty: ethers.parseEther("50"), + requiresProof: false, + proofVerifier: ethers.ZeroAddress, + banNode: false, + appealWindow: 1, // Minimum appeal window (1 second) + enabled: true, + affectsCommittee: true, + failureReason: 4, + }); + const SLASHER_ROLE = await slashingManager.SLASHER_ROLE(); + await slashingManager.grantRole(SLASHER_ROLE, await owner.getAddress()); + await makeRequest([2, 2]); // M=2, N=2 — no room for error await finalizeCommitteeWithOperators(0, [operator1, operator2]); - // Expel one member: 2 → 1 < M=2 → E3 fails immediately - // Both committee members vote (including the accused — contract allows it) - const proof = await signAndEncodeAttestation( - [operator1, operator2], - 0, - await operator1.getAddress(), - ); - const tx = await slashingManager.proposeSlash( + // Lane A cannot slash at M active when the accused is excluded from voting. + // With M=2, N=2: expelling 1 member needs M=2 non-accused votes, but only + // 1 non-accused active voter exists. Lane B (SLASHER_ROLE) is required. + // TODO: See GitHub issue — "Lane B governance flow for M-threshold slashing" + const nextProposalId = await slashingManager.totalProposals(); + await slashingManager.proposeSlashEvidence( 0, await operator1.getAddress(), - REASON_BAD_DKG, - proof, + REASON_EVIDENCE, + ethers.toUtf8Bytes("evidence-data"), ); + // Wait for appeal window to pass, then execute + await time.increase(2); + const tx = await slashingManager.executeSlash(nextProposalId); + await expect(tx).to.emit(enclave, "E3Failed"); // Should emit CommitteeViabilityUpdated(viable=false) @@ -852,6 +893,7 @@ describe("Committee Expulsion & Fault Tolerance", function () { operator1, operator2, operator3, + operator4, setupOperator, makeRequest, finalizeCommitteeWithOperators, @@ -860,15 +902,17 @@ describe("Committee Expulsion & Fault Tolerance", function () { await setupOperator(operator1); await setupOperator(operator2); await setupOperator(operator3); + await setupOperator(operator4); - await makeRequest([2, 3]); // M=2, N=3 + await makeRequest([2, 4]); // M=2, N=4 await finalizeCommitteeWithOperators(0, [ operator1, operator2, operator3, + operator4, ]); - // Expel operator1 — still viable (2 >= 2) + // Expel operator1 — still viable (3 >= 2) const proof1 = await signAndEncodeAttestation( [operator2, operator3], 0, @@ -884,10 +928,9 @@ describe("Committee Expulsion & Fault Tolerance", function () { proof1, ); - // Expel operator2 — now below threshold (1 < 2), E3 fails - // operator1 is expelled but still isCommitteeMember, so can vote + // Expel operator2 — still viable (2 >= 2) const proof2 = await signAndEncodeAttestation( - [operator1, operator3], + [operator3, operator4], 0, await operator2.getAddress(), 0, @@ -901,35 +944,37 @@ describe("Committee Expulsion & Fault Tolerance", function () { proof2, ); - // E3 is now Failed - const stage = await enclave.getE3Stage(0); - expect(stage).to.equal(6); - - // Try to expel operator3 — E3 already failed, but onE3Failed is wrapped - // in try-catch so financial penalties are still applied - const proof3 = await signAndEncodeAttestation( - [operator1, operator2], - 0, - await operator3.getAddress(), - 0, - 31337, - ethers.keccak256(ethers.toUtf8Bytes("expel-op3")), - ); + let stage = await enclave.getE3Stage(0); + expect(stage).to.not.equal(6); // Not failed yet - // The third slash should succeed — penalties are applied even though E3 is already Failed. - // The onE3Failed call silently fails (try-catch) since E3 is already in Failed state. + // At this point only operator3 and operator4 are active (2 == M=2). + // Lane A cannot slash further: to expel operator3, we need M=2 non-accused + // active voters, but only operator4 is available (1 < 2). + // This proves Lane A naturally stops at M active members. + // Lane B (SLASHER_ROLE) is required for the final slash. + // TODO: See GitHub issue — "Lane B governance flow for M-threshold slashing" await expect( slashingManager.proposeSlash( 0, await operator3.getAddress(), REASON_BAD_DKG, - proof3, + await signAndEncodeAttestation( + [operator4], + 0, + await operator3.getAddress(), + 0, + 31337, + ethers.keccak256(ethers.toUtf8Bytes("expel-op3")), + ), ), - ).to.emit(slashingManager, "SlashExecuted"); + ).to.be.revertedWithCustomError( + slashingManager, + "InsufficientAttestations", + ); - // E3 stage should still be Failed + // E3 stage should still NOT be Failed — only 2 active, which equals M const stageAfter = await enclave.getE3Stage(0); - expect(stageAfter).to.equal(6); + expect(stageAfter).to.not.equal(6); }); }); diff --git a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts index 4357b0d439..22dba354e8 100644 --- a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts +++ b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts @@ -651,11 +651,12 @@ describe("SlashingManager", function () { }; await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); - // Set up committee membership for voters (not the operator — voters attest the operator is faulty) + // Set up committee membership: operator must be a member, voters attest the operator is faulty const e3Id = 0; const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(e3Id, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -711,6 +712,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -758,6 +760,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -859,7 +862,10 @@ describe("SlashingManager", function () { // Only voter1 is a committee member, but voter2 also signs const voter1Addr = await voter1.getAddress(); - await mockCiphernodeRegistry.setCommitteeNodes(0, [voter1Addr]); + await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, + voter1Addr, + ]); await mockCiphernodeRegistry.setThreshold(0, 1); const proof = await signAndEncodeAttestation( @@ -950,6 +956,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -987,11 +994,13 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); await mockCiphernodeRegistry.setThreshold(0, 2); await mockCiphernodeRegistry.setCommitteeNodes(1, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -1037,6 +1046,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -1174,6 +1184,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -1352,6 +1363,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); @@ -1647,6 +1659,7 @@ describe("SlashingManager", function () { const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ + operatorAddress, voter1Addr, voter2Addr, ]); From a9615fbf07ee087859f19afacc5f1894e852c42f Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Wed, 11 Mar 2026 18:01:29 +0500 Subject: [PATCH 19/21] fix: review comments --- .../contracts/slashing/SlashingManager.sol | 8 +++++--- .../test/Slashing/CommitteeExpulsion.spec.ts | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol index 07ac1984a9..f990669c61 100644 --- a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol +++ b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol @@ -67,8 +67,9 @@ contract SlashingManager is ISlashingManager, AccessControl { mapping(address node => bool banned) public banned; /// @notice Evidence replay protection: tracks consumed evidence keys - /// @dev Key is keccak256(abi.encode(e3Id, operator, keccak256(proof))) — reason-independent - /// to prevent the same proof/evidence from being used to slash under multiple reasons. + /// @dev Lane A key is keccak256(abi.encodePacked(chainId, e3Id, operator, proofType)) — the accusation identity. + /// This prevents the same fault from being slashed multiple times via different voter subsets. + /// Lane B key is keccak256(abi.encode(e3Id, operator, keccak256(evidence))) — exact evidence bytes. mapping(bytes32 evidenceKey => bool consumed) public evidenceConsumed; // ====================== @@ -253,8 +254,9 @@ contract SlashingManager is ISlashingManager, AccessControl { ); // Evidence replay protection — reason-independent to prevent cross-reason replay + uint256 proofType = abi.decode(proof, (uint256)); bytes32 evidenceKey = keccak256( - abi.encode(e3Id, operator, keccak256(proof)) + abi.encodePacked(block.chainid, e3Id, operator, proofType) ); require(!evidenceConsumed[evidenceKey], DuplicateEvidence()); evidenceConsumed[evidenceKey] = true; diff --git a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts index 2954e02922..0fae79026a 100644 --- a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts +++ b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts @@ -682,19 +682,21 @@ describe("Committee Expulsion & Fault Tolerance", function () { ); expect((await registry.getCommitteeViability(0)).activeCount).to.equal(2); - // Slash operator1 again with different evidence (different dataHash) + // Slash operator1 again for a different proof type to verify expulsion is idempotent. + // Same (e3Id, operator, proofType) would revert DuplicateEvidence — that's correct. + // Using proofType=7 (T5ShareDecryption) with REASON_BAD_DECRYPTION instead. const proof2 = await signAndEncodeAttestation( [operator2, operator3], 0, await operator1.getAddress(), - 0, + 7, // T5ShareDecryption — different proofType 31337, ethers.keccak256(ethers.toUtf8Bytes("second")), ); await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, + REASON_BAD_DECRYPTION, proof2, ); From 51b842468951527df7b5489236557f6669b5b132 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Wed, 11 Mar 2026 18:25:58 +0500 Subject: [PATCH 20/21] fix: review comments --- .../src/actors/accusation_manager.rs | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs index e0827280ad..5671afc60c 100644 --- a/crates/zk-prover/src/actors/accusation_manager.rs +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -39,8 +39,8 @@ use e3_events::{ ComputeRequestError, ComputeResponse, ComputeResponseKind, CorrelationId, E3id, EnclaveEvent, EnclaveEventData, EventContext, EventPublisher, EventSubscriber, EventType, PartyProofsToVerify, ProofFailureAccusation, ProofType, ProofVerificationFailed, - ProofVerificationPassed, Sequenced, SignedProofPayload, TypedEvent, VerifyShareProofsRequest, - ZkRequest, ZkResponse, + ProofVerificationPassed, Sequenced, SignedProofPayload, SlashExecuted, TypedEvent, + VerifyShareProofsRequest, ZkRequest, ZkResponse, }; use e3_utils::NotifySync; use tracing::{error, info, warn}; @@ -158,6 +158,7 @@ impl AccusationManager { 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()); addr } @@ -744,8 +745,14 @@ impl AccusationManager { return; } - // Check if quorum is still possible - let remaining = self.committee.len().saturating_sub(total_votes); + // Check if quorum is still possible. + // Exclude the accused — they cannot vote on their own accusation. + let effective_committee = if self.committee.contains(&pending.accusation.accused) { + self.committee.len().saturating_sub(1) + } else { + self.committee.len() + }; + let remaining = effective_committee.saturating_sub(total_votes); if agree_count + remaining < self.threshold_m { // Even if all remaining voters agree, can't reach quorum. // Collect all unique data hashes (from all votes + the accusation itself) @@ -880,6 +887,33 @@ impl AccusationManager { } } + /// 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); + pending.votes_against.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( @@ -1039,6 +1073,9 @@ impl Handler for AccusationManager { EnclaveEventData::ComputeRequestError(data) => { self.notify_sync(ctx, TypedEvent::new(data, ec)) } + EnclaveEventData::SlashExecuted(data) => { + self.on_slash_executed(data); + } _ => (), } } From 189bafc5d65b71511afc7cb66907de5bb0f7f12a Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Thu, 12 Mar 2026 00:56:21 +0500 Subject: [PATCH 21/21] fix: review comments --- crates/aggregator/src/publickey_aggregator.rs | 8 ++ crates/evm/src/slashing_manager_sol_writer.rs | 80 ++++++++++++------ .../src/actors/accusation_manager.rs | 47 ++++++++++- .../src/actors/accusation_manager_ext.rs | 20 ++++- .../ISlashingManager.json | 7 +- .../contracts/interfaces/ISlashingManager.sol | 6 +- .../contracts/slashing/SlashingManager.sol | 12 ++- .../test/E3Lifecycle/E3Integration.spec.ts | 9 +- .../test/Slashing/CommitteeExpulsion.spec.ts | 43 +++------- .../test/Slashing/SlashingManager.spec.ts | 82 +++++++++---------- 10 files changed, 192 insertions(+), 122 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 2295e390ba..4618dcc2d7 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -423,6 +423,14 @@ impl PublicKeyAggregator { ); } + if *threshold_n < *threshold_m { + warn!( + "PublicKeyAggregator: threshold_n ({}) < threshold_m ({}) after expulsion — committee unviable", + threshold_n, threshold_m + ); + return Ok(state); + } + if keyshares.len() == *threshold_n && *threshold_n > 0 { let m = *threshold_m; info!("PublicKeyAggregator: enough keyshares after expulsion, transitioning to VerifyingC1"); diff --git a/crates/evm/src/slashing_manager_sol_writer.rs b/crates/evm/src/slashing_manager_sol_writer.rs index b4173a6a4d..99447afe1d 100644 --- a/crates/evm/src/slashing_manager_sol_writer.rs +++ b/crates/evm/src/slashing_manager_sol_writer.rs @@ -12,7 +12,7 @@ use crate::send_tx_with_retry; use actix::prelude::*; use actix::Addr; use alloy::{ - primitives::{keccak256, Address, Bytes, U256}, + primitives::{Address, Bytes, U256}, providers::{Provider, WalletProvider}, rpc::types::TransactionReceipt, sol, @@ -27,7 +27,8 @@ use e3_events::EventType; use e3_events::Shutdown; use e3_events::{AccusationOutcome, AccusationQuorumReached, EType}; use e3_utils::NotifySync; -use tracing::info; +use std::time::Duration; +use tracing::{info, warn}; sol!( #[sol(rpc)] @@ -35,6 +36,14 @@ sol!( "../../packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json" ); +/// Maximum number of voters eligible to attempt on-chain submission. +/// Rank 0 submits immediately, rank 1 after one delay interval, etc. +const MAX_SLASH_SUBMITTERS: usize = 3; + +/// Delay between fallback submission attempts (seconds). +/// Rank N waits N * SUBMITTER_DELAY_SECS before submitting. +const SUBMITTER_DELAY_SECS: u64 = 30; + /// Submits `AccusationQuorumReached` events as slash proposals on-chain. pub struct SlashingManagerSolWriter

{ provider: EthProvider

, @@ -84,24 +93,24 @@ impl Handler // Only submit if: // 1. This is the right chain // 2. The quorum decided the accused is at fault OR equivocated - // 3. This node is the designated submitter (lowest-address agreeing - // voter). This is deterministic so exactly one node submits, - // and decouples submission from the accuser to avoid single-point - // failures if the accuser's node goes down after quorum. + // 3. This node is among the top MAX_SLASH_SUBMITTERS voters + // (sorted ascending by address). The lowest-address voter + // submits immediately; higher-ranked fallback voters wait + // progressively longer (rank * SUBMITTER_DELAY_SECS) before + // attempting submission. On-chain DuplicateEvidence protection + // ensures at most one slash executes. let my_addr = self.provider.provider().default_signer_address(); - let is_designated_submitter = data - .votes_for - .iter() - .map(|v| v.voter) - .min() - .map_or(false, |min_voter| min_voter == my_addr); + let mut sorted_voters: Vec

= + data.votes_for.iter().map(|v| v.voter).collect(); + sorted_voters.sort(); + let my_rank = sorted_voters.iter().position(|&v| v == my_addr); if self.provider.chain_id() == data.e3_id.chain_id() && matches!( data.outcome, AccusationOutcome::AccusedFaulted | AccusationOutcome::Equivocation ) - && is_designated_submitter + && my_rank.map_or(false, |r| r < MAX_SLASH_SUBMITTERS) { ctx.notify(data); } @@ -122,17 +131,46 @@ impl Handler = + msg.votes_for.iter().map(|v| v.voter).collect(); + sorted_voters.sort(); + let rank = sorted_voters + .iter() + .position(|&v| v == my_addr) + .unwrap_or(0); + + // Fallback submitters wait before attempting, giving the primary + // submitter time to land the transaction on-chain. + if rank > 0 { + let delay = Duration::from_secs(rank as u64 * SUBMITTER_DELAY_SECS); + info!( + "Fallback submitter (rank {rank}): waiting {delay:?} before submission attempt" + ); + tokio::time::sleep(delay).await; + } + let result = submit_slash_proposal(provider, contract_address, msg).await; match result { Ok(receipt) => { info!(tx=%receipt.transaction_hash, "Submitted attestation-based slash proposal on-chain"); } Err(err) => { - bus.err( - EType::Evm, - anyhow::anyhow!("Error submitting slash proposal: {:?}", err), - ); + if rank > 0 { + // Fallback submitters expect DuplicateEvidence reverts + // when the primary submitter has already landed the tx. + warn!( + "Fallback submitter (rank {rank}): slash submission failed \ + (likely already submitted by primary): {err:?}" + ); + } else { + bus.err( + EType::Evm, + anyhow::anyhow!("Error submitting slash proposal: {:?}", err), + ); + } } } } @@ -179,15 +217,11 @@ async fn submit_slash_proposal( ) -> Result { let e3_id: U256 = data.e3_id.clone().try_into()?; let operator = data.accused; - let reason = keccak256(data.proof_type.slash_reason().as_bytes()); let proof_data = encode_attestation_evidence(&data); send_tx_with_retry("proposeSlash", &[], || { - info!( - "proposeSlash() e3_id={:?} operator={:?} reason={:?}", - e3_id, operator, reason - ); + info!("proposeSlash() e3_id={:?} operator={:?}", e3_id, operator); let proof = Bytes::from(proof_data.clone()); let provider = provider.clone(); @@ -200,7 +234,7 @@ async fn submit_slash_proposal( .await?; let contract = ISlashingManager::new(contract_address, provider.provider()); let builder = contract - .proposeSlash(e3_id, operator, reason.into(), proof) + .proposeSlash(e3_id, operator, proof) .nonce(current_nonce); let receipt = builder.send().await?.get_receipt().await?; Ok(receipt) diff --git a/crates/zk-prover/src/actors/accusation_manager.rs b/crates/zk-prover/src/actors/accusation_manager.rs index 5671afc60c..4b72bd8e3b 100644 --- a/crates/zk-prover/src/actors/accusation_manager.rs +++ b/crates/zk-prover/src/actors/accusation_manager.rs @@ -77,10 +77,25 @@ struct PendingReVerification { /// 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 @@ -509,7 +524,15 @@ impl AccusationManager { let accused_party_id = accusation.accused_party_id; let forwarded_clone = forwarded.clone(); - // Create PendingAccusation without our vote — it arrives after ZK completes + // 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); }); @@ -690,6 +713,20 @@ impl AccusationManager { 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; + } + if vote.agrees { pending.votes_for.push(vote); } else { @@ -755,13 +792,14 @@ impl AccusationManager { let remaining = effective_committee.saturating_sub(total_votes); if agree_count + remaining < self.threshold_m { // Even if all remaining voters agree, can't reach quorum. - // Collect all unique data hashes (from all votes + the accusation itself) + // Collect unique data hashes from actual votes only — do NOT include + // the accusation's data_hash because it is unverified (the accuser's + // own vote already carries their independently-observed hash). let all_hashes: HashSet<[u8; 32]> = pending .votes_for .iter() .chain(pending.votes_against.iter()) .map(|v| v.data_hash) - .chain(std::iter::once(pending.accusation.data_hash)) .collect(); if all_hashes.len() > 1 { @@ -797,12 +835,13 @@ impl AccusationManager { // Check for equivocation: if voters saw different data hashes, // the accused sent different payloads to different nodes. + // Only use actual vote data_hashes — the accusation's data_hash is + // unverified and already represented by the accuser's own vote. let all_hashes: HashSet<[u8; 32]> = pending .votes_for .iter() .chain(pending.votes_against.iter()) .map(|v| v.data_hash) - .chain(std::iter::once(pending.accusation.data_hash)) .collect(); let outcome = if pending.votes_for.len() >= self.threshold_m { diff --git a/crates/zk-prover/src/actors/accusation_manager_ext.rs b/crates/zk-prover/src/actors/accusation_manager_ext.rs index 3700a8df3d..b7fdc7f97e 100644 --- a/crates/zk-prover/src/actors/accusation_manager_ext.rs +++ b/crates/zk-prover/src/actors/accusation_manager_ext.rs @@ -94,11 +94,23 @@ impl E3Extension for AccusationManagerExtension { 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<()> { - // AccusationManager is ephemeral — no state to hydrate. - // On restart, in-flight accusations are lost (acceptable: they would - // have timed out anyway). The actor will be re-created on the next - // CommitteeFinalized. Ok(()) } } diff --git a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index 9abc6ba2bc..738e479e5f 100644 --- a/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -733,11 +733,6 @@ "name": "operator", "type": "address" }, - { - "internalType": "bytes32", - "name": "reason", - "type": "bytes32" - }, { "internalType": "bytes", "name": "proof", @@ -959,5 +954,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-6bc8af1be4d8089e346f35a0a250902383427ca2" + "buildInfoId": "solc-0_8_28-13a9a691eee81d28d467846ed639e157d0edfd2b" } \ No newline at end of file diff --git a/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol b/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol index bb2f6af08a..d5cfbfeb9f 100644 --- a/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol +++ b/packages/enclave-contracts/contracts/interfaces/ISlashingManager.sol @@ -391,6 +391,10 @@ interface ISlashingManager { * @notice Creates a new slash proposal with committee attestation (Lane A - permissionless) * @dev Anyone can call this for attestation-based slashes. Requires a quorum of committee * members to have signed votes attesting that the operator submitted a bad proof. + * The slash reason is derived deterministically on-chain as + * `keccak256(abi.encodePacked(proofType))` — the caller does not pass a reason. + * This creates a 1:1 binding between proof types and slash policies, preventing + * cross-reason replay attacks. * Evidence format: * abi.encode(uint256 proofType, * address[] voters, bool[] agrees, bytes32[] dataHashes, bytes[] signatures) @@ -405,14 +409,12 @@ interface ISlashingManager { * 5. All votes agree the proof is invalid (agrees == true) * @param e3Id ID of the E3 computation this slash relates to * @param operator Address of the ciphernode operator to slash (must be non-zero) - * @param reason Hash of the slash reason (must have an enabled proof-required policy) * @param proof Attestation evidence: abi.encode(proofType, voters, agrees, dataHashes, signatures) * @return proposalId Sequential ID of the created proposal */ function proposeSlash( uint256 e3Id, address operator, - bytes32 reason, bytes calldata proof ) external returns (uint256 proposalId); diff --git a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol index f990669c61..3e31a00777 100644 --- a/packages/enclave-contracts/contracts/slashing/SlashingManager.sol +++ b/packages/enclave-contracts/contracts/slashing/SlashingManager.sol @@ -229,6 +229,9 @@ contract SlashingManager is ISlashingManager, AccessControl { /// @inheritdoc ISlashingManager /// @dev Lane A: Permissionless committee attestation-based slash. Anyone can call. /// Atomically proposes, verifies committee vote signatures, and executes slash. + /// The slash reason is derived deterministically from proofType as + /// `keccak256(abi.encodePacked(proofType))`, eliminating the need for the caller + /// to pass a reason and preventing cross-reason replay attacks. /// Evidence format: /// `abi.encode(uint256 proofType, /// address[] voters, bool[] agrees, bytes32[] dataHashes, bytes[] signatures)` @@ -239,22 +242,25 @@ contract SlashingManager is ISlashingManager, AccessControl { function proposeSlash( uint256 e3Id, address operator, - bytes32 reason, bytes calldata proof ) external returns (uint256 proposalId) { require(operator != address(0), ZeroAddress()); + require(proof.length != 0, ProofRequired()); + + // Extract proofType and derive the slash reason deterministically. + uint256 proofType = abi.decode(proof, (uint256)); + bytes32 reason = keccak256(abi.encodePacked(proofType)); SlashPolicy memory policy = slashPolicies[reason]; require(policy.enabled, SlashReasonDisabled()); require(policy.requiresProof, InvalidPolicy()); - require(proof.length != 0, ProofRequired()); + require( ciphernodeRegistry.isCommitteeMember(e3Id, operator), OperatorNotInCommittee() ); // Evidence replay protection — reason-independent to prevent cross-reason replay - uint256 proofType = abi.decode(proof, (uint256)); bytes32 evidenceKey = keccak256( abi.encodePacked(block.chainid, e3Id, operator, proofType) ); diff --git a/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts b/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts index de5a89d13b..06f5c98cae 100644 --- a/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts +++ b/packages/enclave-contracts/test/E3Lifecycle/E3Integration.spec.ts @@ -74,8 +74,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const encryptionSchemeId = "0x2c2a814a0495f913a3a312fc4771e37552bc14f8a2d4075a08122d356f0849c6"; - // Slash-related constants for E2E tests - const REASON_BAD_PROOF = ethers.keccak256(ethers.toUtf8Bytes("E3_BAD_PROOF")); + // Lane A reason derived on-chain as keccak256(abi.encodePacked(proofType)) + const REASON_PT_0 = ethers.keccak256(ethers.solidityPacked(["uint256"], [0])); const VOTE_TYPEHASH = ethers.keccak256( ethers.toUtf8Bytes( "AccusationVote(uint256 chainId,uint256 e3Id,bytes32 accusationId,address voter,bool agrees,bytes32 dataHash)", @@ -333,7 +333,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { await enclaveTicketToken.setRegistry(await bondingRegistry.getAddress()); // ── Slash Policy (for E2E routing tests) ─────────────────────────────────── - await slashingManagerTyped.setSlashPolicy(REASON_BAD_PROOF, { + await slashingManagerTyped.setSlashPolicy(REASON_PT_0, { ticketPenalty: ethers.parseUnits("50", 6), licensePenalty: ethers.parseEther("100"), requiresProof: true, @@ -810,7 +810,6 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_PROOF, proof, ); @@ -905,7 +904,6 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_PROOF, proof, ); @@ -1455,7 +1453,6 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_PROOF, proof, ); diff --git a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts index 0fae79026a..82f063927f 100644 --- a/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts +++ b/packages/enclave-contracts/test/Slashing/CommitteeExpulsion.spec.ts @@ -52,12 +52,9 @@ describe("Committee Expulsion & Fault Tolerance", function () { const SORTITION_SUBMISSION_WINDOW = 10; const addressOne = "0x0000000000000000000000000000000000000001"; - const REASON_BAD_DKG = ethers.keccak256( - ethers.toUtf8Bytes("E3_BAD_DKG_PROOF"), - ); - const REASON_BAD_DECRYPTION = ethers.keccak256( - ethers.toUtf8Bytes("E3_BAD_DECRYPTION_PROOF"), - ); + // Lane A reasons are derived on-chain as keccak256(abi.encodePacked(proofType)) + const REASON_PT_0 = ethers.keccak256(ethers.solidityPacked(["uint256"], [0])); + const REASON_PT_7 = ethers.keccak256(ethers.solidityPacked(["uint256"], [7])); const abiCoder = ethers.AbiCoder.defaultAbiCoder(); const polynomial_degree = ethers.toBigInt(2048); @@ -347,11 +344,11 @@ describe("Committee Expulsion & Fault Tolerance", function () { affectsCommittee: true, }; - await slashingManager.setSlashPolicy(REASON_BAD_DKG, { + await slashingManager.setSlashPolicy(REASON_PT_0, { ...baseSlashPolicy, failureReason: 4, // FailureReason.DKGInvalidShares }); - await slashingManager.setSlashPolicy(REASON_BAD_DECRYPTION, { + await slashingManager.setSlashPolicy(REASON_PT_7, { ...baseSlashPolicy, failureReason: 11, // FailureReason.DecryptionInvalidShares }); @@ -480,17 +477,12 @@ describe("Committee Expulsion & Fault Tolerance", function () { 0, op1Address, ); - const tx = await slashingManager.proposeSlash( - 0, - op1Address, - REASON_BAD_DKG, - proof, - ); + const tx = await slashingManager.proposeSlash(0, op1Address, proof); // Should emit CommitteeMemberExpelled await expect(tx) .to.emit(registry, "CommitteeMemberExpelled") - .withArgs(0, op1Address, REASON_BAD_DKG, 2); + .withArgs(0, op1Address, REASON_PT_0, 2); // Should emit CommitteeViabilityUpdated await expect(tx) @@ -535,7 +527,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, proof, ); @@ -607,7 +598,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, proof, ); @@ -677,14 +667,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, proof1, ); expect((await registry.getCommitteeViability(0)).activeCount).to.equal(2); // Slash operator1 again for a different proof type to verify expulsion is idempotent. // Same (e3Id, operator, proofType) would revert DuplicateEvidence — that's correct. - // Using proofType=7 (T5ShareDecryption) with REASON_BAD_DECRYPTION instead. + // Using proofType=7 (T5ShareDecryption) with REASON_PT_7 instead. const proof2 = await signAndEncodeAttestation( [operator2, operator3], 0, @@ -696,7 +685,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DECRYPTION, proof2, ); @@ -741,7 +729,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, proof, ); @@ -796,7 +783,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, proof1, ); expect((await registry.getCommitteeViability(0)).activeCount).to.equal(3); @@ -812,7 +798,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator2.getAddress(), - REASON_BAD_DKG, proof2, ); expect((await registry.getCommitteeViability(0)).activeCount).to.equal(2); @@ -926,7 +911,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator1.getAddress(), - REASON_BAD_DKG, proof1, ); @@ -942,7 +926,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { await slashingManager.proposeSlash( 0, await operator2.getAddress(), - REASON_BAD_DKG, proof2, ); @@ -959,7 +942,6 @@ describe("Committee Expulsion & Fault Tolerance", function () { slashingManager.proposeSlash( 0, await operator3.getAddress(), - REASON_BAD_DKG, await signAndEncodeAttestation( [operator4], 0, @@ -1009,18 +991,13 @@ describe("Committee Expulsion & Fault Tolerance", function () { await operator1.getAddress(), ); const op1Addr = await operator1.getAddress(); - const tx = await slashingManager.proposeSlash( - 0, - op1Addr, - REASON_BAD_DKG, - proof, - ); + const tx = await slashingManager.proposeSlash(0, op1Addr, proof); await expect(tx).to.emit(slashingManager, "SlashExecuted").withArgs( 0, // proposalId 0, // e3Id op1Addr, - REASON_BAD_DKG, + REASON_PT_0, ethers.parseUnits("10", 6), // ticketPenalty ethers.parseEther("50"), // licensePenalty true, // executed diff --git a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts index 22dba354e8..acb34c50b1 100644 --- a/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts +++ b/packages/enclave-contracts/test/Slashing/SlashingManager.spec.ts @@ -29,9 +29,10 @@ const { ethers, networkHelpers, ignition } = await network.connect(); const { loadFixture, time } = networkHelpers; describe("SlashingManager", function () { - const REASON_MISBEHAVIOR = ethers.encodeBytes32String("misbehavior"); + // Lane A reasons are derived on-chain as keccak256(abi.encodePacked(proofType)) + const REASON_PT_0 = ethers.keccak256(ethers.solidityPacked(["uint256"], [0])); + const REASON_PT_1 = ethers.keccak256(ethers.solidityPacked(["uint256"], [1])); const REASON_INACTIVITY = ethers.encodeBytes32String("inactivity"); - const REASON_DOUBLE_SIGN = ethers.encodeBytes32String("doubleSign"); const SLASHER_ROLE = ethers.keccak256(ethers.toUtf8Bytes("SLASHER_ROLE")); const GOVERNANCE_ROLE = ethers.keccak256( @@ -194,9 +195,9 @@ describe("SlashingManager", function () { failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); await slashingManager.setSlashPolicy(REASON_INACTIVITY, evidencePolicy); - await slashingManager.setSlashPolicy(REASON_DOUBLE_SIGN, banPolicy); + await slashingManager.setSlashPolicy(REASON_PT_1, banPolicy); } async function setup() { @@ -406,12 +407,11 @@ describe("SlashingManager", function () { failureReason: 0, }; - await expect(slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy)) + await expect(slashingManager.setSlashPolicy(REASON_PT_0, policy)) .to.emit(slashingManager, "SlashPolicyUpdated") - .withArgs(REASON_MISBEHAVIOR, Object.values(policy)); + .withArgs(REASON_PT_0, Object.values(policy)); - const storedPolicy = - await slashingManager.getSlashPolicy(REASON_MISBEHAVIOR); + const storedPolicy = await slashingManager.getSlashPolicy(REASON_PT_0); expect(storedPolicy.ticketPenalty).to.equal(policy.ticketPenalty); expect(storedPolicy.licensePenalty).to.equal(policy.licensePenalty); expect(storedPolicy.requiresProof).to.equal(policy.requiresProof); @@ -456,7 +456,7 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(notTheOwner) - .setSlashPolicy(REASON_MISBEHAVIOR, policy), + .setSlashPolicy(REASON_PT_0, policy), ).to.be.revertedWithCustomError( slashingManager, "AccessControlUnauthorizedAccount", @@ -499,7 +499,7 @@ describe("SlashingManager", function () { }; await expect( - slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy), + slashingManager.setSlashPolicy(REASON_PT_0, policy), ).to.be.revertedWithCustomError(slashingManager, "InvalidPolicy"); }); @@ -519,7 +519,7 @@ describe("SlashingManager", function () { }; await expect( - slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy), + slashingManager.setSlashPolicy(REASON_PT_0, policy), ).to.be.revertedWithCustomError(slashingManager, "InvalidPolicy"); }); @@ -538,9 +538,9 @@ describe("SlashingManager", function () { failureReason: 0, }; - await expect(slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy)) + await expect(slashingManager.setSlashPolicy(REASON_PT_0, policy)) .to.emit(slashingManager, "SlashPolicyUpdated") - .withArgs(REASON_MISBEHAVIOR, Object.values(policy)); + .withArgs(REASON_PT_0, Object.values(policy)); }); it("should revert if proof required but appeal window set", async function () { @@ -559,7 +559,7 @@ describe("SlashingManager", function () { }; await expect( - slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy), + slashingManager.setSlashPolicy(REASON_PT_0, policy), ).to.be.revertedWithCustomError(slashingManager, "InvalidPolicy"); }); @@ -579,7 +579,7 @@ describe("SlashingManager", function () { }; await expect( - slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy), + slashingManager.setSlashPolicy(REASON_PT_0, policy), ).to.be.revertedWithCustomError(slashingManager, "InvalidPolicy"); }); }); @@ -649,7 +649,7 @@ describe("SlashingManager", function () { affectsCommittee: false, failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); // Set up committee membership: operator must be a member, voters attest the operator is faulty const e3Id = 0; @@ -673,13 +673,13 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(proposer) - .proposeSlash(e3Id, operatorAddress, REASON_MISBEHAVIOR, proof), + .proposeSlash(e3Id, operatorAddress, proof), ).to.emit(slashingManager, "SlashProposed"); // Proof-based slashes auto-execute const proposal = await slashingManager.getSlashProposal(0); expect(proposal.operator).to.equal(operatorAddress); - expect(proposal.reason).to.equal(REASON_MISBEHAVIOR); + expect(proposal.reason).to.equal(REASON_PT_0); expect(proposal.proofVerified).to.be.true; expect(proposal.executed).to.be.true; expect(proposal.proposer).to.equal(await proposer.getAddress()); @@ -706,7 +706,7 @@ describe("SlashingManager", function () { affectsCommittee: false, failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); // Threshold is 2 but only 1 vote provided const voter1Addr = await voter1.getAddress(); @@ -726,7 +726,7 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), + .proposeSlash(0, operatorAddress, proof), ).to.be.revertedWithCustomError( slashingManager, "InsufficientAttestations", @@ -755,7 +755,7 @@ describe("SlashingManager", function () { affectsCommittee: false, failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); @@ -833,7 +833,7 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), + .proposeSlash(0, operatorAddress, proof), ).to.be.revertedWithCustomError(slashingManager, "InvalidVoteSignature"); }); @@ -858,7 +858,7 @@ describe("SlashingManager", function () { affectsCommittee: false, failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); // Only voter1 is a committee member, but voter2 also signs const voter1Addr = await voter1.getAddress(); @@ -876,7 +876,7 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), + .proposeSlash(0, operatorAddress, proof), ).to.be.revertedWithCustomError(slashingManager, "VoterNotInCommittee"); }); @@ -890,7 +890,7 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(proposer) - .proposeSlash(0, ethers.ZeroAddress, REASON_MISBEHAVIOR, proof), + .proposeSlash(0, ethers.ZeroAddress, proof), ).to.be.revertedWithCustomError(slashingManager, "ZeroAddress"); }); @@ -903,7 +903,7 @@ describe("SlashingManager", function () { await expect( slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_DOUBLE_SIGN, proof), + .proposeSlash(0, operatorAddress, proof), ).to.be.revertedWithCustomError(slashingManager, "SlashReasonDisabled"); }); @@ -922,12 +922,12 @@ describe("SlashingManager", function () { affectsCommittee: false, failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); await expect( slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, "0x"), + .proposeSlash(0, operatorAddress, "0x"), ).to.be.revertedWithCustomError(slashingManager, "ProofRequired"); }); @@ -952,7 +952,7 @@ describe("SlashingManager", function () { affectsCommittee: false, failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, proofPolicy); + await slashingManager.setSlashPolicy(REASON_PT_0, proofPolicy); const voter1Addr = await voter1.getAddress(); const voter2Addr = await voter2.getAddress(); await mockCiphernodeRegistry.setCommitteeNodes(0, [ @@ -969,13 +969,13 @@ describe("SlashingManager", function () { ); await slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof); + .proposeSlash(0, operatorAddress, proof); // Same proof for same e3Id/operator/reason should be rejected await expect( slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof), + .proposeSlash(0, operatorAddress, proof), ).to.be.revertedWithCustomError(slashingManager, "DuplicateEvidence"); }); @@ -1015,7 +1015,7 @@ describe("SlashingManager", function () { ); await slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof1); + .proposeSlash(0, operatorAddress, proof1); expect(await slashingManager.totalProposals()).to.equal(1); @@ -1026,7 +1026,7 @@ describe("SlashingManager", function () { ); await slashingManager .connect(proposer) - .proposeSlash(1, operatorAddress, REASON_MISBEHAVIOR, proof2); + .proposeSlash(1, operatorAddress, proof2); expect(await slashingManager.totalProposals()).to.equal(2); }); @@ -1058,10 +1058,11 @@ describe("SlashingManager", function () { [voter1, voter2], 0, operatorAddress, + 1, // proofType=1 maps to REASON_PT_1 (ban policy) ); await slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_DOUBLE_SIGN, proof); + .proposeSlash(0, operatorAddress, proof); // banNode=true → auto-executed → node is now banned expect(await slashingManager.isBanned(operatorAddress)).to.be.true; @@ -1198,7 +1199,7 @@ describe("SlashingManager", function () { ); await slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof); + .proposeSlash(0, operatorAddress, proof); // Should revert because already executed await expect( @@ -1377,7 +1378,7 @@ describe("SlashingManager", function () { ); await slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof); + .proposeSlash(0, operatorAddress, proof); // Cannot appeal proof-verified slashes — appeal window is 0 so it's already expired await expect( @@ -1630,10 +1631,9 @@ describe("SlashingManager", function () { failureReason: 0, }; - await slashingManager.setSlashPolicy(REASON_MISBEHAVIOR, policy); + await slashingManager.setSlashPolicy(REASON_PT_0, policy); - const retrieved = - await slashingManager.getSlashPolicy(REASON_MISBEHAVIOR); + const retrieved = await slashingManager.getSlashPolicy(REASON_PT_0); expect(retrieved.ticketPenalty).to.equal(policy.ticketPenalty); expect(retrieved.licensePenalty).to.equal(policy.licensePenalty); expect(retrieved.requiresProof).to.equal(policy.requiresProof); @@ -1672,11 +1672,11 @@ describe("SlashingManager", function () { ); await slashingManager .connect(proposer) - .proposeSlash(0, operatorAddress, REASON_MISBEHAVIOR, proof); + .proposeSlash(0, operatorAddress, proof); const proposal = await slashingManager.getSlashProposal(0); expect(proposal.operator).to.equal(operatorAddress); - expect(proposal.reason).to.equal(REASON_MISBEHAVIOR); + expect(proposal.reason).to.equal(REASON_PT_0); expect(proposal.ticketAmount).to.equal(ethers.parseUnits("50", 6)); expect(proposal.licenseAmount).to.equal(ethers.parseEther("100")); expect(proposal.proposer).to.equal(await proposer.getAddress());