diff --git a/beacon_node/proof_engine/Cargo.toml b/beacon_node/proof_engine/Cargo.toml index 909dfb5b7d6..4313e514827 100644 --- a/beacon_node/proof_engine/Cargo.toml +++ b/beacon_node/proof_engine/Cargo.toml @@ -12,6 +12,7 @@ test-utils = [] [dependencies] ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } +ethereum_ssz_derive = { workspace = true } hex = { workspace = true } metrics = { workspace = true } serde = { workspace = true } @@ -25,7 +26,6 @@ sha2 = { workspace = true, optional = true } tar = { version = "0.4", default-features = false, optional = true } [dev-dependencies] -ethereum_ssz_derive = { workspace = true } execution_layer = { workspace = true } ssz_types = { workspace = true } tree_hash = { workspace = true } diff --git a/beacon_node/proof_engine/src/config.rs b/beacon_node/proof_engine/src/config.rs index e2f23d8e702..c7c47ba67ca 100644 --- a/beacon_node/proof_engine/src/config.rs +++ b/beacon_node/proof_engine/src/config.rs @@ -1,6 +1,6 @@ //! Configuration for mapping EIP-8025 proof types to ERE zkVM verifiers. -use crate::{ProofEngine, ProofEngineError}; +use crate::{ProofEngine, ProofEngineError, test_utils::MockProofEngine}; use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; use std::{collections::HashSet, str::FromStr}; use types::execution::ProofType; @@ -48,6 +48,9 @@ const DEFAULT_ZESU_ZISK_PROGRAM_VK: &str = #[derive(Debug, Clone, Serialize)] pub struct ProofEngineConfig { execution_proofs: Vec, + /// Accept mock proofs of the configured proof types in place of the ERE verifiers. + /// Devnets only. + mock_proofs: bool, /// Ready-built engine returned by [`Self::build_engine`] instead of the configured /// verifiers. Never serialized and only present with the `test-utils` feature, so tests can /// inject a mock without an `ere-verifier` build. @@ -59,7 +62,7 @@ pub struct ProofEngineConfig { /// Equality covers the configured verifiers only; an injected test engine is not compared. impl PartialEq for ProofEngineConfig { fn eq(&self, other: &Self) -> bool { - self.execution_proofs == other.execution_proofs + self.execution_proofs == other.execution_proofs && self.mock_proofs == other.mock_proofs } } @@ -91,6 +94,7 @@ impl ProofEngineConfig { Ok(Self { execution_proofs, + mock_proofs: false, #[cfg(feature = "test-utils")] engine: None, }) @@ -101,6 +105,18 @@ impl ProofEngineConfig { &self.execution_proofs } + /// Accept mock proofs, for devnets without a real prover. See + /// [`MockProofEngine::accepting_mock_proofs`]. + pub fn with_mock_proofs(mut self) -> Self { + self.mock_proofs = true; + self + } + + /// Whether mock proofs are accepted in place of the ERE verifiers. + pub fn mock_proofs(&self) -> bool { + self.mock_proofs + } + /// Yield `engine` from [`Self::build_engine`] instead of building the configured verifiers. #[cfg(feature = "test-utils")] pub fn with_engine(mut self, engine: ProofEngine) -> Self { @@ -109,13 +125,20 @@ impl ProofEngineConfig { } /// Build the proof engine this configuration describes: an injected test engine if one was - /// supplied, otherwise the ERE verifiers, which need the `ere-verifier` feature. + /// supplied, the mock-proof engine in mock mode, otherwise the ERE verifiers, which + /// need the `ere-verifier` feature. pub fn build_engine(&self) -> Result { #[cfg(feature = "test-utils")] if let Some(engine) = &self.engine { return Ok(engine.clone()); } + if self.mock_proofs { + return Ok(ProofEngine::new(MockProofEngine::accepting_mock_proofs( + self.execution_proofs.iter().map(|config| config.proof_type), + ))); + } + #[cfg(feature = "ere-verifier")] { crate::ere::EreProofEngine::new(self.clone()).map(ProofEngine::new) @@ -173,10 +196,17 @@ impl<'de> Deserialize<'de> for ProofEngineConfig { #[derive(Deserialize)] struct Config { execution_proofs: Vec, + #[serde(default)] + mock_proofs: bool, } let config = Config::deserialize(deserializer)?; - Self::new(config.execution_proofs).map_err(D::Error::custom) + let proof_engine = Self::new(config.execution_proofs).map_err(D::Error::custom)?; + Ok(if config.mock_proofs { + proof_engine.with_mock_proofs() + } else { + proof_engine + }) } } @@ -233,6 +263,24 @@ mod tests { ); } + #[test] + fn mock_proofs_survive_a_json_round_trip() { + let config = ProofEngineConfig::default().with_mock_proofs(); + let json = serde_json::to_string(&config).expect("serialize configuration"); + + assert_eq!( + json.parse::() + .expect("deserialize configuration"), + config + ); + assert!( + !format!(r#"{{"execution_proofs":[{{"proof_type":5,"program_vk":"0x{DEFAULT_RETH_SP1_PROGRAM_VK}"}}]}}"#) + .parse::() + .expect("mock_proofs defaults to false") + .mock_proofs() + ); + } + #[test] fn rejects_invalid_json_fields() { for json in [ diff --git a/beacon_node/proof_engine/src/test_utils.rs b/beacon_node/proof_engine/src/test_utils.rs index 485d32b2076..694fd2755cd 100644 --- a/beacon_node/proof_engine/src/test_utils.rs +++ b/beacon_node/proof_engine/src/test_utils.rs @@ -1,13 +1,18 @@ -//! Test utilities for proof-engine consumers. +//! Mock proof engines, for tests and for devnets without a prover. use crate::{ProofEngineError, ProofEngineT, ProofVerificationOutcome}; +use ssz::{Decode, Encode}; +use ssz_derive::{Decode, Encode}; use std::collections::HashSet; -use types::execution::ExecutionProof; +use types::execution::{ExecutionProof, ProofType}; -/// Deterministic proof engine used by beacon-chain tests. +/// Deterministic proof engine used by beacon-chain tests, and by devnets through +/// `--proof-engine-mock-proofs`. #[derive(Debug, Default)] pub struct MockProofEngine { valid_proof_data: HashSet>, + /// Proof types whose mock proofs are accepted. `None` accepts no mock proof. + mock_proof_types: Option>, } impl MockProofEngine { @@ -15,8 +20,31 @@ impl MockProofEngine { pub fn new(valid_proof_data: impl IntoIterator>) -> Self { Self { valid_proof_data: valid_proof_data.into_iter().collect(), + mock_proof_types: None, } } + + /// Construct a mock that accepts mock proofs, as [`MockProof`], for `proof_types`. + /// + /// A mock proof is valid when its public values are the serialized public input of the proof, + /// the bytes a real guest commits; `proof` is not checked. + pub fn accepting_mock_proofs(proof_types: impl IntoIterator) -> Self { + Self { + valid_proof_data: HashSet::new(), + mock_proof_types: Some(proof_types.into_iter().collect()), + } + } +} + +/// The `proof_data` of a mock proof, sent by provers without a zkVM in place of a real proof. +/// The layout is that of `MockProof` in eth-act/zkboost (`crates/types/src/execution_proof.rs`). +/// The `proof_data` list bounds both lists. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct MockProof { + /// The public values that the guest commits, as a verifier of a real proof returns them. + pub public_values: Vec, + /// Random bytes in place of a real proof. + pub proof: Vec, } impl ProofEngineT for MockProofEngine { @@ -24,6 +52,17 @@ impl ProofEngineT for MockProofEngine { &self, proof: &ExecutionProof, ) -> Result { + if let Some(mock_proof_types) = &self.mock_proof_types { + if !mock_proof_types.contains(&proof.proof_type) { + return Err(ProofEngineError::UnconfiguredProofType(proof.proof_type)); + } + if MockProof::from_ssz_bytes(proof.proof_data.as_ref()).is_ok_and(|mock_proof| { + mock_proof.public_values == proof.public_input.as_ssz_bytes() + }) { + return Ok(ProofVerificationOutcome::Valid); + } + } + Ok( if self.valid_proof_data.contains(proof.proof_data.as_ref()) { ProofVerificationOutcome::Valid @@ -69,4 +108,103 @@ mod tests { ProofVerificationOutcome::Invalid ); } + + fn mock_proof(public_values: &[u8], proof: &[u8]) -> Vec { + MockProof { + public_values: public_values.to_vec(), + proof: proof.to_vec(), + } + .as_ssz_bytes() + } + + /// The encoding matches the layout zkboost tests: the offsets of both lists, then the public + /// values and the proof. + #[test] + fn mock_proof_layout() { + let mock_proof = MockProof { + public_values: vec![0xaa; 3], + proof: vec![0xbb; 2], + }; + let encoded = mock_proof.as_ssz_bytes(); + assert_eq!( + encoded, + [8, 0, 0, 0, 11, 0, 0, 0, 0xaa, 0xaa, 0xaa, 0xbb, 0xbb] + ); + assert_eq!( + MockProof::from_ssz_bytes(&encoded).expect("valid mock proof"), + mock_proof + ); + } + + #[test] + fn mock_proofs_verify_their_public_values() { + let proof_engine = ProofEngine::new(MockProofEngine::accepting_mock_proofs([ + ProofType::EthrexSP1, + ])); + let proof = |proof_type, proof_data| { + ExecutionProof::new( + ProofData::new(proof_data).expect("proof data within bound"), + proof_type, + Hash256::repeat_byte(0x33), + 1, + ) + }; + let public_values = proof(ProofType::EthrexSP1, vec![1]) + .public_input + .as_ssz_bytes(); + let verify = |proof_type, proof_data| { + proof_engine.verify_execution_proof(&proof(proof_type, proof_data)) + }; + + assert_eq!( + verify( + ProofType::EthrexSP1, + mock_proof(&public_values, &[0xbb; 64]) + ) + .expect("mock verification succeeds"), + ProofVerificationOutcome::Valid + ); + + let mut other_public_values = public_values.clone(); + other_public_values[0] ^= 1; + for proof_data in [ + mock_proof(&other_public_values, &[0xbb; 64]), + mock_proof(&public_values[..public_values.len() - 1], &[0xbb; 64]), + vec![0xff; 64], + ] { + assert_eq!( + verify(ProofType::EthrexSP1, proof_data).expect("mock verification succeeds"), + ProofVerificationOutcome::Invalid + ); + } + + assert!(matches!( + verify(ProofType::RethSP1, mock_proof(&public_values, &[0xbb; 64])), + Err(ProofEngineError::UnconfiguredProofType(ProofType::RethSP1)) + )); + } + + #[test] + fn exact_proof_data_mock_rejects_mock_proofs() { + let proof = ExecutionProof::new( + ProofData::new(vec![1]).expect("proof data within bound"), + ProofType::RethSP1, + Hash256::default(), + 1, + ); + let mock_proof = ExecutionProof::new( + ProofData::new(mock_proof(&proof.public_input.as_ssz_bytes(), &[0xbb; 64])) + .expect("proof data within bound"), + ProofType::RethSP1, + Hash256::default(), + 1, + ); + + assert_eq!( + ProofEngine::new(MockProofEngine::default()) + .verify_execution_proof(&mock_proof) + .expect("mock verification succeeds"), + ProofVerificationOutcome::Invalid + ); + } } diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 419ad5315a7..0c311c326cd 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -860,6 +860,18 @@ pub fn cli_app() -> Command { .action(ArgAction::Set) .display_order(0) ) + .arg( + Arg::new("proof-engine-mock-proofs") + .long("proof-engine-mock-proofs") + .action(ArgAction::SetTrue) + .help_heading(FLAG_HEADER) + .help("DEVNETS ONLY. Accept mock proofs (an SSZ MockProof { public_values, \ + proof }) for the proof types of --proof-engine, in place of verifying \ + them with ERE. A mock proof is valid when its public values match the \ + proof's public input; its proof bytes are not checked.") + .requires("proof-engine") + .display_order(0) + ) .arg( Arg::new("execution-jwt") .long("execution-jwt") diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 1bfd38c77ad..b925c5ff2d1 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -308,6 +308,13 @@ pub fn get_config( json.parse::() .map_err(|e| format!("Invalid --proof-engine `{path}`: {e}"))? }); + if cli_args.get_flag("proof-engine-mock-proofs") { + warn!("Accepting mock execution proofs without verifying them. Devnets only"); + client_config.proof_engine = client_config + .proof_engine + .take() + .map(ProofEngineConfig::with_mock_proofs); + } client_config.network.enable_execution_proof = true; } diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index c44480ed907..e8931b414e0 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -510,6 +510,35 @@ fn run_execution_jwt_secret_key_is_persisted() { assert_eq!(file_jwt_secret_key, jwt_secret_key); }); } +#[test] +fn proof_engine_mock_proofs_flag() { + CommandLineTest::new() + .flag("proof-engine", None) + .flag("proof-engine-mock-proofs", None) + .run_with_zero_port() + .with_config(|config| { + assert!( + config + .proof_engine + .as_ref() + .is_some_and(|proof_engine| proof_engine.mock_proofs()) + ); + }); +} +#[test] +fn proof_engine_without_mock_proofs_flag() { + CommandLineTest::new() + .flag("proof-engine", None) + .run_with_zero_port() + .with_config(|config| { + assert!( + config + .proof_engine + .as_ref() + .is_some_and(|proof_engine| !proof_engine.mock_proofs()) + ); + }); +} /// A proof-only node verifies execution in-process instead of driving an engine. #[test] fn proof_only_node_builds_without_execution_layer() {