diff --git a/Cargo.lock b/Cargo.lock index 5dafd131ae..a5bc8e35e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3770,12 +3770,14 @@ dependencies = [ "e3-logger", "e3-multithread", "e3-net", + "e3-polynomial", "e3-request", "e3-sdk", "e3-sortition", "e3-test-helpers", "e3-trbfv", "e3-utils", + "e3-zk-helpers", "e3-zk-prover", "fhe", "fhe-traits", diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 10f9f3bccb..09791cb83a 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -10,11 +10,11 @@ use anyhow::Result; use e3_data::Persistable; use e3_events::{ prelude::*, BusHandle, ComputeResponse, ComputeResponseKind, DKGRecursiveAggregationComplete, - Die, E3id, EnclaveEvent, EnclaveEventData, EventContext, KeyshareCreated, OrderedSet, - PartyProofsToVerify, PkAggregationProofPending, PkAggregationProofRequest, - PkAggregationProofSigned, Proof, PublicKeyAggregated, Seed, Sequenced, - ShareVerificationComplete, ShareVerificationDispatched, SignedProofPayload, TypedEvent, - VerificationKind, ZkResponse, + Die, E3Failed, E3Stage, E3id, EnclaveEvent, EnclaveEventData, EventContext, FailureReason, + KeyshareCreated, OrderedSet, PartyProofsToVerify, PkAggregationProofPending, + PkAggregationProofRequest, PkAggregationProofSigned, Proof, ProofType, ProofVerificationPassed, + PublicKeyAggregated, Seed, Sequenced, ShareVerificationComplete, ShareVerificationDispatched, + SignedProofFailed, SignedProofPayload, TypedEvent, VerificationKind, ZkResponse, }; use e3_events::{trap, EType}; use e3_fhe::{Fhe, GetAggregatePublicKey}; @@ -246,6 +246,7 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::VerifyingC1 { submission_order, threshold_m, + c1_proofs, .. } = self .state @@ -257,22 +258,97 @@ impl PublicKeyAggregator { )); }; - let dishonest_parties = &msg.dishonest_parties; + let mut dishonest_parties = msg.dishonest_parties.clone(); let total_parties = submission_order.len(); - // 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 + // Filter out parties that failed C1 ZK verification. + let mut honest_entries: Vec<(usize, (String, ArcBytes))> = submission_order .into_iter() .enumerate() .filter(|(idx, _)| !dishonest_parties.contains(&(*idx as u64))) - .map(|(_, (node, ks))| (ks, node)) + .collect(); + + // Cross-check: verify each party's keyshare matches their C1 pk_commitment. + // Parties that fail are marked dishonest and reported via SignedProofFailed. + let mut commitment_dishonest = Vec::new(); + for (party_idx, (_node, ks)) in &honest_entries { + let signed_proof = match c1_proofs.get(*party_idx).and_then(|opt| opt.as_ref()) { + Some(proof) => proof, + None => { + // No C1 proof for this party — should already be in dishonest_parties. + // If not, treat as dishonest now (defensive). + warn!( + "Party {} has no C1 proof but was not marked dishonest", + party_idx + ); + dishonest_parties.insert(*party_idx as u64); + continue; + } + }; + let ok = match e3_zk_helpers::compute_pk_commitment_from_keyshare_bytes( + ks, + &self.fhe.params, + &self.fhe.crp, + ) { + Ok(computed) => signed_proof + .payload + .proof + .extract_output("pk_commitment") + .map_or(false, |extracted| extracted[..] == computed[..]), + Err(e) => { + warn!( + "Failed to compute pk_commitment for party {}: {}", + party_idx, e + ); + false + } + }; + if !ok { + commitment_dishonest.push((*party_idx as u64, signed_proof.clone())); + } + } + + // Emit SignedProofFailed for each commitment-mismatched party + for (party_idx, signed_proof) in &commitment_dishonest { + dishonest_parties.insert(*party_idx); + match signed_proof.recover_address() { + Ok(faulting_node) => { + if let Err(e) = self.bus.publish( + SignedProofFailed { + e3_id: self.e3_id.clone(), + faulting_node, + proof_type: ProofType::C1PkGeneration, + signed_payload: signed_proof.clone(), + }, + ec.clone(), + ) { + error!("Failed to publish SignedProofFailed: {e}"); + } + } + Err(e) => warn!( + "Could not recover address from C1 proof for party {}: {e}", + party_idx + ), + } + } + + if !commitment_dishonest.is_empty() { + warn!( + "C1 commitment mismatch for {} parties — filtering before aggregation", + commitment_dishonest.len() + ); + // Re-filter honest_entries after commitment check + honest_entries.retain(|(idx, _)| !dishonest_parties.contains(&(*idx as u64))); + } + + let (honest_keyshares, honest_nodes): (Vec, Vec) = honest_entries + .iter() + .map(|(_, (node, ks))| (ks.clone(), node.clone())) .unzip(); if !dishonest_parties.is_empty() { warn!( - "Filtered out {} dishonest parties from C1 verification: {:?}", - dishonest_parties.len(), + "Total dishonest parties (ZK + commitment): {:?}", dishonest_parties ); } @@ -283,11 +359,20 @@ impl PublicKeyAggregator { // Need at least threshold + 1 honest parties for aggregation if honest_keyshares.len() <= threshold_m { - return Err(anyhow::anyhow!( - "Not enough honest parties after C1 verification: {} (need at least {})", + error!( + "Not enough honest parties after filtering: {} (need > {})", honest_keyshares.len(), - threshold_m + 1 - )); + threshold_m + ); + self.bus.publish( + E3Failed { + e3_id: self.e3_id.clone(), + failed_at_stage: E3Stage::CommitteeFinalized, + reason: FailureReason::DKGInvalidShares, + }, + ec, + )?; + return Ok(()); } // Synchronous aggregation @@ -301,12 +386,9 @@ impl PublicKeyAggregator { })?; let committee_h = honest_keyshares.len(); - let honest_nodes_set = OrderedSet::from(honest_nodes); + let honest_nodes_set = OrderedSet::from(honest_nodes.clone()); let keyshare_bytes: Vec<_> = honest_keyshares_set.iter().cloned().collect(); - // Publish pending event before transitioning state so a publish - // failure leaves us in VerifyingC1 (retryable) rather than - // GeneratingC5Proof (no retry path). let pubkey = ArcBytes::from_bytes(&pubkey); info!("Publishing PkAggregationProofPending for C5 proof generation..."); self.bus.publish( @@ -782,9 +864,6 @@ impl Handler for PublicKeyAggregator { EnclaveEventData::ComputeResponse(data) => { self.notify_sync(ctx, TypedEvent::new(data, ec)) } - EnclaveEventData::ComputeRequestError(data) => { - error!("PublicKeyAggregator received ComputeRequestError: {}", data); - } EnclaveEventData::E3RequestComplete(_) => self.notify_sync(ctx, Die), EnclaveEventData::CommitteeMemberExpelled(data) => { // Only process raw events from chain (party_id not yet resolved). diff --git a/crates/events/src/enclave_event/proof.rs b/crates/events/src/enclave_event/proof.rs index 2c3a18ebdb..536f436c09 100644 --- a/crates/events/src/enclave_event/proof.rs +++ b/crates/events/src/enclave_event/proof.rs @@ -2,6 +2,10 @@ use derivative::Derivative; use e3_utils::utility_types::ArcBytes; +use e3_zk_helpers::{ + CircuitOutputLayout, DKG_SHARE_DECRYPTION_OUTPUTS, PK_AGGREGATION_OUTPUTS, PK_BFV_OUTPUTS, + PK_GENERATION_OUTPUTS, SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS, SHARE_COMPUTATION_OUTPUTS, +}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -31,6 +35,18 @@ impl Proof { public_signals: public_signals.into(), } } + + /// Extract a named public output field from this proof's public signals. + /// + /// Return values sit at the **end** of `public_signals`, after any `pub` + /// input parameters. The field name must match one declared in the + /// circuit's [`CircuitOutputLayout`]. + pub fn extract_output(&self, field_name: &str) -> Option { + let layout = self.circuit.output_layout(); + layout + .extract_field(&self.public_signals, field_name) + .map(ArcBytes::from_bytes) + } } /// Circuit variants determine the hash oracle used for VK generation and proving. @@ -150,6 +166,38 @@ impl CircuitName { pub fn wrapper_dir_path(&self) -> String { format!("recursive_aggregation/wrapper/{}", self.dir_path()) } + + /// Public output (return value) layout for this circuit. + pub fn output_layout(&self) -> CircuitOutputLayout { + match self { + CircuitName::PkBfv => CircuitOutputLayout::Fixed { + fields: PK_BFV_OUTPUTS, + }, + CircuitName::PkGeneration => CircuitOutputLayout::Fixed { + fields: PK_GENERATION_OUTPUTS, + }, + CircuitName::SkShareComputationBase | CircuitName::ESmShareComputationBase => { + CircuitOutputLayout::Dynamic + } + CircuitName::ShareComputationChunkBatch => CircuitOutputLayout::Fixed { + fields: SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS, + }, + CircuitName::ShareComputation => CircuitOutputLayout::Fixed { + fields: SHARE_COMPUTATION_OUTPUTS, + }, + CircuitName::DkgShareDecryption => CircuitOutputLayout::Fixed { + fields: DKG_SHARE_DECRYPTION_OUTPUTS, + }, + CircuitName::PkAggregation => CircuitOutputLayout::Fixed { + fields: PK_AGGREGATION_OUTPUTS, + }, + CircuitName::ShareComputationChunk + | CircuitName::ShareEncryption + | CircuitName::ThresholdShareDecryption + | CircuitName::DecryptedSharesAggregation => CircuitOutputLayout::None, + CircuitName::Fold => CircuitOutputLayout::None, + } + } } impl fmt::Display for CircuitName { @@ -157,3 +205,74 @@ impl fmt::Display for CircuitName { write!(f, "{}", self.dir_path()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_proof(circuit: CircuitName, signals: &[u8]) -> Proof { + Proof::new( + circuit, + ArcBytes::from_bytes(&[0u8; 8]), + ArcBytes::from_bytes(signals), + ) + } + + #[test] + fn extract_c1_pk_commitment() { + // C1 has 3 outputs: sk_commitment, pk_commitment, e_sm_commitment + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); // sk_commitment + signals[32..64].copy_from_slice(&[0x22; 32]); // pk_commitment + signals[64..96].copy_from_slice(&[0x33; 32]); // e_sm_commitment + + let proof = make_proof(CircuitName::PkGeneration, &signals); + assert_eq!( + &*proof.extract_output("pk_commitment").unwrap(), + &[0x22; 32] + ); + assert_eq!( + &*proof.extract_output("sk_commitment").unwrap(), + &[0x11; 32] + ); + assert_eq!( + &*proof.extract_output("e_sm_commitment").unwrap(), + &[0x33; 32] + ); + } + + #[test] + fn extract_c5_commitment_after_pub_inputs() { + // C5 has H pub input fields + 1 output. Simulate H=2 → 96 bytes total. + let mut signals = vec![0xAA; 96]; + signals[64..96].copy_from_slice(&[0xFF; 32]); // commitment (last output) + + let proof = make_proof(CircuitName::PkAggregation, &signals); + assert_eq!(&*proof.extract_output("commitment").unwrap(), &[0xFF; 32]); + } + + #[test] + fn extract_nonexistent_field() { + let proof = make_proof(CircuitName::PkBfv, &[0u8; 32]); + assert!(proof.extract_output("nonexistent").is_none()); + } + + #[test] + fn extract_from_void_circuit() { + let proof = make_proof(CircuitName::ShareEncryption, &[0u8; 64]); + assert!(proof.extract_output("commitment").is_none()); + } + + #[test] + fn extract_signals_too_short() { + // C1 needs 96 bytes for outputs, only 64 available + let proof = make_proof(CircuitName::PkGeneration, &[0u8; 64]); + assert!(proof.extract_output("pk_commitment").is_none()); + } + + #[test] + fn extract_empty_signals() { + let proof = make_proof(CircuitName::PkGeneration, &[]); + assert!(proof.extract_output("pk_commitment").is_none()); + } +} diff --git a/crates/events/src/enclave_event/proof_verification_passed.rs b/crates/events/src/enclave_event/proof_verification_passed.rs index aed00f2338..15511f54e4 100644 --- a/crates/events/src/enclave_event/proof_verification_passed.rs +++ b/crates/events/src/enclave_event/proof_verification_passed.rs @@ -33,7 +33,7 @@ pub struct ProofVerificationPassed { /// keccak256 hash of the received data + proof bytes — for equivocation detection. pub data_hash: [u8; 32], /// Raw public signals from the verified proof — for commitment consistency checks. - pub public_outputs: ArcBytes, + pub public_signals: ArcBytes, } impl Display for ProofVerificationPassed { diff --git a/crates/multithread/src/multithread.rs b/crates/multithread/src/multithread.rs index f49ce16857..6357ede622 100644 --- a/crates/multithread/src/multithread.rs +++ b/crates/multithread/src/multithread.rs @@ -82,7 +82,7 @@ use ndarray::Array2; use num_bigint::BigInt; use rand::rngs::OsRng; use rand::Rng; -use tracing::{error, info}; +use tracing::{error, info, warn}; /// Multithread actor pub struct Multithread { @@ -313,7 +313,19 @@ fn handle_pk_aggregation_proof( // 2. Create deterministic CRP let crp = create_deterministic_crp_from_default_seed(&threshold_params); - // 3. Deserialize each keyshare as PublicKeyShare and extract pk0 + // 3. Validate keyshare count before deserialization + if req.keyshare_bytes.len() != req.committee_h { + return Err(make_zk_error( + &request, + format!( + "keyshare_bytes length {} != committee_h {}", + req.keyshare_bytes.len(), + req.committee_h + ), + )); + } + + // 4. Deserialize each keyshare as PublicKeyShare and extract pk0 let mut pk0_shares = Vec::with_capacity(req.keyshare_bytes.len()); for (i, ks_bytes) in req.keyshare_bytes.iter().enumerate() { let pk_share = PublicKeyShare::deserialize(ks_bytes, &threshold_params, crp.clone()) @@ -344,6 +356,10 @@ fn handle_pk_aggregation_proof( a, }; + // C1 commitment consistency is verified by the PublicKeyAggregator before + // dispatching this request (pre-aggregation check). By the time we reach + // the prover, all keyshares are guaranteed to match their C1 proofs. + // 7. Generate proof via Provable trait (C5 is always EVM-targeted for on-chain verification) let circuit = PkAggregationCircuit; let e3_id_str = request.e3_id.to_string(); diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index 11f0575485..9856129b97 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -36,6 +36,8 @@ e3-bfv-client = { workspace = true } e3-fhe-params = { workspace = true } e3-utils = { workspace = true } e3-zk-prover = { workspace = true } +e3-zk-helpers = { workspace = true } +e3-polynomial = { workspace = true } fhe-traits = { workspace = true } fhe-util = { workspace = true } fhe = { workspace = true } diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index ae7e495501..22962d6a01 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -14,14 +14,17 @@ use e3_config::BBPath; use e3_crypto::Cipher; use e3_events::{ prelude::*, BusHandle, CiphertextOutputPublished, CommitteeFinalized, ConfigurationUpdated, - E3Requested, E3id, EnclaveEvent, EnclaveEventData, OperatorActivationChanged, - PlaintextAggregated, Seed, TakeEvents, TicketBalanceUpdated, + E3Requested, E3id, EffectsEnabled, EnclaveEvent, EnclaveEventData, EventType, GetEvents, + HistoryCollector, OperatorActivationChanged, OrderedSet, PkAggregationProofPending, + PkAggregationProofRequest, PlaintextAggregated, Seed, TakeEvents, TicketBalanceUpdated, }; use e3_fhe_params::DEFAULT_BFV_PRESET; -use e3_fhe_params::{encode_bfv_params, BfvParamSet}; +use e3_fhe_params::{build_pair_for_preset, create_deterministic_crp_from_default_seed}; +use e3_fhe_params::{encode_bfv_params, BfvParamSet, BfvPreset}; use e3_multithread::{Multithread, MultithreadReport, ToReport}; use e3_net::events::{GossipData, NetEvent}; use e3_net::NetEventTranslator; +use e3_polynomial::CrtPolynomial; use e3_sortition::{calculate_buffer_size, RegisteredNode, ScoreSortition, Ticket}; use e3_test_helpers::ciphernode_system::CiphernodeSystemBuilder; use e3_test_helpers::{ @@ -30,11 +33,15 @@ use e3_test_helpers::{ use e3_trbfv::helpers::calculate_error_size; use e3_utils::utility_types::ArcBytes; use e3_utils::{colorize, rand_eth_addr, Color}; +use e3_zk_helpers::{compute_modulus_bit, compute_threshold_pk_commitment}; use e3_zk_prover::test_utils::get_tempdir; -use e3_zk_prover::ZkBackend; +use e3_zk_prover::{ProofRequestActor, ZkBackend}; use fhe::bfv::PublicKey; +use fhe::bfv::SecretKey; +use fhe::mbfv::{AggregateIter, PublicKeyShare}; use fhe_traits::{DeserializeParametrized, Serialize}; use num_bigint::BigUint; +use rand::rngs::OsRng; use rand::SeedableRng; use rand_chacha::ChaCha20Rng; use std::time::{Duration, Instant}; diff --git a/crates/zk-helpers/src/circuits/commitments.rs b/crates/zk-helpers/src/circuits/commitments.rs index 15c8b34098..0b2a8e2006 100644 --- a/crates/zk-helpers/src/circuits/commitments.rs +++ b/crates/zk-helpers/src/circuits/commitments.rs @@ -217,6 +217,46 @@ pub fn compute_threshold_pk_commitment( BigInt::from_bytes_le(num_bigint::Sign::Plus, &commitment_bytes) } +/// Compute the pk_commitment for a serialized `PublicKeyShare`, matching what the C1 circuit outputs. +/// +/// Deserializes the keyshare, extracts the pk0 polynomial, and hashes it +/// together with the CRP (pk1) to produce the commitment. Returns 32 +/// big-endian bytes, ready to compare against +/// `Proof::extract_output("pk_commitment")` from a C1 proof. +/// +/// The caller supplies pre-built `params` and `crp` so that batch calls +/// (multiple keyshares with the same parameters) don't rebuild them each time. +pub fn compute_pk_commitment_from_keyshare_bytes( + keyshare_bytes: &[u8], + params: &std::sync::Arc, + crp: &fhe::mbfv::CommonRandomPoly, +) -> Result<[u8; 32], crate::CircuitsErrors> { + let bit_pk = crate::compute_modulus_bit(params); + let moduli = params.moduli(); + + let pk_share = fhe::mbfv::PublicKeyShare::deserialize(keyshare_bytes, params, crp.clone()) + .map_err(|e| { + crate::CircuitsErrors::Other(format!("PublicKeyShare deserialize: {:?}", e)) + })?; + + let mut pk0 = CrtPolynomial::from_fhe_polynomial(&pk_share.p0_share()); + pk0.reverse(); + pk0.center(moduli) + .map_err(|e| crate::CircuitsErrors::Other(format!("pk0 center: {}", e)))?; + + let mut pk1 = CrtPolynomial::from_fhe_polynomial(&crp.poly()); + pk1.reverse(); + pk1.center(moduli) + .map_err(|e| crate::CircuitsErrors::Other(format!("pk1 center: {}", e)))?; + + let commitment = compute_threshold_pk_commitment(&pk0, &pk1, bit_pk); + let (_, be_bytes) = commitment.to_bytes_be(); + let mut padded = [0u8; 32]; + let start = 32usize.saturating_sub(be_bytes.len()); + padded[start..].copy_from_slice(&be_bytes[..be_bytes.len().min(32)]); + Ok(padded) +} + /// Compute a commitment to the threshold secret key share by flattening it and hashing. /// /// This matches the Noir `compute_share_computation_sk_commitment` function exactly. @@ -674,4 +714,44 @@ mod tests { let expected = compute_commitments(vk_hashes.clone(), super::DS_VK_HASH, io_pattern)[0]; assert_eq!(compute_vk_hash(vk_hashes), expected); } + + #[test] + fn compute_pk_commitment_from_keyshare_roundtrip() { + use e3_fhe_params::{ + build_pair_for_preset, create_deterministic_crp_from_default_seed, BfvPreset, + }; + use fhe::bfv::SecretKey; + use fhe::mbfv::PublicKeyShare; + use fhe_traits::Serialize; + use rand::rngs::OsRng; + + let preset = BfvPreset::InsecureThreshold512; + let (params, _) = build_pair_for_preset(preset).unwrap(); + let crp = create_deterministic_crp_from_default_seed(¶ms); + + // Generate a real keyshare + let sk = SecretKey::random(¶ms, &mut OsRng); + let pk_share = PublicKeyShare::new(&sk, crp.clone(), &mut OsRng).unwrap(); + let ks_bytes = pk_share.to_bytes(); + + // Compute commitment via the helper + let commitment = + compute_pk_commitment_from_keyshare_bytes(&ks_bytes, ¶ms, &crp).unwrap(); + + // Compute commitment manually (same steps as PkAggInputs::compute) + let bit_pk = crate::compute_modulus_bit(¶ms); + let mut pk0 = CrtPolynomial::from_fhe_polynomial(&pk_share.p0_share()); + pk0.reverse(); + pk0.center(params.moduli()).unwrap(); + let mut pk1 = CrtPolynomial::from_fhe_polynomial(&crp.poly()); + pk1.reverse(); + pk1.center(params.moduli()).unwrap(); + let expected = compute_threshold_pk_commitment(&pk0, &pk1, bit_pk); + let (_, be_bytes) = expected.to_bytes_be(); + let mut expected_padded = [0u8; 32]; + let start = 32usize.saturating_sub(be_bytes.len()); + expected_padded[start..].copy_from_slice(&be_bytes[..be_bytes.len().min(32)]); + + assert_eq!(commitment, expected_padded); + } } diff --git a/crates/zk-helpers/src/circuits/mod.rs b/crates/zk-helpers/src/circuits/mod.rs index a60579d5df..d3f8713990 100644 --- a/crates/zk-helpers/src/circuits/mod.rs +++ b/crates/zk-helpers/src/circuits/mod.rs @@ -8,6 +8,7 @@ pub mod codegen; pub mod commitments; pub mod computation; pub mod errors; +pub mod output_layout; pub use codegen::{ write_artifacts, write_toml, Artifacts, CircuitCodegen, CodegenConfigs, CodegenToml, @@ -15,6 +16,7 @@ pub use codegen::{ pub use commitments::*; pub use computation::{CircuitComputation, Computation}; pub use errors::CircuitsErrors; +pub use output_layout::*; pub mod dkg; pub mod threshold; diff --git a/crates/zk-helpers/src/circuits/output_layout.rs b/crates/zk-helpers/src/circuits/output_layout.rs new file mode 100644 index 0000000000..63bcb89f54 --- /dev/null +++ b/crates/zk-helpers/src/circuits/output_layout.rs @@ -0,0 +1,274 @@ +// 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. + +//! Describes the public output (return value) layout of each ZK circuit. +//! +//! In Noir, circuits declare `pub` input parameters and `-> pub` return values. +//! Both end up in the proof's `public_signals` byte array, with return values +//! placed **after** all public inputs. This module provides the metadata needed +//! to extract named return fields from a proof's public signals without +//! hard-coding byte offsets. + +use serde::{Deserialize, Serialize}; + +/// Size of a single Noir `Field` element in bytes (BN254 scalar). +pub const FIELD_BYTE_LEN: usize = 32; + +/// A named output field of a circuit proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct OutputField { + /// Human-readable name (e.g. `"pk_commitment"`). + pub name: &'static str, +} + +/// Describes the public return values of a circuit. +/// +/// `fields` lists them in the order they appear in `public_signals`, +/// which is the same order as the Noir `-> pub (A, B, C)` tuple. +/// +/// Circuits whose output count depends on runtime parameters (e.g. +/// `SkShareComputationBase` whose return is `[[Field; L]; N]`) +/// use [`CircuitOutputLayout::Dynamic`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CircuitOutputLayout { + /// Fixed number of `Field`-sized outputs, names known at compile time. + Fixed { fields: &'static [OutputField] }, + /// The circuit returns no public values (void). + None, + /// Output count depends on runtime parameters — callers must supply the + /// element count themselves. + Dynamic, +} + +impl CircuitOutputLayout { + /// Number of fixed output fields, or `None` for dynamic / void layouts. + pub fn field_count(&self) -> Option { + match self { + CircuitOutputLayout::Fixed { fields } => Some(fields.len()), + CircuitOutputLayout::None => Some(0), + CircuitOutputLayout::Dynamic => None, + } + } + + /// Look up a field index by name. + pub fn field_index(&self, name: &str) -> Option { + match self { + CircuitOutputLayout::Fixed { fields } => fields.iter().position(|f| f.name == name), + _ => None, + } + } + + /// Extract a named output field from raw `public_signals` bytes. + /// + /// Return values sit at the **end** of `public_signals`, after any + /// `pub` input parameters. This method indexes from the tail. + pub fn extract_field<'a>(&self, public_signals: &'a [u8], name: &str) -> Option<&'a [u8]> { + let fields = match self { + CircuitOutputLayout::Fixed { fields } => fields, + _ => return None, + }; + let idx = fields.iter().position(|f| f.name == name)?; + let total_output_bytes = fields.len() * FIELD_BYTE_LEN; + if public_signals.len() < total_output_bytes { + return None; + } + let output_start = public_signals.len() - total_output_bytes; + let offset = output_start + idx * FIELD_BYTE_LEN; + Some(&public_signals[offset..offset + FIELD_BYTE_LEN]) + } + + /// Extract all output fields from raw `public_signals` bytes. + /// + /// Returns a vec of `(name, &[u8])` pairs in field order. + pub fn extract_all<'a>( + &self, + public_signals: &'a [u8], + ) -> Option> { + let fields = match self { + CircuitOutputLayout::Fixed { fields } => fields, + CircuitOutputLayout::None => return Some(Vec::new()), + CircuitOutputLayout::Dynamic => return None, + }; + let total_output_bytes = fields.len() * FIELD_BYTE_LEN; + if public_signals.len() < total_output_bytes { + return None; + } + let output_start = public_signals.len() - total_output_bytes; + Some( + fields + .iter() + .enumerate() + .map(|(i, f)| { + let offset = output_start + i * FIELD_BYTE_LEN; + (f.name, &public_signals[offset..offset + FIELD_BYTE_LEN]) + }) + .collect(), + ) + } +} + +// ── Per-circuit output field constants ────────────────────────────────────── + +const fn f(name: &'static str) -> OutputField { + OutputField { name } +} + +/// C0 — BFV public key proof. +pub const PK_BFV_OUTPUTS: &[OutputField] = &[f("pk_commitment")]; + +/// C1 — Threshold public key generation. +pub const PK_GENERATION_OUTPUTS: &[OutputField] = + &[f("sk_commitment"), f("pk_commitment"), f("e_sm_commitment")]; + +/// C2d — Share computation chunk batch. +pub const SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS: &[OutputField] = &[f("commitment")]; + +/// C2 — Share computation (final wrapper). +pub const SHARE_COMPUTATION_OUTPUTS: &[OutputField] = &[f("key_hash"), f("commitment")]; + +/// C4 — DKG share decryption. +pub const DKG_SHARE_DECRYPTION_OUTPUTS: &[OutputField] = &[f("commitment")]; + +/// C5 — Public key aggregation. +pub const PK_AGGREGATION_OUTPUTS: &[OutputField] = &[f("commitment")]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_single_output_field() { + let layout = CircuitOutputLayout::Fixed { + fields: PK_BFV_OUTPUTS, + }; + // 32 bytes pub input + 32 bytes output + let mut signals = vec![0xAAu8; 64]; + signals[32..].copy_from_slice(&[0xBB; 32]); + let commitment = layout.extract_field(&signals, "pk_commitment").unwrap(); + assert_eq!(commitment, &[0xBB; 32]); + } + + #[test] + fn extract_c1_pk_commitment_from_middle() { + let layout = CircuitOutputLayout::Fixed { + fields: PK_GENERATION_OUTPUTS, + }; + // C1 has no pub inputs, only 3 outputs = 96 bytes total + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); // sk_commitment + signals[32..64].copy_from_slice(&[0x22; 32]); // pk_commitment + signals[64..96].copy_from_slice(&[0x33; 32]); // e_sm_commitment + + assert_eq!( + layout.extract_field(&signals, "sk_commitment").unwrap(), + &[0x11; 32] + ); + assert_eq!( + layout.extract_field(&signals, "pk_commitment").unwrap(), + &[0x22; 32] + ); + assert_eq!( + layout.extract_field(&signals, "e_sm_commitment").unwrap(), + &[0x33; 32] + ); + } + + #[test] + fn extract_c5_output_after_pub_inputs() { + let layout = CircuitOutputLayout::Fixed { + fields: PK_AGGREGATION_OUTPUTS, + }; + // C5 has H pub input fields + 1 output. Simulate H=3 → 128 bytes total. + let mut signals = vec![0xAA; 128]; // 3 * 32 pub inputs + signals[96..128].copy_from_slice(&[0xFF; 32]); // 1 output at the end + let commitment = layout.extract_field(&signals, "commitment").unwrap(); + assert_eq!(commitment, &[0xFF; 32]); + } + + #[test] + fn extract_c2_two_outputs() { + let layout = CircuitOutputLayout::Fixed { + fields: SHARE_COMPUTATION_OUTPUTS, + }; + // C2 has 1 pub input (key_hash) + 2 outputs = 96 bytes + let mut signals = vec![0x00; 96]; + signals[32..64].copy_from_slice(&[0xAA; 32]); // key_hash output + signals[64..96].copy_from_slice(&[0xBB; 32]); // commitment output + + assert_eq!( + layout.extract_field(&signals, "key_hash").unwrap(), + &[0xAA; 32] + ); + assert_eq!( + layout.extract_field(&signals, "commitment").unwrap(), + &[0xBB; 32] + ); + } + + #[test] + fn extract_nonexistent_field_returns_none() { + let layout = CircuitOutputLayout::Fixed { + fields: PK_BFV_OUTPUTS, + }; + let signals = vec![0u8; 32]; + assert!(layout.extract_field(&signals, "nonexistent").is_none()); + } + + #[test] + fn extract_from_void_circuit_returns_none() { + let layout = CircuitOutputLayout::None; + let signals = vec![0u8; 64]; + assert!(layout.extract_field(&signals, "anything").is_none()); + } + + #[test] + fn extract_from_dynamic_circuit_returns_none() { + let layout = CircuitOutputLayout::Dynamic; + let signals = vec![0u8; 256]; + assert!(layout.extract_field(&signals, "anything").is_none()); + } + + #[test] + fn signals_too_short_returns_none() { + let layout = CircuitOutputLayout::Fixed { + fields: PK_GENERATION_OUTPUTS, + }; + // Need 96 bytes for 3 outputs, only 64 available + let signals = vec![0u8; 64]; + assert!(layout.extract_field(&signals, "pk_commitment").is_none()); + } + + #[test] + fn extract_all_c1_outputs() { + let layout = CircuitOutputLayout::Fixed { + fields: PK_GENERATION_OUTPUTS, + }; + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); + signals[32..64].copy_from_slice(&[0x22; 32]); + signals[64..96].copy_from_slice(&[0x33; 32]); + + let all = layout.extract_all(&signals).unwrap(); + assert_eq!(all.len(), 3); + assert_eq!(all[0].0, "sk_commitment"); + assert_eq!(all[1].0, "pk_commitment"); + assert_eq!(all[2].0, "e_sm_commitment"); + assert_eq!(all[1].1, &[0x22; 32]); + } + + #[test] + fn field_count() { + assert_eq!( + CircuitOutputLayout::Fixed { + fields: PK_GENERATION_OUTPUTS + } + .field_count(), + Some(3) + ); + assert_eq!(CircuitOutputLayout::None.field_count(), Some(0)); + assert_eq!(CircuitOutputLayout::Dynamic.field_count(), None); + } +} diff --git a/crates/zk-helpers/src/circuits/threshold/pk_generation/computation.rs b/crates/zk-helpers/src/circuits/threshold/pk_generation/computation.rs index fd8aa4c829..1136e19b5d 100644 --- a/crates/zk-helpers/src/circuits/threshold/pk_generation/computation.rs +++ b/crates/zk-helpers/src/circuits/threshold/pk_generation/computation.rs @@ -130,12 +130,17 @@ impl Computation for Bits { type Data = Bounds; type Error = CircuitsErrors; - fn compute(_: Self::Preset, data: &Self::Data) -> Result { + fn compute(preset: Self::Preset, data: &Self::Data) -> Result { // Calculate bit widths for each bound type let eek_bit = calculate_bit_width(BigInt::from(data.eek_bound.clone())); let sk_bit = calculate_bit_width(BigInt::from(data.sk_bound.clone())); let e_sm_bit = calculate_bit_width(BigInt::from(data.e_sm_bound.clone())); - let pk_bit = calculate_bit_width(BigInt::from(data.pk_bound.clone())); + + // pk_bit: centered representation uses (max(qi) - 1) / 2 as the bound, + // matching compute_modulus_bit() used in C5 (pk_aggregation). + let (threshold_params, _) = + build_pair_for_preset(preset).map_err(|e| CircuitsErrors::Other(e.to_string()))?; + let pk_bit = crate::compute_modulus_bit(&threshold_params); // For r1, use the maximum of all low and up bounds let mut r1_bit = 0; @@ -370,10 +375,13 @@ mod tests { #[test] fn test_bound_and_bits_computation_consistency() { - let bounds = Bounds::compute(BfvPreset::InsecureThreshold512, &()).unwrap(); - let bits = Bits::compute(BfvPreset::InsecureThreshold512, &bounds).unwrap(); + let preset = BfvPreset::InsecureThreshold512; + let bounds = Bounds::compute(preset, &()).unwrap(); + let bits = Bits::compute(preset, &bounds).unwrap(); - let expected_bit = calculate_bit_width(BigInt::from(bounds.pk_bound.clone())); + // pk_bit uses compute_modulus_bit: (max(qi) - 1) / 2 for centered representation + let (threshold_params, _) = build_pair_for_preset(preset).unwrap(); + let expected_bit = crate::compute_modulus_bit(&threshold_params); assert_eq!(bits.pk_bit, expected_bit); } diff --git a/crates/zk-prover/src/actors/commitment_consistency_checker.rs b/crates/zk-prover/src/actors/commitment_consistency_checker.rs index 634c1bafaa..30642cb45a 100644 --- a/crates/zk-prover/src/actors/commitment_consistency_checker.rs +++ b/crates/zk-prover/src/actors/commitment_consistency_checker.rs @@ -36,7 +36,7 @@ use tracing::{info, warn}; struct VerifiedProofData { party_id: u64, address: Address, - public_outputs: ArcBytes, + public_signals: ArcBytes, } /// Per-E3 actor that enforces cross-circuit commitment consistency. @@ -102,8 +102,8 @@ impl CommitmentConsistencyChecker { let target = self.verified.get(&(address, tgt_type)); if let (Some(src), Some(tgt)) = (source, target) { - let source_values = link.extract_source_values(&src.public_outputs); - if !link.check_consistency(&source_values, &tgt.public_outputs) { + let source_values = link.extract_source_values(&src.public_signals); + if !link.check_consistency(&source_values, &tgt.public_signals) { warn!( "[{}] Commitment mismatch for E3 {} — party {} ({}): \ source {:?} vs target {:?} from same address", @@ -146,12 +146,12 @@ impl CommitmentConsistencyChecker { // For each (source, target) pair, check consistency. for src in &sources { - let source_values = link.extract_source_values(&src.public_outputs); + let source_values = link.extract_source_values(&src.public_signals); if source_values.is_empty() { continue; } for tgt in &targets { - if !link.check_consistency(&source_values, &tgt.public_outputs) { + if !link.check_consistency(&source_values, &tgt.public_signals) { warn!( "[{}] Commitment mismatch for E3 {} — source party {} ({}) {:?} \ not consistent with target party {} ({}) {:?}", @@ -205,14 +205,14 @@ impl Handler> for CommitmentConsistencyCheck let proof_type = data.proof_type; let address = data.address; - let public_outputs = data.public_outputs; + let public_signals = data.public_signals; self.verified.insert( (address, proof_type), VerifiedProofData { party_id: data.party_id, address, - public_outputs, + public_signals, }, ); diff --git a/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs b/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs index 89169a4ee5..db5519cdb0 100644 --- a/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs +++ b/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs @@ -23,10 +23,8 @@ //! `expected_threshold_pk_commitments` array. use super::{CommitmentLink, FieldValue, LinkScope}; -use e3_events::ProofType; - -/// Size of one BN254 field element in bytes. -const FIELD_SIZE: usize = 32; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::{CircuitOutputLayout, FIELD_BYTE_LEN}; /// C1 → C5 pk_commitment consistency link. pub struct C1ToC5PkCommitmentLink; @@ -49,13 +47,12 @@ impl CommitmentLink for C1ToC5PkCommitmentLink { } fn extract_source_values(&self, public_signals: &[u8]) -> Vec { - // C1 outputs: (sk_commitment, pk_commitment, e_sm_commitment) — 3 fields, no public inputs - // pk_commitment is at field index 1 (bytes 32..64) - if public_signals.len() < 3 * FIELD_SIZE { + let layout = CircuitName::PkGeneration.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "pk_commitment") else { return vec![]; - } - let mut value = [0u8; FIELD_SIZE]; - value.copy_from_slice(&public_signals[FIELD_SIZE..2 * FIELD_SIZE]); + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); vec![value] } @@ -65,30 +62,27 @@ impl CommitmentLink for C1ToC5PkCommitmentLink { target_public_signals: &[u8], ) -> bool { if source_values.is_empty() { - // No source values to check — vacuously consistent. return true; } - if target_public_signals.len() < 2 * FIELD_SIZE { - // Target proof is present but has malformed/truncated signals — non-consistent. + // C5 public_signals layout: [pub inputs: pk_commitments[0..H]] [output: commitment] + // The output count comes from the circuit layout; everything before it is public inputs. + let output_count = CircuitName::PkAggregation + .output_layout() + .field_count() + .unwrap_or(1); + let total_fields = target_public_signals.len() / FIELD_BYTE_LEN; + if total_fields <= output_count { return false; } + let h = total_fields - output_count; let source_pk_commitment = &source_values[0]; - // C5 public_signals: [expected_pk_commitments[0..H], pk_agg_commitment] - // H = total_fields - 1 (last field is the output) - let total_fields = target_public_signals.len() / FIELD_SIZE; - if total_fields < 2 { - // Target proof present but not enough fields — non-consistent. - return false; - } - let h = total_fields - 1; - // Check if the source pk_commitment appears in any of the H input fields for i in 0..h { - let offset = i * FIELD_SIZE; - if target_public_signals[offset..offset + FIELD_SIZE] == *source_pk_commitment { + let offset = i * FIELD_BYTE_LEN; + if target_public_signals[offset..offset + FIELD_BYTE_LEN] == *source_pk_commitment { return true; } } diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index d8eb23991c..61fc4ac134 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -16,10 +16,10 @@ use e3_events::{ DecryptionShareProofsPending, DecryptionshareCreated, DkgProofSigned, E3Failed, E3Stage, E3id, EnclaveEvent, EnclaveEventData, EncryptionKey, EncryptionKeyCreated, EncryptionKeyPending, EventContext, EventPublisher, EventSubscriber, EventType, FailureReason, - PkAggregationProofPending, PkAggregationProofSigned, PkBfvProofRequest, - PkGenerationProofSigned, Proof, ProofPayload, ProofType, Sequenced, - ShareDecryptionProofPending, SignedProofPayload, ThresholdShare, ThresholdShareCreated, - ThresholdSharePending, TypedEvent, ZkRequest, ZkResponse, + PkAggregationProofPending, PkAggregationProofRequest, PkAggregationProofSigned, + PkBfvProofRequest, PkGenerationProofSigned, Proof, ProofPayload, ProofType, Sequenced, + ShareDecryptionProofPending, SignedProofFailed, SignedProofPayload, ThresholdShare, + ThresholdShareCreated, ThresholdSharePending, TypedEvent, ZkRequest, ZkResponse, }; use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; @@ -181,6 +181,7 @@ impl PendingDecryptionProofs { #[derive(Clone, Debug)] struct PendingPkAggregationProof { ec: EventContext, + request: PkAggregationProofRequest, } /// Pending C6 (ShareDecryptionProof) proof generation state. @@ -905,8 +906,13 @@ impl ProofRequestActor { return; } - self.pending_pk_aggregation - .insert(e3_id.clone(), PendingPkAggregationProof { ec: ec.clone() }); + self.pending_pk_aggregation.insert( + e3_id.clone(), + PendingPkAggregationProof { + ec: ec.clone(), + request: msg.proof_request.clone(), + }, + ); let correlation_id = CorrelationId::new(); self.pk_aggregation_correlation @@ -1467,6 +1473,7 @@ impl ProofRequestActor { e3_id ); self.pending_pk_aggregation.remove(&e3_id); + if let Err(e) = self.bus.publish( E3Failed { e3_id, diff --git a/crates/zk-prover/src/actors/proof_verification.rs b/crates/zk-prover/src/actors/proof_verification.rs index 10b1aa0869..d9d1371509 100644 --- a/crates/zk-prover/src/actors/proof_verification.rs +++ b/crates/zk-prover/src/actors/proof_verification.rs @@ -251,7 +251,7 @@ impl Handler> for ProofVerificationActor { address: recovered_signer, proof_type: ProofType::C0PkBfv, data_hash, - public_outputs: signed_payload.payload.proof.public_signals.clone(), + public_signals: signed_payload.payload.proof.public_signals.clone(), }, ec, ) { diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index 89ff8c33cb..38a24c9b83 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -460,7 +460,7 @@ impl ShareVerificationActor { .unwrap_or_default(); let signals = pending.party_public_signals.get(&result.sender_party_id); for (i, &(proof_type, data_hash)) in hashes.iter().enumerate() { - let public_outputs = signals + let public_signals = signals .and_then(|s| s.get(i)) .map(|(_, ps)| ps.clone()) .unwrap_or_default(); @@ -471,7 +471,7 @@ impl ShareVerificationActor { address: addr, proof_type, data_hash, - public_outputs, + public_signals, }, pending.ec.clone(), ) { diff --git a/templates/default/tests/integration.spec.ts b/templates/default/tests/integration.spec.ts index baec0bb5f6..dedf593598 100644 --- a/templates/default/tests/integration.spec.ts +++ b/templates/default/tests/integration.spec.ts @@ -189,7 +189,7 @@ describe('Integration', () => { const { waitForEvent } = await setupEventListeners(sdk, store) const committeeSize = CommitteeSize.Micro - const duration = 600 + const duration = 500 const inputWindow = await calculateInputWindow(publicClient, duration) const thresholdBfvParams = await sdk.getThresholdBfvParamsSet() const e3ProgramParams = encodeBfvParams(thresholdBfvParams)