diff --git a/beacon_node/execution_layer/src/eip8025/proof_node_client.rs b/beacon_node/execution_layer/src/eip8025/proof_node_client.rs index 76f2b1ce918..25af9895479 100644 --- a/beacon_node/execution_layer/src/eip8025/proof_node_client.rs +++ b/beacon_node/execution_layer/src/eip8025/proof_node_client.rs @@ -142,8 +142,8 @@ impl HttpProofNodeClient { impl ProofNodeClient for HttpProofNodeClient { /// `POST /v1/execution_proof_requests?proof_types=reth-sp1,ethrex-risc0` /// - /// Converts EIP-8025 `u8` proof types to string identifiers - /// for the wire format. + /// Converts EIP-8025 `u8` proof types to string identifiers for the wire + /// format. async fn request_proofs( &self, ssz_body: Vec, diff --git a/beacon_node/execution_layer/src/eip8025/tests.rs b/beacon_node/execution_layer/src/eip8025/tests.rs index 28dd28f5495..882d33dea34 100644 --- a/beacon_node/execution_layer/src/eip8025/tests.rs +++ b/beacon_node/execution_layer/src/eip8025/tests.rs @@ -2,7 +2,7 @@ use crate::eip8025::proof_engine::HttpProofEngine; use crate::eip8025::proof_node_client::ProofNodeClient; -use crate::test_utils::{MockClientEvent, MockProofNodeClient}; +use crate::test_utils::{MockClientEvent, MockProofNodeClient, make_test_fulu_ssz}; use bls::{FixedBytesExtended, SignatureBytes}; use futures::StreamExt; use tokio::time::{Duration, timeout}; @@ -37,13 +37,13 @@ async fn next_event(rx: &mut tokio::sync::broadcast::Receiver) // ─── MockProofNodeClient tests ──────────────────────────────────────────────── -/// `request_proofs` records the body and emits `ProofRequested`. +/// `request_proofs` decodes SSZ, records the body, and emits `ProofRequested`. #[tokio::test] async fn mock_client_request_proofs_emits_event() { let mock = MockProofNodeClient::new(0); let mut rx = mock.subscribe_client_events(); - let body = vec![0xAAu8; 32]; + let (body, expected_root) = make_test_fulu_ssz(Hash256::repeat_byte(0xAA)); let attrs = ProofAttributes { proof_types: vec![1, 2], }; @@ -53,6 +53,7 @@ async fn mock_client_request_proofs_emits_event() { .await .expect("request_proofs should succeed"); + assert_eq!(root, expected_root); assert_eq!(mock.request_count(), 1); let event = next_event(&mut rx).await; @@ -104,11 +105,14 @@ async fn mock_client_request_proofs_broadcasts_sse_events() { let attrs = ProofAttributes { proof_types: vec![0, 1], }; + let (body, expected_root) = make_test_fulu_ssz(Hash256::repeat_byte(0x42)); let root = mock - .request_proofs(vec![], attrs) + .request_proofs(body, attrs) .await .expect("request_proofs should succeed"); + assert_eq!(root, expected_root); + for expected_type in [0u8, 1u8] { let event = timeout(Duration::from_secs(2), sse.next()) .await @@ -127,9 +131,10 @@ async fn mock_client_multiple_subscribers_each_get_events() { let mut rx1 = mock.subscribe_client_events(); let mut rx2 = mock.subscribe_client_events(); + let (body, _) = make_test_fulu_ssz(Hash256::repeat_byte(0x01)); let _ = mock .request_proofs( - vec![], + body, ProofAttributes { proof_types: vec![], }, @@ -147,18 +152,25 @@ async fn mock_client_multiple_subscribers_each_get_events() { )); } -/// Roots generated by sequential `request_proofs` calls are unique. +/// Different SSZ bodies produce different roots (computed via tree-hash). #[tokio::test] -async fn mock_client_sequential_roots_are_unique() { +async fn mock_client_computes_distinct_roots_from_ssz() { let mock = MockProofNodeClient::new(0); let attrs = ProofAttributes { proof_types: vec![], }; - let root1 = mock.request_proofs(vec![], attrs.clone()).await.unwrap(); - let root2 = mock.request_proofs(vec![], attrs.clone()).await.unwrap(); - let root3 = mock.request_proofs(vec![], attrs).await.unwrap(); + let (body1, expected1) = make_test_fulu_ssz(Hash256::repeat_byte(0x01)); + let (body2, expected2) = make_test_fulu_ssz(Hash256::repeat_byte(0x02)); + let (body3, expected3) = make_test_fulu_ssz(Hash256::repeat_byte(0x03)); + + let root1 = mock.request_proofs(body1, attrs.clone()).await.unwrap(); + let root2 = mock.request_proofs(body2, attrs.clone()).await.unwrap(); + let root3 = mock.request_proofs(body3, attrs).await.unwrap(); + assert_eq!(root1, expected1); + assert_eq!(root2, expected2); + assert_eq!(root3, expected3); assert_ne!(root1, root2); assert_ne!(root2, root3); assert_eq!(mock.request_count(), 3); @@ -247,14 +259,15 @@ async fn engine_subscribe_proof_events_filters_by_root() { proof_types: vec![0], }; + let (body1, root1) = make_test_fulu_ssz(Hash256::from_low_u64_be(1)); + let (body2, _root2) = make_test_fulu_ssz(Hash256::from_low_u64_be(2)); + // Subscribe before making requests. - let root1 = Hash256::from_low_u64_be(1); let mut filtered = mock.subscribe_proof_events(Some(root1)); - // Calling request_proofs produces roots in sequence (1, 2, …). // root1 matches the filter; root2 should be silently dropped. - let _ = mock.request_proofs(vec![], attrs.clone()).await.unwrap(); // → root 1 - let _ = mock.request_proofs(vec![], attrs).await.unwrap(); // → root 2 + let _ = mock.request_proofs(body1, attrs.clone()).await.unwrap(); + let _ = mock.request_proofs(body2, attrs).await.unwrap(); // Only the event for root1 should arrive on the filtered stream. let event = timeout(Duration::from_secs(2), filtered.next()) diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 400ee6e82c8..657d6ffe5ea 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -70,6 +70,20 @@ mod payload_status; pub mod test_utils; pub mod versioned_hashes; +/// Combine two optional results, preferring `Ok` values over `Err` values. +/// +/// If both are `Some`, the first `Ok` is returned. If only one is `Ok`, that one wins. +/// If both are `Err`, the first error is returned. +fn prefer_ok(a: Option>, b: Option>) -> Option> { + match (a, b) { + (Some(Ok(val)), _) => Some(Ok(val)), + (_, Some(Ok(val))) => Some(Ok(val)), + (some @ Some(_), _) => some, + (_, some @ Some(_)) => some, + (None, None) => None, + } +} + /// Indicates the default jwt authenticated execution endpoint. pub const DEFAULT_EXECUTION_ENDPOINT: &str = "http://localhost:8551/"; @@ -566,8 +580,13 @@ impl ExecutionLayer { let proof_engine: Option> = if let Some(proof_url) = proof_engine_endpoint { if let Some(idx) = test_utils::parse_mock_index(proof_url.expose_full().as_str()) { - let mock = test_utils::get_mock_proof_engine(idx) - .unwrap_or_else(|| panic!("no mock registered at index {idx}")); + let mock = test_utils::get_mock_proof_engine(idx).unwrap_or_else(|| { + debug!( + idx, + "No pre-registered mock; creating MockProofNodeClient on the fly" + ); + test_utils::register_mock_proof_engine(idx, 0) + }); debug!(idx, "Instantiating mock proof engine from registry"); Some(Arc::new(eip8025::HttpProofEngine::with_proof_node( (*mock).clone(), @@ -1476,13 +1495,18 @@ impl ExecutionLayer { }; let proof_engine_result = if let Some(proof_engine) = self.proof_engine() { - Some(Ok(proof_engine.new_payload(&new_payload_request).await?)) + match proof_engine.new_payload(&new_payload_request).await { + Ok(status) => Some(Ok(status)), + Err(e) => { + debug!(error = ?e, "Proof engine new_payload error (non-fatal)"); + None + } + } } else { None }; - let result = engine_result - .or(proof_engine_result) + let result = prefer_ok(engine_result, proof_engine_result) .expect("at least one of engine or proof engine must be present"); if let Ok(status) = &result { @@ -1635,15 +1659,18 @@ impl ExecutionLayer { }; let proof_engine_result = if let Some(proof_engine) = self.proof_engine() { - Some(Ok(proof_engine - .forkchoice_updated(forkchoice_state) - .await?)) + match proof_engine.forkchoice_updated(forkchoice_state).await { + Ok(response) => Some(Ok(response)), + Err(e) => { + debug!(error = ?e, "Proof engine forkchoice_updated error (non-fatal)"); + None + } + } } else { None }; - let result = engine_result - .or(proof_engine_result) + let result = prefer_ok(engine_result, proof_engine_result) .expect("at least one of engine or proof engine must be present"); if let Ok(status) = &result { diff --git a/beacon_node/execution_layer/src/test_utils/mock_proof_node_client.rs b/beacon_node/execution_layer/src/test_utils/mock_proof_node_client.rs index 9e8203b3a68..4b305e2b027 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_proof_node_client.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_proof_node_client.rs @@ -10,20 +10,24 @@ use crate::eip8025::errors::ProofEngineError; use crate::eip8025::proof_node_client::ProofNodeClient; use crate::eip8025::types::{ProofComplete, ProofEvent}; -use bls::FixedBytesExtended; +use crate::engine_api::NewPayloadRequestFulu; use bytes::Bytes; use futures::stream::Stream; use parking_lot::Mutex; +use ssz::{Encode, SszDecoderBuilder}; +use ssz_types::VariableList; use std::collections::HashMap; use std::pin::Pin; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::Duration; use tokio::sync::broadcast; use tokio_stream::StreamExt; use tokio_stream::wrappers::BroadcastStream; -use types::Hash256; +use tree_hash::TreeHash; use types::execution::eip8025::{ProofAttributes, ProofStatus}; +use types::{ + EthSpec, ExecutionPayloadFulu, ExecutionRequests, Hash256, MainnetEthSpec, VersionedHash, +}; /// Events emitted by [`MockProofNodeClient`] for each method invocation. /// @@ -68,16 +72,71 @@ pub fn mock_proof_engine_url(index: usize) -> String { /// Parse the index from a mock URL. Returns `None` for non-mock URLs. pub fn parse_mock_index(url: &str) -> Option { - url.strip_prefix("http://mock/") - .and_then(|s| s.strip_suffix('/')) - .and_then(|s| s.parse().ok()) + url.strip_prefix("http://mock/").map(|s| { + let s = s.strip_suffix('/').unwrap_or(s); + if s.is_empty() { + 0 + } else { + s.parse().unwrap_or(0) + } + }) +} + +/// Decode SSZ bytes as a `NewPayloadRequestFulu` and compute +/// the tree-hash root. +/// +/// Decodes each field individually via `SszDecoderBuilder`, constructs a +/// `NewPayloadRequestFulu` borrowing the owned fields, and returns the +/// tree-hash root of the real superstruct type. +fn decode_fulu_tree_hash_root(ssz_body: &[u8]) -> Result { + let mut builder = SszDecoderBuilder::new(ssz_body); + builder.register_type::>()?; + builder.register_type::::MaxBlobCommitmentsPerBlock>>()?; + builder.register_type::()?; + builder.register_type::>()?; + let mut decoder = builder.build()?; + + let execution_payload: ExecutionPayloadFulu = decoder.decode_next()?; + let versioned_hashes: VariableList< + VersionedHash, + ::MaxBlobCommitmentsPerBlock, + > = decoder.decode_next()?; + let parent_beacon_block_root: Hash256 = decoder.decode_next()?; + let execution_requests: ExecutionRequests = decoder.decode_next()?; + + let request = NewPayloadRequestFulu { + execution_payload: &execution_payload, + versioned_hashes, + parent_beacon_block_root, + execution_requests: &execution_requests, + }; + Ok(request.tree_hash_root()) +} + +/// Build a test SSZ body encoding a `NewPayloadRequestFulu` with the given +/// parent beacon block root. Returns `(ssz_bytes, expected_tree_hash_root)`. +pub fn make_test_fulu_ssz(parent_root: Hash256) -> (Vec, Hash256) { + let execution_payload = ExecutionPayloadFulu::::default(); + let versioned_hashes = VariableList::< + VersionedHash, + ::MaxBlobCommitmentsPerBlock, + >::default(); + let execution_requests = ExecutionRequests::::default(); + let request = NewPayloadRequestFulu { + execution_payload: &execution_payload, + versioned_hashes, + parent_beacon_block_root: parent_root, + execution_requests: &execution_requests, + }; + (request.as_ssz_bytes(), request.tree_hash_root()) } /// In-memory proof node client for testing. /// -/// Each call to [`request_proofs`] assigns a sequential `Hash256` root, -/// records the raw SSZ body, and schedules a [`ProofEvent::ProofComplete`] -/// event for each requested proof type after `callback_delay_ms` milliseconds. +/// Each call to [`request_proofs`] decodes the SSZ body as a Fulu +/// `NewPayloadRequest`, computes the tree-hash root, records the raw SSZ body, +/// and schedules a [`ProofEvent::ProofComplete`] event for each requested +/// proof type after `callback_delay_ms` milliseconds. /// /// Call [`subscribe_client_events`] to receive a [`MockClientEvent`] stream /// that fires once per method invocation — useful for asserting that the proof @@ -93,8 +152,6 @@ pub struct MockProofNodeClient { event_tx: broadcast::Sender, /// Broadcast channel for method-invocation events. call_tx: broadcast::Sender, - /// Counter used to generate unique sequential roots. - next_root: Arc, /// Delay in milliseconds before broadcasting proof complete events. callback_delay_ms: u64, } @@ -111,7 +168,6 @@ impl MockProofNodeClient { requests: Arc::new(Mutex::new(Vec::new())), event_tx, call_tx, - next_root: Arc::new(AtomicU64::new(1)), callback_delay_ms, } } @@ -143,8 +199,8 @@ impl ProofNodeClient for MockProofNodeClient { ssz_body: Vec, proof_attributes: ProofAttributes, ) -> Result { - let idx = self.next_root.fetch_add(1, Ordering::SeqCst); - let root = Hash256::from_low_u64_be(idx); + let root = decode_fulu_tree_hash_root(&ssz_body) + .map_err(|e| ProofEngineError::InvalidPayload(format!("SSZ decode failed: {e:?}")))?; self.requests.lock().push(ssz_body.clone()); diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index ffe546f6a2a..fd357737ce1 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -35,8 +35,8 @@ pub use hook::Hook; pub use mock_builder::{MockBuilder, Operation, mock_builder_extra_data}; pub use mock_execution_layer::MockExecutionLayer; pub use mock_proof_node_client::{ - MockClientEvent, MockProofNodeClient, get_mock_proof_engine, mock_proof_engine_url, - parse_mock_index, register_mock_proof_engine, + MockClientEvent, MockProofNodeClient, get_mock_proof_engine, make_test_fulu_ssz, + mock_proof_engine_url, parse_mock_index, register_mock_proof_engine, }; pub const DEFAULT_TERMINAL_DIFFICULTY: u64 = 6400; diff --git a/scripts/local_testnet/network_params_eip8025.yaml b/scripts/local_testnet/network_params_eip8025.yaml new file mode 100644 index 00000000000..cd70704d0ea --- /dev/null +++ b/scripts/local_testnet/network_params_eip8025.yaml @@ -0,0 +1,39 @@ +# EIP-8025 multi-node testnet configuration. +# +# Uses MockProofNodeClient via the http://mock/{n}/ URL pattern. +# See start_eip8025_testnet.sh for usage. +# +# Full configuration reference: https://github.com/ethpandaops/ethereum-package#configuration +participants: + # Supernode participants with proof engine enabled + - cl_type: lighthouse + cl_image: lighthouse:local + el_type: geth + el_image: ethereum/client-go:latest + supernode: true + cl_extra_params: + - --target-peers=3 + - --proof-engine-endpoint=http://mock/0/ + vc_extra_params: + - --proof-engine-endpoint=http://mock/0/ + count: 2 + # Non-supernode participants with proof engine enabled + - cl_type: lighthouse + cl_image: lighthouse:local + el_type: geth + el_image: ethereum/client-go:latest + supernode: false + cl_extra_params: + - --target-peers=3 + - --proof-engine-endpoint=http://mock/0/ + vc_extra_params: + - --proof-engine-endpoint=http://mock/0/ + count: 2 +network_params: + fulu_fork_epoch: 0 + seconds_per_slot: 6 +snooper_enabled: false +global_log_level: debug +additional_services: + - dora + - prometheus_grafana diff --git a/scripts/local_testnet/start_eip8025_testnet.sh b/scripts/local_testnet/start_eip8025_testnet.sh new file mode 100755 index 00000000000..21cc60ebace --- /dev/null +++ b/scripts/local_testnet/start_eip8025_testnet.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +# Start a local EIP-8025 testnet with mock proof engines using Kurtosis. +# +# Requires: docker, kurtosis, yq +# +# This script builds Lighthouse and launches a Kurtosis enclave using +# network_params_eip8025.yaml. Mock proof engines are enabled via the +# http://mock/0/ URL pattern (no special build feature required). + +set -Eeuo pipefail + +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +ROOT_DIR="$SCRIPT_DIR/../.." +ENCLAVE_NAME=eip8025-testnet +NETWORK_PARAMS_FILE=$SCRIPT_DIR/network_params_eip8025.yaml +ETHEREUM_PKG_VERSION=main + +BUILD_IMAGE=true +KEEP_ENCLAVE=false + +# Get options +while getopts "e:n:bkh" flag; do + case "${flag}" in + e) ENCLAVE_NAME=${OPTARG};; + n) NETWORK_PARAMS_FILE=${OPTARG};; + b) BUILD_IMAGE=false;; + k) KEEP_ENCLAVE=true;; + h) + echo "Start a local EIP-8025 testnet with Kurtosis." + echo + echo "usage: $0 " + echo + echo "Options:" + echo " -e: enclave name default: $ENCLAVE_NAME" + echo " -n: kurtosis network params file path default: $NETWORK_PARAMS_FILE" + echo " -b: skip building Lighthouse docker image" + echo " -k: keep existing enclave (don't destroy first)" + echo " -h: this help" + exit + ;; + esac +done + +LH_IMAGE_NAME=$(yq eval ".participants[0].cl_image" "$NETWORK_PARAMS_FILE") + +for cmd in docker kurtosis yq; do + if ! command -v "$cmd" &> /dev/null; then + echo "$cmd is not installed. Please install $cmd and try again." + exit 1 + fi +done + +if [ "$KEEP_ENCLAVE" = false ]; then + kurtosis enclave rm -f "$ENCLAVE_NAME" 2>/dev/null || true +fi + +if [ "$BUILD_IMAGE" = true ]; then + echo "Building Lighthouse Docker image." + docker build \ + --build-arg FEATURES=portable,spec-minimal \ + -f "$ROOT_DIR/Dockerfile" \ + -t "$LH_IMAGE_NAME" \ + "$ROOT_DIR" +else + echo "Skipping Lighthouse Docker image build." +fi + +echo "Starting EIP-8025 testnet enclave: $ENCLAVE_NAME" +kurtosis run --enclave "$ENCLAVE_NAME" \ + "github.com/ethpandaops/ethereum-package@$ETHEREUM_PKG_VERSION" \ + --args-file "$NETWORK_PARAMS_FILE" + +echo "EIP-8025 testnet started!" +echo +echo "Useful commands:" +echo " kurtosis enclave inspect $ENCLAVE_NAME" +echo " kurtosis service logs $ENCLAVE_NAME cl-1-lighthouse-geth" +echo " kurtosis enclave rm -f $ENCLAVE_NAME" diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 3308b8a9663..e3f80391665 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -541,7 +541,13 @@ impl ProductionValidatorClient { let proof_engine_client = Arc::new( if let Some(idx) = execution_layer::test_utils::parse_mock_index(url_str.as_str()) { let mock = execution_layer::test_utils::get_mock_proof_engine(idx) - .unwrap_or_else(|| panic!("no mock registered at index {idx}")); + .unwrap_or_else(|| { + debug!( + idx, + "No pre-registered mock; creating MockProofNodeClient on the fly" + ); + execution_layer::test_utils::register_mock_proof_engine(idx, 0) + }); execution_layer::eip8025::HttpProofEngine::with_proof_node((*mock).clone()) } else { execution_layer::eip8025::HttpProofEngine::new(endpoint.clone(), None)