From 154ff8823f8d2645b8c29fad48a7b7aeca42c189 Mon Sep 17 00:00:00 2001 From: ninaiiad Date: Tue, 25 Aug 2026 12:11:11 +0100 Subject: [PATCH 01/29] header stream toggler (#511) --- crates/common/src/api_provider.rs | 6 ++++-- crates/common/src/config.rs | 3 ++- crates/relay/src/api/proposer/header_stream.rs | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/common/src/api_provider.rs b/crates/common/src/api_provider.rs index c17652874..0e9ae517f 100644 --- a/crates/common/src/api_provider.rs +++ b/crates/common/src/api_provider.rs @@ -6,7 +6,8 @@ use tracing::warn; pub use uuid::Uuid; use crate::{ - Filtering, PreferencesHeader, SignedValidatorRegistrationEntry, ValidatorPreferences, + Filtering, HeaderStreamConfig, PreferencesHeader, SignedValidatorRegistrationEntry, + ValidatorPreferences, api::{HEADER_FORWARDED_FOR, HEADER_TIMEOUT_MS, proposer_api::GetHeaderParams}, }; @@ -63,8 +64,9 @@ pub trait ApiProvider: Send + Sync + Clone + 'static { _params: &GetHeaderParams, _headers: &HeaderMap, _registered: &ValidatorPreferences, + config: &HeaderStreamConfig, ) -> Result<(), &'static str> { - Err("header stream not available") + if config.admit_all { Ok(()) } else { Err("header stream not available") } } } diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 7318db37f..0d86fb274 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -758,11 +758,12 @@ pub struct HeaderStreamConfig { /// Interval between bid updates. #[serde(default = "default_u64::<5>")] pub interval_ms: u64, + pub admit_all: bool, } impl Default for HeaderStreamConfig { fn default() -> Self { - Self { stream_for_ms: 300, interval_ms: 5 } + Self { stream_for_ms: 300, interval_ms: 5, admit_all: false } } } diff --git a/crates/relay/src/api/proposer/header_stream.rs b/crates/relay/src/api/proposer/header_stream.rs index 33ba33869..32ab3a6aa 100644 --- a/crates/relay/src/api/proposer/header_stream.rs +++ b/crates/relay/src/api/proposer/header_stream.rs @@ -64,7 +64,7 @@ impl ProposerApi { proposer_api .api_provider - .admit_header_stream(¶ms, &headers, &preferences) + .admit_header_stream(¶ms, &headers, &preferences, &config) .map_err(|reason| { warn!(slot = params.slot, proposer = %params.pubkey, reason, "refusing header stream"); ProposerApiError::StreamNotAdmitted From 3e4b78fe45c52f9f8fd14ce47a2bd2d1437fb0eb Mon Sep 17 00:00:00 2001 From: vladimir-ea <85992906+vladimir-ea@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:13:04 +0100 Subject: [PATCH 02/29] move from floodsub to gossipsub for operator p2p (#505) Co-authored-by: vladimir-ea --- Cargo.lock | 110 +++++++++++----------------------- Cargo.toml | 2 +- crates/operator/src/lib.rs | 29 +++++++-- crates/operator/src/pubsub.rs | 98 +++++++++++++++++++++++------- 4 files changed, 135 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 508f01e8d..8f6ffdb25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3777,17 +3777,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "cuckoofilter" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b810a8449931679f64cd7eef1bbd0fa315801b6d5d9cdc1ace2804d6529eee18" -dependencies = [ - "byteorder", - "fnv", - "rand 0.7.3", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -6038,17 +6027,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -6316,6 +6294,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -6361,6 +6342,15 @@ dependencies = [ "fxhash", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -6899,6 +6889,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "hex_fmt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" + [[package]] name = "hickory-net" version = "0.26.1" @@ -8483,7 +8479,7 @@ dependencies = [ "libp2p-connection-limits", "libp2p-core", "libp2p-dns", - "libp2p-floodsub", + "libp2p-gossipsub", "libp2p-identity", "libp2p-mdns", "libp2p-metrics", @@ -8562,25 +8558,33 @@ dependencies = [ ] [[package]] -name = "libp2p-floodsub" -version = "0.47.0" +name = "libp2p-gossipsub" +version = "0.49.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0914997f56315c83bc64ffb721cd4e764ad819370582db287232c5791469697" +checksum = "3573f3d8e30bd62cda336df5c7c1041a1caa40a648376b8e1e274d585c0ed25c" dependencies = [ + "async-channel 2.5.0", "asynchronous-codec", + "base64 0.22.1", + "byteorder", "bytes", - "cuckoofilter", + "either", "fnv", "futures", + "futures-timer", + "getrandom 0.2.17", + "hashlink 0.9.1", + "hex_fmt", "libp2p-core", "libp2p-identity", "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", "rand 0.8.7", - "smallvec", - "thiserror 2.0.20", + "regex", + "sha2 0.10.9", "tracing", + "web-time", ] [[package]] @@ -8630,6 +8634,7 @@ checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" dependencies = [ "futures", "libp2p-core", + "libp2p-gossipsub", "libp2p-identity", "libp2p-ping", "libp2p-swarm", @@ -11473,19 +11478,6 @@ dependencies = [ "ptr_meta", ] -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - [[package]] name = "rand" version = "0.8.7" @@ -11520,16 +11512,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -11550,15 +11532,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -11584,15 +11557,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "rand_pcg" version = "0.10.2" @@ -18197,12 +18161,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 3d3308a10..c6d25d00f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ lh-eth2 = { package = "eth2", git = "https://github.com/sigp/lighthouse", rev = lh-kzg = { package = "kzg", git = "https://github.com/sigp/lighthouse", rev = "f064e9e061997c41658c3544da47627c6db92bc9" } lh-slot-clock = { package = "slot_clock", git = "https://github.com/sigp/lighthouse", rev = "f064e9e061997c41658c3544da47627c6db92bc9" } lh-types = { package = "types", git = "https://github.com/sigp/lighthouse", rev = "f064e9e061997c41658c3544da47627c6db92bc9" } -libp2p = { version = "0.56", features = ["macros", "ping", "quic", "floodsub", "secp256k1", "tokio"] } +libp2p = { version = "0.56", features = ["macros", "ping", "quic", "gossipsub", "secp256k1", "tokio"] } metrics = "0.24.0" mime_guess = "2" mockito = "1.1.1" diff --git a/crates/operator/src/lib.rs b/crates/operator/src/lib.rs index 5016016cd..a4fb9cd95 100644 --- a/crates/operator/src/lib.rs +++ b/crates/operator/src/lib.rs @@ -16,7 +16,7 @@ use helix_common::{ }; use helix_database::{PostgresDatabaseService, handle::DbHandle}; use helix_types::{BuilderCollateral, Operator, OperatorMessage, Payload}; -use libp2p::{BehaviourBuilderError, TransportError, identity::Keypair, multiaddr}; +use libp2p::{BehaviourBuilderError, TransportError, gossipsub, identity::Keypair, multiaddr}; use thiserror::Error; use tokio::task::AbortHandle; @@ -33,6 +33,9 @@ pub enum OperatorError { MultiaddrParseError(#[from] multiaddr::Error), SwarmNetworkError(#[from] TransportError), SwarmBuildError(#[from] BehaviourBuilderError), + GossipsubConfigError(#[from] gossipsub::ConfigBuilderError), + GossipsubBehaviourError(&'static str), + GossipsubSubscriptionError(#[from] gossipsub::SubscriptionError), MessageSendError(#[from] SendError<(Option, OperatorMessage)>), MessageTrySendError(#[from] TrySendError<(Option, OperatorMessage)>), MessageRecvError(#[from] RecvError), @@ -270,7 +273,7 @@ where #[cfg(test)] mod tests { - use std::str::FromStr; + use std::{str::FromStr, time::Duration}; use alloy_primitives::B256; use helix_types::{BlsPublicKeyBytes, Demotion, OperatorMessage, Promotion}; @@ -303,12 +306,17 @@ mod tests { vec![operator_b], helix_common::OperatorP2pMode::On, ); + // Ensure A is listening before B initiates its dial. + tokio::time::sleep(Duration::from_millis(100)).await; let op_b = OperatorPubSub::new( 32023, keypair_b, vec![operator_a], helix_common::OperatorP2pMode::On, ); + // Wait for the gossipsub subscription exchange before publishing. Messages are + // intentionally best-effort and are not queued for peers that have not subscribed yet. + tokio::time::sleep(Duration::from_millis(500)).await; let builder_pubkey = BlsPublicKeyBytes::random(); let demotion = Demotion { @@ -316,15 +324,24 @@ mod tests { slot: 1, builder_pubkey, block_hash: B256::random(), - reason_msg: "fail".as_bytes().to_vec(), + // Exercise a message larger than floodsub's former 2 KiB frame limit. + reason_msg: vec![42; 4 * 1024], }; let promotion = Promotion { ts_ms: 2, slot: 2, builder_pubkey }; op_a.send(None, helix_types::OperatorMessage::Demotion(demotion)).await.unwrap(); - let (_, msg) = op_b.recv().await.unwrap(); - assert!(matches!(msg, OperatorMessage::Demotion(_))); + let (_, msg) = tokio::time::timeout(Duration::from_secs(5), op_b.recv()) + .await + .expect("timed out waiting for demotion") + .unwrap(); + assert!( + matches!(msg, OperatorMessage::Demotion(demotion) if demotion.reason_msg.len() == 4 * 1024) + ); op_b.send(None, OperatorMessage::Promotion(promotion)).await.unwrap(); - let (_, msg) = op_a.recv().await.unwrap(); + let (_, msg) = tokio::time::timeout(Duration::from_secs(5), op_a.recv()) + .await + .expect("timed out waiting for promotion") + .unwrap(); assert!(matches!(msg, OperatorMessage::Promotion(_))); } } diff --git a/crates/operator/src/pubsub.rs b/crates/operator/src/pubsub.rs index c549c9656..1226155c7 100644 --- a/crates/operator/src/pubsub.rs +++ b/crates/operator/src/pubsub.rs @@ -6,8 +6,8 @@ use helix_types::{BuilderCollateral, OperatorMessage}; use libp2p::{ PeerId, SwarmBuilder, allow_block_list::{self, AllowedPeers}, - floodsub::{self, Event, FloodsubMessage}, futures::StreamExt, + gossipsub::{self, Event, IdentTopic, MessageAcceptance, MessageAuthenticity, ValidationMode}, identity::Keypair, ping, swarm::{NetworkBehaviour, SwarmEvent}, @@ -17,13 +17,35 @@ use ssz::{Decode, Encode}; use super::{Operator, OperatorError}; use crate::utils::{PromotionState, PromotionStates}; +const MAX_OPERATOR_MESSAGE_SIZE: usize = 16 * 1024 * 1024; + #[derive(NetworkBehaviour)] struct NetBehaviour { allow_list: allow_block_list::Behaviour, - floodsub: floodsub::Behaviour, + gossipsub: gossipsub::Behaviour, ping: ping::Behaviour, } +fn publish_operator_message( + behaviour: &mut gossipsub::Behaviour, + topic: &IdentTopic, + data: Vec, +) { + let message_size = data.len(); + if let Err(error) = behaviour.publish(topic.clone(), data) { + tracing::warn!(?error, message_size, "failed to publish operator message"); + } +} + +fn operator_gossipsub_config() -> Result { + gossipsub::ConfigBuilder::default() + .flood_publish(true) + .validate_messages() + .validation_mode(ValidationMode::Strict) + .max_transmit_size(MAX_OPERATOR_MESSAGE_SIZE) + .build() +} + fn record_builder_collateral( builder_collateral: &mut HashMap, builder_id: String, @@ -47,8 +69,11 @@ pub(super) async fn run_operator_connection( incoming: Sender<(Operator, OperatorMessage)>, mode: OperatorP2pMode, ) -> Result<(), OperatorError> { - let floodsub_topic = floodsub::Topic::new("operator"); - let local_peer_id = PeerId::from_public_key(&keypair.public()); + let operator_topic = IdentTopic::new("operator"); + let gossipsub_config = operator_gossipsub_config()?; + let gossipsub = + gossipsub::Behaviour::new(MessageAuthenticity::Signed(keypair.clone()), gossipsub_config) + .map_err(OperatorError::GossipsubBehaviourError)?; let mut allow_list = allow_block_list::Behaviour::default(); for op in &operators { @@ -59,17 +84,13 @@ pub(super) async fn run_operator_connection( .with_tokio() .with_quic() .with_behaviour(|_key| { - Ok(NetBehaviour { - allow_list, - floodsub: floodsub::Behaviour::new(local_peer_id), - ping: ping::Behaviour::default(), - }) + Ok(NetBehaviour { allow_list, gossipsub, ping: ping::Behaviour::default() }) })? .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(u64::MAX))) .build(); // Subscribe to operator topic. - swarm.behaviour_mut().floodsub.subscribe(floodsub_topic.clone()); + swarm.behaviour_mut().gossipsub.subscribe(&operator_topic)?; // Listen for incoming connections. swarm.listen_on(format!("/ip4/0.0.0.0/udp/{quic_port}/quic-v1").parse()?)?; @@ -82,7 +103,7 @@ pub(super) async fn run_operator_connection( for (peer_id, operator) in &peers { // Dial other operators. - swarm.behaviour_mut().floodsub.add_node_to_partial_view(*peer_id); + swarm.behaviour_mut().gossipsub.add_explicit_peer(peer_id); if let Err(e) = swarm.dial(operator.multiaddr.clone()) { tracing::warn!(?operator, ?e, "failed to dial operator"); } @@ -116,20 +137,37 @@ pub(super) async fn run_operator_connection( _ => true, }; if transmit && connected_peers > 0 { - swarm.behaviour_mut().floodsub.publish(floodsub_topic.clone(), msg.as_ssz_bytes()); + publish_operator_message( + &mut swarm.behaviour_mut().gossipsub, + &operator_topic, + msg.as_ssz_bytes(), + ); } } Err(_) => break, // channel closed }, event = swarm.select_next_some() => match event { SwarmEvent::Behaviour(b_event) => match b_event { - NetBehaviourEvent::Floodsub(f_event) => match f_event { - Event::Message(msg) => { - let FloodsubMessage { source, data, .. } = msg; + NetBehaviourEvent::Gossipsub(g_event) => match g_event { + Event::Message { propagation_source, message_id, message } => { + // Operator messages are pushed directly to every subscribed peer. Mark + // them as ignored by gossipsub after local delivery so they are never + // forwarded to another peer. + let _ = swarm.behaviour_mut().gossipsub.report_message_validation_result( + &message_id, + &propagation_source, + MessageAcceptance::Ignore, + ); + + let Some(source) = message.source else { + tracing::warn!(?propagation_source, "received operator message without a source"); + let _ = swarm.disconnect_peer_id(propagation_source); + continue; + }; match peers.get(&source) { Some(operator) => { - let operator_msg = match OperatorMessage::from_ssz_bytes(&data) { + let operator_msg = match OperatorMessage::from_ssz_bytes(&message.data) { Ok(msg) => msg, Err(e) => { tracing::error!(?e, operator=operator.name, "failed to decode operator message"); @@ -153,13 +191,13 @@ pub(super) async fn run_operator_connection( } } None => { - tracing::warn!(?source, "received operator message from unknown peer"); - let _ = swarm.disconnect_peer_id(source); + tracing::warn!(?source, ?propagation_source, "received operator message from unknown peer"); + let _ = swarm.disconnect_peer_id(propagation_source); } } } Event::Subscribed { peer_id, topic } => { - if peers.contains_key(&peer_id) && topic == floodsub_topic { + if peers.contains_key(&peer_id) && topic == operator_topic.hash() { // Send current demotion and collateral state. for state in demotions.iter() { let msg = match state { @@ -170,10 +208,18 @@ pub(super) async fn run_operator_connection( OperatorMessage::Promotion(promotion.clone()).as_ssz_bytes() } }; - swarm.behaviour_mut().floodsub.publish(floodsub_topic.clone(), msg); + publish_operator_message( + &mut swarm.behaviour_mut().gossipsub, + &operator_topic, + msg, + ); } for (_, collateral) in &builder_collateral { - swarm.behaviour_mut().floodsub.publish(floodsub_topic.clone(), OperatorMessage::Collateral(collateral.clone()).as_ssz_bytes()); + publish_operator_message( + &mut swarm.behaviour_mut().gossipsub, + &operator_topic, + OperatorMessage::Collateral(collateral.clone()).as_ssz_bytes(), + ); } } else { let _ = swarm.disconnect_peer_id(peer_id); @@ -207,6 +253,16 @@ mod tests { use super::*; + #[test] + fn gossipsub_is_configured_for_direct_16_mib_messages() { + let config = operator_gossipsub_config().unwrap(); + + assert_eq!(config.max_transmit_size(), MAX_OPERATOR_MESSAGE_SIZE); + assert!(config.flood_publish()); + assert!(config.validate_messages()); + assert!(matches!(config.validation_mode(), ValidationMode::Strict)); + } + #[test] fn first_builder_collateral_message_is_recorded_for_publish_and_replay() { let mut state = HashMap::new(); From ec755ba5517a5ff542801dce4cfec8ccefe031d9 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:27:32 +0100 Subject: [PATCH 03/29] fix(relay): scope the unbundling check to appended txs only (#524) Co-authored-by: Claude Sonnet 5 --- crates/relay/src/block_merging/tile.rs | 84 ++++++++++++++++++-- crates/relay/src/block_merging/unbundling.rs | 2 +- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index a00ffd1c9..c25a6132a 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -1,6 +1,6 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; -use alloy_primitives::{B256, Bytes, U256, keccak256}; +use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use flux::{ spine::SpineProducers, tile::Tile, @@ -22,7 +22,10 @@ use helix_tcp_types::merging::{ order::{MergeOrderRef, order_id}, relay_to_builder::{ActivateBaseBlockV1, MergeableBlockV1, RevokeOrderV1, SlotStartV1}, }; -use helix_types::{BlobWithMetadata, BlsPublicKeyBytes, HydrationCache, Submission, payload_to_v3}; +use helix_types::{ + BlobWithMetadata, BlsPublicKeyBytes, BuilderInclusionResult, HydrationCache, Submission, + payload_to_v3, +}; use rustc_hash::{FxHashMap, FxHashSet}; use ssz::Decode; use tracing::{debug, error, info, trace, warn}; @@ -260,6 +263,18 @@ fn revoked_ids( prev.iter().filter(|(id, _)| !new.contains_key(*id)).map(|(&id, &hash)| (id, hash)).collect() } +/// Tx hashes the merge builder actually appended onto the base block. +/// `builder_inclusions` only ever records orders that were applied (see +/// `record_inclusion` on the builder side), so this never includes anything +/// from the base block's own original content — which is what the +/// unbundling check must ignore, since orders sharing a tx hash with base +/// content that was never touched by the merge builder aren't its concern. +fn appended_tx_hashes( + builder_inclusions: &HashMap, +) -> FxHashSet { + builder_inclusions.values().flat_map(|inclusion| inclusion.txs.iter().copied()).collect() +} + impl BlockMergingTile { pub fn new( config: BlockMergingTcpConfig, @@ -521,7 +536,8 @@ impl BlockMergingTile { ); return; }; - let final_txs: Vec = response + let appended = appended_tx_hashes(&response.builder_inclusions); + let appended_txs: Vec = response .execution_payload .transactions .iter() @@ -530,9 +546,10 @@ impl BlockMergingTile { .entry(tx.0.clone()) .or_insert_with(|| keccak256(tx.as_ref())) }) + .filter(|hash| appended.contains(hash)) .collect(); let unbundled = find_unbundled_txs( - &final_txs, + &appended_txs, &slot.order_txs, unbundled_scratch_bundled, unbundled_scratch_covered, @@ -1049,4 +1066,61 @@ mod tests { latest_only_ids(BlsPublicKeyBytes::default(), &[bundle(true)], &[B256::repeat_byte(7)]); assert!(revoked_ids(None, &new).is_empty()); } + + fn inclusion(txs: Vec) -> BuilderInclusionResult { + BuilderInclusionResult { contribution: U256::ZERO, revenue: U256::ZERO, txs } + } + + #[test] + fn appended_tx_hashes_collects_across_builders() { + let tx_a = B256::repeat_byte(1); + let tx_b = B256::repeat_byte(2); + let tx_c = B256::repeat_byte(3); + let builder_inclusions = HashMap::from([ + (Address::repeat_byte(0xa), inclusion(vec![tx_a, tx_b])), + (Address::repeat_byte(0xb), inclusion(vec![tx_c])), + ]); + + let appended = appended_tx_hashes(&builder_inclusions); + + assert_eq!(appended, FxHashSet::from_iter([tx_a, tx_b, tx_c])); + } + + #[test] + fn appended_tx_hashes_empty_when_nothing_was_appended() { + assert!(appended_tx_hashes(&HashMap::new()).is_empty()); + } + + // Regression test for a false-positive class: an order sharing a tx hash + // with the base block's own (untouched) content, that was never itself + // satisfied, must not flag that base-block tx as unbundled. Filtering to + // `appended_tx_hashes` before the check removes base content from + // consideration entirely, so an unrelated, never-applied order can no + // longer explain (or fail to explain) it. + #[test] + fn filtering_to_appended_txs_ignores_base_block_content() { + let base_tx = B256::repeat_byte(1); + let never_appended_tx = B256::repeat_byte(2); + let appended_tx = B256::repeat_byte(3); + + // An unrelated, never-applied bundle that happens to share `base_tx` + // with the base block's own plain content. + let foreign_unsatisfied_order = OrderTxs::new(vec![base_tx, never_appended_tx], []); + // The order actually applied by the merge builder. + let applied_order = OrderTxs::new(vec![appended_tx], []); + + let builder_inclusions = + HashMap::from([(Address::repeat_byte(0xa), inclusion(vec![appended_tx]))]); + let appended = appended_tx_hashes(&builder_inclusions); + + let full_final_txs = vec![base_tx, appended_tx]; + let filtered_final_txs: Vec = + full_final_txs.iter().copied().filter(|h| appended.contains(h)).collect(); + + let orders = [foreign_unsatisfied_order, applied_order]; + assert_eq!( + find_unbundled_txs(&filtered_final_txs, &orders, &mut Vec::new(), &mut Vec::new()), + Vec::::new(), + ); + } } diff --git a/crates/relay/src/block_merging/unbundling.rs b/crates/relay/src/block_merging/unbundling.rs index 61e6f5268..fa20fd65b 100644 --- a/crates/relay/src/block_merging/unbundling.rs +++ b/crates/relay/src/block_merging/unbundling.rs @@ -31,7 +31,7 @@ impl OrderTxs { } #[cfg(test)] - fn new(hashes: Vec, droppable: impl IntoIterator) -> Self { + pub(crate) fn new(hashes: Vec, droppable: impl IntoIterator) -> Self { Self { hashes, droppable: droppable.into_iter().collect() } } } From 7f64ac4ea176e86359a059cc779a6f85514b6814 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:27:51 +0100 Subject: [PATCH 04/29] Fix flaky/slow test_duration_into_slot (#522) Co-authored-by: Claude Sonnet 5 --- crates/types/src/clock.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/types/src/clock.rs b/crates/types/src/clock.rs index befe52434..b38a8917d 100644 --- a/crates/types/src/clock.rs +++ b/crates/types/src/clock.rs @@ -18,22 +18,27 @@ pub fn duration_into_slot(clock: &SlotClock, slot: Slot) -> Option { #[cfg(test)] mod tests { - use std::thread::sleep; - use super::*; + /// `duration_into_slot` takes its own live `SystemTime::now()` reading, so it can't be + /// pinned to an exact expected value without mocking time. Instead this brackets it: the + /// result must fall between `now - slot_start` measured just before and just after the + /// call, which holds by construction regardless of scheduling delays — unlike comparing two + /// independent live clock reads against a fixed tolerance, which is exactly what made the + /// old version of this test flaky under parallel test-thread contention. #[test] fn test_duration_into_slot() { let clock = new_slot_clock(MAINNET_GENESIS_TIME, Duration::from_secs(12)); + let slot = clock.now().unwrap(); + let slot_start = clock.start_of(slot).unwrap(); - for _ in 0..100 { - let slot = clock.now().unwrap(); - let dur_1 = clock.millis_from_current_slot_start().unwrap().as_nanos() as i128; - let dur_2 = duration_into_slot(&clock, slot).unwrap().as_nanos() as i128; - let delta = dur_1 - dur_2; - assert!(delta.abs() < 1_000_000, "clock delta above 1ms: {delta}"); + let before = SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - slot_start; + let actual = duration_into_slot(&clock, slot).unwrap(); + let after = SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - slot_start; - sleep(Duration::from_millis(10)); - } + assert!( + actual >= before && actual <= after, + "duration {actual:?} not within [{before:?}, {after:?}]" + ); } } From d4a27c547d18446f24a726822e2e4fbf56c73283 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:28:11 +0100 Subject: [PATCH 05/29] Withhold V1 getPayload response inside a safety buffer before the cutoff (#520) --- config.example.yml | 1 + crates/common/src/config.rs | 8 ++ crates/common/src/metrics.rs | 6 + crates/relay/src/api/proposer/get_payload.rs | 132 +++++++++++++++---- 4 files changed, 122 insertions(+), 25 deletions(-) diff --git a/config.example.yml b/config.example.yml index 8447b00ad..352fbae6b 100644 --- a/config.example.yml +++ b/config.example.yml @@ -51,6 +51,7 @@ block_merging_config: # - builder_coinbase: "0x0000000000000000000000000000000000000000" # collateral_safe: "0x0000000000000000000000000000000000000000" target_get_payload_propagation_duration_ms: 0 +get_payload_v1_response_buffer_ms: 800 primev_config: null discord_webhook_url: null is_submission_instance: true diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 0d86fb274..0858c35a7 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -45,6 +45,9 @@ pub struct RelayConfig { pub router_config: RouterConfig, #[serde(default = "default_duration")] pub target_get_payload_propagation_duration_ms: u64, + /// Requests this close to the V1 getPayload cutoff are published but not returned. + #[serde(default = "default_get_payload_v1_response_buffer_ms")] + pub get_payload_v1_response_buffer_ms: u64, /// Configuration for block merging parameters. #[serde(default)] pub block_merging_config: BlockMergingConfig, @@ -101,6 +104,7 @@ impl RelayConfig { validator_preferences: Default::default(), router_config: Default::default(), target_get_payload_propagation_duration_ms: Default::default(), + get_payload_v1_response_buffer_ms: Default::default(), block_merging_config: Default::default(), header_stream: Default::default(), primev_config: Default::default(), @@ -720,6 +724,10 @@ fn default_duration() -> u64 { 1000 } +fn default_get_payload_v1_response_buffer_ms() -> u64 { + 800 +} + #[derive(Serialize, Deserialize, Clone)] pub struct S3Config { pub bucket: String, diff --git a/crates/common/src/metrics.rs b/crates/common/src/metrics.rs index 62ba049f9..9ee5ec770 100644 --- a/crates/common/src/metrics.rs +++ b/crates/common/src/metrics.rs @@ -449,6 +449,12 @@ lazy_static! { &RELAY_METRICS_REGISTRY ) .unwrap(); + pub static ref GET_PAYLOAD_V1_RESPONSE_WITHHELD: IntCounter = register_int_counter_with_registry!( + "get_payload_v1_response_withheld_total", + "Count of V1 getPayload responses withheld due to the response-safety buffer", + &RELAY_METRICS_REGISTRY + ) + .unwrap(); //////////////// GET HEADER //////////////// pub static ref HEADER_TIMEOUT_FETCH: IntCounter = register_int_counter_with_registry!( diff --git a/crates/relay/src/api/proposer/get_payload.rs b/crates/relay/src/api/proposer/get_payload.rs index 06698fc53..32fabb66a 100644 --- a/crates/relay/src/api/proposer/get_payload.rs +++ b/crates/relay/src/api/proposer/get_payload.rs @@ -8,7 +8,7 @@ use helix_common::{ beacon::types::BroadcastValidation, chain_info::ChainInfo, decoder::{Encoding, HEADER_SSZ}, - metrics::BEACON_BLOCK_PUBLISH_FAILURES, + metrics::{BEACON_BLOCK_PUBLISH_FAILURES, GET_PAYLOAD_V1_RESPONSE_WITHHELD}, spawn_tracked, utils::{extract_request_id, utcnow_ms, utcnow_ns}, }; @@ -344,22 +344,24 @@ impl ProposerApi { trace.payload_fetched = utcnow_ns(); // Handle early/late requests - if let Err(err) = - self.await_and_validate_slot_start_time(head_slot + 1, trace.receive).await - { - warn!(error = %err, "get_payload was sent too late"); - - self.db.save_too_late_get_payload( - (head_slot + 1).into(), - proposer_public_key, - block_hash, - trace.receive, - trace.payload_fetched, - ); + let slot_timing = + match self.await_and_validate_slot_start_time(head_slot + 1, trace.receive).await { + Ok(slot_timing) => slot_timing, + Err(err) => { + warn!(error = %err, "get_payload was sent too late"); + + self.db.save_too_late_get_payload( + (head_slot + 1).into(), + proposer_public_key, + block_hash, + trace.receive, + trace.payload_fetched, + ); - let _ = dedup_tx.send(Arc::new(None)); - return Err(err); - } + let _ = dedup_tx.send(Arc::new(None)); + return Err(err); + } + }; self.gossip_payload( to_publish.signed_block.slot(), @@ -462,6 +464,13 @@ impl ProposerApi { if remaining_sleep_ms > 0 { sleep(Duration::from_millis(remaining_sleep_ms)).await; } + + if let SlotTiming::WithinResponseBuffer(err) = slot_timing { + warn!(error = %err, "get_payload landed in the V1 response-safety buffer, withholding payload"); + GET_PAYLOAD_V1_RESPONSE_WITHHELD.inc(); + let _ = dedup_tx.send(Arc::new(None)); + return Err(err); + } } // Notify dedup waiters with the successful response. @@ -476,7 +485,7 @@ impl ProposerApi { &self, slot: Slot, request_time_ns: u64, - ) -> Result<(), ProposerApiError> { + ) -> Result { let Some((since_slot_start, until_slot_start)) = calculate_slot_time_info(&self.chain_info, slot, request_time_ns) else { @@ -491,15 +500,16 @@ impl ProposerApi { if let Some(until_slot_start) = until_slot_start { info!("waiting until slot start t=0: {} ms", until_slot_start.as_millis()); sleep(until_slot_start).await; - } else if let Some(since_slot_start) = since_slot_start && - since_slot_start.as_millis() > GET_PAYLOAD_REQUEST_CUTOFF_MS as u128 - { - return Err(ProposerApiError::GetPayloadRequestTooLate { - cutoff: GET_PAYLOAD_REQUEST_CUTOFF_MS as u64, - request_time: since_slot_start.as_millis() as u64, - }); + return Ok(SlotTiming::OnTime); } - Ok(()) + + let Some(since_slot_start) = since_slot_start else { return Ok(SlotTiming::OnTime) }; + + evaluate_response_buffer( + since_slot_start.as_millis() as u64, + GET_PAYLOAD_REQUEST_CUTOFF_MS as u64, + self.relay_config.get_payload_v1_response_buffer_ms, + ) } async fn save_delivered_payload_info( @@ -565,6 +575,30 @@ impl ProposerApi { } } +enum SlotTiming { + OnTime, + WithinResponseBuffer(ProposerApiError), +} + +fn evaluate_response_buffer( + since_slot_start_ms: u64, + cutoff_ms: u64, + buffer_ms: u64, +) -> Result { + let too_late = || ProposerApiError::GetPayloadRequestTooLate { + cutoff: cutoff_ms, + request_time: since_slot_start_ms, + }; + + if since_slot_start_ms > cutoff_ms { + return Err(too_late()); + } + if since_slot_start_ms > cutoff_ms.saturating_sub(buffer_ms) { + return Ok(SlotTiming::WithinResponseBuffer(too_late())); + } + Ok(SlotTiming::OnTime) +} + /// Calculates the time information for a given slot. fn calculate_slot_time_info( chain_info: &ChainInfo, @@ -585,3 +619,51 @@ pub(super) fn fork_name_from_header(headers: &HeaderMap) -> Result Date: Wed, 26 Aug 2026 14:28:41 +0100 Subject: [PATCH 06/29] Fix needless-borrow clippy lint in header_stream.rs (#516) --- crates/relay/src/api/proposer/header_stream.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/relay/src/api/proposer/header_stream.rs b/crates/relay/src/api/proposer/header_stream.rs index 32ab3a6aa..ce753336f 100644 --- a/crates/relay/src/api/proposer/header_stream.rs +++ b/crates/relay/src/api/proposer/header_stream.rs @@ -64,7 +64,7 @@ impl ProposerApi { proposer_api .api_provider - .admit_header_stream(¶ms, &headers, &preferences, &config) + .admit_header_stream(¶ms, &headers, &preferences, config) .map_err(|reason| { warn!(slot = params.slot, proposer = %params.pubkey, reason, "refusing header stream"); ProposerApiError::StreamNotAdmitted From 5ecade43facb681b5fdd30ba8853ba92c8bf3281 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:29:11 +0100 Subject: [PATCH 07/29] Stop mis-routing Gloas submissions to the Electra-shaped validation RPC (#517) --- crates/common/src/simulator.rs | 8 ++++- crates/relay/src/simulator/client.rs | 45 ++++++++++++++++++++++++++-- crates/relay/src/simulator/tile.rs | 22 +++++++++++++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/common/src/simulator.rs b/crates/common/src/simulator.rs index 79727955b..9c009ea8f 100644 --- a/crates/common/src/simulator.rs +++ b/crates/common/src/simulator.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use alloy_primitives::B256; use helix_types::{ - BidTrace, BlobsBundle, BlsSignatureBytes, ExecutionPayload, ExecutionRequests, + BidTrace, BlobsBundle, BlsSignatureBytes, ExecutionPayload, ExecutionRequests, ForkName, SignedBidSubmission, }; use ssz_derive::{Decode, Encode}; @@ -90,6 +90,11 @@ pub enum BlockSimError { #[error("hydration miss: simulator cache does not have required transactions/blobs")] HydrationMiss, + + /// Not the builder's fault -- helix's own simulator client has no validation RPC method for + /// this fork yet. See gattaca-com/helix#518. + #[error("no validation RPC method for fork {0}")] + UnsupportedFork(ForkName), } impl BlockSimError { @@ -106,6 +111,7 @@ impl BlockSimError { BlockSimError::Timeout => true, BlockSimError::RpcError => true, BlockSimError::NoSimulatorAvailable => true, + BlockSimError::UnsupportedFork(_) => true, _ => false, } } diff --git a/crates/relay/src/simulator/client.rs b/crates/relay/src/simulator/client.rs index da6f18a4e..94dbd1026 100644 --- a/crates/relay/src/simulator/client.rs +++ b/crates/relay/src/simulator/client.rs @@ -56,9 +56,17 @@ impl SimulatorClient { self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate"))) } - pub fn sim_request_builder(&self, fork: ForkName) -> (RequestBuilder, &str) { - let method = if fork == ForkName::Fulu { &self.sim_method_v5 } else { &self.sim_method_v4 }; - (self.client.post(&self.config.url), method) + /// Returns `None` for a fork this client has no validation RPC method for yet, rather than + /// silently mis-routing it to a method shaped for a different fork. + pub fn sim_request_builder(&self, fork: ForkName) -> Option<(RequestBuilder, &str)> { + let method = match fork { + ForkName::Fulu => &self.sim_method_v5, + ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | ForkName::Electra => { + &self.sim_method_v4 + } + ForkName::Base | ForkName::Altair | ForkName::Gloas | ForkName::Heze => return None, + }; + Some((self.client.post(&self.config.url), method)) } pub async fn do_json_sim_request( @@ -179,6 +187,37 @@ impl SimulatorClient { mod test { use alloy_primitives::hex::FromHex; use helix_common::SimulatorConfig; + use helix_types::ForkName; + + use super::SimulatorClient; + + fn sim_client() -> SimulatorClient { + SimulatorClient::new(reqwest::Client::new(), SimulatorConfig { + url: "http://localhost:8545".into(), + namespace: "relay".into(), + max_concurrent_tasks: 1, + ssz_url: None, + }) + } + + #[test] + fn routes_fulu_to_v5() { + let client = sim_client(); + let (_, method) = client.sim_request_builder(ForkName::Fulu).unwrap(); + assert!(method.ends_with("V5")); + } + + #[test] + fn routes_electra_to_v4() { + let client = sim_client(); + let (_, method) = client.sim_request_builder(ForkName::Electra).unwrap(); + assert!(method.ends_with("V4")); + } + + #[test] + fn gloas_has_no_validation_method_yet() { + assert!(sim_client().sim_request_builder(ForkName::Gloas).is_none()); + } #[tokio::test] async fn balance_request() { diff --git a/crates/relay/src/simulator/tile.rs b/crates/relay/src/simulator/tile.rs index 220a6b5aa..0c872a8ea 100644 --- a/crates/relay/src/simulator/tile.rs +++ b/crates/relay/src/simulator/tile.rs @@ -324,7 +324,27 @@ impl SimulatorTile { http: sim.client.client.clone(), } } else { - let (builder, method) = sim.client.sim_request_builder(submission.fork_name()); + let fork = submission.fork_name(); + let Some((builder, method)) = sim.client.sim_request_builder(fork) else { + warn!(%fork, "no validation RPC method for fork, dropping submission"); + sim.pending += 1; + let result_ix = self.sim_results.push(SimResult::Validate(( + id, + Some(SimulationResultInner { + submission_ref: req.submission_ref, + optimistic_version: req.optimistic_version(), + bid: None, + result: Err(BlockSimError::UnsupportedFork(fork)), + }), + ))); + let _ = self.task_tx.try_send(SimTileInternalEvent::TaskDone { + id, + paused_until: None, + result_ix, + elapsed: None, + }); + return; + }; SimDispatch::Json { to_send: builder, method: method.to_owned() } }; sim.pending += 1; From 3a53b6147c363b0d2ee56c3a15d49fea11eed651 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:25:09 +0100 Subject: [PATCH 08/29] Re-arm ChainHead to send a corrective SlotUpdate on late data (#526) Co-authored-by: Claude Sonnet 5 --- crates/relay/src/housekeeper/chain_head.rs | 68 +++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/relay/src/housekeeper/chain_head.rs b/crates/relay/src/housekeeper/chain_head.rs index ba40145a4..b0fe5fbfa 100644 --- a/crates/relay/src/housekeeper/chain_head.rs +++ b/crates/relay/src/housekeeper/chain_head.rs @@ -24,6 +24,12 @@ pub struct ChainHead { payload_attributes_done: bool, duties_done: bool, inclusion_list_done: bool, + /// Whether duties/payload-attributes/inclusion-list were all done at the moment + /// of the last `sent()` call. `false` means that send went out incomplete (the + /// deadline fired first), so `is_ready()` re-arms once the picture completes, + /// letting a corrective `SlotUpdate` go out instead of losing late-arriving data + /// for this slot. + sent_was_complete: bool, } // let epoch = self.chain_head.slot.epoch(self.chain_info.slots_per_epoch()); @@ -48,6 +54,7 @@ impl ChainHead { duties_done: false, payload_attributes_done: false, inclusion_list_done: false, + sent_was_complete: false, chain_info, } } @@ -78,7 +85,14 @@ impl ChainHead { self.payload_attributes_done && self.inclusion_list_done) } - ChainHeadState::Sent => false, + // Already sent: only re-ready for a corrective send if the original went + // out incomplete and the picture has since become fully complete. + ChainHeadState::Sent => { + !self.sent_was_complete && + self.duties_done && + self.payload_attributes_done && + self.inclusion_list_done + } } } pub fn is_new_slot(&mut self) -> bool { @@ -98,6 +112,7 @@ impl ChainHead { self.duties_done = false; self.payload_attributes_done = false; self.inclusion_list_done = false; + self.sent_was_complete = false; true } else { false @@ -125,6 +140,7 @@ impl ChainHead { self.duties_done = false; self.payload_attributes_done = false; self.inclusion_list_done = false; + self.sent_was_complete = false; true } } @@ -139,6 +155,8 @@ impl ChainHead { } pub fn sent(&mut self) { self.state = ChainHeadState::Sent; + self.sent_was_complete = + self.duties_done && self.payload_attributes_done && self.inclusion_list_done; } #[cfg(test)] @@ -356,6 +374,54 @@ mod tests { assert!(!ch.is_ready()); } + // --- Corrective update after an incomplete send --- + + #[test] + fn sent_incomplete_becomes_ready_once_late_data_completes_the_picture() { + // The deadline fires before the inclusion list has arrived, so an + // incomplete SlotUpdate goes out (RELAY-FR: this is why merged-block + // simulation sometimes sees a slot with no known fee recipient/beacon + // root -- housekeeper gave up early and never corrected itself). + // Housekeeper does eventually learn the inclusion list; it must be able + // to send a corrective update instead of silently dropping the data for + // this slot forever. + let (mut ch, ci) = make_head(); + ch.update(head_event(ci.current_slot() + 1)); + ch.mark_duties_done(); + ch.mark_payload_attrs_done(); + // inclusion list not done yet + ch.force_deadline_expired(); + assert!(ch.is_ready()); + ch.sent(); + assert!(!ch.is_ready()); + + // Late-arriving inclusion list completes the picture. + ch.mark_il_done(); + + assert!( + ch.is_ready(), + "a corrective SlotUpdate must be sendable once previously-missing data arrives" + ); + } + + #[test] + fn sent_incomplete_stays_not_ready_while_still_incomplete() { + // Only one of two missing pieces arrives -- the picture is still + // incomplete, so no corrective update should be sendable yet. + let (mut ch, ci) = make_head(); + ch.update(head_event(ci.current_slot() + 1)); + ch.mark_duties_done(); + // payload attrs and il both still missing + ch.force_deadline_expired(); + assert!(ch.is_ready()); + ch.sent(); + assert!(!ch.is_ready()); + + ch.mark_payload_attrs_done(); + + assert!(!ch.is_ready(), "must not re-ready until the picture is fully complete"); + } + // --- Flag reset on new head --- #[test] From 701970c35ca9ac9ef6afea545c29642be81e2769 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 28 Aug 2026 13:42:59 +0100 Subject: [PATCH 09/29] add links to tg merge blk msg --- crates/types/src/block_merging.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/types/src/block_merging.rs b/crates/types/src/block_merging.rs index 8a8268b19..2c42bec47 100644 --- a/crates/types/src/block_merging.rs +++ b/crates/types/src/block_merging.rs @@ -278,8 +278,8 @@ impl MergedBlock { format!( "📦 *Merged Block Delivered*\n\ \n\ - *Slot:* `{}`\n\ - *Block Number:* `{}`\n\ + *Slot:* [{}](https://beaconcha.in/slot/{})\n\ + *Block Number:* [{}](https://etherscan.io/block/{})\n\ *Block Hash:* `{}`\n\ *Value:* `{}` → `{}`\n\ *Transactions:* `{}` → `{}`\n\ @@ -289,6 +289,8 @@ impl MergedBlock { *Merged txs by builder:*\n{}\n\ ━━━━━━━━━━━━━━━\n", self.slot, + self.slot, + self.block_number, self.block_number, self.block_hash, self.original_value, From a122a02dc1b6a3247c051e27e54ddee30f0893ae Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:19:01 +0100 Subject: [PATCH 10/29] Add admin-toggled block_merging_enabled kill switch (#502) Co-authored-by: Claude Sonnet 5 --- Cargo.lock | 1 + crates/relay/Cargo.toml | 1 + crates/relay/src/api/admin_service.rs | 90 ++++- crates/relay/src/api/mod.rs | 10 +- crates/relay/src/block_merging/tile.rs | 435 +++++++++++++++++++++---- crates/relay/src/main.rs | 9 +- 6 files changed, 472 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f6ffdb25..7d6501c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6635,6 +6635,7 @@ dependencies = [ "alloy-eips", "alloy-primitives", "alloy-rlp", + "alloy-rpc-types", "askama", "async-trait", "aws-sdk-s3", diff --git a/crates/relay/Cargo.toml b/crates/relay/Cargo.toml index 93d9faad3..3b9f20b10 100644 --- a/crates/relay/Cargo.toml +++ b/crates/relay/Cargo.toml @@ -84,6 +84,7 @@ uuid.workspace = true zstd.workspace = true [dev-dependencies] +alloy-rpc-types.workspace = true tracing-subscriber.workspace = true criterion.workspace = true diff --git a/crates/relay/src/api/admin_service.rs b/crates/relay/src/api/admin_service.rs index 0a3c5b310..a599c51a3 100644 --- a/crates/relay/src/api/admin_service.rs +++ b/crates/relay/src/api/admin_service.rs @@ -1,4 +1,10 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{ + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; use axum::{ Extension, Json, Router, @@ -18,6 +24,10 @@ pub async fn run_admin_service( auctioneer: Arc, db: Arc, admin_token: String, + // Bare Arc: the router's only extension of this exact type today. + // A second one would silently collide (axum keys extensions by type) — wrap + // both in distinct newtypes if that's ever added. + block_merging_enabled: Arc, ) { let router = Router::new() .route("/admin/v1/status", get(status)) @@ -26,11 +36,13 @@ pub async fn run_admin_service( "/admin/v1/merged-headers", post(enable_merged_headers).delete(disable_merged_headers), ) + .route("/admin/v1/block-merging", post(enable_block_merging).delete(disable_block_merging)) .route("/admin/v1/builders/{pubkey}/demote", post(demote_builder)) .route("/admin/v1/builders/{pubkey}/promote", post(promote_builder)) .route("/admin/v1/adjustments/disable", post(disable_adjustments)) .layer(Extension(auctioneer)) .layer(Extension(db)) + .layer(Extension(block_merging_enabled)) .layer(ValidateRequestHeaderLayer::bearer(&admin_token)); let listener = tokio::net::TcpListener::bind("0.0.0.0:4050").await.unwrap(); @@ -42,8 +54,12 @@ pub async fn run_admin_service( async fn status( Extension(auctioneer): Extension>, + Extension(block_merging_enabled): Extension>, ) -> Result { - Ok(Json(serde_json::json!({ "kill_switch_enabled": auctioneer.kill_switch_enabled() }))) + Ok(Json(serde_json::json!({ + "kill_switch_enabled": auctioneer.kill_switch_enabled(), + "block_merging_enabled": block_merging_enabled.load(Ordering::Relaxed), + }))) } async fn enable_kill_switch( @@ -78,6 +94,22 @@ async fn disable_merged_headers( Ok((StatusCode::NO_CONTENT, ())) } +async fn enable_block_merging( + Extension(block_merging_enabled): Extension>, +) -> Result { + block_merging_enabled.store(true, Ordering::Relaxed); + info!("Block merging enabled"); + Ok((StatusCode::NO_CONTENT, ())) +} + +async fn disable_block_merging( + Extension(block_merging_enabled): Extension>, +) -> Result { + block_merging_enabled.store(false, Ordering::Relaxed); + info!("Block merging disabled"); + Ok((StatusCode::NO_CONTENT, ())) +} + #[derive(Deserialize)] struct DemoteRequest { reason: Option, @@ -143,7 +175,10 @@ async fn disable_adjustments( #[cfg(test)] #[allow(clippy::field_reassign_with_default)] mod test { - use std::sync::Arc; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; use helix_common::local_cache::LocalCache; use helix_database::postgres::postgres_db_service::PostgresDatabaseService; @@ -156,9 +191,10 @@ mod test { async fn test_admin_service() { let auctioneer = Arc::new(LocalCache::new()); let db = Arc::new(PostgresDatabaseService::default()); + let block_merging_enabled = Arc::new(AtomicBool::new(true)); let admin_token = "test_token".into(); - tokio::spawn(run_admin_service(auctioneer.clone(), db, admin_token)); + tokio::spawn(run_admin_service(auctioneer.clone(), db, admin_token, block_merging_enabled)); tokio::time::sleep(std::time::Duration::from_secs(1)).await; // wait for server to start let client = reqwest::Client::new(); @@ -201,10 +237,11 @@ mod test { async fn test_admin_service_merged_headers() { let auctioneer = Arc::new(LocalCache::new()); let db = Arc::new(PostgresDatabaseService::default()); + let block_merging_enabled = Arc::new(AtomicBool::new(true)); assert!(auctioneer.merged_headers_enabled(), "should be enabled by default"); let admin_token = "test_token".into(); - tokio::spawn(run_admin_service(auctioneer.clone(), db, admin_token)); + tokio::spawn(run_admin_service(auctioneer.clone(), db, admin_token, block_merging_enabled)); tokio::time::sleep(std::time::Duration::from_secs(1)).await; // wait for server to start let client = reqwest::Client::new(); @@ -232,9 +269,10 @@ mod test { async fn test_admin_service_unauthorized() { let auctioneer = Arc::new(LocalCache::new()); let db = Arc::new(PostgresDatabaseService::default()); + let block_merging_enabled = Arc::new(AtomicBool::new(true)); let admin_token = "test_token".into(); - tokio::spawn(run_admin_service(auctioneer.clone(), db, admin_token)); + tokio::spawn(run_admin_service(auctioneer.clone(), db, admin_token, block_merging_enabled)); tokio::time::sleep(std::time::Duration::from_secs(1)).await; // wait for server to start let client = reqwest::Client::new(); @@ -248,4 +286,44 @@ mod test { assert_eq!(response.status(), 401); assert!(!auctioneer.kill_switch_enabled()); } + + #[tokio::test] + #[serial] + async fn test_admin_service_block_merging() { + let auctioneer = Arc::new(LocalCache::new()); + let db = Arc::new(PostgresDatabaseService::default()); + let block_merging_enabled = Arc::new(AtomicBool::new(true)); + + let admin_token = "test_token".into(); + tokio::spawn(run_admin_service(auctioneer, db, admin_token, block_merging_enabled.clone())); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; // wait for server to start + let client = reqwest::Client::new(); + + let response = client + .delete("http://localhost:4050/admin/v1/block-merging") + .bearer_auth("test_token") + .send() + .await + .unwrap(); + assert_eq!(response.status(), 204); + assert!(!block_merging_enabled.load(Ordering::Relaxed)); + + let response = client + .get("http://localhost:4050/admin/v1/status") + .bearer_auth("test_token") + .send() + .await + .unwrap(); + let status: serde_json::Value = response.json().await.unwrap(); + assert_eq!(status["block_merging_enabled"], false); + + let response = client + .post("http://localhost:4050/admin/v1/block-merging") + .bearer_auth("test_token") + .send() + .await + .unwrap(); + assert_eq!(response.status(), 204); + assert!(block_merging_enabled.load(Ordering::Relaxed)); + } } diff --git a/crates/relay/src/api/mod.rs b/crates/relay/src/api/mod.rs index 7434380d1..a1a857ea3 100644 --- a/crates/relay/src/api/mod.rs +++ b/crates/relay/src/api/mod.rs @@ -1,6 +1,6 @@ #![allow(clippy::too_many_arguments)] -use std::sync::Arc; +use std::sync::{Arc, atomic::AtomicBool}; use helix_common::{api_provider::ApiProvider, local_cache::LocalCache}; pub use helix_data_api::{ @@ -27,8 +27,14 @@ pub fn start_admin_service( auctioneer: Arc, db: Arc, admin_token: String, + block_merging_enabled: Arc, ) { - tokio::spawn(admin_service::run_admin_service(auctioneer, db, admin_token)); + tokio::spawn(admin_service::run_admin_service( + auctioneer, + db, + admin_token, + block_merging_enabled, + )); } pub trait Api: Clone + Send + Sync + 'static { diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index c25a6132a..187924b7c 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -1,4 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use flux::{ @@ -194,6 +200,12 @@ pub struct BlockMergingTile { decoded: Arc>, slot_events: Arc>, merged_blocks: Arc>, + /// Admin-toggled kill switch. The connection itself (dial, handshake, + /// ping/pong) is unaffected — only an admin can set this back to `true` + /// (never automatic). While `false`, the tile stops forwarding + /// mergeable blocks and top-bid activations, and silently drops any + /// merged blocks it receives. + block_merging_enabled: Arc, // Buffered during `poll_with` (the connector is exclusively borrowed // there), drained right after. @@ -239,6 +251,87 @@ impl Tile for BlockMergingTile { } } +/// Validates and converts an incoming `MergedBlockV1` into a response to +/// forward to the auctioneer, or `None` if it's dropped (disabled, stale, +/// regressed, missing a blob sidecar, or unbundled by the builder). +/// Extracted from `poll_sockets` so this compute is unit-testable without a +/// real connection: `enabled` is checked first, before any state mutation, +/// so a disabled tile silently drops every merged block it receives. +#[allow(clippy::too_many_arguments)] +fn handle_merged_block( + enabled: bool, + token: Token, + merged: MergedBlockV1, + slot: &mut SlotState, + stats: &mut SlotStats, + blob_sidecars: &FxHashMap, + tx_hash_cache: &mut FxHashMap, + unbundled_scratch_bundled: &mut Vec, + unbundled_scratch_covered: &mut Vec, +) -> Option { + if !enabled { + return None; + } + if merged.slot != slot.bid_slot || !slot.appendable.contains(&merged.base_block_hash) { + stats.merged_stale += 1; + debug!( + ?token, + slot = merged.slot, + bid_slot = slot.bid_slot, + "stale or unknown merged block" + ); + return None; + } + // Builders only guarantee monotonicity within a connection; filter + // so the stored merged bid never regresses. + if slot + .best_merged + .get(&merged.base_block_hash) + .is_some_and(|floor| merged.proposer_value <= floor.value) + { + stats.merged_regressed += 1; + return None; + } + slot.best_merged.insert(merged.base_block_hash, BestMergedFloor { + value: merged.proposer_value, + order_ids: merged.included_order_ids.iter().copied().collect(), + }); + let Some(response) = merged_block_to_response(merged, blob_sidecars) else { + stats.merged_blob_missing += 1; + warn!( + ?token, + "could not build merge response (missing blob sidecar or invalid payload), dropping \ + merged block" + ); + return None; + }; + let appended = appended_tx_hashes(&response.builder_inclusions); + let appended_txs: Vec = response + .execution_payload + .transactions + .iter() + .map(|tx| *tx_hash_cache.entry(tx.0.clone()).or_insert_with(|| keccak256(tx.as_ref()))) + .filter(|hash| appended.contains(hash)) + .collect(); + let unbundled = find_unbundled_txs( + &appended_txs, + &slot.order_txs, + unbundled_scratch_bundled, + unbundled_scratch_covered, + ); + if !unbundled.is_empty() { + stats.merged_unbundled += 1; + warn!( + ?token, + count = unbundled.len(), + "merge builder unbundled an order, dropping merged block" + ); + return None; + } + stats.merged_blocks += 1; + Some(response) +} + /// order_id -> order_hash for every `latest_only` bundle in this submission. fn latest_only_ids( builder_pubkey: BlsPublicKeyBytes, @@ -283,6 +376,7 @@ impl BlockMergingTile { slot_events: Arc>, merged_blocks: Arc>, chain_info: ChainInfo, + block_merging_enabled: Arc, ) -> Self { let relay_config_msg = RelayConfigV1 { relay_fee_recipient: config.relay_fee_recipient, @@ -339,6 +433,7 @@ impl BlockMergingTile { decoded, slot_events, merged_blocks, + block_merging_enabled, to_disconnect: Vec::new(), to_register: Vec::new(), handshaken: Vec::new(), @@ -408,6 +503,8 @@ impl BlockMergingTile { } fn poll_sockets(&mut self) { + let enabled = self.block_merging_enabled.load(Ordering::Relaxed); + // Split borrows: the connector is exclusively borrowed for the whole // poll, all reactions are buffered. let Self { @@ -501,71 +598,20 @@ impl BlockMergingTile { warn!(?token, "undecodable merged block"); return; }; - if merged.slot != slot.bid_slot || - !slot.appendable.contains(&merged.base_block_hash) - { - stats.merged_stale += 1; - debug!( - ?token, - slot = merged.slot, - bid_slot = slot.bid_slot, - "stale or unknown merged block" - ); - return; - } - // Builders only guarantee monotonicity within a connection; filter - // so the stored merged bid never regresses. - if slot - .best_merged - .get(&merged.base_block_hash) - .is_some_and(|floor| merged.proposer_value <= floor.value) - { - stats.merged_regressed += 1; - return; - } - slot.best_merged.insert(merged.base_block_hash, BestMergedFloor { - value: merged.proposer_value, - order_ids: merged.included_order_ids.iter().copied().collect(), - }); - let Some(response) = merged_block_to_response(merged, blob_sidecars) else { - stats.merged_blob_missing += 1; - warn!( - ?token, - "could not build merge response (missing blob sidecar or invalid \ - payload), dropping merged block" - ); - return; - }; - let appended = appended_tx_hashes(&response.builder_inclusions); - let appended_txs: Vec = response - .execution_payload - .transactions - .iter() - .map(|tx| { - *tx_hash_cache - .entry(tx.0.clone()) - .or_insert_with(|| keccak256(tx.as_ref())) - }) - .filter(|hash| appended.contains(hash)) - .collect(); - let unbundled = find_unbundled_txs( - &appended_txs, - &slot.order_txs, + if let Some(response) = handle_merged_block( + enabled, + token, + merged, + slot, + stats, + blob_sidecars, + tx_hash_cache, unbundled_scratch_bundled, unbundled_scratch_covered, - ); - if !unbundled.is_empty() { - stats.merged_unbundled += 1; - warn!( - ?token, - count = unbundled.len(), - "merge builder unbundled an order, dropping merged block" - ); - return; + ) { + let ix = merged_blocks.push(response); + merged_ixs.push(ix); } - stats.merged_blocks += 1; - let ix = merged_blocks.push(response); - merged_ixs.push(ix); } MergingMsgId::RejectV1 => { if let Ok(reject) = RejectV1::from_ssz_bytes(body) { @@ -718,8 +764,12 @@ impl BlockMergingTile { } /// Forwards the decoded submission at `ix` as a `MergeableBlockV1`, or - /// replays it to `only` on re-handshake. + /// replays it to `only` on re-handshake. A no-op while block merging is + /// administratively disabled. fn forward_decoded(&mut self, ix: usize, only: Option) { + if !self.block_merging_enabled.load(Ordering::Relaxed) { + return; + } let is_replay = only.is_some(); if is_replay { self.stats.replayed += 1; @@ -946,6 +996,9 @@ impl BlockMergingTile { } self.stats.last_top_bid_ns = top_bid.timestamp; + if !self.block_merging_enabled.load(Ordering::Relaxed) { + return; + } if !self.slot.appendable.contains(&top_bid.block_hash) { return; } @@ -993,9 +1046,30 @@ impl BlockMergingTile { #[cfg(test)] mod tests { - use helix_tcp_types::merging::order::{BundleOrderRef, TxOrderRef}; + use alloy_primitives::{Address, Bloom}; + use alloy_rpc_types::{ + beacon::{BlsPublicKey, requests::ExecutionRequestsV4}, + engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}, + }; + use flux::timing::Nanos; + use helix_common::{ + MergingBuilderCollateral, MergingBuilderEndpoint, SubmissionTrace, + decoder::{Encoding, SubmissionDecoderParams}, + }; + use helix_tcp_types::{ + MergeType, + merging::{ + builder_to_relay::MergeTraceV1, + order::{BundleOrderRef, TxOrderRef}, + }, + }; + use helix_types::{ + BlockMergingData, Compression, ForkName, SignedBidSubmission, SubmissionVersion, + TestRandomSeed, + }; use super::*; + use crate::{SubmissionRef, auctioneer::SubmissionData}; fn bundle(latest_only: bool) -> MergeOrderRef { MergeOrderRef::Bundle(BundleOrderRef { @@ -1123,4 +1197,235 @@ mod tests { Vec::::new(), ); } + + fn test_tile(enabled: bool) -> BlockMergingTile { + let config = BlockMergingTcpConfig { + builder: MergingBuilderEndpoint { + addr: "127.0.0.1:1".parse().unwrap(), + api_key: Uuid::nil().to_string(), + }, + relay_fee_recipient: Address::ZERO, + multisend_contract: Address::ZERO, + relay_bps: 0, + merged_builder_bps: 0, + winning_builder_bps: 0, + distribution_gas_limit: 140_000, + builder_collaterals: vec![MergingBuilderCollateral { + builder_coinbase: Address::ZERO, + collateral_safe: Address::ZERO, + }], + }; + BlockMergingTile::new( + config, + "test-relay".to_string(), + Arc::new(SharedVector::default()), + Arc::new(SharedVector::default()), + Arc::new(SharedVector::default()), + ChainInfo::default(), + Arc::new(AtomicBool::new(enabled)), + ) + } + + /// A decoded submission carrying merging data for `bid_slot`/`block_hash`, with no merge + /// orders — the per-slot order-budget/unbundling logic isn't under test here. + fn test_submission( + bid_slot: u64, + block_hash: B256, + allow_appending: bool, + ) -> SubmissionDataWithSpan { + let mut signed = SignedBidSubmission::test_random(); + signed.message.slot = bid_slot; + signed.message.block_hash = block_hash; + // `TestRandom` for `BlobsBundle` doesn't respect the + // proofs/blobs/commitments length invariant (see the #[ignore]d + // `fulu_bid_submission*` tests in helix-types) and panics on use; + // this submission carries no blobs so it isn't touched. + signed.blobs_bundle = Arc::new(Default::default()); + let submission_data = SubmissionData { + submission_ref: SubmissionRef::Internal, + submission: Submission::Full(signed), + merging_data: Some(BlockMergingData { + allow_appending, + builder_address: Address::ZERO, + merge_orders: vec![], + }), + bid_adjustment_data: None, + version: SubmissionVersion::new(0, None), + withdrawals_root: B256::ZERO, + trace: SubmissionTrace::default(), + decoder_params: SubmissionDecoderParams { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::default(), + is_dehydrated: false, + with_mergeable_data: true, + with_adjustments: false, + mark_all_txs_mergeable: false, + fork_name: ForkName::Deneb, + }, + is_pessimistic: false, + }; + SubmissionDataWithSpan { submission_data, span: tracing::Span::none(), sent_at: Nanos(0) } + } + + #[test] + fn forward_decoded_noop_when_disabled() { + let mut tile = test_tile(false); + tile.slot.bid_slot = 5; + tile.slot.slot_start = Some(SlotStartV1 { + slot: 5, + parent_hash: B256::ZERO, + proposer_fee_recipient: Address::ZERO, + parent_beacon_block_root: B256::ZERO, + }); + let block_hash = B256::repeat_byte(7); + let ix = tile.decoded.push(test_submission(5, block_hash, true)); + + tile.forward_decoded(ix, None); + + assert!(tile.slot.appendable.is_empty()); + assert!(tile.slot.replay_log.is_empty()); + } + + #[test] + fn forward_decoded_tracks_when_enabled() { + let mut tile = test_tile(true); + tile.slot.bid_slot = 5; + tile.slot.slot_start = Some(SlotStartV1 { + slot: 5, + parent_hash: B256::ZERO, + proposer_fee_recipient: Address::ZERO, + parent_beacon_block_root: B256::ZERO, + }); + let block_hash = B256::repeat_byte(7); + let ix = tile.decoded.push(test_submission(5, block_hash, true)); + + tile.forward_decoded(ix, None); + + assert!(tile.slot.appendable.contains(&block_hash)); + assert_eq!(tile.slot.replay_log.len(), 1); + } + + #[test] + fn on_top_bid_skips_activation_when_disabled() { + let mut tile = test_tile(false); + let block_hash = B256::repeat_byte(3); + tile.slot.bid_slot = 5; + tile.slot.appendable.insert(block_hash); + tile.conn.forwarded.insert(block_hash); + tile.conn.active = true; + // Never touched: the disabled gate returns before the connector is reached. + tile.token = Some(Token(0)); + + tile.on_top_bid(TopBidUpdate { + timestamp: 1, + slot: 5, + block_number: 0, + block_hash, + parent_hash: B256::ZERO, + builder_pubkey: BlsPublicKeyBytes::default(), + fee_recipient: Address::ZERO, + value: U256::ZERO, + }); + + assert!(tile.conn.activated.is_none()); + assert_eq!(tile.stats.activations_sent, 0); + } + + fn test_merged_block(bid_slot: u64, base_block_hash: B256) -> MergedBlockV1 { + let execution_payload = ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::default(), + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: U256::from(1), + block_hash: base_block_hash, + transactions: vec![], + }, + withdrawals: vec![], + }, + blob_gas_used: 0, + excess_blob_gas: 0, + }; + MergedBlockV1 { + slot: bid_slot, + response_id: 0, + base_block_hash, + base_builder_pubkey: BlsPublicKey::default(), + execution_payload, + execution_requests: ExecutionRequestsV4::default(), + appended_blobs: vec![], + proposer_value: U256::from(1), + base_builder_revenue: U256::ZERO, + relay_revenue: U256::ZERO, + builder_inclusions: vec![], + included_order_ids: vec![], + trace: MergeTraceV1::default(), + } + } + + #[test] + fn handle_merged_block_dropped_when_disabled() { + let base_block_hash = B256::repeat_byte(9); + let mut slot = SlotState { bid_slot: 5, ..Default::default() }; + slot.appendable.insert(base_block_hash); + let mut stats = SlotStats::default(); + let blob_sidecars = FxHashMap::default(); + let mut tx_hash_cache = FxHashMap::default(); + let mut bundled_scratch = Vec::new(); + let mut covered_scratch = Vec::new(); + + let result = handle_merged_block( + false, + Token(0), + test_merged_block(5, base_block_hash), + &mut slot, + &mut stats, + &blob_sidecars, + &mut tx_hash_cache, + &mut bundled_scratch, + &mut covered_scratch, + ); + + assert!(result.is_none()); + assert!(slot.best_merged.is_empty()); + assert_eq!(stats.merged_blocks, 0); + } + + #[test] + fn handle_merged_block_accepted_when_enabled() { + let base_block_hash = B256::repeat_byte(9); + let mut slot = SlotState { bid_slot: 5, ..Default::default() }; + slot.appendable.insert(base_block_hash); + let mut stats = SlotStats::default(); + let blob_sidecars = FxHashMap::default(); + let mut tx_hash_cache = FxHashMap::default(); + let mut bundled_scratch = Vec::new(); + let mut covered_scratch = Vec::new(); + + let result = handle_merged_block( + true, + Token(0), + test_merged_block(5, base_block_hash), + &mut slot, + &mut stats, + &blob_sidecars, + &mut tx_hash_cache, + &mut bundled_scratch, + &mut covered_scratch, + ); + + assert!(result.is_some()); + assert!(slot.best_merged.contains_key(&base_block_hash)); + assert_eq!(stats.merged_blocks, 1); + } } diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index d91e137d9..d57aa4a45 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -185,6 +185,7 @@ async fn run( let alert_manager = Arc::new(AlertManager::from_relay_config(&config)); let failsafe_triggered = Arc::new(AtomicBool::new(false)); + let block_merging_enabled = Arc::new(AtomicBool::new(config.block_merging_config.is_enabled)); let (gossip_sender, gossip_receiver) = tokio::sync::mpsc::channel(10_000); let operator_api = config.operator_config.as_ref().map(|operator_config| { @@ -214,7 +215,12 @@ async fn run( }); spine.start(None, Some(termination_grace_period), |spine| { - start_admin_service(local_cache.clone(), db.clone(), expect_env_var(ADMIN_TOKEN_ENV_VAR)); + start_admin_service( + local_cache.clone(), + db.clone(), + expect_env_var(ADMIN_TOKEN_ENV_VAR), + block_merging_enabled.clone(), + ); let auctioneer_handle = AuctioneerHandle::new(event_tx.clone()); let registrations_handle = RegWorkerHandle::new(reg_worker_tx); @@ -359,6 +365,7 @@ async fn run( slot_events.clone(), merged_blocks.clone(), chain_info.as_ref().clone(), + block_merging_enabled.clone(), ); attach_tile( merging_tile, From 6eb2ce1f44bcd3d686362b12e8ba044438c67519 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:22:37 +0100 Subject: [PATCH 11/29] Add SimRequest::ValidateMerged and dispatch merged blocks through the simulator (#506) Co-authored-by: Claude Sonnet 5 --- crates/relay/src/auctioneer/mod.rs | 2 +- crates/relay/src/main.rs | 4 +- crates/relay/src/simulator/mod.rs | 25 +- crates/relay/src/simulator/tile.rs | 433 ++++++++++++++++++++++++++++- 4 files changed, 447 insertions(+), 17 deletions(-) diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index 921aa26f9..09b9f616d 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -143,7 +143,7 @@ impl Tile for Auctioneer { tracing::error!(?msg, "sim outbound payload not found"); return; }; - let SimResult::Validate(sim_result) = payload.as_ref(); + let SimResult::Validate(sim_result) = payload.as_ref() else { return }; let event = Event::SimResult(sim_result.clone()); self.state.step(event, &mut self.ctx, &mut self.tel, producers); }); diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index d57aa4a45..a7fdd9893 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -340,12 +340,14 @@ async fn run( Arc::new(SharedVector::::with_capacity(MAX_SUBMISSIONS_PER_SLOT)); let sim_results = Arc::new(SharedVector::::with_capacity(MAX_SUBMISSIONS_PER_SLOT)); + let merged_blocks = Arc::new(SharedVector::::with_capacity(1024)); let (accept_optimistic, failsafe_triggered, sim_tile) = SimulatorTile::create( config.simulators.clone(), sim_requests.clone(), sim_results.clone(), decoded.clone(), + merged_blocks.clone(), chain_info.as_ref().clone(), failsafe_triggered, ); @@ -353,8 +355,6 @@ async fn run( let sim_core = config.cores.simulator; attach_tile(sim_tile, spine, TileConfig::new(sim_core, ThreadPriority::OSDefault)); - let merged_blocks = Arc::new(SharedVector::::with_capacity(1024)); - if config.block_merging_config.is_enabled && let Some(merging_tcp) = config.block_merging_config.tcp.clone() { diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index dc44e8394..a1c0a2813 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -9,7 +9,10 @@ use helix_types::{ BlobWithMetadata, BuilderInclusionResult, ExecutionPayload, ExecutionRequests, MergedBlockTrace, }; -use crate::{SubmissionRef, simulator::tile::ValidationResult}; +use crate::{ + SubmissionRef, + simulator::tile::{MergedSimulationResult, ValidationResult}, +}; pub mod client; pub mod tile; @@ -31,6 +34,24 @@ pub struct ValidationRequest { pub type MergeResult = (usize, Result); +/// Simulation of an incoming merged block from the merge builder. Unlike `ValidationRequest`, +/// there's no decoded bid submission to look up: the block itself lives in `merged_blocks`, +/// indexed by `merged_block_ix`. +#[derive(Debug, Clone)] +pub struct MergedValidationRequest { + pub merged_block_ix: usize, + /// Kept alongside the index for `PendingMergeRequests`' eviction key, avoiding a + /// `merged_blocks` lookup at queue time. + pub base_block_hash: B256, + pub slot: u64, + pub parent_beacon_block_root: B256, + pub proposer_fee_recipient: Address, + pub registered_gas_limit: u64, + pub apply_blacklist: bool, + pub inclusion_list: InclusionListWithMetadata, + pub receive_ns: u64, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BlockMergeResponse { pub base_block_hash: B256, @@ -50,6 +71,7 @@ pub struct BlockMergeResponse { /// Large payload stored in `SharedVector` for auctioneer → sim tile transfer. pub enum SimRequest { Validate { req: Box, fast_track: bool }, + ValidateMerged(Box), } /// Large payload stored in `SharedVector` for sim tile → auctioneer transfer. @@ -57,6 +79,7 @@ pub enum SimRequest { #[allow(clippy::large_enum_variant)] pub enum SimResult { Validate(ValidationResult), + ValidateMerged(MergedSimulationResult), } impl ValidationRequest { diff --git a/crates/relay/src/simulator/tile.rs b/crates/relay/src/simulator/tile.rs index 0c872a8ea..2ae6bf122 100644 --- a/crates/relay/src/simulator/tile.rs +++ b/crates/relay/src/simulator/tile.rs @@ -7,6 +7,7 @@ use std::{ time::{Duration, Instant}, }; +use alloy_primitives::B256; use flux::{ spine::SpineProducers as _, tile::{Tile, TileName}, @@ -16,6 +17,7 @@ use flux_profiler::timed; use flux_utils::SharedVector; use helix_common::{ SimulatorConfig, SubmissionTrace, + api::builder_api::InclusionListWithMetadata, bid_submission::OptimisticVersion, chain_info::ChainInfo, is_local_dev, @@ -26,7 +28,10 @@ use helix_common::{ utils::avg_duration, validator_preferences::{Filtering, ValidatorPreferences}, }; -use helix_types::{BlsPublicKeyBytes, SignedBidSubmission, SimHydrationCache, Submission}; +use helix_types::{ + BidTrace, BlobWithMetadata, BlobsBundle, BlsPublicKeyBytes, BlsSignatureBytes, KzgCommitments, + SignedBidSubmission, SimHydrationCache, Submission, +}; use ssz::Encode as _; use tracing::{debug, error, info, warn}; @@ -34,7 +39,7 @@ use crate::{ HelixSpine, SimRequest, ValidationRequest, auctioneer::Bid, bid_decoder::SubmissionDataWithSpan, - simulator::{SimResult, client::SimulatorClient}, + simulator::{BlockMergeResponse, MergedValidationRequest, SimResult, client::SimulatorClient}, spine::{ HelixSpineProducers, messages::{FromSimMsg, ToSimKind, ToSimMsg}, @@ -47,6 +52,7 @@ pub struct SimulatorTile { ssz_sim_indices: Vec, requests: PendingRequests, priority_requests: PendingRequests, + merge_requests: PendingMergeRequests, last_bid_slot: u64, local_telemetry: LocalTelemetry, /// Per-simulator counters for the current slot, indexed like `simulators`. @@ -57,6 +63,7 @@ pub struct SimulatorTile { sim_requests: Arc>, sim_results: Arc>, decoded: Arc>, + merged_blocks: Arc>, hydration_cache: SimHydrationCache, chain_info: ChainInfo, /// If we have any synced simulator @@ -94,6 +101,9 @@ impl Tile for SimulatorTile { SimRequest::Validate { req, fast_track } => { self.handle_sim_request((**req).clone(), *fast_track, producers); } + SimRequest::ValidateMerged(req) => { + self.handle_merge_sim_request((**req).clone(), producers); + } }, None => error!(?msg, "sim inbound payload not found"), }, @@ -114,6 +124,7 @@ impl SimulatorTile { sim_requests: Arc>, sim_results: Arc>, decoded: Arc>, + merged_blocks: Arc>, chain_info: ChainInfo, failsafe_triggered: Arc, ) -> (Arc, Arc, Self) { @@ -129,6 +140,7 @@ impl SimulatorTile { let requests = PendingRequests::with_capacity(200); let priority_requests = PendingRequests::with_capacity(30); + let merge_requests = PendingMergeRequests::with_capacity(30); if !is_local_dev() { let clients: Vec = @@ -170,6 +182,7 @@ impl SimulatorTile { ssz_sim_indices, requests, priority_requests, + merge_requests, last_bid_slot: 0, local_telemetry: LocalTelemetry::default(), sim_slot_stats, @@ -178,6 +191,7 @@ impl SimulatorTile { sim_requests, sim_results, decoded, + merged_blocks, hydration_cache: SimHydrationCache::new(), chain_info, accept_optimistic: accept_optimistic.clone(), @@ -240,6 +254,32 @@ impl SimulatorTile { } } + #[timed] + fn handle_merge_sim_request( + &mut self, + req: MergedValidationRequest, + producers: &mut HelixSpineProducers, + ) { + if self.merged_blocks.get(req.merged_block_ix).is_none() { + error!(ix = req.merged_block_ix, "merged block not found in ring"); + let result_ix = self + .sim_results + .push(SimResult::ValidateMerged((0, Some(infra_merge_error(&req))))); + producers.produce(FromSimMsg { ix: result_ix }); + return; + } + + self.local_telemetry.sims_reqs += 1; + + if let Some(id) = self.next_client(|s| s.can_simulate()) { + self.local_telemetry.sims_sent_immediately += 1; + self.spawn_merge_sim(id, req); + } else { + self.local_telemetry.queued += 1; + self.merge_requests.store(req); + } + } + fn handle_task_response( &mut self, id: usize, @@ -260,18 +300,18 @@ impl SimulatorTile { producers.produce(FromSimMsg { ix: result_ix }); - if let Some(id) = self.next_client(|s| s.can_simulate()) && - let Some(req) = self.priority_requests.next_req().or(self.requests.next_req()) - { - self.local_telemetry.sims_sent_from_queue += 1; - self.spawn_sim(id, req); + if let Some(id) = self.next_client(|s| s.can_simulate()) { + if let Some(req) = self.priority_requests.next_req().or(self.requests.next_req()) { + self.local_telemetry.sims_sent_from_queue += 1; + self.spawn_sim(id, req); + } else if let Some(req) = self.merge_requests.next_req() { + self.spawn_merge_sim(id, req); + } } } #[timed] fn spawn_sim(&mut self, id: usize, req: ValidationRequest) { - const PAUSE_DURATION: Duration = Duration::from_secs(60); - let Some(decoded_data) = self.decoded.get(req.decoded_ix) else { error!(ix = req.decoded_ix, "decoded submission not found in ring"); // Balance pending so handle_task_response can route the next request. @@ -442,6 +482,137 @@ impl SimulatorTile { }); } + #[timed] + fn spawn_merge_sim(&mut self, id: usize, req: MergedValidationRequest) { + let Some(response) = self.merged_blocks.get(req.merged_block_ix) else { + error!(ix = req.merged_block_ix, "merged block not found in ring"); + let sim = &mut self.simulators[id]; + sim.pending += 1; + let result_ix = self + .sim_results + .push(SimResult::ValidateMerged((id, Some(infra_merge_error(&req))))); + let _ = self.task_tx.try_send(SimTileInternalEvent::TaskDone { + id, + paused_until: None, + result_ix, + elapsed: None, + }); + return; + }; + + let submission = match merged_block_to_submission(&response, &req) { + Ok(submission) => submission, + Err(err) => { + let sim = &mut self.simulators[id]; + sim.pending += 1; + let inner = MergedSimulationResultInner { + merged_block_ix: req.merged_block_ix, + result: Err(err), + }; + let result_ix = self.sim_results.push(SimResult::ValidateMerged((id, Some(inner)))); + let _ = self.task_tx.try_send(SimTileInternalEvent::TaskDone { + id, + paused_until: None, + result_ix, + elapsed: None, + }); + return; + } + }; + + let sim = &mut self.simulators[id]; + let dispatch = if let Some(url) = &sim.client.ssz_url { + SimDispatch::Ssz { + to_send: sim.client.client.post(format!("{url}/validate")), + ssz_url: url.clone(), + http: sim.client.client.clone(), + } + } else { + let fork = submission.fork_name(); + let Some((builder, method)) = sim.client.sim_request_builder(fork) else { + warn!(%fork, "no validation RPC method for fork, dropping merged block"); + sim.pending += 1; + let inner = MergedSimulationResultInner { + merged_block_ix: req.merged_block_ix, + result: Err(BlockSimError::UnsupportedFork(fork)), + }; + let result_ix = self.sim_results.push(SimResult::ValidateMerged((id, Some(inner)))); + let _ = self.task_tx.try_send(SimTileInternalEvent::TaskDone { + id, + paused_until: None, + result_ix, + elapsed: None, + }); + return; + }; + SimDispatch::Json { to_send: builder, method: method.to_owned() } + }; + sim.pending += 1; + + self.local_telemetry.max_in_flight = self.local_telemetry.max_in_flight.max(sim.pending); + let timer = SimulatorMetrics::timer(sim.client.endpoint()); + let task_tx = self.task_tx.clone(); + let sim_results = self.sim_results.clone(); + let merged_block_ix = req.merged_block_ix; + let apply_blacklist = req.apply_blacklist; + let registered_gas_limit = req.registered_gas_limit; + let parent_beacon_block_root = req.parent_beacon_block_root; + let inclusion_list = req.inclusion_list.clone(); + spawn_tracked!(async move { + let start_sim = Nanos::now(); + let block_hash = submission.execution_payload.block_hash; + debug!(%block_hash, "sending merged block simulation request"); + + SimulatorMetrics::sim_count(false); + let res = match dispatch { + SimDispatch::Ssz { to_send, .. } => { + let request = ssz_request( + apply_blacklist, + registered_gas_limit, + parent_beacon_block_root, + inclusion_list, + &submission, + ); + SimulatorClient::do_sim_request(&request, false, to_send).await + } + SimDispatch::Json { to_send, method } => { + let filtering = + if apply_blacklist { Filtering::Regional } else { Filtering::Global }; + let json_req = JsonValidationRequest::new( + registered_gas_limit, + &submission, + ValidatorPreferences { filtering, ..Default::default() }, + Some(parent_beacon_block_root), + Some(inclusion_list), + ); + SimulatorClient::do_json_sim_request(&json_req, false, &method, to_send).await + } + }; + + let time = timer.stop_and_record(); + debug!(%block_hash, time_secs = time, ?res, "merged block simulation completed"); + + let paused_until = if let Err(err) = res.as_ref() { + SimulatorMetrics::sim_status(false); + if err.is_temporary() { Some(Instant::now() + PAUSE_DURATION) } else { None } + } else { + SimulatorMetrics::sim_status(true); + None + }; + + record_submission_step("merge_simulation", start_sim.elapsed()); + + let inner = MergedSimulationResultInner { merged_block_ix, result: res }; + let result_ix = sim_results.push(SimResult::ValidateMerged((id, Some(inner)))); + let _ = task_tx.try_send(SimTileInternalEvent::TaskDone { + id, + paused_until, + result_ix, + elapsed: Some(Duration::from_secs_f64(time)), + }); + }); + } + /// Selection priority: /// 1. Sticky sim with SSZ endpoint (state locality + binary protocol) /// 2. Any SSZ-capable sim, least pending (binary protocol) @@ -484,6 +655,7 @@ impl SimulatorTile { self.last_bid_slot = bid_slot; self.requests.clear(); self.priority_requests.clear(); + self.merge_requests.clear(); self.hydration_cache.clear(); let now = Instant::now(); for s in self.simulators.iter_mut() { @@ -561,6 +733,10 @@ impl SimEntry { pub(crate) const SIMULATOR_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// How long a simulator is paused after a temporary error, for both submission and +/// merged-block simulations. +const PAUSE_DURATION: Duration = Duration::from_secs(60); + #[derive(Default)] struct LocalTelemetry { sims_reqs: usize, @@ -592,6 +768,14 @@ pub struct SimulationResultInner { pub result: Result, } +pub type MergedSimulationResult = (usize, Option); +#[derive(Clone)] +pub struct MergedSimulationResultInner { + pub merged_block_ix: usize, + /// Ok on a valid merged block; Err carries the simulation failure. + pub result: Result<(), BlockSimError>, +} + enum SimDispatch { Ssz { to_send: reqwest::RequestBuilder, ssz_url: String, http: reqwest::Client }, Json { to_send: reqwest::RequestBuilder, method: String }, @@ -685,6 +869,40 @@ impl PendingRequests { } } +/// Pending merged-block requests. There's exactly one merge builder connection, so unlike +/// `PendingRequests` (keyed per-builder) we only keep the last request per base block. +struct PendingMergeRequests { + reqs: Vec, +} + +impl PendingMergeRequests { + fn with_capacity(capacity: usize) -> Self { + Self { reqs: Vec::with_capacity(capacity) } + } + + /// Returns the evicted request if a newer one replaced it. + fn store(&mut self, req: MergedValidationRequest) -> Option { + if let Some(i) = self.reqs.iter().position(|r| r.base_block_hash == req.base_block_hash) { + if req.receive_ns > self.reqs[i].receive_ns { + return Some(std::mem::replace(&mut self.reqs[i], req)); + } + return None; + } + self.reqs.push(req); + None + } + + fn next_req(&mut self) -> Option { + let i = self.reqs.iter().enumerate().max_by_key(|(_, r)| r.receive_ns).map(|(i, _)| i)?; + Some(self.reqs.swap_remove(i)) + } + + /// Clear backlog of simulations from the previous bid slot. + fn clear(&mut self) { + self.reqs.clear(); + } +} + fn infra_error(req: &ValidationRequest) -> SimulationResultInner { SimulationResultInner { submission_ref: req.submission_ref, @@ -694,16 +912,205 @@ fn infra_error(req: &ValidationRequest) -> SimulationResultInner { } } +fn infra_merge_error(req: &MergedValidationRequest) -> MergedSimulationResultInner { + MergedSimulationResultInner { + merged_block_ix: req.merged_block_ix, + result: Err(BlockSimError::RpcError), + } +} + +/// Converts a merged block into a synthetic `SignedBidSubmission` so it can be simulated +/// through the same SSZ/JSON dispatch the simulator already exposes for bid submissions. +/// `builder_pubkey`/`proposer_pubkey`/`signature` are zeroed: the simulator never checks the +/// BLS signature, and these fields are otherwise cosmetic (only `tx_sink` logging reads them). +fn merged_block_to_submission( + response: &BlockMergeResponse, + req: &MergedValidationRequest, +) -> Result { + let payload = &response.execution_payload; + let message = BidTrace { + slot: req.slot, + parent_hash: payload.parent_hash, + block_hash: payload.block_hash, + builder_pubkey: BlsPublicKeyBytes::default(), + proposer_pubkey: BlsPublicKeyBytes::default(), + proposer_fee_recipient: req.proposer_fee_recipient, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + value: response.proposer_value, + }; + Ok(SignedBidSubmission { + message, + execution_payload: Arc::new(payload.clone()), + blobs_bundle: Arc::new(blobs_bundle_from_appended(&response.appended_blobs)?), + execution_requests: Arc::new(response.execution_requests.clone()), + signature: BlsSignatureBytes::default(), + }) +} + +fn blobs_bundle_from_appended(appended: &[BlobWithMetadata]) -> Result { + let mut commitments = Vec::with_capacity(appended.len()); + let mut proofs = Vec::new(); + let mut blobs = Vec::with_capacity(appended.len()); + for b in appended { + commitments.push(b.commitment); + proofs.extend(b.proofs.iter().copied()); + blobs.push(b.blob.clone()); + } + let commitments = KzgCommitments::new(commitments) + .map_err(|_| BlockSimError::BlockValidationFailed("too many appended blobs".to_owned()))?; + Ok(BlobsBundle { commitments, proofs, blobs }) +} + fn create_ssz_request( req: &ValidationRequest, submission: &SignedBidSubmission, +) -> SszValidationRequest { + ssz_request( + req.apply_blacklist, + req.registered_gas_limit, + req.parent_beacon_block_root, + req.inclusion_list.clone(), + submission, + ) +} + +fn ssz_request( + apply_blacklist: bool, + registered_gas_limit: u64, + parent_beacon_block_root: B256, + inclusion_list: InclusionListWithMetadata, + submission: &SignedBidSubmission, ) -> SszValidationRequest { SszValidationRequest { - apply_blacklist: req.apply_blacklist, - registered_gas_limit: req.registered_gas_limit, - parent_beacon_block_root: req.parent_beacon_block_root, - inclusion_list: req.inclusion_list.clone(), + apply_blacklist, + registered_gas_limit, + parent_beacon_block_root, + inclusion_list, decoder_params: None, signed_bid_submission: submission.as_ssz_bytes(), } } + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256}; + use helix_types::{ExecutionPayload, ExecutionRequests, MergedBlockTrace, TestRandom}; + use rand::{SeedableRng, rngs::SmallRng}; + + use super::*; + + fn merge_response( + payload: ExecutionPayload, + proposer_value: U256, + blobs: Vec, + ) -> BlockMergeResponse { + BlockMergeResponse { + base_block_hash: payload.parent_hash, + execution_payload: payload, + execution_requests: ExecutionRequests::default(), + appended_blobs: blobs, + proposer_value, + base_builder_revenue: U256::ZERO, + relay_revenue: U256::ZERO, + builder_inclusions: Default::default(), + trace: MergedBlockTrace::default(), + } + } + + fn merge_request(base_block_hash: B256, receive_ns: u64) -> MergedValidationRequest { + MergedValidationRequest { + merged_block_ix: 0, + base_block_hash, + slot: 123, + parent_beacon_block_root: B256::repeat_byte(9), + proposer_fee_recipient: Address::repeat_byte(7), + registered_gas_limit: 30_000_000, + apply_blacklist: true, + inclusion_list: InclusionListWithMetadata::default(), + receive_ns, + } + } + + #[test] + fn merged_block_to_submission_derives_bid_trace_from_payload_and_context() { + let mut rng = SmallRng::seed_from_u64(1); + let payload = ExecutionPayload::random_for_test(&mut rng); + let response = merge_response(payload.clone(), U256::from(42u64), vec![]); + let req = merge_request(response.base_block_hash, 0); + + let submission = merged_block_to_submission(&response, &req).unwrap(); + + assert_eq!(submission.message.slot, req.slot); + assert_eq!(submission.message.parent_hash, payload.parent_hash); + assert_eq!(submission.message.block_hash, payload.block_hash); + assert_eq!(submission.message.gas_limit, payload.gas_limit); + assert_eq!(submission.message.gas_used, payload.gas_used); + assert_eq!(submission.message.value, response.proposer_value); + assert_eq!(submission.message.proposer_fee_recipient, req.proposer_fee_recipient); + assert_eq!(submission.message.builder_pubkey, BlsPublicKeyBytes::default()); + assert_eq!(submission.message.proposer_pubkey, BlsPublicKeyBytes::default()); + assert_eq!(submission.signature, BlsSignatureBytes::default()); + } + + #[test] + fn merged_block_to_submission_converts_appended_blobs_to_blobs_bundle() { + let mut rng = SmallRng::seed_from_u64(2); + let payload = ExecutionPayload::random_for_test(&mut rng); + let blob = BlobWithMetadata { + commitment: Default::default(), + proofs: vec![Default::default(); 128], + blob: Default::default(), + }; + let response = merge_response(payload, U256::ZERO, vec![blob.clone()]); + let req = merge_request(response.base_block_hash, 0); + + let submission = merged_block_to_submission(&response, &req).unwrap(); + + assert_eq!(submission.blobs_bundle.commitments.len(), 1); + assert_eq!(submission.blobs_bundle.commitments[0], blob.commitment); + assert_eq!(submission.blobs_bundle.proofs, blob.proofs); + assert_eq!(submission.blobs_bundle.blobs.len(), 1); + } + + #[test] + fn pending_merge_requests_evicts_older_same_base_block() { + let mut pending = PendingMergeRequests::with_capacity(4); + let base = B256::repeat_byte(1); + assert!(pending.store(merge_request(base, 10)).is_none()); + + let evicted = pending.store(merge_request(base, 20)); + assert_eq!(evicted.map(|r| r.receive_ns), Some(10)); + } + + #[test] + fn pending_merge_requests_keeps_existing_if_new_is_older() { + let mut pending = PendingMergeRequests::with_capacity(4); + let base = B256::repeat_byte(1); + pending.store(merge_request(base, 20)); + + let evicted = pending.store(merge_request(base, 10)); + assert!(evicted.is_none()); + assert_eq!(pending.next_req().map(|r| r.receive_ns), Some(20)); + } + + #[test] + fn pending_merge_requests_next_req_returns_and_removes() { + let mut pending = PendingMergeRequests::with_capacity(4); + pending.store(merge_request(B256::repeat_byte(1), 5)); + pending.store(merge_request(B256::repeat_byte(2), 15)); + + let next = pending.next_req().unwrap(); + assert_eq!(next.receive_ns, 15); + assert_eq!(pending.next_req().unwrap().receive_ns, 5); + assert!(pending.next_req().is_none()); + } + + #[test] + fn pending_merge_requests_clear_empties_queue() { + let mut pending = PendingMergeRequests::with_capacity(4); + pending.store(merge_request(B256::repeat_byte(1), 5)); + pending.clear(); + assert!(pending.next_req().is_none()); + } +} From 2d23533a45328e4782efb23b734223bcc7ab37c3 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:43:15 +0100 Subject: [PATCH 12/29] Simulate merged blocks and disable block merging on builder-attributable failures (#508) Co-authored-by: Claude Sonnet 5 --- crates/relay/src/block_merging/tile.rs | 228 ++++++++++++++++++++++++- crates/relay/src/main.rs | 2 + 2 files changed, 222 insertions(+), 8 deletions(-) diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index 187924b7c..c86bcafbe 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -10,14 +10,20 @@ use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use flux::{ spine::SpineProducers, tile::Tile, - timing::{Duration, Repeater}, + timing::{Duration, Nanos, Repeater}, }; use flux_network::{ Token, tcp::{PollEvent, SendBehavior, TcpConnector, TcpTelemetry}, }; use flux_utils::SharedVector; -use helix_common::{BlockMergingTcpConfig, api::builder_api::TopBidUpdate, chain_info::ChainInfo}; +use helix_common::{ + BlockMergingTcpConfig, + api::builder_api::{InclusionListWithMetadata, TopBidUpdate}, + chain_info::ChainInfo, + simulator::BlockSimError, + utils::alert_discord, +}; use helix_tcp_types::merging::{ MERGING_HEADER_SIZE, MERGING_PROTOCOL_VERSION, MergingFrameHeader, MergingHeaderError, MergingMsgId, @@ -38,15 +44,17 @@ use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; use crate::{ - HelixSpine, SubmissionDataWithSpan, + HelixSpine, SimRequest, SimResult, SubmissionDataWithSpan, block_merging::{ append_frame, merged_block_to_response, order_ref_hash, order_to_ref, submission_blob_sidecars, unbundling::{OrderTxs, find_unbundled_txs}, }, housekeeper::SlotUpdate, - simulator::BlockMergeResponse, - spine::messages::{DecodedSubmission, MergedBlockMsg, SlotMsg}, + simulator::{BlockMergeResponse, MergedValidationRequest, tile::MergedSimulationResultInner}, + spine::messages::{ + DecodedSubmission, FromSimMsg, MergedBlockMsg, SlotMsg, ToSimKind, ToSimMsg, + }, }; const REDIAL_INTERVAL_S: u64 = 2; @@ -103,6 +111,12 @@ struct SlotState { /// went out; cached for handshake replay. slot_start: Option, fee_recipient: Option, + /// Registered gas limit of the current proposer, for merged-block simulation requests. + registered_gas_limit: Option, + /// Current proposer's blacklist-filtering preference, for merged-block simulation requests. + apply_blacklist: Option, + /// Current inclusion list, for merged-block simulation requests. + inclusion_list: Option, /// parent_hash -> parent_beacon_block_root. attrs: FxHashMap, /// Appendable block hashes forwarded this slot. @@ -200,6 +214,8 @@ pub struct BlockMergingTile { decoded: Arc>, slot_events: Arc>, merged_blocks: Arc>, + sim_requests: Arc>, + sim_results: Arc>, /// Admin-toggled kill switch. The connection itself (dial, handshake, /// ping/pong) is unaffected — only an admin can set this back to `true` /// (never automatic). While `false`, the tile stops forwarding @@ -214,6 +230,7 @@ pub struct BlockMergingTile { handshaken: Vec, pongs: Vec<(Token, u64)>, merged_ixs: Vec, + merge_sim_ixs: Vec, encode_buf: Vec, // Scratch space for `find_unbundled_txs`, reused across calls. unbundled_scratch_bundled: Vec, @@ -227,6 +244,9 @@ impl Tile for BlockMergingTile { for ix in std::mem::take(&mut self.merged_ixs) { adapter.producers.produce(MergedBlockMsg { ix }); } + for ix in std::mem::take(&mut self.merge_sim_ixs) { + adapter.producers.produce(ToSimMsg { kind: ToSimKind::Request, ix, bid_slot: 0 }); + } if self.redial.fired() { self.dial_endpoint(); @@ -238,6 +258,7 @@ impl Tile for BlockMergingTile { adapter.consume(|msg: SlotMsg, _| self.on_slot_msg(msg)); adapter.consume(|msg: DecodedSubmission, _| self.forward_decoded(msg.ix, None)); adapter.consume(|top_bid: TopBidUpdate, _| self.on_top_bid(top_bid)); + adapter.consume(|msg: FromSimMsg, _| self.on_merge_sim_result(msg)); } fn try_init(&mut self, _adapter: &mut flux::spine::SpineAdapter) -> bool { @@ -332,6 +353,39 @@ fn handle_merged_block( Some(response) } +/// Whether a merged-block simulation failure is attributable to the merge builder, as +/// opposed to a relay/simulator-side infra hiccup. Builds on `is_demotable()` (the same +/// logic that decides whether a failed bid-submission simulation demotes its builder) but +/// additionally excludes internal channel/queue failures, which are never the builder's +/// fault even though `is_demotable()` -- calibrated for bid-submission demotion -- doesn't +/// exclude them. +fn is_merge_builder_attributable(err: &BlockSimError) -> bool { + err.is_demotable() && + !matches!( + err, + BlockSimError::SendError | + BlockSimError::SimulationDropped | + BlockSimError::HydrationMiss + ) +} + +/// Decides whether a merged-block simulation result should disable block merging. +/// Returns the block's hash and the failure reason to report if so. +fn merge_sim_disable_check( + result: &MergedSimulationResultInner, + merged_blocks: &SharedVector, +) -> Option<(B256, BlockSimError)> { + let Err(err) = &result.result else { return None }; + if !is_merge_builder_attributable(err) { + return None; + } + let block_hash = merged_blocks + .get(result.merged_block_ix) + .map(|r| r.execution_payload.block_hash) + .unwrap_or_default(); + Some((block_hash, err.clone())) +} + /// order_id -> order_hash for every `latest_only` bundle in this submission. fn latest_only_ids( builder_pubkey: BlsPublicKeyBytes, @@ -369,12 +423,15 @@ fn appended_tx_hashes( } impl BlockMergingTile { + #[allow(clippy::too_many_arguments)] pub fn new( config: BlockMergingTcpConfig, relay_id: String, decoded: Arc>, slot_events: Arc>, merged_blocks: Arc>, + sim_requests: Arc>, + sim_results: Arc>, chain_info: ChainInfo, block_merging_enabled: Arc, ) -> Self { @@ -433,12 +490,15 @@ impl BlockMergingTile { decoded, slot_events, merged_blocks, + sim_requests, + sim_results, block_merging_enabled, to_disconnect: Vec::new(), to_register: Vec::new(), handshaken: Vec::new(), pongs: Vec::new(), merged_ixs: Vec::new(), + merge_sim_ixs: Vec::new(), encode_buf: Vec::new(), unbundled_scratch_bundled: Vec::new(), unbundled_scratch_covered: Vec::new(), @@ -519,6 +579,8 @@ impl BlockMergingTile { pongs, merged_ixs, merged_blocks, + sim_requests, + merge_sim_ixs, blob_sidecars, tx_hash_cache, unbundled_scratch_bundled, @@ -609,8 +671,29 @@ impl BlockMergingTile { unbundled_scratch_bundled, unbundled_scratch_covered, ) { + let base_block_hash = response.base_block_hash; + let parent_hash = response.execution_payload.parent_hash; let ix = merged_blocks.push(response); merged_ixs.push(ix); + + let sim_req = MergedValidationRequest { + merged_block_ix: ix, + base_block_hash, + slot: slot.bid_slot, + parent_beacon_block_root: slot + .attrs + .get(&parent_hash) + .copied() + .unwrap_or_default(), + proposer_fee_recipient: slot.fee_recipient.unwrap_or_default(), + registered_gas_limit: slot.registered_gas_limit.unwrap_or_default(), + apply_blacklist: slot.apply_blacklist.unwrap_or(true), + inclusion_list: slot.inclusion_list.clone().unwrap_or_default(), + receive_ns: Nanos::now().0, + }; + let sim_ix = + sim_requests.push(SimRequest::ValidateMerged(Box::new(sim_req))); + merge_sim_ixs.push(sim_ix); } } MergingMsgId::RejectV1 => { @@ -684,6 +767,11 @@ impl BlockMergingTile { // housekeeper sends incremental updates for the same slot if let Some(reg) = &ev.registration_data { self.slot.fee_recipient = Some(reg.entry.registration.message.fee_recipient); + self.slot.registered_gas_limit = Some(reg.entry.registration.message.gas_limit); + self.slot.apply_blacklist = Some(reg.entry.preferences.filtering.is_regional()); + } + if let Some(il) = &ev.il { + self.slot.inclusion_list = Some(il.clone()); } for attr in &ev.payload_attributes { self.slot @@ -1017,6 +1105,36 @@ impl BlockMergingTile { }); } + /// Ignores results for anything other than this tile's own `ValidateMerged` requests + /// (the `from_sim` queue also carries the auctioneer's ordinary submission-validation + /// results). On a builder-attributable failure, disables block merging -- this alone + /// triggers the existing force-disconnect gating in `poll_sockets`/`dial_endpoint`, so + /// no separate disconnect call is needed here. Nothing re-enables the flag except the + /// admin API. + fn on_merge_sim_result(&mut self, msg: FromSimMsg) { + let Some(result) = self.sim_results.get(msg.ix) else { + error!(?msg, "sim outbound payload not found"); + return; + }; + let SimResult::ValidateMerged((_, Some(inner))) = result.as_ref() else { return }; + let Some((block_hash, err)) = merge_sim_disable_check(inner, &self.merged_blocks) else { + return; + }; + + self.block_merging_enabled.store(false, Ordering::Relaxed); + error!( + %block_hash, + %err, + endpoint = %self.endpoint.addr, + "merged block simulation failed, disabling block merging" + ); + alert_discord(&format!( + "CRITICAL: block merging disabled -- merged block simulation failed for block \ + {block_hash:#x} from merge builder {} ({err})", + self.endpoint.addr + )); + } + /// Median of unsorted samples; 0 if empty. fn median(samples: &mut [u64]) -> u64 { if samples.is_empty() { @@ -1046,7 +1164,7 @@ impl BlockMergingTile { #[cfg(test)] mod tests { - use alloy_primitives::{Address, Bloom}; + use alloy_primitives::{Address, Bloom, U256}; use alloy_rpc_types::{ beacon::{BlsPublicKey, requests::ExecutionRequestsV4}, engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}, @@ -1064,9 +1182,10 @@ mod tests { }, }; use helix_types::{ - BlockMergingData, Compression, ForkName, SignedBidSubmission, SubmissionVersion, - TestRandomSeed, + BlockMergingData, Compression, ExecutionPayload, ExecutionRequests, ForkName, + MergedBlockTrace, SignedBidSubmission, SubmissionVersion, TestRandom, TestRandomSeed, }; + use rand::{SeedableRng, rngs::SmallRng}; use super::*; use crate::{SubmissionRef, auctioneer::SubmissionData}; @@ -1080,6 +1199,24 @@ mod tests { }) } + fn merge_response( + payload: ExecutionPayload, + proposer_value: U256, + blobs: Vec, + ) -> BlockMergeResponse { + BlockMergeResponse { + base_block_hash: payload.parent_hash, + execution_payload: payload, + execution_requests: ExecutionRequests::default(), + appended_blobs: blobs, + proposer_value, + base_builder_revenue: U256::ZERO, + relay_revenue: U256::ZERO, + builder_inclusions: Default::default(), + trace: MergedBlockTrace::default(), + } + } + #[test] fn latest_only_ids_ignores_non_flagged_and_tx_orders() { let builder_pubkey = BlsPublicKeyBytes::default(); @@ -1221,6 +1358,8 @@ mod tests { Arc::new(SharedVector::default()), Arc::new(SharedVector::default()), Arc::new(SharedVector::default()), + Arc::new(SharedVector::default()), + Arc::new(SharedVector::default()), ChainInfo::default(), Arc::new(AtomicBool::new(enabled)), ) @@ -1428,4 +1567,77 @@ mod tests { assert!(slot.best_merged.contains_key(&base_block_hash)); assert_eq!(stats.merged_blocks, 1); } + + #[test] + fn merge_sim_disable_check_table() { + let mut rng = SmallRng::seed_from_u64(3); + let payload = ExecutionPayload::random_for_test(&mut rng); + let merged_blocks = SharedVector::::with_capacity(4); + let ix = merged_blocks.push(merge_response(payload, U256::from(1u64), vec![])); + + let cases: &[(BlockSimError, bool)] = &[ + (BlockSimError::RpcError, false), + (BlockSimError::Timeout, false), + (BlockSimError::NoSimulatorAvailable, false), + (BlockSimError::SendError, false), + (BlockSimError::SimulationDropped, false), + (BlockSimError::HydrationMiss, false), + (BlockSimError::BlockValidationFailed("unknown ancestor".to_owned()), false), + (BlockSimError::BlockValidationFailed("parent block not found".to_owned()), false), + (BlockSimError::BlockValidationFailed("block requires a reorg".to_owned()), false), + (BlockSimError::BlockValidationFailed("block already known".to_owned()), false), + ( + BlockSimError::BlockValidationFailed( + "block is too old, outside validation window".to_owned(), + ), + false, + ), + ( + BlockSimError::BlockValidationFailed("some other validation failure".to_owned()), + true, + ), + ( + BlockSimError::InvalidTxRoot { got: B256::ZERO, expected: B256::repeat_byte(1) }, + true, + ), + ]; + + for (err, expect_disable) in cases { + let inner = + MergedSimulationResultInner { merged_block_ix: ix, result: Err(err.clone()) }; + let outcome = merge_sim_disable_check(&inner, &merged_blocks); + assert_eq!(outcome.is_some(), *expect_disable, "case: {err:?}"); + } + } + + #[test] + fn merge_sim_disable_check_reports_block_hash() { + let mut rng = SmallRng::seed_from_u64(4); + let payload = ExecutionPayload::random_for_test(&mut rng); + let merged_blocks = SharedVector::::with_capacity(4); + let ix = merged_blocks.push(merge_response(payload.clone(), U256::from(1u64), vec![])); + + let inner = MergedSimulationResultInner { + merged_block_ix: ix, + result: Err(BlockSimError::InvalidTxRoot { + got: B256::ZERO, + expected: B256::repeat_byte(1), + }), + }; + let (block_hash, err) = merge_sim_disable_check(&inner, &merged_blocks).unwrap(); + assert_eq!(block_hash, payload.block_hash); + assert!(matches!(err, BlockSimError::InvalidTxRoot { .. })); + } + + #[test] + fn merge_sim_disable_check_none_on_success() { + let merged_blocks = SharedVector::::with_capacity(4); + let ix = merged_blocks.push(merge_response( + ExecutionPayload::random_for_test(&mut SmallRng::seed_from_u64(5)), + U256::ZERO, + vec![], + )); + let inner = MergedSimulationResultInner { merged_block_ix: ix, result: Ok(()) }; + assert!(merge_sim_disable_check(&inner, &merged_blocks).is_none()); + } } diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index a7fdd9893..19b64e86c 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -364,6 +364,8 @@ async fn run( decoded.clone(), slot_events.clone(), merged_blocks.clone(), + sim_requests.clone(), + sim_results.clone(), chain_info.as_ref().clone(), block_merging_enabled.clone(), ); From 34a1da0d8987b75da7d1eeae3f605e6a6589fdf5 Mon Sep 17 00:00:00 2001 From: Owen <85877303+0w3n-d@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:53:00 +0100 Subject: [PATCH 13/29] Wire block-merging re-enable into the admin portal (#513) Co-authored-by: Claude Sonnet 5 --- crates/admin/frontend/src/lib/api.ts | 3 + crates/admin/frontend/src/pages/Overview.tsx | 7 + crates/admin/frontend/src/pages/Settings.tsx | 64 ++- crates/admin/src/handlers.rs | 25 +- crates/admin/src/models.rs | 2 + crates/admin/src/relay_client.rs | 67 ++++ crates/admin/src/service.rs | 4 + crates/common/src/simulator.rs | 24 ++ crates/relay/src/auctioneer/block_merger.rs | 48 +-- crates/relay/src/auctioneer/context.rs | 2 +- crates/relay/src/block_merging/mod.rs | 368 +++++++++++++++++- crates/relay/src/block_merging/tile.rs | 219 ++++++++--- crates/relay/src/simulator/client.rs | 27 +- crates/relay/src/simulator/mod.rs | 16 +- crates/relay/src/simulator/tile.rs | 116 +++--- crates/simulator/src/ssz_server.rs | 82 +++- crates/simulator/src/validation/mod.rs | 386 ++++++++++++++++++- 17 files changed, 1265 insertions(+), 195 deletions(-) diff --git a/crates/admin/frontend/src/lib/api.ts b/crates/admin/frontend/src/lib/api.ts index 4078b00c6..fb4b00d41 100644 --- a/crates/admin/frontend/src/lib/api.ts +++ b/crates/admin/frontend/src/lib/api.ts @@ -53,6 +53,7 @@ async function apiFetch(path: string, init?: RequestInit): Promise { export interface Overview { adjustments_enabled: boolean; kill_switch_enabled: boolean | null; + block_merging_enabled: boolean | null; builders_pending_promotion: number; } @@ -107,6 +108,8 @@ export const api = { setKillSwitch: (enabled: boolean) => apiFetch("/api/v1/actions/killswitch", { method: enabled ? "POST" : "DELETE" }), + setBlockMerging: (enabled: boolean) => + apiFetch("/api/v1/actions/block-merging", { method: enabled ? "POST" : "DELETE" }), demoteBuilder: (pubkey: string, reason: string) => apiFetch(`/api/v1/actions/builders/${pubkey}/demote`, { method: "POST", diff --git a/crates/admin/frontend/src/pages/Overview.tsx b/crates/admin/frontend/src/pages/Overview.tsx index cf37a3681..920eb531b 100644 --- a/crates/admin/frontend/src/pages/Overview.tsx +++ b/crates/admin/frontend/src/pages/Overview.tsx @@ -77,6 +77,13 @@ export default function Overview() { badText="Engaged — relay halted" unknown={data.kill_switch_enabled === null} /> + { if (action === "engage-kill-switch") await api.setKillSwitch(true); else if (action === "disarm-kill-switch") await api.setKillSwitch(false); + else if (action === "disable-block-merging") await api.setBlockMerging(false); + else if (action === "enable-block-merging") await api.setBlockMerging(true); else await api.disableAdjustments(); }, onSuccess: () => { @@ -31,6 +38,7 @@ export default function Settings() { }); const killSwitchOn = overview?.kill_switch_enabled === true; + const blockMergingOn = overview?.block_merging_enabled === true; return (
@@ -72,6 +80,34 @@ export default function Settings() { )}
+
+
+
Block merging
+

+ {overview?.block_merging_enabled === null + ? "Relay admin API unreachable — state unknown." + : blockMergingOn + ? "Enabled. The relay is connected to the merge builder." + : "Disabled. The relay is not dialing the merge builder."} +

+
+ {blockMergingOn ? ( + + ) : ( + + )} +
+
Bid adjustments
@@ -115,6 +151,32 @@ export default function Settings() { The relay will resume accepting bids. )} + {pending === "disable-block-merging" && ( + mutation.mutate(pending)} + onCancel={() => setPending(null)} + > + The relay will disconnect from the merge builder and stop dialing it. Re-enabling + requires this action again. + + )} + {pending === "enable-block-merging" && ( + mutation.mutate(pending)} + onCancel={() => setPending(null)} + > + The relay will resume dialing the merge builder. Only do this after confirming the + reason block merging was disabled is resolved. + + )} {pending === "disable-adjustments" && ( Some(status.kill_switch_enabled), + let (kill_switch_enabled, block_merging_enabled) = match relay.status().await { + Ok(status) => (Some(status.kill_switch_enabled), Some(status.block_merging_enabled)), Err(err) => { - warn!(%err, "relay admin API unreachable, omitting kill switch state"); - None + warn!(%err, "relay admin API unreachable, omitting kill switch/block merging state"); + (None, None) } }; Ok(Json(OverviewResponse { adjustments_enabled, kill_switch_enabled, + block_merging_enabled, builders_pending_promotion, })) } @@ -194,6 +195,22 @@ pub async fn disable_kill_switch( Ok(StatusCode::NO_CONTENT) } +pub async fn enable_block_merging( + Extension(relay): Extension, +) -> Result { + relay.set_block_merging(true).await?; + info!("block merging enabled via admin website"); + Ok(StatusCode::NO_CONTENT) +} + +pub async fn disable_block_merging( + Extension(relay): Extension, +) -> Result { + relay.set_block_merging(false).await?; + info!("block merging disabled via admin website"); + Ok(StatusCode::NO_CONTENT) +} + #[derive(Deserialize, Default)] pub struct DemoteBuilderRequest { pub reason: Option, diff --git a/crates/admin/src/models.rs b/crates/admin/src/models.rs index b67ba0048..8b49ee936 100644 --- a/crates/admin/src/models.rs +++ b/crates/admin/src/models.rs @@ -61,5 +61,7 @@ pub struct OverviewResponse { pub adjustments_enabled: bool, /// `None` when the relay admin API is unreachable. pub kill_switch_enabled: Option, + /// `None` when the relay admin API is unreachable. + pub block_merging_enabled: Option, pub builders_pending_promotion: i64, } diff --git a/crates/admin/src/relay_client.rs b/crates/admin/src/relay_client.rs index 81b265411..50422b676 100644 --- a/crates/admin/src/relay_client.rs +++ b/crates/admin/src/relay_client.rs @@ -9,6 +9,7 @@ use crate::error::AdminApiError; #[derive(Deserialize, Serialize, Clone, Copy)] pub struct RelayAdminStatus { pub kill_switch_enabled: bool, + pub block_merging_enabled: bool, } #[derive(Serialize)] @@ -49,6 +50,13 @@ impl RelayAdminClient { Self::check_status(&response) } + pub async fn set_block_merging(&self, enabled: bool) -> Result<(), AdminApiError> { + let url = format!("{}/admin/v1/block-merging", self.base); + let request = if enabled { self.client.post(url) } else { self.client.delete(url) }; + let response = request.bearer_auth(&self.token).send().await?; + Self::check_status(&response) + } + pub async fn demote_builder( &self, pubkey: &BlsPublicKeyBytes, @@ -88,3 +96,62 @@ impl RelayAdminClient { } } } + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + use axum::{ + Extension, Json, Router, + routing::{get, post}, + }; + + use super::*; + + async fn spawn_fake_relay(block_merging_enabled: Arc) -> String { + async fn status(Extension(flag): Extension>) -> Json { + Json(serde_json::json!({ + "kill_switch_enabled": false, + "block_merging_enabled": flag.load(Ordering::Relaxed), + })) + } + async fn enable(Extension(flag): Extension>) -> StatusCode { + flag.store(true, Ordering::Relaxed); + StatusCode::NO_CONTENT + } + async fn disable(Extension(flag): Extension>) -> StatusCode { + flag.store(false, Ordering::Relaxed); + StatusCode::NO_CONTENT + } + + let router = Router::new() + .route("/admin/v1/status", get(status)) + .route("/admin/v1/block-merging", post(enable).delete(disable)) + .layer(Extension(block_merging_enabled)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router.into_make_service()).await.unwrap(); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn set_block_merging_toggles_and_status_reflects_it() { + let flag = Arc::new(AtomicBool::new(true)); + let base = spawn_fake_relay(flag.clone()).await; + let client = RelayAdminClient::new(base, "unused".into()); + + client.set_block_merging(false).await.unwrap(); + assert!(!flag.load(Ordering::Relaxed)); + assert!(!client.status().await.unwrap().block_merging_enabled); + + client.set_block_merging(true).await.unwrap(); + assert!(flag.load(Ordering::Relaxed)); + assert!(client.status().await.unwrap().block_merging_enabled); + } +} diff --git a/crates/admin/src/service.rs b/crates/admin/src/service.rs index a64b45416..aa5d601cb 100644 --- a/crates/admin/src/service.rs +++ b/crates/admin/src/service.rs @@ -35,6 +35,10 @@ pub fn build_admin_router( "/actions/killswitch", post(handlers::enable_kill_switch).delete(handlers::disable_kill_switch), ) + .route( + "/actions/block-merging", + post(handlers::enable_block_merging).delete(handlers::disable_block_merging), + ) .route("/actions/builders/{pubkey}/demote", post(handlers::demote_builder)) .route("/actions/builders/{pubkey}/promote", post(handlers::promote_builder)) .route("/actions/adjustments/disable", post(handlers::disable_adjustments)) diff --git a/crates/common/src/simulator.rs b/crates/common/src/simulator.rs index 9c009ea8f..9bdabc488 100644 --- a/crates/common/src/simulator.rs +++ b/crates/common/src/simulator.rs @@ -149,6 +149,21 @@ pub struct SszValidationRequest { pub signed_bid_submission: Vec, } +/// Merged-block counterpart of [`SszValidationRequest`], carrying the extra +/// `base_payment_tx_index` a merged-block-only validation endpoint uses to recognise the +/// base block's own payment tx directly, instead of scanning every tx in the block -- see +/// `BlockMergeResponse::base_payment_tx_index`. +#[derive(Debug, Clone, Encode, Decode)] +pub struct SszMergedValidationRequest { + pub apply_blacklist: bool, + pub registered_gas_limit: u64, + pub parent_beacon_block_root: B256, + pub inclusion_list: InclusionListWithMetadata, + pub decoder_params: Option, + pub signed_bid_submission: Vec, + pub base_payment_tx_index: u64, +} + // TODO: refactor this in a SignedBidSubmission + extra fields #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct JsonValidationRequest { @@ -188,6 +203,15 @@ impl JsonValidationRequest { } } +/// Merged-block counterpart of [`JsonValidationRequest`], carrying the extra +/// `base_payment_tx_index` -- see [`SszMergedValidationRequest`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MergedJsonValidationRequest { + #[serde(flatten)] + pub base: JsonValidationRequest, + pub base_payment_tx_index: u64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relay/src/auctioneer/block_merger.rs b/crates/relay/src/auctioneer/block_merger.rs index 74c521c00..573706240 100644 --- a/crates/relay/src/auctioneer/block_merger.rs +++ b/crates/relay/src/auctioneer/block_merger.rs @@ -8,15 +8,11 @@ use alloy_primitives::{Address, B256, U256}; use flux_profiler::timed; use helix_common::{ RelayConfig, - chain_info::ChainInfo, local_cache::LocalCache, metrics::MERGE_TRACE_LATENCY, utils::{utcnow_ms, utcnow_ns}, }; -use helix_types::{ - BlobWithMetadata, BlobsBundle, BlsPublicKeyBytes, MergedBlock, PayloadAndBlobs, PayloadBidData, - Transactions, -}; +use helix_types::{BlsPublicKeyBytes, MergedBlock, PayloadAndBlobs, PayloadBidData, Transactions}; use rustc_hash::{FxBuildHasher, FxHashSet}; use tracing::{debug, error, info, trace, warn}; @@ -30,8 +26,6 @@ pub enum PayloadMergingError { "merged payload value is lower or equal to original bid. original: {original}, merged: {merged}" )] MergedPayloadNotValuable { original: U256, merged: U256 }, - #[error("reached maximum blob count for block")] - MaxBlobCountReached, } /// Stores merged blocks so they can be served via `get_header`/`get_payload`. Everything @@ -42,7 +36,6 @@ pub enum PayloadMergingError { pub struct BlockMerger { curr_bid_slot: u64, config: RelayConfig, - chain_info: ChainInfo, local_cache: LocalCache, best_merged_blocks: HashMap, /// Base block hashes for which `get_header` found that the merged bid only differed @@ -53,16 +46,10 @@ pub struct BlockMerger { } impl BlockMerger { - pub fn new( - curr_bid_slot: u64, - chain_info: ChainInfo, - local_cache: LocalCache, - config: RelayConfig, - ) -> Self { + pub fn new(curr_bid_slot: u64, local_cache: LocalCache, config: RelayConfig) -> Self { Self { curr_bid_slot, config, - chain_info, local_cache, best_merged_blocks: HashMap::with_capacity(16), flagged_payment_tx_only_blocks: FxHashSet::with_capacity_and_hasher(16, FxBuildHasher), @@ -201,7 +188,6 @@ impl BlockMerger { } let bid_slot = self.curr_bid_slot; - let max_blobs_per_block = self.chain_info.max_blobs_per_block(); let original_block_hash = original_payload.execution_payload.block_hash; if self.flagged_payment_tx_only_blocks.remove(&original_block_hash) { @@ -236,8 +222,7 @@ impl BlockMerger { original_tx_count: original_payload.execution_payload.transactions.len(), merged_tx_count: response.execution_payload.transactions.len(), original_blob_count: original_payload.blobs_bundle.blobs.len(), - merged_blob_count: original_payload.blobs_bundle.blobs.len() + - response.appended_blobs.len(), + merged_blob_count: response.blobs_bundle.blobs.len(), original_gas_used: original_payload.execution_payload.gas_used, merged_gas_used: response.execution_payload.gas_used, builder_inclusions: response.builder_inclusions, @@ -246,18 +231,11 @@ impl BlockMerger { trace!(%block_hash, "stored merged block in local cache"); - let mut merged_blobs_bundle = original_payload.blobs_bundle.as_ref().to_owned(); - append_merged_blobs( - &mut merged_blobs_bundle, - response.appended_blobs, - max_blobs_per_block, - )?; - let withdrawals_root = response.execution_payload.withdrawals_root(); let payload_and_blobs = PayloadAndBlobs { execution_payload: Arc::new(response.execution_payload), - blobs_bundle: Arc::new(merged_blobs_bundle), + blobs_bundle: Arc::new(response.blobs_bundle), }; let bid_data = PayloadBidData { @@ -268,7 +246,7 @@ impl BlockMerger { builder_pubkey, }; - trace!(%block_hash, %response.proposer_value, "blobs appended to merged payload"); + trace!(%block_hash, %response.proposer_value, "merged payload ready for storage"); let new_bid = PayloadEntry::new_gossip(payload_and_blobs, bid_data); @@ -301,22 +279,6 @@ struct BestMergedBlock { bid: PayloadEntry, } -/// Appends the merged blobs to the original blobs bundle. -#[timed] -fn append_merged_blobs( - original_blobs_bundle: &mut BlobsBundle, - appended_blobs: Vec, - max_blobs_per_block: usize, -) -> Result<(), PayloadMergingError> { - for blob_data in appended_blobs { - original_blobs_bundle - .push_blob(blob_data.commitment, &blob_data.proofs, blob_data.blob, max_blobs_per_block) - .map_err(|_| PayloadMergingError::MaxBlobCountReached)?; - } - - Ok(()) -} - /// Checks whether the merged block kept the original builder's tx ordering completely /// unchanged except for the payment tx (the last tx in the original block), with any /// additional orders appended after it. When that's the case, this is logged since it diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index 361b83649..ffb1febfc 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -108,7 +108,7 @@ impl Context { api_key: None, }; - let block_merger = BlockMerger::new(0, chain_info.clone(), cache.clone(), config.clone()); + let block_merger = BlockMerger::new(0, cache.clone(), config.clone()); let slot_context = SlotContext { bid_slot: Slot::new(0), diff --git a/crates/relay/src/block_merging/mod.rs b/crates/relay/src/block_merging/mod.rs index 0fc94ba4f..a6798ceb5 100644 --- a/crates/relay/src/block_merging/mod.rs +++ b/crates/relay/src/block_merging/mod.rs @@ -8,18 +8,19 @@ mod unbundling; use std::collections::HashMap; -use alloy_consensus::Bytes48; -use alloy_primitives::B256; +use alloy_consensus::{Bytes48, Transaction as _, TxEnvelope}; +use alloy_primitives::{Address, B256}; +use alloy_rlp::Decodable; use helix_tcp_types::merging::{ MergingFrameHeader, MergingMsgId, builder_to_relay::MergedBlockV1, order::{BundleOrderRef, MergeOrderRef, TxOrderRef, bundle_order_hash}, }; use helix_types::{ - BlobWithMetadata, BlobsBundle, BuilderInclusionResult, KzgCommitment, MergeOrderFlags, - MergedBlockTrace, Order, payload_from_v3, requests_from_v4, + BlobWithMetadata, BlobsBundle, BuilderInclusionResult, ExecutionPayload, KzgCommitment, + MergeOrderFlags, MergedBlockTrace, Order, payload_from_v3, requests_from_v4, }; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use ssz::Encode; pub use tile::BlockMergingTile; // Bench-only visibility, see benches/unbundling.rs. @@ -84,18 +85,19 @@ fn order_ref_hash(order_ref: &MergeOrderRef, tx_hashes: &[B256]) -> B256 { } /// Maps the wire message onto the simulator response type so the auctioneer reuses -/// `handle_merge_response` unchanged. Resolves each appended blob hash from `blob_sidecars` -/// (this tile's own cache of blob sidecars seen in submissions this slot); `None` if any -/// hash can't be resolved, since a merged block missing a blob sidecar can't be finalized. +/// `handle_merge_response` unchanged. Resolves the merged block's full blob set -- +/// the base block's own blob txs as well as any newly appended ones -- from +/// `blob_sidecars` (this tile's own cache of blob sidecars seen in submissions this +/// slot); `None` if any referenced hash can't be resolved or the resolved set is +/// invalid, since a merged block missing a blob sidecar can't be finalized. fn merged_block_to_response( m: MergedBlockV1, blob_sidecars: &FxHashMap, + max_blobs_per_block: usize, ) -> Option { - let appended_blobs = m - .appended_blobs - .iter() - .map(|h| blob_sidecars.get(h).cloned()) - .collect::>>()?; + let execution_payload = payload_from_v3(m.execution_payload)?; + let blobs_bundle = + resolve_blobs_bundle(&execution_payload, blob_sidecars, max_blobs_per_block)?; let builder_inclusions: HashMap<_, _> = m .builder_inclusions @@ -108,15 +110,25 @@ fn merged_block_to_response( }) }) .collect(); + // Base txs are replayed verbatim at the front of a merged block; every appended order tx + // and the trailing distribution tx come after them, in that order (see + // `MergeSession::emit` in crates/builder/src/engine/session.rs). So the base payment tx's + // index is always total-appended-count minus the appended order txs minus the distribution + // tx. `checked_sub` guards against a malformed/adversarial wire count exceeding the actual + // tx list, same failure mode as this function's other validity checks. + let appended_order_txs = appended_tx_hashes(&builder_inclusions).len(); + let base_payment_tx_index = + execution_payload.transactions.len().checked_sub(appended_order_txs + 2)?; Some(BlockMergeResponse { base_block_hash: m.base_block_hash, - execution_payload: payload_from_v3(m.execution_payload)?, + execution_payload, execution_requests: requests_from_v4(m.execution_requests)?, - appended_blobs, + blobs_bundle, proposer_value: m.proposer_value, base_builder_revenue: m.base_builder_revenue, relay_revenue: m.relay_revenue, builder_inclusions, + base_payment_tx_index, trace: MergedBlockTrace { request_time_ns: m.trace.base_block_recv_ns, sim_start_time_ns: m.trace.sim_start_ns, @@ -130,6 +142,46 @@ fn merged_block_to_response( }) } +/// Every tx hash contributed by a merged-in order, across all builders -- as opposed to the +/// base block's own content or the trailing distribution tx (which isn't attributed to any +/// builder's `revenue.txs`; see `MergeSession::emit`). Used both to filter the unbundling +/// check to genuinely appended content and to locate the base block's own payment tx. +fn appended_tx_hashes( + builder_inclusions: &HashMap, +) -> FxHashSet { + builder_inclusions.values().flat_map(|inclusion| inclusion.txs.iter().copied()).collect() +} + +/// Resolves every blob versioned hash referenced by the merged block's own transactions -- +/// base txs and newly appended txs alike -- from `blob_sidecars`. Trusting only the wire's +/// `appended_blobs` list would drop the base block's own blob sidecars whenever it already +/// carried blob txs, producing a merged block whose transactions reference blob hashes with +/// no matching `blobs_bundle` entry (surfaces downstream as a blob-versioned-hash mismatch +/// during simulation). +fn resolve_blobs_bundle( + payload: &ExecutionPayload, + blob_sidecars: &FxHashMap, + max_blobs_per_block: usize, +) -> Option { + let mut bundle = BlobsBundle::default(); + for tx in payload.transactions.iter() { + let envelope = TxEnvelope::decode(&mut tx.0.as_ref()).ok()?; + for hash in envelope.blob_versioned_hashes().unwrap_or_default() { + let sidecar = blob_sidecars.get(hash)?; + bundle + .push_blob( + sidecar.commitment, + &sidecar.proofs, + sidecar.blob.clone(), + max_blobs_per_block, + ) + .ok()?; + } + } + bundle.validate_ssz_lengths(max_blobs_per_block).ok()?; + Some(bundle) +} + /// This submission's own blob sidecars, keyed by KZG versioned hash — cached so a later /// merged block can re-attach one if it appended a blob tx originating from this submission. fn submission_blob_sidecars( @@ -245,4 +297,290 @@ mod tests { let ping = PingV1::from_ssz_bytes(&buf[2..]).unwrap(); assert_eq!(ping.nonce, 42); } + + /// Reproduces the RELAY-FR incident: a merge builder appends no new blob + /// txs (`appended_blobs` empty on the wire) to a base block that already + /// carries one. The resolved response must still include the base + /// block's own blob, or downstream simulation sees a blob tx with no + /// matching `blobs_bundle` entry ("expected blob versioned hashes do not + /// match the given transactions"). + #[test] + fn merged_block_to_response_includes_base_blocks_own_blob_tx() { + use alloy_consensus::{TxEip4844, TxEnvelope}; + use alloy_primitives::{Address, Bloom, Signature, U256}; + use alloy_rlp::Encodable; + use alloy_rpc_types::{ + beacon::{BlsPublicKey, requests::ExecutionRequestsV4}, + engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}, + }; + use helix_tcp_types::merging::builder_to_relay::MergeTraceV1; + use helix_types::Blob; + + let commitment: Bytes48 = Bytes48::default(); + let hash = calculate_versioned_hash(commitment); + + let tx = TxEip4844 { blob_versioned_hashes: vec![hash], ..Default::default() }; + let envelope = TxEnvelope::new_unhashed( + tx.into(), + Signature::new(Default::default(), Default::default(), Default::default()), + ); + let mut raw = vec![]; + envelope.encode(&mut raw); + + let base_block_hash = B256::repeat_byte(9); + let execution_payload = ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::default(), + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: U256::from(1), + block_hash: base_block_hash, + // The blob tx plus a trailing distribution tx -- every real merged block + // has at least these two (see `MergeSession::emit`). + transactions: vec![raw.into(), raw_plain_tx()], + }, + withdrawals: vec![], + }, + blob_gas_used: 0, + excess_blob_gas: 0, + }; + let merged = MergedBlockV1 { + slot: 5, + response_id: 0, + base_block_hash, + base_builder_pubkey: BlsPublicKey::default(), + execution_payload, + execution_requests: ExecutionRequestsV4::default(), + appended_blobs: vec![], + proposer_value: U256::from(1), + base_builder_revenue: U256::ZERO, + relay_revenue: U256::ZERO, + builder_inclusions: vec![], + included_order_ids: vec![], + trace: MergeTraceV1::default(), + }; + + let mut blob_sidecars = FxHashMap::default(); + blob_sidecars.insert(hash, BlobWithMetadata { + commitment, + proofs: vec![Bytes48::default(); 128], + blob: Blob::default(), + }); + + let response = + merged_block_to_response(merged, &blob_sidecars, 9).expect("known blob resolves"); + + assert_eq!(response.blobs_bundle.blobs.len(), 1); + assert_eq!(response.blobs_bundle.commitments[0], commitment); + } + + /// The base block's own blob tx and a merge builder's newly appended + /// blob tx must both survive resolution, not just the appended one. + #[test] + fn merged_block_to_response_includes_both_base_and_appended_blobs() { + use alloy_consensus::{TxEip4844, TxEnvelope}; + use alloy_primitives::{Address, Bloom, Signature, U256}; + use alloy_rlp::Encodable; + use alloy_rpc_types::{ + beacon::{BlsPublicKey, requests::ExecutionRequestsV4}, + engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}, + }; + use helix_tcp_types::merging::builder_to_relay::MergeTraceV1; + use helix_types::Blob; + + fn raw_blob_tx(hash: B256) -> alloy_primitives::Bytes { + let tx = TxEip4844 { blob_versioned_hashes: vec![hash], ..Default::default() }; + let envelope = TxEnvelope::new_unhashed( + tx.into(), + Signature::new(Default::default(), Default::default(), Default::default()), + ); + let mut raw = vec![]; + envelope.encode(&mut raw); + raw.into() + } + + let base_commitment: Bytes48 = Bytes48::default(); + let base_hash = calculate_versioned_hash(base_commitment); + let appended_commitment: Bytes48 = Bytes48::repeat_byte(1); + let appended_hash = calculate_versioned_hash(appended_commitment); + + let base_block_hash = B256::repeat_byte(9); + let execution_payload = ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::default(), + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: U256::from(1), + block_hash: base_block_hash, + transactions: vec![raw_blob_tx(base_hash), raw_blob_tx(appended_hash)], + }, + withdrawals: vec![], + }, + blob_gas_used: 0, + excess_blob_gas: 0, + }; + let merged = MergedBlockV1 { + slot: 5, + response_id: 0, + base_block_hash, + base_builder_pubkey: BlsPublicKey::default(), + execution_payload, + execution_requests: ExecutionRequestsV4::default(), + appended_blobs: vec![appended_hash], + proposer_value: U256::from(1), + base_builder_revenue: U256::ZERO, + relay_revenue: U256::ZERO, + builder_inclusions: vec![], + included_order_ids: vec![], + trace: MergeTraceV1::default(), + }; + + let mut blob_sidecars = FxHashMap::default(); + blob_sidecars.insert(base_hash, BlobWithMetadata { + commitment: base_commitment, + proofs: vec![Bytes48::default(); 128], + blob: Blob::default(), + }); + blob_sidecars.insert(appended_hash, BlobWithMetadata { + commitment: appended_commitment, + proofs: vec![Bytes48::default(); 128], + blob: Blob::default(), + }); + + let response = + merged_block_to_response(merged, &blob_sidecars, 9).expect("both blobs resolve"); + + let commitments: Vec<_> = response.blobs_bundle.commitments.iter().copied().collect(); + assert_eq!(commitments.len(), 2); + assert!(commitments.contains(&base_commitment)); + assert!(commitments.contains(&appended_commitment)); + } + + fn raw_plain_tx() -> alloy_primitives::Bytes { + use alloy_consensus::{TxEip1559, TxEnvelope}; + use alloy_primitives::Signature; + use alloy_rlp::Encodable; + + let envelope = TxEnvelope::new_unhashed( + TxEip1559::default().into(), + Signature::new(Default::default(), Default::default(), Default::default()), + ); + let mut raw = vec![]; + envelope.encode(&mut raw); + raw.into() + } + + fn merged_block_with_txs(n_txs: usize, appended: &[B256]) -> MergedBlockV1 { + use alloy_rpc_types::{ + beacon::{BlsPublicKey, requests::ExecutionRequestsV4}, + engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}, + }; + use helix_tcp_types::merging::builder_to_relay::{BuilderInclusion, MergeTraceV1}; + + let base_block_hash = B256::repeat_byte(9); + let execution_payload = ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Default::default(), + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: alloy_primitives::U256::from(1), + block_hash: base_block_hash, + transactions: (0..n_txs).map(|_| raw_plain_tx()).collect(), + }, + withdrawals: vec![], + }, + blob_gas_used: 0, + excess_blob_gas: 0, + }; + let builder_inclusions = if appended.is_empty() { + vec![] + } else { + vec![BuilderInclusion { + builder_pubkey: BlsPublicKey::default(), + origin_coinbase: Address::repeat_byte(0xa), + contribution: alloy_primitives::U256::ZERO, + revenue: alloy_primitives::U256::ZERO, + txs: appended.to_vec(), + }] + }; + MergedBlockV1 { + slot: 5, + response_id: 0, + base_block_hash, + base_builder_pubkey: BlsPublicKey::default(), + execution_payload, + execution_requests: ExecutionRequestsV4::default(), + appended_blobs: vec![], + proposer_value: alloy_primitives::U256::from(1), + base_builder_revenue: alloy_primitives::U256::ZERO, + relay_revenue: alloy_primitives::U256::ZERO, + builder_inclusions, + included_order_ids: vec![], + trace: MergeTraceV1::default(), + } + } + + /// No appended orders: the base block's own trailing tx is the only + /// candidate, so `base_payment_tx_index` is just the second-to-last + /// position (the distribution tx is always last). + #[test] + fn merged_block_to_response_base_payment_tx_index_with_no_appended_orders() { + let merged = merged_block_with_txs(4, &[]); + let blob_sidecars = FxHashMap::default(); + + let response = merged_block_to_response(merged, &blob_sidecars, 9).unwrap(); + + assert_eq!(response.base_payment_tx_index, 2); + } + + /// With appended order txs in between the base content and the + /// distribution tx, the base payment tx index must skip over them. + #[test] + fn merged_block_to_response_base_payment_tx_index_with_appended_orders() { + let appended = [B256::repeat_byte(1), B256::repeat_byte(2)]; + let merged = merged_block_with_txs(5, &appended); + let blob_sidecars = FxHashMap::default(); + + let response = merged_block_to_response(merged, &blob_sidecars, 9).unwrap(); + + assert_eq!(response.base_payment_tx_index, 1); + } + + /// A wire count of appended txs that exceeds the actual tx list is + /// malformed; must be rejected rather than underflow the index. + #[test] + fn merged_block_to_response_none_when_appended_count_exceeds_tx_list() { + let appended = [B256::repeat_byte(1), B256::repeat_byte(2), B256::repeat_byte(3)]; + let merged = merged_block_with_txs(2, &appended); + let blob_sidecars = FxHashMap::default(); + + assert!(merged_block_to_response(merged, &blob_sidecars, 9).is_none()); + } } diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index c86bcafbe..d89a00632 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -1,12 +1,9 @@ -use std::{ - collections::HashMap, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; -use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; +use alloy_primitives::{B256, Bytes, U256, keccak256}; use flux::{ spine::SpineProducers, tile::Tile, @@ -34,10 +31,7 @@ use helix_tcp_types::merging::{ order::{MergeOrderRef, order_id}, relay_to_builder::{ActivateBaseBlockV1, MergeableBlockV1, RevokeOrderV1, SlotStartV1}, }; -use helix_types::{ - BlobWithMetadata, BlsPublicKeyBytes, BuilderInclusionResult, HydrationCache, Submission, - payload_to_v3, -}; +use helix_types::{BlobWithMetadata, BlsPublicKeyBytes, HydrationCache, Submission, payload_to_v3}; use rustc_hash::{FxHashMap, FxHashSet}; use ssz::Decode; use tracing::{debug, error, info, trace, warn}; @@ -46,7 +40,7 @@ use uuid::Uuid; use crate::{ HelixSpine, SimRequest, SimResult, SubmissionDataWithSpan, block_merging::{ - append_frame, merged_block_to_response, order_ref_hash, order_to_ref, + append_frame, appended_tx_hashes, merged_block_to_response, order_ref_hash, order_to_ref, submission_blob_sidecars, unbundling::{OrderTxs, find_unbundled_txs}, }, @@ -169,6 +163,10 @@ struct SlotStats { merged_regressed: usize, /// Merged blocks dropped because an appended blob's sidecar wasn't in our cache. merged_blob_missing: usize, + /// Merged blocks whose simulation was skipped because a required piece of this + /// slot's state (beacon parent root, fee recipient, or registered gas limit) + /// isn't known yet. + merged_slot_data_missing: usize, /// Merged blocks dropped because the builder broke an order's atomicity. merged_unbundled: usize, /// TopBidUpdate messages received for the current bid slot. @@ -289,6 +287,7 @@ fn handle_merged_block( tx_hash_cache: &mut FxHashMap, unbundled_scratch_bundled: &mut Vec, unbundled_scratch_covered: &mut Vec, + max_blobs_per_block: usize, ) -> Option { if !enabled { return None; @@ -317,7 +316,8 @@ fn handle_merged_block( value: merged.proposer_value, order_ids: merged.included_order_ids.iter().copied().collect(), }); - let Some(response) = merged_block_to_response(merged, blob_sidecars) else { + let Some(response) = merged_block_to_response(merged, blob_sidecars, max_blobs_per_block) + else { stats.merged_blob_missing += 1; warn!( ?token, @@ -353,6 +353,37 @@ fn handle_merged_block( Some(response) } +/// Builds the simulation request for a freshly accepted merged block, resolving +/// `parent_beacon_block_root`, `proposer_fee_recipient`, and `registered_gas_limit` +/// from this slot's cached state. `None` if any of them isn't known yet, rather than +/// silently defaulting: the external validator checks the merge builder's payment tx +/// against `proposer_fee_recipient` and re-executes with `parent_beacon_block_root` +/// (written into the EIP-4788 beacon-roots contract), so a zero-defaulted value +/// produces a genuine validation failure that isn't actually the merge builder's +/// fault. +fn merged_validation_request( + base_block_hash: B256, + parent_hash: B256, + slot: &SlotState, + merged_block_ix: usize, + receive_ns: u64, +) -> Option { + let parent_beacon_block_root = *slot.attrs.get(&parent_hash)?; + let proposer_fee_recipient = slot.fee_recipient?; + let registered_gas_limit = slot.registered_gas_limit?; + Some(MergedValidationRequest { + merged_block_ix, + base_block_hash, + slot: slot.bid_slot, + parent_beacon_block_root, + proposer_fee_recipient, + registered_gas_limit, + apply_blacklist: slot.apply_blacklist.unwrap_or(true), + inclusion_list: slot.inclusion_list.clone().unwrap_or_default(), + receive_ns, + }) +} + /// Whether a merged-block simulation failure is attributable to the merge builder, as /// opposed to a relay/simulator-side infra hiccup. Builds on `is_demotable()` (the same /// logic that decides whether a failed bid-submission simulation demotes its builder) but @@ -410,18 +441,6 @@ fn revoked_ids( prev.iter().filter(|(id, _)| !new.contains_key(*id)).map(|(&id, &hash)| (id, hash)).collect() } -/// Tx hashes the merge builder actually appended onto the base block. -/// `builder_inclusions` only ever records orders that were applied (see -/// `record_inclusion` on the builder side), so this never includes anything -/// from the base block's own original content — which is what the -/// unbundling check must ignore, since orders sharing a tx hash with base -/// content that was never touched by the merge builder aren't its concern. -fn appended_tx_hashes( - builder_inclusions: &HashMap, -) -> FxHashSet { - builder_inclusions.values().flat_map(|inclusion| inclusion.txs.iter().copied()).collect() -} - impl BlockMergingTile { #[allow(clippy::too_many_arguments)] pub fn new( @@ -564,6 +583,7 @@ impl BlockMergingTile { fn poll_sockets(&mut self) { let enabled = self.block_merging_enabled.load(Ordering::Relaxed); + let max_blobs_per_block = self.chain_info.max_blobs_per_block(); // Split borrows: the connector is exclusively borrowed for the whole // poll, all reactions are buffered. @@ -670,30 +690,36 @@ impl BlockMergingTile { tx_hash_cache, unbundled_scratch_bundled, unbundled_scratch_covered, + max_blobs_per_block, ) { let base_block_hash = response.base_block_hash; let parent_hash = response.execution_payload.parent_hash; let ix = merged_blocks.push(response); merged_ixs.push(ix); - let sim_req = MergedValidationRequest { - merged_block_ix: ix, + match merged_validation_request( base_block_hash, - slot: slot.bid_slot, - parent_beacon_block_root: slot - .attrs - .get(&parent_hash) - .copied() - .unwrap_or_default(), - proposer_fee_recipient: slot.fee_recipient.unwrap_or_default(), - registered_gas_limit: slot.registered_gas_limit.unwrap_or_default(), - apply_blacklist: slot.apply_blacklist.unwrap_or(true), - inclusion_list: slot.inclusion_list.clone().unwrap_or_default(), - receive_ns: Nanos::now().0, - }; - let sim_ix = - sim_requests.push(SimRequest::ValidateMerged(Box::new(sim_req))); - merge_sim_ixs.push(sim_ix); + parent_hash, + slot, + ix, + Nanos::now().0, + ) { + Some(sim_req) => { + let sim_ix = sim_requests + .push(SimRequest::ValidateMerged(Box::new(sim_req))); + merge_sim_ixs.push(sim_ix); + } + None => { + stats.merged_slot_data_missing += 1; + warn!( + ?token, + %parent_hash, + "beacon parent root, fee recipient, or gas limit not \ + yet known for this slot, skipping merged block \ + simulation" + ); + } + } } } MergingMsgId::RejectV1 => { @@ -832,6 +858,7 @@ impl BlockMergingTile { merged_stale = stats.merged_stale, merged_regressed = stats.merged_regressed, merged_blob_missing = stats.merged_blob_missing, + merged_slot_data_missing = stats.merged_slot_data_missing, merged_unbundled = stats.merged_unbundled, appendable_blocks = self.slot.appendable.len(), hydration_txs = self.hydration_cache.tx_count(), @@ -1164,6 +1191,8 @@ impl BlockMergingTile { #[cfg(test)] mod tests { + use std::collections::HashMap; + use alloy_primitives::{Address, Bloom, U256}; use alloy_rpc_types::{ beacon::{BlsPublicKey, requests::ExecutionRequestsV4}, @@ -1182,8 +1211,9 @@ mod tests { }, }; use helix_types::{ - BlockMergingData, Compression, ExecutionPayload, ExecutionRequests, ForkName, - MergedBlockTrace, SignedBidSubmission, SubmissionVersion, TestRandom, TestRandomSeed, + BlobsBundle, BlockMergingData, BuilderInclusionResult, Compression, ExecutionPayload, + ExecutionRequests, ForkName, MergedBlockTrace, SignedBidSubmission, SubmissionVersion, + TestRandom, TestRandomSeed, }; use rand::{SeedableRng, rngs::SmallRng}; @@ -1204,15 +1234,20 @@ mod tests { proposer_value: U256, blobs: Vec, ) -> BlockMergeResponse { + let mut blobs_bundle = BlobsBundle::default(); + for blob in blobs { + blobs_bundle.push_blob(blob.commitment, &blob.proofs, blob.blob, 9).unwrap(); + } BlockMergeResponse { base_block_hash: payload.parent_hash, execution_payload: payload, execution_requests: ExecutionRequests::default(), - appended_blobs: blobs, + blobs_bundle, proposer_value, base_builder_revenue: U256::ZERO, relay_revenue: U256::ZERO, builder_inclusions: Default::default(), + base_payment_tx_index: 0, trace: MergedBlockTrace::default(), } } @@ -1471,6 +1506,20 @@ mod tests { assert_eq!(tile.stats.activations_sent, 0); } + fn raw_plain_tx() -> Bytes { + use alloy_consensus::TxEip1559; + use alloy_primitives::Signature; + use alloy_rlp::Encodable; + + let envelope = alloy_consensus::TxEnvelope::new_unhashed( + TxEip1559::default().into(), + Signature::new(Default::default(), Default::default(), Default::default()), + ); + let mut raw = vec![]; + envelope.encode(&mut raw); + raw.into() + } + fn test_merged_block(bid_slot: u64, base_block_hash: B256) -> MergedBlockV1 { let execution_payload = ExecutionPayloadV3 { payload_inner: ExecutionPayloadV2 { @@ -1488,7 +1537,9 @@ mod tests { extra_data: Default::default(), base_fee_per_gas: U256::from(1), block_hash: base_block_hash, - transactions: vec![], + // The base block's own payment tx plus the trailing distribution tx -- + // every real merged block has at least these two (see `MergeSession::emit`). + transactions: vec![raw_plain_tx(), raw_plain_tx()], }, withdrawals: vec![], }, @@ -1533,6 +1584,7 @@ mod tests { &mut tx_hash_cache, &mut bundled_scratch, &mut covered_scratch, + 9, ); assert!(result.is_none()); @@ -1561,6 +1613,7 @@ mod tests { &mut tx_hash_cache, &mut bundled_scratch, &mut covered_scratch, + 9, ); assert!(result.is_some()); @@ -1640,4 +1693,78 @@ mod tests { let inner = MergedSimulationResultInner { merged_block_ix: ix, result: Ok(()) }; assert!(merge_sim_disable_check(&inner, &merged_blocks).is_none()); } + + /// RELAY-FR: when this slot has no cached beacon payload attributes for the merged + /// block's own parent hash, the request must be skipped rather than silently carry + /// a zero `parent_beacon_block_root`. EIP-4788 writes this value into the + /// beacon-roots contract during execution, so sending zero when the real root is + /// non-zero produces a genuine state-root mismatch downstream ("invalid merkle + /// root"). + #[test] + fn merged_validation_request_none_when_attrs_missing() { + let slot = SlotState { bid_slot: 5, ..Default::default() }; // attrs empty + let base_block_hash = B256::repeat_byte(1); + let parent_hash = B256::repeat_byte(2); + + let req = merged_validation_request(base_block_hash, parent_hash, &slot, 0, 0); + + assert!(req.is_none(), "must not silently send a zero beacon root when it's unknown"); + } + + /// RELAY-FR: `proposer_fee_recipient` must not silently default to the zero + /// address when this slot's registered fee recipient isn't known yet -- the + /// external validator checks the merge builder's payment tx against it, so a + /// zero-defaulted recipient produces "could not verify proposer payment" for a + /// perfectly valid block. + #[test] + fn merged_validation_request_none_when_fee_recipient_missing() { + let mut slot = SlotState { bid_slot: 5, ..Default::default() }; + let base_block_hash = B256::repeat_byte(1); + let parent_hash = B256::repeat_byte(2); + slot.attrs.insert(parent_hash, B256::repeat_byte(3)); + slot.registered_gas_limit = Some(30_000_000); + // fee_recipient left unset + + let req = merged_validation_request(base_block_hash, parent_hash, &slot, 0, 0); + + assert!(req.is_none(), "must not silently send a zero fee recipient when it's unknown"); + } + + /// Same silent-default hazard as the fee recipient: a gas limit of zero would + /// simulate a merged block against the wrong registered limit for this proposer. + #[test] + fn merged_validation_request_none_when_gas_limit_missing() { + let mut slot = SlotState { bid_slot: 5, ..Default::default() }; + let base_block_hash = B256::repeat_byte(1); + let parent_hash = B256::repeat_byte(2); + slot.attrs.insert(parent_hash, B256::repeat_byte(3)); + slot.fee_recipient = Some(alloy_primitives::Address::repeat_byte(4)); + // registered_gas_limit left unset + + let req = merged_validation_request(base_block_hash, parent_hash, &slot, 0, 0); + + assert!(req.is_none(), "must not silently send a zero gas limit when it's unknown"); + } + + #[test] + fn merged_validation_request_uses_known_slot_fields() { + let mut slot = SlotState { bid_slot: 5, ..Default::default() }; + let base_block_hash = B256::repeat_byte(1); + let parent_hash = B256::repeat_byte(2); + let expected_root = B256::repeat_byte(3); + let expected_fee_recipient = alloy_primitives::Address::repeat_byte(4); + slot.attrs.insert(parent_hash, expected_root); + slot.fee_recipient = Some(expected_fee_recipient); + slot.registered_gas_limit = Some(30_000_000); + + let req = merged_validation_request(base_block_hash, parent_hash, &slot, 7, 42) + .expect("known fields resolve"); + + assert_eq!(req.parent_beacon_block_root, expected_root); + assert_eq!(req.proposer_fee_recipient, expected_fee_recipient); + assert_eq!(req.registered_gas_limit, 30_000_000); + assert_eq!(req.base_block_hash, base_block_hash); + assert_eq!(req.merged_block_ix, 7); + assert_eq!(req.receive_ns, 42); + } } diff --git a/crates/relay/src/simulator/client.rs b/crates/relay/src/simulator/client.rs index 94dbd1026..d2275175e 100644 --- a/crates/relay/src/simulator/client.rs +++ b/crates/relay/src/simulator/client.rs @@ -1,8 +1,5 @@ use alloy_primitives::{Address, U256}; -use helix_common::{ - SimulatorConfig, - simulator::{BlockSimError, JsonValidationRequest, SszValidationRequest}, -}; +use helix_common::{SimulatorConfig, simulator::BlockSimError}; use helix_types::ForkName; use reqwest::{ RequestBuilder, @@ -36,6 +33,9 @@ pub struct SimulatorClient { pub config: SimulatorConfig, pub sim_method_v4: String, pub sim_method_v5: String, + /// Relay-internal merged-block validation method; never reachable by an externally + /// submitted builder block. See `helix_common::simulator::MergedJsonValidationRequest`. + pub sim_method_merged_v5: String, /// If set, use SSZ binary endpoint instead of JSON-RPC for simulations pub ssz_url: Option, } @@ -44,8 +44,10 @@ impl SimulatorClient { pub fn new(client: reqwest::Client, config: SimulatorConfig) -> Self { let sim_method_v4 = format!("{}_validateBuilderSubmissionV4", config.namespace); let sim_method_v5 = format!("{}_validateBuilderSubmissionV5", config.namespace); + let sim_method_merged_v5 = + format!("{}_validateMergedBuilderSubmissionV5", config.namespace); let ssz_url = config.ssz_url.clone(); - Self { client, config, sim_method_v4, sim_method_v5, ssz_url } + Self { client, config, sim_method_v4, sim_method_v5, sim_method_merged_v5, ssz_url } } pub fn endpoint(&self) -> &str { @@ -56,6 +58,11 @@ impl SimulatorClient { self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate"))) } + /// Relay-internal merged-block SSZ route; see `sim_method_merged_v5`. + pub fn ssz_merged_request_builder(&self) -> Option { + self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate_merged"))) + } + /// Returns `None` for a fork this client has no validation RPC method for yet, rather than /// silently mis-routing it to a method shaped for a different fork. pub fn sim_request_builder(&self, fork: ForkName) -> Option<(RequestBuilder, &str)> { @@ -69,8 +76,14 @@ impl SimulatorClient { Some((self.client.post(&self.config.url), method)) } + /// Merged-block counterpart of `sim_request_builder`: only V5 applies (the network is long + /// past the forks V4 covers), so this doesn't need a fork parameter. + pub fn merged_sim_request_builder(&self) -> (RequestBuilder, &str) { + (self.client.post(&self.config.url), &self.sim_method_merged_v5) + } + pub async fn do_json_sim_request( - request: &JsonValidationRequest, + request: &impl serde::Serialize, is_top_bid: bool, sim_method: &str, to_send: RequestBuilder, @@ -107,7 +120,7 @@ impl SimulatorClient { } pub async fn do_sim_request( - ssz_req: &SszValidationRequest, + ssz_req: &impl Encode, is_top_bid: bool, to_send: RequestBuilder, ) -> Result<(), BlockSimError> { diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index a1c0a2813..bb80f5420 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -6,7 +6,7 @@ use helix_common::{ simulator::BlockSimError, }; use helix_types::{ - BlobWithMetadata, BuilderInclusionResult, ExecutionPayload, ExecutionRequests, MergedBlockTrace, + BlobsBundle, BuilderInclusionResult, ExecutionPayload, ExecutionRequests, MergedBlockTrace, }; use crate::{ @@ -57,14 +57,22 @@ pub struct BlockMergeResponse { pub base_block_hash: B256, pub execution_payload: ExecutionPayload, pub execution_requests: ExecutionRequests, - /// Blob sidecars for appended blob transactions, re-attached from `BlockMergingTile`'s - /// own cache of blobs seen in submissions this slot. - pub appended_blobs: Vec, + /// The merged block's full blob set (base block's own blob txs plus any newly + /// appended ones), re-attached from `BlockMergingTile`'s own cache of blobs seen in + /// submissions this slot. + pub blobs_bundle: BlobsBundle, /// Total value for the proposer pub proposer_value: U256, pub base_builder_revenue: U256, pub relay_revenue: U256, pub builder_inclusions: HashMap, + /// Index, within `execution_payload.transactions`, of the base block's own proposer + /// payment tx. Base txs keep their original positions in a merged block -- only new + /// content is ever appended after them -- so this is always + /// `execution_payload.transactions.len() - - 2` (the `- 2` for the + /// appended order txs' own count and the trailing distribution tx). Lets a merged-block + /// validator recognise the base block's payment directly instead of scanning every tx. + pub base_payment_tx_index: usize, pub trace: MergedBlockTrace, } diff --git a/crates/relay/src/simulator/tile.rs b/crates/relay/src/simulator/tile.rs index 2ae6bf122..0b6d3844a 100644 --- a/crates/relay/src/simulator/tile.rs +++ b/crates/relay/src/simulator/tile.rs @@ -23,14 +23,17 @@ use helix_common::{ is_local_dev, metrics::SimulatorMetrics, record_submission_step, - simulator::{BlockSimError, JsonValidationRequest, SszValidationRequest}, + simulator::{ + BlockSimError, JsonValidationRequest, MergedJsonValidationRequest, + SszMergedValidationRequest, SszValidationRequest, + }, spawn_tracked, utils::avg_duration, validator_preferences::{Filtering, ValidatorPreferences}, }; use helix_types::{ - BidTrace, BlobWithMetadata, BlobsBundle, BlsPublicKeyBytes, BlsSignatureBytes, KzgCommitments, - SignedBidSubmission, SimHydrationCache, Submission, + BidTrace, BlsPublicKeyBytes, BlsSignatureBytes, SignedBidSubmission, SimHydrationCache, + Submission, }; use ssz::Encode as _; use tracing::{debug, error, info, warn}; @@ -520,32 +523,14 @@ impl SimulatorTile { } }; + let base_payment_tx_index = response.base_payment_tx_index as u64; + let sim = &mut self.simulators[id]; let dispatch = if let Some(url) = &sim.client.ssz_url { - SimDispatch::Ssz { - to_send: sim.client.client.post(format!("{url}/validate")), - ssz_url: url.clone(), - http: sim.client.client.clone(), - } + MergedSimDispatch::Ssz(sim.client.client.post(format!("{url}/validate_merged"))) } else { - let fork = submission.fork_name(); - let Some((builder, method)) = sim.client.sim_request_builder(fork) else { - warn!(%fork, "no validation RPC method for fork, dropping merged block"); - sim.pending += 1; - let inner = MergedSimulationResultInner { - merged_block_ix: req.merged_block_ix, - result: Err(BlockSimError::UnsupportedFork(fork)), - }; - let result_ix = self.sim_results.push(SimResult::ValidateMerged((id, Some(inner)))); - let _ = self.task_tx.try_send(SimTileInternalEvent::TaskDone { - id, - paused_until: None, - result_ix, - elapsed: None, - }); - return; - }; - SimDispatch::Json { to_send: builder, method: method.to_owned() } + let (builder, method) = sim.client.merged_sim_request_builder(); + MergedSimDispatch::Json { to_send: builder, method: method.to_owned() } }; sim.pending += 1; @@ -565,26 +550,30 @@ impl SimulatorTile { SimulatorMetrics::sim_count(false); let res = match dispatch { - SimDispatch::Ssz { to_send, .. } => { - let request = ssz_request( + MergedSimDispatch::Ssz(to_send) => { + let request = ssz_merged_request( apply_blacklist, registered_gas_limit, parent_beacon_block_root, inclusion_list, &submission, + base_payment_tx_index, ); SimulatorClient::do_sim_request(&request, false, to_send).await } - SimDispatch::Json { to_send, method } => { + MergedSimDispatch::Json { to_send, method } => { let filtering = if apply_blacklist { Filtering::Regional } else { Filtering::Global }; - let json_req = JsonValidationRequest::new( - registered_gas_limit, - &submission, - ValidatorPreferences { filtering, ..Default::default() }, - Some(parent_beacon_block_root), - Some(inclusion_list), - ); + let json_req = MergedJsonValidationRequest { + base: JsonValidationRequest::new( + registered_gas_limit, + &submission, + ValidatorPreferences { filtering, ..Default::default() }, + Some(parent_beacon_block_root), + Some(inclusion_list), + ), + base_payment_tx_index, + }; SimulatorClient::do_json_sim_request(&json_req, false, &method, to_send).await } }; @@ -781,6 +770,13 @@ enum SimDispatch { Json { to_send: reqwest::RequestBuilder, method: String }, } +/// Merged-block counterpart of [`SimDispatch`]: no hydration-miss retry (merged blocks are +/// always full, never dehydrated), so it doesn't need `SimDispatch::Ssz`'s extra fields. +enum MergedSimDispatch { + Ssz(reqwest::RequestBuilder), + Json { to_send: reqwest::RequestBuilder, method: String }, +} + /// Internal-only events: async task → sim tile (not tile-to-tile). pub(super) enum SimTileInternalEvent { /// `elapsed` is `None` for infra errors where no request was actually sent @@ -942,26 +938,12 @@ fn merged_block_to_submission( Ok(SignedBidSubmission { message, execution_payload: Arc::new(payload.clone()), - blobs_bundle: Arc::new(blobs_bundle_from_appended(&response.appended_blobs)?), + blobs_bundle: Arc::new(response.blobs_bundle.clone()), execution_requests: Arc::new(response.execution_requests.clone()), signature: BlsSignatureBytes::default(), }) } -fn blobs_bundle_from_appended(appended: &[BlobWithMetadata]) -> Result { - let mut commitments = Vec::with_capacity(appended.len()); - let mut proofs = Vec::new(); - let mut blobs = Vec::with_capacity(appended.len()); - for b in appended { - commitments.push(b.commitment); - proofs.extend(b.proofs.iter().copied()); - blobs.push(b.blob.clone()); - } - let commitments = KzgCommitments::new(commitments) - .map_err(|_| BlockSimError::BlockValidationFailed("too many appended blobs".to_owned()))?; - Ok(BlobsBundle { commitments, proofs, blobs }) -} - fn create_ssz_request( req: &ValidationRequest, submission: &SignedBidSubmission, @@ -992,10 +974,33 @@ fn ssz_request( } } +#[allow(clippy::too_many_arguments)] +fn ssz_merged_request( + apply_blacklist: bool, + registered_gas_limit: u64, + parent_beacon_block_root: B256, + inclusion_list: InclusionListWithMetadata, + submission: &SignedBidSubmission, + base_payment_tx_index: u64, +) -> SszMergedValidationRequest { + SszMergedValidationRequest { + apply_blacklist, + registered_gas_limit, + parent_beacon_block_root, + inclusion_list, + decoder_params: None, + signed_bid_submission: submission.as_ssz_bytes(), + base_payment_tx_index, + } +} + #[cfg(test)] mod tests { use alloy_primitives::{Address, U256}; - use helix_types::{ExecutionPayload, ExecutionRequests, MergedBlockTrace, TestRandom}; + use helix_types::{ + BlobWithMetadata, BlobsBundle, ExecutionPayload, ExecutionRequests, MergedBlockTrace, + TestRandom, + }; use rand::{SeedableRng, rngs::SmallRng}; use super::*; @@ -1005,15 +1010,20 @@ mod tests { proposer_value: U256, blobs: Vec, ) -> BlockMergeResponse { + let mut blobs_bundle = BlobsBundle::default(); + for blob in blobs { + blobs_bundle.push_blob(blob.commitment, &blob.proofs, blob.blob, 9).unwrap(); + } BlockMergeResponse { base_block_hash: payload.parent_hash, execution_payload: payload, execution_requests: ExecutionRequests::default(), - appended_blobs: blobs, + blobs_bundle, proposer_value, base_builder_revenue: U256::ZERO, relay_revenue: U256::ZERO, builder_inclusions: Default::default(), + base_payment_tx_index: 0, trace: MergedBlockTrace::default(), } } diff --git a/crates/simulator/src/ssz_server.rs b/crates/simulator/src/ssz_server.rs index a99aaa947..77d3ef4c9 100644 --- a/crates/simulator/src/ssz_server.rs +++ b/crates/simulator/src/ssz_server.rs @@ -8,7 +8,7 @@ use axum::{ }; use helix_common::{ decoder::{DecoderError, SubmissionDecoder}, - simulator::SszValidationRequest, + simulator::{SszMergedValidationRequest, SszValidationRequest}, }; use helix_types::Submission; use ssz::Decode; @@ -16,11 +16,15 @@ use tokio::net::TcpListener; use tracing::error; use crate::validation::{ - BlockSubmissionValidationApiServer, ExtendedValidationRequestV5, ValidationApi, + BlockSubmissionValidationApiServer, ExtendedMergedValidationRequestV5, + ExtendedValidationRequestV5, ValidationApi, }; pub async fn run(api: ValidationApi, port: u16) { - let router = Router::new().route("/validate", post(handler)).with_state(api); + let router = Router::new() + .route("/validate", post(handler)) + .route("/validate_merged", post(merged_handler)) + .with_state(api); let listener = match TcpListener::bind(("0.0.0.0", port)).await { Ok(l) => l, Err(e) => { @@ -33,29 +37,42 @@ pub async fn run(api: ValidationApi, port: u16) { } } -async fn handler( - State(api): State, - body: axum::body::Bytes, -) -> Result { - let req = SszValidationRequest::from_ssz_bytes(&body)?; - - let signed_bid_submission = match req.decoder_params { +/// Decodes the submission carried by an SSZ validation request body, in either shape: full +/// bytes, or a dehydrated reference (which this server can't yet rehydrate -- see the 424 +/// below). +fn decode_submission( + decoder_params: Option, + signed_bid_submission: &[u8], +) -> Result, DecoderError> { + Ok(match decoder_params { Some(decode_params) => { let mut buf = vec![]; let mut decoder = SubmissionDecoder::new(&decode_params); - let (submission, _, _) = - decoder.decode(req.signed_bid_submission.as_slice(), &mut buf)?; + let (submission, _, _) = decoder.decode(signed_bid_submission, &mut buf)?; match submission { - Submission::Full(s) => s.into(), + Submission::Full(s) => Ok(s.into()), Submission::Dehydrated(_) => { // Simulator-side hydration cache not yet implemented. // Return 424 so the relay retries with full SSZ bytes. - return Ok(StatusCode::FAILED_DEPENDENCY.into_response()); + Err(StatusCode::FAILED_DEPENDENCY.into_response()) } } } - None => SignedBidSubmissionV5::from_ssz_bytes(req.signed_bid_submission.as_slice())?, - }; + None => Ok(SignedBidSubmissionV5::from_ssz_bytes(signed_bid_submission)?), + }) +} + +async fn handler( + State(api): State, + body: axum::body::Bytes, +) -> Result { + let req = SszValidationRequest::from_ssz_bytes(&body)?; + + let signed_bid_submission = + match decode_submission(req.decoder_params, &req.signed_bid_submission)? { + Ok(submission) => submission, + Err(early_response) => return Ok(early_response), + }; let ext = ExtendedValidationRequestV5 { base: BuilderBlockValidationRequestV5 { @@ -72,3 +89,36 @@ async fn handler( Err(e) => (StatusCode::BAD_REQUEST, e.message().to_string()).into_response(), }) } + +/// Relay-internal merged-block route; never reachable by an externally submitted builder +/// block. See `ExtendedMergedValidationRequestV5`. +async fn merged_handler( + State(api): State, + body: axum::body::Bytes, +) -> Result { + let req = SszMergedValidationRequest::from_ssz_bytes(&body)?; + + let signed_bid_submission = + match decode_submission(req.decoder_params, &req.signed_bid_submission)? { + Ok(submission) => submission, + Err(early_response) => return Ok(early_response), + }; + + let ext = ExtendedMergedValidationRequestV5 { + base: ExtendedValidationRequestV5 { + base: BuilderBlockValidationRequestV5 { + request: signed_bid_submission, + registered_gas_limit: req.registered_gas_limit, + parent_beacon_block_root: req.parent_beacon_block_root, + }, + inclusion_list: Some(req.inclusion_list), + apply_blacklist: req.apply_blacklist, + }, + base_payment_tx_index: req.base_payment_tx_index, + }; + + Ok(match api.validate_merged_builder_submission_v5(ext).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.message().to_string()).into_response(), + }) +} diff --git a/crates/simulator/src/validation/mod.rs b/crates/simulator/src/validation/mod.rs index 4eacc0640..1c8067bf4 100644 --- a/crates/simulator/src/validation/mod.rs +++ b/crates/simulator/src/validation/mod.rs @@ -16,6 +16,7 @@ use alloy_rpc_types::{ ExecutionPayloadSidecar, PraguePayloadFields, }, }; +use alloy_sol_types::{SolCall, sol}; use async_trait::async_trait; use dashmap::DashSet; use helix_common::{ @@ -182,7 +183,10 @@ impl ValidationApi { } impl ValidationApi { - /// Validates the given block and a [`BidTrace`] against it. + /// Validates the given block and a [`BidTrace`] against it. `base_payment_tx_index` is + /// `Some` only for the merged-block-only validation endpoint, and switches the payment + /// check from `ensure_payment` (regular submissions, single trailing tx) to + /// `ensure_merged_payment` (a merged block's base payment tx plus its distribution tx). pub async fn validate_message_against_block( &self, block: RecoveredBlock, @@ -190,6 +194,7 @@ impl ValidationApi { _registered_gas_limit: u64, apply_blacklist: bool, inclusion_list: Option, + base_payment_tx_index: Option, ) -> Result<(), ValidationApiError> { self.validate_message_against_header(block.sealed_header(), &message)?; @@ -234,7 +239,10 @@ impl ValidationApi { self.consensus.validate_block_post_execution(&block, &output, None, None)?; - self.ensure_payment(&block, &output, &message)?; + match base_payment_tx_index { + Some(ix) => self.ensure_merged_payment(&block, &output, &message, ix)?, + None => self.ensure_payment(&block, &output, &message)?, + } let state_root = state_provider.state_root(state_provider.hashed_post_state(&output.state))?; @@ -472,9 +480,13 @@ impl ValidationApi { /// Ensures that the proposer has received [`BidTrace::value`] for this block. /// - /// Firstly attempts to verify the payment by checking the state changes, otherwise falls back - /// to checking the latest block transaction, which may pay the recipient directly or through - /// the [`PAYMENT_FORWARDER`]. + /// Firstly attempts to verify the payment by checking the state changes, otherwise falls + /// back to requiring the last transaction in the block to pay it directly. Shared by every + /// externally-submitted builder block (v4/v5) -- regular submissions always pay in a single + /// trailing transaction, so this deliberately doesn't scan the rest of the block. A merged + /// block's payment can legitimately span two transactions (see + /// crates/builder/src/engine/session.rs); that's handled by `ensure_merged_payment`, on the + /// merged-block-only validation endpoint, not here. fn ensure_payment( &self, block: &SealedBlock, @@ -553,6 +565,122 @@ impl ValidationApi { Ok(()) } + /// Merged-block counterpart of `ensure_payment`, for the merged-block-only validation + /// endpoint. A merged block's payment is legitimately split across exactly two + /// transactions: the base block's own payment tx (kept at `base_payment_tx_index`, paying + /// `base_value`) and a newly appended distribution tx at the very end of the block (paying + /// the incremental `proposer_added_value`) -- see crates/builder/src/engine/session.rs. + /// Checking only these two positions (as opposed to `ensure_payment`'s regular-submission + /// single-last-tx check, or scanning every tx in the block) is safe here because + /// `base_payment_tx_index` is derived and trusted upstream, at the relay + /// (`BlockMergeResponse::base_payment_tx_index`) -- if it's wrong, this fails closed (the + /// payment isn't found), it never lets an underpaid block through. + fn ensure_merged_payment( + &self, + block: &SealedBlock, + output: &BlockExecutionOutput, + message: &BidTrace, + base_payment_tx_index: usize, + ) -> Result<(), ValidationApiError> { + let (mut balance_before, balance_after) = if let Some(acc) = + output.state.state.get(&message.proposer_fee_recipient) + { + let balance_before = acc.original_info.as_ref().map(|i| i.balance).unwrap_or_default(); + let balance_after = acc.info.as_ref().map(|i| i.balance).unwrap_or_default(); + + (balance_before, balance_after) + } else { + (U256::ZERO, U256::ZERO) + }; + + if let Some(withdrawals) = block.body().withdrawals() { + for withdrawal in withdrawals { + if withdrawal.address == message.proposer_fee_recipient { + balance_before += withdrawal.amount_wei(); + } + } + } + + if balance_after >= balance_before + message.value { + return Ok(()); + } + + let Some(last_ix) = block.body().transactions().count().checked_sub(1) else { + return Err(ValidationApiError::ProposerPayment); + }; + + let mut total = + self.recognized_payment_at(block, output, message.proposer_fee_recipient, last_ix); + if base_payment_tx_index != last_ix { + total += self.recognized_payment_at( + block, + output, + message.proposer_fee_recipient, + base_payment_tx_index, + ); + } + + if total >= message.value { + return Ok(()); + } + + Err(ValidationApiError::ProposerPayment) + } + + /// The recognized payment to `recipient` from the transaction at `ix`: a direct transfer, + /// one routed through the [`PAYMENT_FORWARDER`], or a batched entry in a Gnosis Safe + /// `execTransaction` -> `multiSend` delegatecall (the merge-builder payment engine's + /// mechanism, see crates/builder/src/engine/payment.rs). Zero for an out-of-range index, an + /// unsuccessful receipt, or a transaction that fails the same anti-MEV-extraction checks + /// (chain id, zero priority fee) the payment-recognition checks above apply -- never an + /// error, since a single unrecognized/invalid position shouldn't itself fail validation. + fn recognized_payment_at( + &self, + block: &SealedBlock, + output: &BlockExecutionOutput, + recipient: Address, + ix: usize, + ) -> U256 { + let Some((receipt, tx)) = output.receipts.get(ix).zip(block.body().transactions().nth(ix)) + else { + return U256::ZERO; + }; + if !receipt.status() { + return U256::ZERO; + } + + let paid_directly = tx.to() == Some(recipient) && tx.input().is_empty(); + let paid_via_forwarder = tx.to() == Some(PAYMENT_FORWARDER) && + payment_forwarder_recipient(tx.input()) == Some(recipient) && + self.provider + .latest() + .and_then(|state| state.basic_account(&PAYMENT_FORWARDER)) + .is_ok_and(|account| { + account.is_some_and(|account| { + account.bytecode_hash == Some(PAYMENT_FORWARDER_CODE_HASH) + }) + }); + let contributed = if paid_directly || paid_via_forwarder { + tx.value() + } else { + multisend_paid_amount(tx.input(), recipient) + }; + if contributed.is_zero() { + return U256::ZERO; + } + + if tx.chain_id() != Some(self.evm_config.chain_spec().chain().id()) { + return U256::ZERO; + } + if let Some(block_base_fee) = block.header().base_fee_per_gas() && + tx.effective_tip_per_gas(block_base_fee).unwrap_or_default() != 0 + { + return U256::ZERO; + } + + contributed + } + /// Validates the given [`BlobsBundleV1`] and returns versioned hashes for blobs. pub fn validate_blobs_bundle( &self, @@ -624,6 +752,7 @@ impl ValidationApi { request.base.registered_gas_limit, request.apply_blacklist, request.inclusion_list, + None, ) .await } @@ -632,6 +761,28 @@ impl ValidationApi { async fn _validate_builder_submission_v5( &self, request: ExtendedValidationRequestV5, + ) -> Result<(), ValidationApiError> { + self._validate_builder_submission_v5_inner(request, None).await + } + + /// Core logic for validating a merged block through the merged-block-only endpoint: the + /// same v5 pipeline, but with `base_payment_tx_index` passed through to + /// `ensure_merged_payment` instead of the regular submission path's `ensure_payment`. + async fn _validate_merged_builder_submission_v5( + &self, + request: ExtendedMergedValidationRequestV5, + ) -> Result<(), ValidationApiError> { + self._validate_builder_submission_v5_inner( + request.base, + Some(request.base_payment_tx_index as usize), + ) + .await + } + + async fn _validate_builder_submission_v5_inner( + &self, + request: ExtendedValidationRequestV5, + base_payment_tx_index: Option, ) -> Result<(), ValidationApiError> { let block = self.payload_validator.ensure_well_formed_payload(ExecutionData { payload: ExecutionPayload::V3(request.base.request.execution_payload), @@ -666,6 +817,7 @@ impl ValidationApi { request.base.registered_gas_limit, request.apply_blacklist, request.inclusion_list, + base_payment_tx_index, ) .await } @@ -697,6 +849,190 @@ impl ValidationApi { } } +sol! { + /// Gnosis Safe entry point the merge-builder payment engine calls + /// (crates/builder/src/engine/payment.rs) to delegatecall `multiSend`. + function execTransaction( + address to, + uint256 value, + bytes data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes signatures + ) external returns (bool); + + /// Gnosis `MultiSendCallOnly` entry point, delegatecalled by `execTransaction`. + function multiSend(bytes transactions) external payable; +} + +const SAFE_DELEGATECALL: u8 = 1; + +/// Sums the value a Safe `execTransaction` -> `multiSend` delegatecall's batched +/// calls pay `recipient` (zero on any decode failure or shape mismatch: not a +/// delegatecall, not a multiSend payload, no matching entry). Best-effort +/// recognition, not a signature/authenticity check: the caller must already know +/// the transaction actually succeeded on-chain. +fn multisend_paid_amount(input: &[u8], recipient: Address) -> U256 { + let Ok(exec) = execTransactionCall::abi_decode(input) else { return U256::ZERO }; + if exec.operation != SAFE_DELEGATECALL { + return U256::ZERO; + } + let Ok(multisend) = multiSendCall::abi_decode(&exec.data) else { return U256::ZERO }; + multisend_entries(&multisend.transactions) + .filter(|(to, _)| *to == recipient) + .fold(U256::ZERO, |acc, (_, value)| acc + value) +} + +/// Iterates a Safe `multiSend` packed payload: per entry, `[1B operation][20B +/// to][32B value][32B dataLength][dataLength B data]`. Stops (yields no more +/// entries) on any length that doesn't fit the remaining bytes, rather than +/// panicking on malformed/adversarial input. +fn multisend_entries(transactions: &[u8]) -> impl Iterator + '_ { + const ENTRY_HEADER_LEN: usize = 1 + 20 + 32 + 32; + let mut offset = 0usize; + std::iter::from_fn(move || { + if transactions.len().saturating_sub(offset) < ENTRY_HEADER_LEN { + return None; + } + offset += 1; // operation: irrelevant for a payment check + let to = Address::from_slice(&transactions[offset..offset + 20]); + offset += 20; + let value = U256::from_be_slice(&transactions[offset..offset + 32]); + offset += 32; + let data_len: usize = + U256::from_be_slice(&transactions[offset..offset + 32]).try_into().ok()?; + offset += 32; + if transactions.len().saturating_sub(offset) < data_len { + return None; + } + offset += data_len; + Some((to, value)) + }) +} + +#[cfg(test)] +mod multisend_payment_tests { + use alloy_primitives::{Bytes, address}; + use alloy_sol_types::SolCall; + + use super::*; + + /// Mirrors `crates/builder/src/engine/payment.rs::build_multisend_payload`. + fn multisend_payload(entries: &[(Address, U256)]) -> Vec { + let mut payload = Vec::new(); + for (to, value) in entries { + payload.push(0u8); // operation = CALL + payload.extend_from_slice(to.as_slice()); + payload.extend_from_slice(&value.to_be_bytes::<32>()); + payload.extend_from_slice(&U256::ZERO.to_be_bytes::<32>()); // data length + } + payload + } + + /// Mirrors `crates/builder/src/engine/payment.rs::encode_multisend_calldata`'s + /// `execTransaction` wrapping, minus the real Safe signature (irrelevant here: + /// `multisend_paid_amount` only decodes calldata shape, it doesn't verify + /// signatures). + fn exec_transaction_calldata( + multisend_contract: Address, + entries: &[(Address, U256)], + ) -> Vec { + let multisend_calldata = + multiSendCall { transactions: multisend_payload(entries).into() }.abi_encode(); + execTransactionCall { + to: multisend_contract, + value: U256::ZERO, + data: multisend_calldata.into(), + operation: SAFE_DELEGATECALL, + safeTxGas: U256::ZERO, + baseGas: U256::ZERO, + gasPrice: U256::ZERO, + gasToken: Address::ZERO, + refundReceiver: Address::ZERO, + signatures: Bytes::new(), + } + .abi_encode() + } + + #[test] + fn multisend_paid_amount_finds_matching_entry_among_several() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let other = address!("0x3333333333333333333333333333333333333333"); + let calldata = exec_transaction_calldata(multisend_contract, &[ + (other, U256::from(100)), + (proposer, U256::from(42)), + ]); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::from(42)); + } + + #[test] + fn multisend_paid_amount_sums_multiple_entries_to_the_same_recipient() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let calldata = exec_transaction_calldata(multisend_contract, &[ + (proposer, U256::from(42)), + (proposer, U256::from(8)), + ]); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::from(50)); + } + + #[test] + fn multisend_paid_amount_zero_when_recipient_absent() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let other = address!("0x3333333333333333333333333333333333333333"); + let calldata = exec_transaction_calldata(multisend_contract, &[(other, U256::from(42))]); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::ZERO); + } + + #[test] + fn multisend_paid_amount_zero_for_non_delegatecall_operation() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let multisend_calldata = + multiSendCall { transactions: multisend_payload(&[(proposer, U256::from(42))]).into() } + .abi_encode(); + let calldata = execTransactionCall { + to: multisend_contract, + value: U256::ZERO, + data: multisend_calldata.into(), + operation: 0, // CALL, not DELEGATECALL + safeTxGas: U256::ZERO, + baseGas: U256::ZERO, + gasPrice: U256::ZERO, + gasToken: Address::ZERO, + refundReceiver: Address::ZERO, + signatures: Bytes::new(), + } + .abi_encode(); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::ZERO); + } + + #[test] + fn multisend_paid_amount_zero_for_unrelated_calldata() { + let proposer = address!("0x2222222222222222222222222222222222222222"); + assert_eq!(multisend_paid_amount(&[0xde, 0xad, 0xbe, 0xef], proposer), U256::ZERO); + } + + #[test] + fn multisend_entries_stops_on_truncated_payload() { + let to = address!("0x2222222222222222222222222222222222222222"); + let mut payload = multisend_payload(&[(to, U256::from(42))]); + payload.truncate(payload.len() - 1); // cut into the last entry's data-length field + + assert_eq!(multisend_entries(&payload).count(), 0); + } +} + #[async_trait] impl BlockSubmissionValidationApiServer for ValidationApi { async fn validate_builder_submission_v1( @@ -759,6 +1095,26 @@ impl BlockSubmissionValidationApiServer for ValidationApi { rx.await.map_err(|_| internal_rpc_err("Internal blocking task error"))? } + + /// Validates a merged block. Relay-internal only -- never reachable by an externally + /// submitted builder block -- so it can recognise the base block's payment tx by index + /// instead of requiring it to be the last transaction. + async fn validate_merged_builder_submission_v5( + &self, + request: ExtendedMergedValidationRequestV5, + ) -> RpcResult<()> { + let this = self.clone(); + let (tx, rx) = oneshot::channel(); + + self.task_spawner.spawn_blocking_task(Box::pin(async move { + let result = Self::_validate_merged_builder_submission_v5(&this, request) + .await + .map_err(ErrorObject::from); + let _ = tx.send(result); + })); + + rx.await.map_err(|_| internal_rpc_err("Internal blocking task error"))? + } } /// Result of [`ValidationApi::execute_block`]. @@ -877,6 +1233,19 @@ pub struct ExtendedValidationRequestV5 { pub apply_blacklist: bool, } +/// Merged-block counterpart of [`ExtendedValidationRequestV5`], carrying the extra +/// `base_payment_tx_index` the merged-block-only validation endpoint uses to recognise the +/// base block's own payment tx directly -- see +/// `helix_common::simulator::SszMergedValidationRequest`. +#[serde_as] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtendedMergedValidationRequestV5 { + #[serde(flatten)] + pub base: ExtendedValidationRequestV5, + + pub base_payment_tx_index: u64, +} + /// Block validation rpc interface. #[rpc(server, namespace = "relay")] pub trait BlockSubmissionValidationApi { @@ -914,6 +1283,13 @@ pub trait BlockSubmissionValidationApi { &self, request: ExtendedValidationRequestV5, ) -> jsonrpsee::core::RpcResult<()>; + + /// A request to validate a merged block. Relay-internal only. + #[method(name = "validateMergedBuilderSubmissionV5")] + async fn validate_merged_builder_submission_v5( + &self, + request: ExtendedMergedValidationRequestV5, + ) -> jsonrpsee::core::RpcResult<()>; } #[cfg(test)] From c972bddb0fb18a6f84686619327cb1a1671e54d5 Mon Sep 17 00:00:00 2001 From: christn Date: Sat, 29 Aug 2026 15:55:30 +0200 Subject: [PATCH 14/29] combined adjustable + mergeable submissions (#482) --- crates/common/src/decoder.rs | 169 +++++++++++++++++++++++++++-- crates/types/src/bid_submission.rs | 76 ++++++++++++- crates/types/src/hydration.rs | 100 ++++++++++++++++- 3 files changed, 331 insertions(+), 14 deletions(-) diff --git a/crates/common/src/decoder.rs b/crates/common/src/decoder.rs index 67582eec3..d545b04c9 100644 --- a/crates/common/src/decoder.rs +++ b/crates/common/src/decoder.rs @@ -8,9 +8,12 @@ use flate2::read::GzDecoder; use flux_profiler::timed; use helix_types::{ BidAdjustmentData, BlockMergingData, Compression, DehydratedBidSubmission, - DehydratedBidSubmissionFuluWithAdjustments, DehydratedBidSubmissionFuluWithMergingData, - ForkName, ForkVersionDecode, MergeType, SignedBidSubmission, - SignedBidSubmissionWithAdjustments, SignedBidSubmissionWithMergingData, Submission, + DehydratedBidSubmissionFuluWithAdjustments, + DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, + DehydratedBidSubmissionFuluWithMergingData, ForkName, ForkVersionDecode, MergeType, + SignedBidSubmission, SignedBidSubmissionWithAdjustments, + SignedBidSubmissionWithAdjustmentsAndMergingData, SignedBidSubmissionWithMergingData, + Submission, }; use http::{ HeaderMap, HeaderValue, StatusCode, @@ -280,6 +283,18 @@ impl SubmissionDecoder { ) -> Result<(Submission, Option, Option), DecoderError> { if self.merge_type == MergeType::Mergeable { + if self.with_adjustments { + let sub: DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData = + self.decode_by_fork(body, self.fork_name)?; + let (submission, adjustment_data, merging_data) = sub.split(); + + return Ok(( + Submission::Dehydrated(submission), + Some(merging_data), + Some(adjustment_data), + )); + } + let sub_with_merging: DehydratedBidSubmissionFuluWithMergingData = self.decode_by_fork(body, self.fork_name)?; let (submission, merging_data) = sub_with_merging.split(); @@ -326,22 +341,32 @@ impl SubmissionDecoder { body: &[u8], ) -> Result<(Submission, Option, Option), DecoderError> { - let sub_with_merging: SignedBidSubmissionWithMergingData = self._decode(body)?; + let (submission, merging_data, bid_adjustment) = if self.with_adjustments { + let sub: SignedBidSubmissionWithAdjustmentsAndMergingData = self._decode(body)?; + let (submission, adjustment_data, merging_data) = sub.split(); + + (submission, merging_data, Some(adjustment_data)) + } else { + let sub_with_merging: SignedBidSubmissionWithMergingData = self._decode(body)?; + + (sub_with_merging.submission, sub_with_merging.merging_data, None) + }; + let merging_data = match self.merge_type { - MergeType::Mergeable => Some(sub_with_merging.merging_data), + MergeType::Mergeable => Some(merging_data), //Handle append-only by creating empty mergeable orders //this allows builder to switch between append-only and mergeable without changing // submission alternatively we could reject or ignore append-only here if the // submission is mergeable? MergeType::AppendOnly => Some(BlockMergingData { - allow_appending: sub_with_merging.merging_data.allow_appending, - builder_address: sub_with_merging.merging_data.builder_address, + allow_appending: merging_data.allow_appending, + builder_address: merging_data.builder_address, merge_orders: vec![], }), - MergeType::None => Some(sub_with_merging.merging_data), + MergeType::None => Some(merging_data), MergeType::Pause => None, }; - Ok((Submission::Full(sub_with_merging.submission), merging_data, None)) + Ok((Submission::Full(submission), merging_data, bid_adjustment)) } #[timed] @@ -486,7 +511,9 @@ fn gzip_size_hint(buf: &[u8]) -> Option { mod tests { use helix_types::{ BidAdjData, BidAdjustmentDataV1, BlobsBundle, BundleOrder, DehydratedBidSubmission, - DehydratedBidSubmissionFuluWithMergingData, MergeType, Order, TestRandom, + DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, + DehydratedBidSubmissionFuluWithMergingData, MergeType, Order, + SignedBidSubmissionWithAdjustmentsAndMergingData, TestRandom, }; use ssz::Encode; @@ -887,4 +914,126 @@ mod tests { assert!(merging_data.is_none()); assert!(bid_adjustment.is_none()); } + + #[test] + fn decode_dehydrated_mergeable_with_adjustments_carries_both() { + let submission = DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData::random_for_test( + &mut rand::rng(), + ); + let body = submission.as_ssz_bytes(); + let (_, expected_adjustment_data, expected_merging_data) = submission.split(); + + let params = SubmissionDecoderParams { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::Mergeable, + is_dehydrated: true, + with_mergeable_data: false, + with_adjustments: true, + mark_all_txs_mergeable: false, + fork_name: ForkName::Fulu, + }; + let mut decoder = SubmissionDecoder::new(¶ms); + let mut buf = Vec::new(); + let (decoded_submission, merging_data, bid_adjustment_data) = + decoder.decode(&body, &mut buf).expect("decode should succeed"); + + assert!(matches!(decoded_submission, Submission::Dehydrated(_))); + assert_eq!( + merging_data.expect("combined submission should carry merging data"), + expected_merging_data + ); + assert_eq!( + bid_adjustment_data.expect("combined submission should carry adjustment data"), + expected_adjustment_data + ); + } + + #[test] + fn decode_merge_mergeable_with_adjustments_carries_both() { + let submission = + SignedBidSubmissionWithAdjustmentsAndMergingData::random_for_test(&mut rand::rng()); + let body = submission.as_ssz_bytes(); + let expected_block_hash = submission.message.block_hash; + let (_, expected_adjustment_data, expected_merging_data) = submission.split(); + + let params = SubmissionDecoderParams { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::Mergeable, + is_dehydrated: false, + with_mergeable_data: true, + with_adjustments: true, + mark_all_txs_mergeable: false, + fork_name: ForkName::Fulu, + }; + let mut decoder = SubmissionDecoder::new(¶ms); + let mut buf = Vec::new(); + let (decoded_submission, merging_data, bid_adjustment_data) = + decoder.decode(&body, &mut buf).expect("decode should succeed"); + + match decoded_submission { + Submission::Full(s) => assert_eq!(s.message.block_hash, expected_block_hash), + Submission::Dehydrated(_) => panic!("expected full submission"), + } + assert_eq!( + merging_data.expect("combined submission should carry merging data"), + expected_merging_data + ); + assert_eq!( + bid_adjustment_data.expect("combined submission should carry adjustment data"), + expected_adjustment_data + ); + } + + #[test] + fn decode_merge_append_only_with_adjustments_carries_adjustments_and_clears_orders() { + // Retry until a non-empty `merge_orders` shows up, since that's what proves AppendOnly + // actively clears them on the adjustments path too. + let (body, expected_adjustment_data, expected_allow_appending, expected_builder_address) = + (0..100) + .find_map(|_| { + let submission = + SignedBidSubmissionWithAdjustmentsAndMergingData::random_for_test( + &mut rand::rng(), + ); + if submission.merging_data.merge_orders.is_empty() { + return None; + } + let body = submission.as_ssz_bytes(); + let (_, adjustment_data, merging_data) = submission.split(); + Some(( + body, + adjustment_data, + merging_data.allow_appending, + merging_data.builder_address, + )) + }) + .expect("should produce a submission with non-empty merge_orders within 100 tries"); + + let params = SubmissionDecoderParams { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::AppendOnly, + is_dehydrated: false, + with_mergeable_data: true, + with_adjustments: true, + mark_all_txs_mergeable: false, + fork_name: ForkName::Fulu, + }; + let mut decoder = SubmissionDecoder::new(¶ms); + let mut buf = Vec::new(); + let (decoded_submission, merging_data, bid_adjustment_data) = + decoder.decode(&body, &mut buf).expect("decode should succeed"); + + assert!(matches!(decoded_submission, Submission::Full(_))); + let merging_data = merging_data.expect("append-only should still carry merging data"); + assert!(merging_data.merge_orders.is_empty()); + assert_eq!(merging_data.allow_appending, expected_allow_appending); + assert_eq!(merging_data.builder_address, expected_builder_address); + assert_eq!( + bid_adjustment_data.expect("adjustments should be carried"), + expected_adjustment_data + ); + } } diff --git a/crates/types/src/bid_submission.rs b/crates/types/src/bid_submission.rs index 37171c491..f0383aa68 100644 --- a/crates/types/src/bid_submission.rs +++ b/crates/types/src/bid_submission.rs @@ -15,9 +15,11 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ - BlobsBundle, BlobsError, Bloom, BlsPublicKey, BlsPublicKeyBytes, BlsSignature, - BlsSignatureBytes, DehydratedBidSubmission, ExecutionPayload, ExtraData, PayloadAndBlobs, - SszError, TestRandom, bid_adjustment_data::BidAdjustmentData, error::SigError, + BlobsBundle, BlobsError, BlockMergingData, Bloom, BlsPublicKey, BlsPublicKeyBytes, + BlsSignature, BlsSignatureBytes, DehydratedBidSubmission, ExecutionPayload, ExtraData, + PayloadAndBlobs, SszError, TestRandom, + bid_adjustment_data::{BidAdjData, BidAdjustmentData, BidAdjustmentDataV1}, + error::SigError, fields::ExecutionRequests, }; @@ -734,6 +736,51 @@ impl SignedBidSubmissionWithAdjustments { } } +/// Flat combination of [`SignedBidSubmissionWithAdjustments`] and merging data: +/// core fields ++ bid_adjustment_data ++ merging_data. +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct SignedBidSubmissionWithAdjustmentsAndMergingData { + pub message: BidTrace, + pub execution_payload: Arc, + pub blobs_bundle: Arc, + pub execution_requests: Arc, + pub signature: BlsSignatureBytes, + pub bid_adjustment_data: BidAdjustmentData, + pub merging_data: BlockMergingData, +} + +impl SignedBidSubmissionWithAdjustmentsAndMergingData { + pub fn split(self) -> (SignedBidSubmission, BidAdjustmentData, BlockMergingData) { + ( + SignedBidSubmission { + message: self.message, + execution_payload: self.execution_payload, + blobs_bundle: self.blobs_bundle, + execution_requests: self.execution_requests, + signature: self.signature, + }, + self.bid_adjustment_data, + self.merging_data, + ) + } +} + +impl TestRandom for SignedBidSubmissionWithAdjustmentsAndMergingData { + fn random_for_test(rng: &mut impl rand::RngCore) -> Self { + Self { + message: BidTrace::random_for_test(rng), + execution_payload: ExecutionPayload::random_for_test(rng).into(), + blobs_bundle: BlobsBundle::with_capacity(0).into(), + execution_requests: ExecutionRequests::random_for_test(rng).into(), + signature: BlsSignatureBytes::random(), + bid_adjustment_data: BidAdjustmentData::V1(BidAdjustmentDataV1::Original( + BidAdjData::default(), + )), + merging_data: BlockMergingData::random_for_test(rng), + } + } +} + #[derive(Clone, Copy, PartialEq, Eq)] pub struct SubmissionVersion { on_receive_ns: u64, @@ -865,4 +912,27 @@ mod tests { assert_eq!(data_ssz, s.as_ssz_bytes().as_slice()); assert_eq!(s.fork_name(), ForkName::Fulu); } + + #[test] + fn with_adjustments_and_merging_data_ssz_round_trip() { + let data_ssz = + SignedBidSubmissionWithAdjustmentsAndMergingData::random_for_test(&mut rand::rng()) + .as_ssz_bytes(); + let s = + test_encode_decode_ssz::(&data_ssz); + assert_eq!(data_ssz, s.as_ssz_bytes().as_slice()); + } + + #[test] + // the combined type is a flat append: the variable-size heap ends with the + // bid_adjustment_data bytes followed by the merging_data bytes + fn with_adjustments_and_merging_data_flat_layout() { + let combined = + SignedBidSubmissionWithAdjustmentsAndMergingData::random_for_test(&mut rand::rng()); + let bytes = combined.as_ssz_bytes(); + + let mut tail = combined.bid_adjustment_data.as_ssz_bytes(); + tail.extend(combined.merging_data.as_ssz_bytes()); + assert!(bytes.ends_with(&tail)); + } } diff --git a/crates/types/src/hydration.rs b/crates/types/src/hydration.rs index 7c2540817..812594201 100644 --- a/crates/types/src/hydration.rs +++ b/crates/types/src/hydration.rs @@ -13,7 +13,7 @@ use tree_hash::TreeHash; use crate::{ BidTrace, Blob, BlobsBundle, BlockMergingData, BlockValidationError, BlsPublicKeyBytes, BlsSignatureBytes, ExecutionPayload, SignedBidSubmission, TestRandom, - bid_adjustment_data::BidAdjustmentData, + bid_adjustment_data::{BidAdjData, BidAdjustmentData, BidAdjustmentDataV1}, bid_submission, fields::{ExecutionRequests, KzgCommitment, KzgProof, Transaction}, }; @@ -277,6 +277,72 @@ impl TestRandom for DehydratedBidSubmissionFuluWithMergingData { } } +/// Flat combination of [`DehydratedBidSubmissionFuluWithAdjustments`] and merging data: +/// core fields ++ bid_adjustment_data ++ merging_data. +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData { + message: BidTrace, + execution_payload: ExecutionPayload, + blobs_bundle: DehydratedBlobsFulu, + execution_requests: Arc, + signature: BlsSignatureBytes, + tx_root: Option, + bid_adjustment_data: BidAdjustmentData, + merging_data: BlockMergingData, +} + +impl DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData { + pub fn split(self) -> (DehydratedBidSubmission, BidAdjustmentData, BlockMergingData) { + ( + DehydratedBidSubmission::Fulu(DehydratedBidSubmissionFulu { + message: self.message, + execution_payload: self.execution_payload, + blobs_bundle: self.blobs_bundle, + execution_requests: self.execution_requests, + signature: self.signature, + tx_root: self.tx_root, + }), + self.bid_adjustment_data, + self.merging_data, + ) + } +} + +impl ForkVersionDecode for DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData { + fn from_ssz_bytes_by_fork(bytes: &[u8], fork: ForkName) -> Result { + match fork { + ForkName::Base | + ForkName::Altair | + ForkName::Bellatrix | + ForkName::Capella | + ForkName::Deneb | + ForkName::Gloas | + ForkName::Heze | + ForkName::Electra => Err(DecodeError::NoMatchingVariant), + ForkName::Fulu => { + DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData::from_ssz_bytes(bytes) + } + } + } +} + +impl TestRandom for DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData { + fn random_for_test(rng: &mut impl rand::RngCore) -> Self { + Self { + message: BidTrace::random_for_test(rng), + execution_payload: ExecutionPayload::random_for_test(rng), + blobs_bundle: DehydratedBlobsFulu { commitments: vec![], new_items: vec![] }, + execution_requests: Arc::new(ExecutionRequests::random_for_test(rng)), + signature: BlsSignatureBytes::random(), + tx_root: None, + bid_adjustment_data: BidAdjustmentData::V1(BidAdjustmentDataV1::Original( + BidAdjData::default(), + )), + merging_data: BlockMergingData::random_for_test(rng), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] struct DehydratedBlobsFulu { commitments: Vec, @@ -645,4 +711,36 @@ mod tests { assert_eq!(split_merging_data, expected_merging_data); assert!(matches!(dehydrated, DehydratedBidSubmission::Fulu(_))); } + + #[test] + fn dehydrated_with_adjustments_and_merging_data_ssz_round_trip() { + let submission = DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData::random_for_test( + &mut rand::rng(), + ); + + let bytes = submission.as_ssz_bytes(); + let decoded = + DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData::from_ssz_bytes(&bytes) + .expect("SSZ decode should succeed"); + + assert_eq!(submission.message, decoded.message); + assert_eq!(submission.bid_adjustment_data, decoded.bid_adjustment_data); + assert_eq!(submission.merging_data, decoded.merging_data); + assert_eq!(bytes, decoded.as_ssz_bytes()); + } + + #[test] + fn dehydrated_with_adjustments_and_merging_data_split() { + let submission = DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData::random_for_test( + &mut rand::rng(), + ); + let expected_adjustment_data = submission.bid_adjustment_data.clone(); + let expected_merging_data = submission.merging_data.clone(); + + let (dehydrated, split_adjustment_data, split_merging_data) = submission.split(); + + assert_eq!(split_adjustment_data, expected_adjustment_data); + assert_eq!(split_merging_data, expected_merging_data); + assert!(matches!(dehydrated, DehydratedBidSubmission::Fulu(_))); + } } From e71b38612a1e0b21f21a87fc2ddde51cf343856d Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 28 Aug 2026 01:31:39 +0100 Subject: [PATCH 15/29] Select the builder's roles from the supplied configs --merging.config activates merging, the new --sim.config activates simulation, neither is a startup error. The relay signer loads only for merging. The simulation role boots the node only; its servers come later. Step 1 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/builder/Cargo.toml | 1 + crates/builder/sim-config.example.yml | 14 +++ crates/builder/src/cli.rs | 10 +- crates/builder/src/config.rs | 169 ++++++++++++++++++++++++++ crates/builder/src/main.rs | 93 ++++++++------ 6 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 crates/builder/sim-config.example.yml diff --git a/Cargo.lock b/Cargo.lock index 7d6501c98..c94d1fd03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6475,6 +6475,7 @@ dependencies = [ "flux-utils", "helix-tcp-types", "hex", + "num_cpus", "rand 0.9.5", "rayon", "rustc-hash", diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 0c88f4c68..daf115543 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -35,6 +35,7 @@ hex.workspace = true flux-network.workspace = true flux-utils.workspace = true helix-tcp-types.workspace = true +num_cpus.workspace = true rand.workspace = true rayon.workspace = true rustc-hash.workspace = true diff --git a/crates/builder/sim-config.example.yml b/crates/builder/sim-config.example.yml new file mode 100644 index 000000000..f96804185 --- /dev/null +++ b/crates/builder/sim-config.example.yml @@ -0,0 +1,14 @@ +# helix-builder simulation configuration (--sim.config) + +ssz_addr: "0.0.0.0:8552" + +# Must differ from ssz_addr. +rpc_addr: "0.0.0.0:8553" + +blacklist_endpoint: "http://localhost:3520/blacklist" + +# Maximum parent-to-head block distance a submission may build on. +validation_window: 3 + +# Defaults to the core count. +max_concurrent_validations: 32 diff --git a/crates/builder/src/cli.rs b/crates/builder/src/cli.rs index c94fd70c0..24e78d987 100644 --- a/crates/builder/src/cli.rs +++ b/crates/builder/src/cli.rs @@ -12,15 +12,19 @@ use tracing::Level; #[derive(Parser)] #[command( name = "helix-builder", - about = "Helix block-merging builder: embedded ethrex node + relay-facing merging TCP server" + about = "Helix embedded ethrex node, running the block-merging role, the block-simulation role, or both" )] pub struct BuilderCli { #[command(flatten)] pub node: NodeOptions, - /// Path to the merging YAML config (listen address, relay api keys, limits) + /// Path to the merging YAML config. Activates the merging role. #[arg(long = "merging.config", env = "HELIX_BUILDER_MERGING_CONFIG")] - pub merging_config: PathBuf, + pub merging_config: Option, + + /// Path to the simulation YAML config. Activates the simulation role. + #[arg(long = "sim.config", env = "HELIX_BUILDER_SIM_CONFIG")] + pub sim_config: Option, } /// Embedded-ethrex node options. Flag and env names match the upstream `ethrex` diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 3414e98be..064c0925d 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -89,6 +89,95 @@ impl MergingConfig { } } +/// Builder-owned simulation configuration, loaded from YAML (`--sim.config`). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SimulationConfig { + pub ssz_addr: SocketAddr, + pub rpc_addr: SocketAddr, + #[serde(default = "default_blacklist_endpoint")] + pub blacklist_endpoint: String, + /// Maximum parent-to-head block distance a submission may build on. + #[serde(default = "default_validation_window")] + pub validation_window: u64, + #[serde(default = "default_max_concurrent_validations")] + pub max_concurrent_validations: usize, +} + +impl SimulationConfig { + pub fn load(path: &Path) -> eyre::Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| eyre::eyre!("failed to read simulation config {}: {e}", path.display()))?; + let config: Self = serde_yaml::from_str(&raw).map_err(|e| { + eyre::eyre!("failed to parse simulation config {}: {e}", path.display()) + })?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> eyre::Result<()> { + if self.ssz_addr == self.rpc_addr { + eyre::bail!("simulation config: ssz_addr and rpc_addr must differ"); + } + if self.blacklist_endpoint.is_empty() { + eyre::bail!("simulation config: blacklist_endpoint must not be empty"); + } + if self.validation_window == 0 { + eyre::bail!("simulation config: validation_window must be > 0"); + } + if self.max_concurrent_validations == 0 { + eyre::bail!("simulation config: max_concurrent_validations must be > 0"); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub enum Roles { + Merging(MergingConfig), + Simulation(SimulationConfig), + Both { merging: MergingConfig, simulation: SimulationConfig }, +} + +impl Roles { + pub fn resolve( + merging: Option, + simulation: Option, + ) -> eyre::Result { + match (merging, simulation) { + (Some(merging), Some(simulation)) => Ok(Self::Both { merging, simulation }), + (Some(merging), None) => Ok(Self::Merging(merging)), + (None, Some(simulation)) => Ok(Self::Simulation(simulation)), + (None, None) => { + eyre::bail!("no role selected: supply --merging.config, --sim.config, or both") + } + } + } + + pub fn merging(&self) -> Option<&MergingConfig> { + match self { + Self::Merging(merging) | Self::Both { merging, .. } => Some(merging), + Self::Simulation(_) => None, + } + } + + pub fn simulation(&self) -> Option<&SimulationConfig> { + match self { + Self::Simulation(simulation) | Self::Both { simulation, .. } => Some(simulation), + Self::Merging(_) => None, + } + } +} + +fn default_blacklist_endpoint() -> String { + "http://localhost:3520/blacklist".to_string() +} +fn default_validation_window() -> u64 { + 3 +} +fn default_max_concurrent_validations() -> usize { + num_cpus::get() +} fn default_max_orders_per_slot() -> u32 { 8192 } @@ -143,3 +232,83 @@ mod tests { assert!(config.cores.server_tile.is_none()); } } + +#[cfg(test)] +mod simulation_config_tests { + use super::*; + + fn minimal_merging_config() -> MergingConfig { + serde_yaml::from_str( + "listen_addr: \"0.0.0.0:9876\"\napi_keys: [\"00000000-0000-0000-0000-000000000001\"]\n", + ) + .expect("the minimal merging config must parse") + } + + fn minimal_simulation_config() -> SimulationConfig { + serde_yaml::from_str("ssz_addr: \"0.0.0.0:8552\"\nrpc_addr: \"0.0.0.0:8553\"\n") + .expect("the minimal simulation config must parse") + } + + #[test] + fn parses_the_example_sim_config() { + let example = include_str!("../sim-config.example.yml"); + let config: SimulationConfig = serde_yaml::from_str(example).unwrap(); + config.validate().unwrap(); + + assert_eq!(config.ssz_addr, "0.0.0.0:8552".parse::().unwrap()); + assert_eq!(config.rpc_addr, "0.0.0.0:8553".parse::().unwrap()); + assert_eq!(config.blacklist_endpoint, "http://localhost:3520/blacklist"); + assert_eq!(config.validation_window, 3); + assert_eq!(config.max_concurrent_validations, 32); + } + + #[test] + fn minimal_sim_config_gets_defaults() { + let config = minimal_simulation_config(); + config.validate().unwrap(); + + assert_eq!(config.blacklist_endpoint, "http://localhost:3520/blacklist"); + assert_eq!(config.validation_window, 3); + assert_eq!(config.max_concurrent_validations, num_cpus::get()); + } + + #[test] + fn sim_config_rejects_one_address_for_both_servers() { + let config: SimulationConfig = + serde_yaml::from_str("ssz_addr: \"0.0.0.0:8552\"\nrpc_addr: \"0.0.0.0:8552\"\n") + .unwrap(); + + assert!(config.validate().is_err(), "ssz_addr must differ from rpc_addr"); + } + + #[test] + fn a_merging_config_alone_selects_the_merging_role() { + let roles = Roles::resolve(Some(minimal_merging_config()), None).unwrap(); + + assert!(roles.merging().is_some()); + assert!(roles.simulation().is_none()); + } + + #[test] + fn a_sim_config_alone_selects_the_simulation_role() { + let roles = Roles::resolve(None, Some(minimal_simulation_config())).unwrap(); + + assert!(roles.simulation().is_some()); + assert!(roles.merging().is_none(), "no merging role means no RELAY_KEY is needed"); + } + + #[test] + fn both_configs_select_both_roles() { + let roles = + Roles::resolve(Some(minimal_merging_config()), Some(minimal_simulation_config())) + .unwrap(); + + assert!(roles.merging().is_some()); + assert!(roles.simulation().is_some()); + } + + #[test] + fn neither_config_is_a_startup_error() { + assert!(Roles::resolve(None, None).is_err(), "the builder must run at least one role"); + } +} diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index eafba8323..52b471ad1 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -16,7 +16,7 @@ mod spine; mod utils; use cli::BuilderCli; -use config::MergingConfig; +use config::{MergingConfig, Roles, SimulationConfig}; use engine::{MergeEngine, types::EngineConfig}; use server::MergingServerTile; use spine::BuilderSpine; @@ -28,50 +28,67 @@ fn main() -> eyre::Result<()> { let cli = BuilderCli::parse(); init_tracing(&cli); - let merging_config = MergingConfig::load(&cli.merging_config)?; - info!(listen_addr = %merging_config.listen_addr, "Loaded merging config"); + let merging_config = cli.merging_config.as_deref().map(MergingConfig::load).transpose()?; + let simulation_config = cli.sim_config.as_deref().map(SimulationConfig::load).transpose()?; + let roles = Roles::resolve(merging_config, simulation_config)?; // Fail fast on a missing/invalid RELAY_KEY, before the node boots. - let relay_signer = EngineConfig::load_relay_signer(); + let relay_signer = roles.merging().map(|merging_config| { + info!(listen_addr = %merging_config.listen_addr, "Loaded merging config"); + EngineConfig::load_relay_signer() + }); let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; let node = runtime.block_on(node::start(&cli.node))?; - let (event_tx, event_rx) = crossbeam_channel::bounded(merging_config.event_queue_capacity); - let (output_tx, output_rx) = crossbeam_channel::bounded(64); - - let engine_config = EngineConfig { - relay_signer, - max_blocks_per_slot: merging_config.max_blocks_per_slot, - max_orders_per_slot: merging_config.max_orders_per_slot as usize, - min_value_increase_wei: alloy_primitives::U256::from( - merging_config.emission.min_value_increase_wei, - ), - min_emission_interval: std::time::Duration::from_millis( - merging_config.emission.min_interval_ms, - ), - core: merging_config.cores.merge_worker, - }; - let _engine = MergeEngine::spawn( - engine_config, - node.store.clone(), - node.blockchain.clone(), - node.head.clone(), - event_rx, - output_tx, - ); - - BuilderSpine::remove_all_files(); - let spine = BuilderSpine::new(None); - let server_tile_config = match merging_config.cores.server_tile { - Some(core) => TileConfig::new(core, ThreadPriority::High), - None => TileConfig::background(None, None), - }; - spine.start(None, None, |spine| { - let tile = MergingServerTile::new(&merging_config, event_tx.clone(), output_rx.clone()); - attach_tile(tile, spine, server_tile_config); - }); + if let Some(simulation_config) = roles.simulation() { + info!( + ssz_addr = %simulation_config.ssz_addr, + rpc_addr = %simulation_config.rpc_addr, + "Simulation role active" + ); + } + + // `BuilderSpine::start` blocks until its tiles stop, so merging starts last. + if let Some(merging_config) = roles.merging() { + let relay_signer = relay_signer.expect("the merging role loads a relay signer"); + + let (event_tx, event_rx) = crossbeam_channel::bounded(merging_config.event_queue_capacity); + let (output_tx, output_rx) = crossbeam_channel::bounded(64); + + let engine_config = EngineConfig { + relay_signer, + max_blocks_per_slot: merging_config.max_blocks_per_slot, + max_orders_per_slot: merging_config.max_orders_per_slot as usize, + min_value_increase_wei: alloy_primitives::U256::from( + merging_config.emission.min_value_increase_wei, + ), + min_emission_interval: std::time::Duration::from_millis( + merging_config.emission.min_interval_ms, + ), + core: merging_config.cores.merge_worker, + }; + let _engine = MergeEngine::spawn( + engine_config, + node.store.clone(), + node.blockchain.clone(), + node.head.clone(), + event_rx, + output_tx, + ); + + BuilderSpine::remove_all_files(); + let spine = BuilderSpine::new(None); + let server_tile_config = match merging_config.cores.server_tile { + Some(core) => TileConfig::new(core, ThreadPriority::High), + None => TileConfig::background(None, None), + }; + spine.start(None, None, |spine| { + let tile = MergingServerTile::new(merging_config, event_tx.clone(), output_rx.clone()); + attach_tile(tile, spine, server_tile_config); + }); + } runtime.block_on(async { let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) From 9165a12e59d22716317da40db696b0a6d66930b4 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 28 Aug 2026 13:31:31 +0100 Subject: [PATCH 16/29] Share the simulators' payment and disallow-list helpers from helix-common The disallow list's parsing and digest, and the Safe multiSend payment recognition, carry no reth types. Move them so the ethrex simulator uses the same code. The functions and their tests move verbatim. Step 2 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 +- crates/common/Cargo.toml | 1 + crates/common/src/blacklist.rs | 92 ++++++++ crates/common/src/lib.rs | 2 + crates/common/src/payment.rs | 186 +++++++++++++++++ crates/simulator/Cargo.toml | 1 - crates/simulator/src/validation/mod.rs | 279 +------------------------ 7 files changed, 287 insertions(+), 276 deletions(-) create mode 100644 crates/common/src/blacklist.rs create mode 100644 crates/common/src/payment.rs diff --git a/Cargo.lock b/Cargo.lock index c94d1fd03..3ae2ab266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6500,6 +6500,7 @@ dependencies = [ "alloy-consensus", "alloy-primitives", "alloy-rlp", + "alloy-sol-types", "axum 0.8.9", "backtrace", "bytes", @@ -6744,7 +6745,6 @@ dependencies = [ "serde", "serde_json", "serde_with 1.14.0", - "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tracing", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 06f6d8266..26b3cf23d 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -10,6 +10,7 @@ version.workspace = true alloy-consensus.workspace = true alloy-primitives.workspace = true alloy-rlp.workspace = true +alloy-sol-types.workspace = true axum.workspace = true backtrace.workspace = true bytes.workspace = true diff --git a/crates/common/src/blacklist.rs b/crates/common/src/blacklist.rs new file mode 100644 index 000000000..136d1dd50 --- /dev/null +++ b/crates/common/src/blacklist.rs @@ -0,0 +1,92 @@ +use alloy_primitives::Address; +use dashmap::DashSet; +use sha2::{Digest, Sha256}; + +/// Parses a blacklist payload into the addresses to disallow. +pub fn parse_disallow_list(list: Vec) -> Vec
{ + let mut addrs = Vec::new(); + for hex in list { + if let Ok(addr) = hex.strip_prefix("0x").unwrap_or(&hex).parse::
() { + addrs.push(addr); + } + } + addrs +} + +/// Fingerprints the disallow list so operators can confirm every node enforces the same one. +/// +/// Entries are sorted first because `DashSet` iteration order is not stable. Mirrors reth's +/// `hash_disallow_list` so the digests are comparable against reth-based builders. +pub fn hash_disallow_list(disallow: &DashSet
) -> String { + let mut sorted: Vec
= disallow.iter().map(|addr| *addr).collect(); + sorted.sort_unstable(); + + let mut hasher = Sha256::new(); + for addr in &sorted { + hasher.update(addr.as_slice()); + } + + format!("{:x}", hasher.finalize()) +} + +/// Returns the disallow list's digest, or `None` when it still matches `previous`. +pub fn changed_disallow_hash( + disallow: &DashSet
, + previous: Option<&str>, +) -> Option { + let hash = hash_disallow_list(disallow); + (previous != Some(hash.as_str())).then_some(hash) +} + +#[cfg(test)] +mod blacklist_tests { + use alloy_primitives::address; + + use super::*; + + #[test] + fn loads_an_address_list() { + let parsed = parse_disallow_list(vec![ + "0x8589427373D6D84E98730D7795D8f6f8731FDA16".into(), + "722122dF12D4e14e13Ac3b6895a86e84145b6967".into(), + "0xdd4c48c0b24039969fc16d1cdf626eab821d3384".into(), + ]); + + assert_eq!(parsed.len(), 3, "every entry must load"); + assert!(parsed.contains(&address!("0x8589427373D6D84E98730D7795D8f6f8731FDA16"))); + } + + const ADDR_A: Address = address!("0x722122dF12D4e14e13Ac3b6895a86e84145b6967"); + const ADDR_B: Address = address!("0x8589427373D6D84E98730D7795D8f6f8731FDA16"); + + fn disallow_set(addrs: &[Address]) -> DashSet
{ + let set = DashSet::new(); + for addr in addrs { + set.insert(*addr); + } + set + } + + #[test] + fn an_unchanged_list_reports_no_change() { + let previous = disallow_set(&[ADDR_A, ADDR_B]); + let current = disallow_set(&[ADDR_B, ADDR_A]); + + let hash = changed_disallow_hash(&previous, None).expect("a first list is always new"); + + assert_eq!(changed_disallow_hash(¤t, Some(&hash)), None); + } + + #[test] + fn an_amended_list_reports_a_new_hash() { + let previous = disallow_set(&[ADDR_A]); + let current = disallow_set(&[ADDR_A, ADDR_B]); + + let superseded = + changed_disallow_hash(&previous, None).expect("a first list is always new"); + + let in_force = + changed_disallow_hash(¤t, Some(&superseded)).expect("the digest must change"); + assert_ne!(in_force, superseded); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 36770459a..5917559b5 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -6,6 +6,7 @@ pub mod api; pub mod api_provider; pub mod beacon; pub mod bid_submission; +pub mod blacklist; pub mod builder_info; pub mod chain_info; pub mod config; @@ -13,6 +14,7 @@ pub mod decoder; pub mod http; pub mod local_cache; pub mod metrics; +pub mod payment; pub mod proposer; pub mod signing; pub mod simulator; diff --git a/crates/common/src/payment.rs b/crates/common/src/payment.rs new file mode 100644 index 000000000..e4fb0d226 --- /dev/null +++ b/crates/common/src/payment.rs @@ -0,0 +1,186 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::{SolCall, sol}; + +sol! { + /// Gnosis Safe entry point the merge-builder payment engine calls + /// (crates/builder/src/engine/payment.rs) to delegatecall `multiSend`. + function execTransaction( + address to, + uint256 value, + bytes data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes signatures + ) external returns (bool); + + /// Gnosis `MultiSendCallOnly` entry point, delegatecalled by `execTransaction`. + function multiSend(bytes transactions) external payable; +} + +pub const SAFE_DELEGATECALL: u8 = 1; + +/// Sums the value a Safe `execTransaction` -> `multiSend` delegatecall's batched +/// calls pay `recipient` (zero on any decode failure or shape mismatch: not a +/// delegatecall, not a multiSend payload, no matching entry). Best-effort +/// recognition, not a signature/authenticity check: the caller must already know +/// the transaction actually succeeded on-chain. +pub fn multisend_paid_amount(input: &[u8], recipient: Address) -> U256 { + let Ok(exec) = execTransactionCall::abi_decode(input) else { return U256::ZERO }; + if exec.operation != SAFE_DELEGATECALL { + return U256::ZERO; + } + let Ok(multisend) = multiSendCall::abi_decode(&exec.data) else { return U256::ZERO }; + multisend_entries(&multisend.transactions) + .filter(|(to, _)| *to == recipient) + .fold(U256::ZERO, |acc, (_, value)| acc + value) +} + +/// Iterates a Safe `multiSend` packed payload: per entry, `[1B operation][20B +/// to][32B value][32B dataLength][dataLength B data]`. Stops (yields no more +/// entries) on any length that doesn't fit the remaining bytes, rather than +/// panicking on malformed/adversarial input. +pub fn multisend_entries(transactions: &[u8]) -> impl Iterator + '_ { + const ENTRY_HEADER_LEN: usize = 1 + 20 + 32 + 32; + let mut offset = 0usize; + std::iter::from_fn(move || { + if transactions.len().saturating_sub(offset) < ENTRY_HEADER_LEN { + return None; + } + offset += 1; // operation: irrelevant for a payment check + let to = Address::from_slice(&transactions[offset..offset + 20]); + offset += 20; + let value = U256::from_be_slice(&transactions[offset..offset + 32]); + offset += 32; + let data_len: usize = + U256::from_be_slice(&transactions[offset..offset + 32]).try_into().ok()?; + offset += 32; + if transactions.len().saturating_sub(offset) < data_len { + return None; + } + offset += data_len; + Some((to, value)) + }) +} + +#[cfg(test)] +mod multisend_payment_tests { + use alloy_primitives::{Bytes, address}; + use alloy_sol_types::SolCall; + + use super::*; + + /// Mirrors `crates/builder/src/engine/payment.rs::build_multisend_payload`. + fn multisend_payload(entries: &[(Address, U256)]) -> Vec { + let mut payload = Vec::new(); + for (to, value) in entries { + payload.push(0u8); // operation = CALL + payload.extend_from_slice(to.as_slice()); + payload.extend_from_slice(&value.to_be_bytes::<32>()); + payload.extend_from_slice(&U256::ZERO.to_be_bytes::<32>()); // data length + } + payload + } + + /// Mirrors `crates/builder/src/engine/payment.rs::encode_multisend_calldata`'s + /// `execTransaction` wrapping, minus the real Safe signature (irrelevant here: + /// `multisend_paid_amount` only decodes calldata shape, it doesn't verify + /// signatures). + fn exec_transaction_calldata( + multisend_contract: Address, + entries: &[(Address, U256)], + ) -> Vec { + let multisend_calldata = + multiSendCall { transactions: multisend_payload(entries).into() }.abi_encode(); + execTransactionCall { + to: multisend_contract, + value: U256::ZERO, + data: multisend_calldata.into(), + operation: SAFE_DELEGATECALL, + safeTxGas: U256::ZERO, + baseGas: U256::ZERO, + gasPrice: U256::ZERO, + gasToken: Address::ZERO, + refundReceiver: Address::ZERO, + signatures: Bytes::new(), + } + .abi_encode() + } + + #[test] + fn multisend_paid_amount_finds_matching_entry_among_several() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let other = address!("0x3333333333333333333333333333333333333333"); + let calldata = exec_transaction_calldata(multisend_contract, &[ + (other, U256::from(100)), + (proposer, U256::from(42)), + ]); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::from(42)); + } + + #[test] + fn multisend_paid_amount_sums_multiple_entries_to_the_same_recipient() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let calldata = exec_transaction_calldata(multisend_contract, &[ + (proposer, U256::from(42)), + (proposer, U256::from(8)), + ]); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::from(50)); + } + + #[test] + fn multisend_paid_amount_zero_when_recipient_absent() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let other = address!("0x3333333333333333333333333333333333333333"); + let calldata = exec_transaction_calldata(multisend_contract, &[(other, U256::from(42))]); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::ZERO); + } + + #[test] + fn multisend_paid_amount_zero_for_non_delegatecall_operation() { + let multisend_contract = address!("0x1111111111111111111111111111111111111111"); + let proposer = address!("0x2222222222222222222222222222222222222222"); + let multisend_calldata = + multiSendCall { transactions: multisend_payload(&[(proposer, U256::from(42))]).into() } + .abi_encode(); + let calldata = execTransactionCall { + to: multisend_contract, + value: U256::ZERO, + data: multisend_calldata.into(), + operation: 0, // CALL, not DELEGATECALL + safeTxGas: U256::ZERO, + baseGas: U256::ZERO, + gasPrice: U256::ZERO, + gasToken: Address::ZERO, + refundReceiver: Address::ZERO, + signatures: Bytes::new(), + } + .abi_encode(); + + assert_eq!(multisend_paid_amount(&calldata, proposer), U256::ZERO); + } + + #[test] + fn multisend_paid_amount_zero_for_unrelated_calldata() { + let proposer = address!("0x2222222222222222222222222222222222222222"); + assert_eq!(multisend_paid_amount(&[0xde, 0xad, 0xbe, 0xef], proposer), U256::ZERO); + } + + #[test] + fn multisend_entries_stops_on_truncated_payload() { + let to = address!("0x2222222222222222222222222222222222222222"); + let mut payload = multisend_payload(&[(to, U256::from(42))]); + payload.truncate(payload.len() - 1); // cut into the last entry's data-length field + + assert_eq!(multisend_entries(&payload).count(), 0); + } +} diff --git a/crates/simulator/Cargo.toml b/crates/simulator/Cargo.toml index 382e7d06f..83bedb710 100644 --- a/crates/simulator/Cargo.toml +++ b/crates/simulator/Cargo.toml @@ -39,7 +39,6 @@ revm.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true -sha2.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/simulator/src/validation/mod.rs b/crates/simulator/src/validation/mod.rs index 1c8067bf4..e1b3347f7 100644 --- a/crates/simulator/src/validation/mod.rs +++ b/crates/simulator/src/validation/mod.rs @@ -16,11 +16,13 @@ use alloy_rpc_types::{ ExecutionPayloadSidecar, PraguePayloadFields, }, }; -use alloy_sol_types::{SolCall, sol}; use async_trait::async_trait; use dashmap::DashSet; use helix_common::{ - PAYMENT_FORWARDER, PAYMENT_FORWARDER_CODE_HASH, api::builder_api::InclusionListWithMetadata, + PAYMENT_FORWARDER, PAYMENT_FORWARDER_CODE_HASH, + api::builder_api::InclusionListWithMetadata, + blacklist::{changed_disallow_hash, parse_disallow_list}, + payment::multisend_paid_amount, payment_forwarder_recipient, }; use jsonrpsee::{core::RpcResult, proc_macros::rpc, types::ErrorObject}; @@ -52,7 +54,6 @@ use reth_tasks::TaskExecutor; use revm::{Database, database::State}; use serde::{Deserialize, Serialize}; use serde_with::serde_as; -use sha2::{Digest, Sha256}; use tokio::{ spawn, sync::{RwLock, oneshot}, @@ -123,7 +124,7 @@ impl ValidationApi { match client.get(&ep).send().await { Ok(resp) if resp.status().is_success() => { if let Ok(list) = resp.json::>().await { - let parsed = ValidationApi::parse_disallow_list(list); + let parsed = parse_disallow_list(list); dash.clear(); for addr in parsed { dash.insert(addr); @@ -154,17 +155,6 @@ impl ValidationApi { Self { inner } } - /// Parses a blacklist payload into the addresses to disallow. - fn parse_disallow_list(list: Vec) -> Vec
{ - let mut addrs = Vec::new(); - for hex in list { - if let Ok(addr) = hex.strip_prefix("0x").unwrap_or(&hex).parse::
() { - addrs.push(addr); - } - } - addrs - } - /// Returns the cached reads for the given head hash. pub(crate) async fn cached_reads(&self, head: B256) -> CachedReads { let cache = self.inner.cached_state.read().await; @@ -849,190 +839,6 @@ impl ValidationApi { } } -sol! { - /// Gnosis Safe entry point the merge-builder payment engine calls - /// (crates/builder/src/engine/payment.rs) to delegatecall `multiSend`. - function execTransaction( - address to, - uint256 value, - bytes data, - uint8 operation, - uint256 safeTxGas, - uint256 baseGas, - uint256 gasPrice, - address gasToken, - address refundReceiver, - bytes signatures - ) external returns (bool); - - /// Gnosis `MultiSendCallOnly` entry point, delegatecalled by `execTransaction`. - function multiSend(bytes transactions) external payable; -} - -const SAFE_DELEGATECALL: u8 = 1; - -/// Sums the value a Safe `execTransaction` -> `multiSend` delegatecall's batched -/// calls pay `recipient` (zero on any decode failure or shape mismatch: not a -/// delegatecall, not a multiSend payload, no matching entry). Best-effort -/// recognition, not a signature/authenticity check: the caller must already know -/// the transaction actually succeeded on-chain. -fn multisend_paid_amount(input: &[u8], recipient: Address) -> U256 { - let Ok(exec) = execTransactionCall::abi_decode(input) else { return U256::ZERO }; - if exec.operation != SAFE_DELEGATECALL { - return U256::ZERO; - } - let Ok(multisend) = multiSendCall::abi_decode(&exec.data) else { return U256::ZERO }; - multisend_entries(&multisend.transactions) - .filter(|(to, _)| *to == recipient) - .fold(U256::ZERO, |acc, (_, value)| acc + value) -} - -/// Iterates a Safe `multiSend` packed payload: per entry, `[1B operation][20B -/// to][32B value][32B dataLength][dataLength B data]`. Stops (yields no more -/// entries) on any length that doesn't fit the remaining bytes, rather than -/// panicking on malformed/adversarial input. -fn multisend_entries(transactions: &[u8]) -> impl Iterator + '_ { - const ENTRY_HEADER_LEN: usize = 1 + 20 + 32 + 32; - let mut offset = 0usize; - std::iter::from_fn(move || { - if transactions.len().saturating_sub(offset) < ENTRY_HEADER_LEN { - return None; - } - offset += 1; // operation: irrelevant for a payment check - let to = Address::from_slice(&transactions[offset..offset + 20]); - offset += 20; - let value = U256::from_be_slice(&transactions[offset..offset + 32]); - offset += 32; - let data_len: usize = - U256::from_be_slice(&transactions[offset..offset + 32]).try_into().ok()?; - offset += 32; - if transactions.len().saturating_sub(offset) < data_len { - return None; - } - offset += data_len; - Some((to, value)) - }) -} - -#[cfg(test)] -mod multisend_payment_tests { - use alloy_primitives::{Bytes, address}; - use alloy_sol_types::SolCall; - - use super::*; - - /// Mirrors `crates/builder/src/engine/payment.rs::build_multisend_payload`. - fn multisend_payload(entries: &[(Address, U256)]) -> Vec { - let mut payload = Vec::new(); - for (to, value) in entries { - payload.push(0u8); // operation = CALL - payload.extend_from_slice(to.as_slice()); - payload.extend_from_slice(&value.to_be_bytes::<32>()); - payload.extend_from_slice(&U256::ZERO.to_be_bytes::<32>()); // data length - } - payload - } - - /// Mirrors `crates/builder/src/engine/payment.rs::encode_multisend_calldata`'s - /// `execTransaction` wrapping, minus the real Safe signature (irrelevant here: - /// `multisend_paid_amount` only decodes calldata shape, it doesn't verify - /// signatures). - fn exec_transaction_calldata( - multisend_contract: Address, - entries: &[(Address, U256)], - ) -> Vec { - let multisend_calldata = - multiSendCall { transactions: multisend_payload(entries).into() }.abi_encode(); - execTransactionCall { - to: multisend_contract, - value: U256::ZERO, - data: multisend_calldata.into(), - operation: SAFE_DELEGATECALL, - safeTxGas: U256::ZERO, - baseGas: U256::ZERO, - gasPrice: U256::ZERO, - gasToken: Address::ZERO, - refundReceiver: Address::ZERO, - signatures: Bytes::new(), - } - .abi_encode() - } - - #[test] - fn multisend_paid_amount_finds_matching_entry_among_several() { - let multisend_contract = address!("0x1111111111111111111111111111111111111111"); - let proposer = address!("0x2222222222222222222222222222222222222222"); - let other = address!("0x3333333333333333333333333333333333333333"); - let calldata = exec_transaction_calldata(multisend_contract, &[ - (other, U256::from(100)), - (proposer, U256::from(42)), - ]); - - assert_eq!(multisend_paid_amount(&calldata, proposer), U256::from(42)); - } - - #[test] - fn multisend_paid_amount_sums_multiple_entries_to_the_same_recipient() { - let multisend_contract = address!("0x1111111111111111111111111111111111111111"); - let proposer = address!("0x2222222222222222222222222222222222222222"); - let calldata = exec_transaction_calldata(multisend_contract, &[ - (proposer, U256::from(42)), - (proposer, U256::from(8)), - ]); - - assert_eq!(multisend_paid_amount(&calldata, proposer), U256::from(50)); - } - - #[test] - fn multisend_paid_amount_zero_when_recipient_absent() { - let multisend_contract = address!("0x1111111111111111111111111111111111111111"); - let proposer = address!("0x2222222222222222222222222222222222222222"); - let other = address!("0x3333333333333333333333333333333333333333"); - let calldata = exec_transaction_calldata(multisend_contract, &[(other, U256::from(42))]); - - assert_eq!(multisend_paid_amount(&calldata, proposer), U256::ZERO); - } - - #[test] - fn multisend_paid_amount_zero_for_non_delegatecall_operation() { - let multisend_contract = address!("0x1111111111111111111111111111111111111111"); - let proposer = address!("0x2222222222222222222222222222222222222222"); - let multisend_calldata = - multiSendCall { transactions: multisend_payload(&[(proposer, U256::from(42))]).into() } - .abi_encode(); - let calldata = execTransactionCall { - to: multisend_contract, - value: U256::ZERO, - data: multisend_calldata.into(), - operation: 0, // CALL, not DELEGATECALL - safeTxGas: U256::ZERO, - baseGas: U256::ZERO, - gasPrice: U256::ZERO, - gasToken: Address::ZERO, - refundReceiver: Address::ZERO, - signatures: Bytes::new(), - } - .abi_encode(); - - assert_eq!(multisend_paid_amount(&calldata, proposer), U256::ZERO); - } - - #[test] - fn multisend_paid_amount_zero_for_unrelated_calldata() { - let proposer = address!("0x2222222222222222222222222222222222222222"); - assert_eq!(multisend_paid_amount(&[0xde, 0xad, 0xbe, 0xef], proposer), U256::ZERO); - } - - #[test] - fn multisend_entries_stops_on_truncated_payload() { - let to = address!("0x2222222222222222222222222222222222222222"); - let mut payload = multisend_payload(&[(to, U256::from(42))]); - payload.truncate(payload.len() - 1); // cut into the last entry's data-length field - - assert_eq!(multisend_entries(&payload).count(), 0); - } -} - #[async_trait] impl BlockSubmissionValidationApiServer for ValidationApi { async fn validate_builder_submission_v1( @@ -1187,28 +993,6 @@ pub(crate) struct ValidationMetrics { pub(crate) disallow_size: Gauge, } -/// Fingerprints the disallow list so operators can confirm every node enforces the same one. -/// -/// Entries are sorted first because `DashSet` iteration order is not stable. Mirrors reth's -/// `hash_disallow_list` so the digests are comparable against reth-based builders. -fn hash_disallow_list(disallow: &DashSet
) -> String { - let mut sorted: Vec
= disallow.iter().map(|addr| *addr).collect(); - sorted.sort_unstable(); - - let mut hasher = Sha256::new(); - for addr in &sorted { - hasher.update(addr.as_slice()); - } - - format!("{:x}", hasher.finalize()) -} - -/// Returns the disallow list's digest, or `None` when it still matches `previous`. -fn changed_disallow_hash(disallow: &DashSet
, previous: Option<&str>) -> Option { - let hash = hash_disallow_list(disallow); - (previous != Some(hash.as_str())).then_some(hash) -} - #[serde_as] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ExtendedValidationRequestV4 { @@ -1291,56 +1075,3 @@ pub trait BlockSubmissionValidationApi { request: ExtendedMergedValidationRequestV5, ) -> jsonrpsee::core::RpcResult<()>; } - -#[cfg(test)] -mod blacklist_tests { - use alloy_primitives::address; - - use super::*; - - #[test] - fn loads_an_address_list() { - let parsed = ValidationApi::parse_disallow_list(vec![ - "0x8589427373D6D84E98730D7795D8f6f8731FDA16".into(), - "722122dF12D4e14e13Ac3b6895a86e84145b6967".into(), - "0xdd4c48c0b24039969fc16d1cdf626eab821d3384".into(), - ]); - - assert_eq!(parsed.len(), 3, "every entry must load"); - assert!(parsed.contains(&address!("0x8589427373D6D84E98730D7795D8f6f8731FDA16"))); - } - - const ADDR_A: Address = address!("0x722122dF12D4e14e13Ac3b6895a86e84145b6967"); - const ADDR_B: Address = address!("0x8589427373D6D84E98730D7795D8f6f8731FDA16"); - - fn disallow_set(addrs: &[Address]) -> DashSet
{ - let set = DashSet::new(); - for addr in addrs { - set.insert(*addr); - } - set - } - - #[test] - fn an_unchanged_list_reports_no_change() { - let previous = disallow_set(&[ADDR_A, ADDR_B]); - let current = disallow_set(&[ADDR_B, ADDR_A]); - - let hash = changed_disallow_hash(&previous, None).expect("a first list is always new"); - - assert_eq!(changed_disallow_hash(¤t, Some(&hash)), None); - } - - #[test] - fn an_amended_list_reports_a_new_hash() { - let previous = disallow_set(&[ADDR_A]); - let current = disallow_set(&[ADDR_A, ADDR_B]); - - let superseded = - changed_disallow_hash(&previous, None).expect("a first list is always new"); - - let in_force = - changed_disallow_hash(¤t, Some(&superseded)).expect("the digest must change"); - assert_ne!(in_force, superseded); - } -} From f252ec9c502f35cde39d31d24ad9bdfe773e2b47 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 28 Aug 2026 16:14:03 +0100 Subject: [PATCH 17/29] Convert a submitted payload to an ethrex block and locate its parent BlockValidator turns an ExecutionPayloadV3 into the block it describes, checks the bid trace against that block, and finds the parent within the validation window. Nothing executes yet. The head comes from the node's watch channel rather than a store read per call, which keeps prepare synchronous. Step 3 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/builder/Cargo.toml | 1 + crates/builder/src/engine/convert.rs | 67 ++++- crates/builder/src/engine/tests.rs | 73 +----- crates/builder/src/main.rs | 3 + crates/builder/src/testing.rs | 83 ++++++ crates/builder/src/validation/error.rs | 25 ++ crates/builder/src/validation/mod.rs | 114 +++++++++ crates/builder/src/validation/tests.rs | 342 +++++++++++++++++++++++++ 9 files changed, 640 insertions(+), 69 deletions(-) create mode 100644 crates/builder/src/testing.rs create mode 100644 crates/builder/src/validation/error.rs create mode 100644 crates/builder/src/validation/mod.rs create mode 100644 crates/builder/src/validation/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 3ae2ab266..ac407dcbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6473,6 +6473,7 @@ dependencies = [ "flux", "flux-network", "flux-utils", + "helix-common", "helix-tcp-types", "hex", "num_cpus", diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index daf115543..26c989581 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -53,3 +53,4 @@ uuid = { workspace = true, features = ["serde"] } zstd.workspace = true [dev-dependencies] +helix-common.workspace = true diff --git a/crates/builder/src/engine/convert.rs b/crates/builder/src/engine/convert.rs index a2764c745..5719a17d1 100644 --- a/crates/builder/src/engine/convert.rs +++ b/crates/builder/src/engine/convert.rs @@ -9,8 +9,16 @@ use alloy_rpc_types::{ }; use ethrex_common::{ Address as EAddress, H256, U256 as EU256, - types::{Block, Withdrawal, requests::EncodedRequests}, + constants::DEFAULT_OMMERS_HASH, + types::{ + Block, BlockBody, BlockHeader, Transaction, Withdrawal, compute_transactions_root, + compute_withdrawals_root, + requests::{EncodedRequests, compute_requests_hash}, + }, }; +use ethrex_crypto::NativeCrypto; + +use crate::validation::error::ValidationError; pub fn h256(b: B256) -> H256 { H256(b.0) @@ -83,6 +91,63 @@ pub fn block_to_payload_v3(block: &Block) -> ExecutionPayloadV3 { } } +/// Inverse of [`block_to_payload_v3`]. The roots the payload omits are +/// recomputed, so the hash this yields is the one the submission is bound to. +#[allow(dead_code)] +pub fn payload_v3_to_block( + payload: &ExecutionPayloadV3, + parent_beacon_block_root: B256, + requests: &ExecutionRequestsV4, +) -> Result { + let inner = &payload.payload_inner.payload_inner; + + let transactions = inner + .transactions + .iter() + .map(|encoded| Transaction::decode_canonical(encoded)) + .collect::, _>>() + .map_err(|e| ValidationError::DecodeTransaction(e.to_string()))?; + let withdrawals: Vec = + payload.payload_inner.withdrawals.iter().map(ewithdrawal).collect(); + + let base_fee_per_gas: u64 = + inner.base_fee_per_gas.try_into().map_err(|_| ValidationError::BaseFeeTooLarge)?; + + let header = BlockHeader { + parent_hash: h256(inner.parent_hash), + ommers_hash: *DEFAULT_OMMERS_HASH, + coinbase: eaddr(inner.fee_recipient), + state_root: h256(inner.state_root), + transactions_root: compute_transactions_root(&transactions, &NativeCrypto), + receipts_root: h256(inner.receipts_root), + logs_bloom: ethrex_common::Bloom(inner.logs_bloom.0.0), + difficulty: EU256::zero(), + number: inner.block_number, + gas_limit: inner.gas_limit, + gas_used: inner.gas_used, + timestamp: inner.timestamp, + extra_data: inner.extra_data.0.clone(), + prev_randao: h256(inner.prev_randao), + nonce: 0, + base_fee_per_gas: Some(base_fee_per_gas), + withdrawals_root: Some(compute_withdrawals_root(&withdrawals, &NativeCrypto)), + blob_gas_used: Some(payload.blob_gas_used), + excess_blob_gas: Some(payload.excess_blob_gas), + parent_beacon_block_root: Some(h256(parent_beacon_block_root)), + requests_hash: Some(compute_requests_hash(&encoded_requests(requests))), + ..Default::default() + }; + + let body = BlockBody { transactions, ommers: vec![], withdrawals: Some(withdrawals) }; + Ok(Block::new(header, body)) +} + +/// Inverse of [`requests_to_v4`]. `compute_requests_hash` skips type-byte-only +/// entries, so the empty ones the wire format drops need not be restored. +fn encoded_requests(requests: &ExecutionRequestsV4) -> Vec { + requests.to_requests().iter().map(|request| EncodedRequests(request.clone().0.into())).collect() +} + /// Converts ethrex's encoded EIP-7685 requests into the wire /// `ExecutionRequestsV4`, dropping empty requests per the EIP. pub fn requests_to_v4(encoded: &[EncodedRequests]) -> Result { diff --git a/crates/builder/src/engine/tests.rs b/crates/builder/src/engine/tests.rs index 58c30932c..6bc5f0ece 100644 --- a/crates/builder/src/engine/tests.rs +++ b/crates/builder/src/engine/tests.rs @@ -1,20 +1,16 @@ //! Merge-engine tests on an in-memory ethrex store: the end-to-end flow //! (spawned worker), session parking, throttled-emission retry, and stats. -use std::{str::FromStr, sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration}; -use alloy_consensus::{SignableTransaction, TxEip1559}; -use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Address, B256, U256}; -use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use ethrex_blockchain::{ Blockchain, BlockchainOptions, BlockchainType, payload::{BuildPayloadArgs, create_payload}, }; use ethrex_common::types::ELASTICITY_MULTIPLIER; -use ethrex_config::networks::Network; -use ethrex_storage::{EngineType, Store}; +use ethrex_storage::Store; use helix_tcp_types::merging::{ control::{BuilderCollateral, RelayConfigV1}, order::{MergeOrderRef, TxOrderRef}, @@ -30,62 +26,11 @@ use crate::{ types::EngineConfig, }, node::HeadInfo, + testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}, }; -/// Dev keys from ethrex's `fixtures/keys/private_keys_l1.txt`; the test picks -/// the ones the `LocalDevnet` genesis actually funds. -const KEYS: [&str; 20] = [ - "0x941e103320615d394a55708be13e45994c7d93b932b064dbcb2b511fe3254e2e", - "0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31", - "0x39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d", - "0x53321db7c1e331d93a11a41d16f004d7ff63972ec8ec7c25db329728ceeb1710", - "0xab63b23eb7941c1251757e24b3d2350d2bc05c3c388d06f8fe6feafefb1e8c70", - "0x5d2344259f42259f82d2c140aa66102ba89b57b4883ee441a8b312622bd42491", - "0x27515f805127bebad2fb9b183508bdacb8c763da16f54e0678b16e8f28ef3fff", - "0x7ff1a4c1d57e5e784d327c4c7651e952350bc271f156afb3d00d20f5ef924856", - "0x3a91003acaf4c21b3953d94fa4a6db694fa69e5242b2e37be05dd82761058899", - "0xbb1d0f125b4fb2bb173c318cdead45468474ca71474e2247776b2b4c0fa2d3f5", - "0x850643a0224065ecce3882673c21f56bcf6eef86274cc21cadff15930b59fc8c", - "0x94eb3102993b41ec55c241060f47daa0f6372e2e3ad7e91612ae36c364042e44", - "0xdaf15504c22a352648a71ef2926334fe040ac1d5005019e09f6c979808024dc7", - "0xeaba42282ad33c8ef2524f07277c03a776d98ae19f581990ce75becb7cfa1c23", - "0x3fd98b5187bf6526734efaa644ffbb4e3670d66f5d0268ce0323ec09124bff61", - "0x5288e2f440c7f0cb61a9be8afdeb4295f786383f96f5e35eb0c94ef103996b64", - "0xf296c7802555da2a5a662be70e078cbd38b44f96f8615ae529da41122ce8db05", - "0xbf3beef3bd999ba9f2451e06936f0423cd62b815c9233dd3bc90f7e02a1e8673", - "0x6ecadc396415970e91293726c3f5775225440ea0844ae5616135fd10d66b5954", - "0xa492823c3e193d6c595f37a18e3c06650cf4c74558cc818b16130b293716106f", -]; - -const GWEI: u128 = 1_000_000_000; -const ETH: u128 = 1_000_000_000_000_000_000; const SLOT: u64 = 1; -#[allow(clippy::too_many_arguments)] -fn signed_transfer( - signer: &PrivateKeySigner, - chain_id: u64, - nonce: u64, - to: Address, - value: U256, - max_fee_per_gas: u128, - max_priority_fee_per_gas: u128, -) -> Vec { - let tx = TxEip1559 { - chain_id, - nonce, - gas_limit: 21_000, - max_fee_per_gas, - max_priority_fee_per_gas, - to: to.into(), - value, - access_list: Default::default(), - input: Default::default(), - }; - let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); - alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718() -} - /// Shared in-memory ethrex chain plus the merge participants. Signer roles: /// 0 = winning builder (base coinbase + payment sender), 1 = base user tx, /// 2 = donor origin coinbase, 3/7 = order senders, 4 = relay fee recipient, @@ -105,27 +50,19 @@ struct Fixture { impl Fixture { async fn new() -> Self { - let genesis = Network::LocalDevnet.get_genesis().unwrap(); + let (store, genesis) = dev_genesis_store().await; let chain_id = genesis.config.chain_id; let genesis_block = genesis.get_block(); let genesis_hash = genesis_block.hash(); let genesis_header = genesis_block.header.clone(); - let mut store = Store::new("memory", EngineType::InMemory).unwrap(); - store.add_initial_state(genesis.clone()).await.unwrap(); let blockchain: Arc = Blockchain::new(store.clone(), BlockchainOptions { r#type: BlockchainType::L1, ..Default::default() }) .into(); - let signers: Vec = KEYS - .iter() - .map(|key| PrivateKeySigner::from_str(key).unwrap()) - .filter(|signer| genesis.alloc.contains_key(&eaddr(signer.address()))) - .take(8) - .collect(); - assert_eq!(signers.len(), 8, "dev genesis funds too few of the fixture keys"); + let signers = funded_signers(&genesis, 8); let relay_config = RelayConfigV1 { relay_fee_recipient: signers[4].address(), diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index 52b471ad1..b1d5d2c45 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -13,7 +13,10 @@ mod engine; mod node; mod server; mod spine; +#[cfg(test)] +mod testing; mod utils; +mod validation; use cli::BuilderCli; use config::{MergingConfig, Roles, SimulationConfig}; diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs new file mode 100644 index 000000000..8f791c6c3 --- /dev/null +++ b/crates/builder/src/testing.rs @@ -0,0 +1,83 @@ +use std::str::FromStr; + +use alloy_consensus::{SignableTransaction, TxEip1559}; +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::{Address, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use ethrex_common::types::Genesis; +use ethrex_config::networks::Network; +use ethrex_storage::{EngineType, Store}; + +use crate::engine::convert::eaddr; + +/// Dev keys from ethrex's `fixtures/keys/private_keys_l1.txt`; a fixture picks +/// the ones the `LocalDevnet` genesis actually funds. +pub const KEYS: [&str; 20] = [ + "0x941e103320615d394a55708be13e45994c7d93b932b064dbcb2b511fe3254e2e", + "0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31", + "0x39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d", + "0x53321db7c1e331d93a11a41d16f004d7ff63972ec8ec7c25db329728ceeb1710", + "0xab63b23eb7941c1251757e24b3d2350d2bc05c3c388d06f8fe6feafefb1e8c70", + "0x5d2344259f42259f82d2c140aa66102ba89b57b4883ee441a8b312622bd42491", + "0x27515f805127bebad2fb9b183508bdacb8c763da16f54e0678b16e8f28ef3fff", + "0x7ff1a4c1d57e5e784d327c4c7651e952350bc271f156afb3d00d20f5ef924856", + "0x3a91003acaf4c21b3953d94fa4a6db694fa69e5242b2e37be05dd82761058899", + "0xbb1d0f125b4fb2bb173c318cdead45468474ca71474e2247776b2b4c0fa2d3f5", + "0x850643a0224065ecce3882673c21f56bcf6eef86274cc21cadff15930b59fc8c", + "0x94eb3102993b41ec55c241060f47daa0f6372e2e3ad7e91612ae36c364042e44", + "0xdaf15504c22a352648a71ef2926334fe040ac1d5005019e09f6c979808024dc7", + "0xeaba42282ad33c8ef2524f07277c03a776d98ae19f581990ce75becb7cfa1c23", + "0x3fd98b5187bf6526734efaa644ffbb4e3670d66f5d0268ce0323ec09124bff61", + "0x5288e2f440c7f0cb61a9be8afdeb4295f786383f96f5e35eb0c94ef103996b64", + "0xf296c7802555da2a5a662be70e078cbd38b44f96f8615ae529da41122ce8db05", + "0xbf3beef3bd999ba9f2451e06936f0423cd62b815c9233dd3bc90f7e02a1e8673", + "0x6ecadc396415970e91293726c3f5775225440ea0844ae5616135fd10d66b5954", + "0xa492823c3e193d6c595f37a18e3c06650cf4c74558cc818b16130b293716106f", +]; + +pub const GWEI: u128 = 1_000_000_000; +pub const ETH: u128 = 1_000_000_000_000_000_000; + +#[allow(clippy::too_many_arguments)] +pub fn signed_transfer( + signer: &PrivateKeySigner, + chain_id: u64, + nonce: u64, + to: Address, + value: U256, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, +) -> Vec { + let tx = TxEip1559 { + chain_id, + nonce, + gas_limit: 21_000, + max_fee_per_gas, + max_priority_fee_per_gas, + to: to.into(), + value, + access_list: Default::default(), + input: Default::default(), + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718() +} + +pub async fn dev_genesis_store() -> (Store, Genesis) { + let genesis = Network::LocalDevnet.get_genesis().unwrap(); + let mut store = Store::new("memory", EngineType::InMemory).unwrap(); + store.add_initial_state(genesis.clone()).await.unwrap(); + (store, genesis) +} + +pub fn funded_signers(genesis: &Genesis, count: usize) -> Vec { + let signers: Vec = KEYS + .iter() + .map(|key| PrivateKeySigner::from_str(key).unwrap()) + .filter(|signer| genesis.alloc.contains_key(&eaddr(signer.address()))) + .take(count) + .collect(); + assert_eq!(signers.len(), count, "dev genesis funds too few of the fixture keys"); + signers +} diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs new file mode 100644 index 000000000..15528d090 --- /dev/null +++ b/crates/builder/src/validation/error.rs @@ -0,0 +1,25 @@ +use alloy_primitives::B256; + +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + #[error("block hash mismatch: got {got}, expected {expected}")] + BlockHashMismatch { got: B256, expected: B256 }, + #[error("block parent hash mismatch: got {got}, expected {expected}")] + ParentHashMismatch { got: B256, expected: B256 }, + #[error("block gas limit mismatch: got {got}, expected {expected}")] + GasLimitMismatch { got: u64, expected: u64 }, + #[error("block gas used mismatch: got {got}, expected {expected}")] + GasUsedMismatch { got: u64, expected: u64 }, + #[error("could not decode transaction: {0}")] + DecodeTransaction(String), + #[error("base fee per gas exceeds u64")] + BaseFeeTooLarge, + // Text matched by `BlockSimError::is_temporary`; changing it demotes builders. + #[error("parent block not found")] + MissingParentBlock, + // Text matched by `BlockSimError::is_too_old`. + #[error("block is too old, outside validation window")] + BlockTooOld, + #[error("store error: {0}")] + Store(String), +} diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs new file mode 100644 index 000000000..d3869c0bc --- /dev/null +++ b/crates/builder/src/validation/mod.rs @@ -0,0 +1,114 @@ +// Reached from main once the servers land in step 9 of #527. +#![allow(dead_code)] + +pub mod error; +#[cfg(test)] +mod tests; + +use alloy_primitives::B256; +use alloy_rpc_types::{ + beacon::{relay::BidTrace, requests::ExecutionRequestsV4}, + engine::ExecutionPayloadV3, +}; +use ethrex_common::types::{Block, BlockHeader}; +use ethrex_storage::Store; +use tokio::sync::watch; + +use crate::{ + engine::convert::{b256, payload_v3_to_block}, + node::HeadInfo, + validation::error::ValidationError, +}; + +#[derive(Debug)] +pub struct PreparedBlock { + pub block: Block, + pub parent_header: BlockHeader, +} + +#[derive(Clone)] +pub struct BlockValidator { + store: Store, + head: watch::Receiver, + validation_window: u64, +} + +impl BlockValidator { + pub fn new(store: Store, head: watch::Receiver, validation_window: u64) -> Self { + Self { store, head, validation_window } + } + + pub fn prepare( + &self, + payload: &ExecutionPayloadV3, + message: &BidTrace, + parent_beacon_block_root: B256, + requests: &ExecutionRequestsV4, + ) -> Result { + let block = self.to_block(payload, parent_beacon_block_root, requests)?; + self.validate_message_against_header(&block, message)?; + let parent_header = self.parent_header(&block.header)?; + Ok(PreparedBlock { block, parent_header }) + } + + fn to_block( + &self, + payload: &ExecutionPayloadV3, + parent_beacon_block_root: B256, + requests: &ExecutionRequestsV4, + ) -> Result { + payload_v3_to_block(payload, parent_beacon_block_root, requests) + } + + /// The relay serves the trace's fields, so a trace that misdescribes a valid + /// block is still rejected. + fn validate_message_against_header( + &self, + block: &Block, + message: &BidTrace, + ) -> Result<(), ValidationError> { + let header = &block.header; + let block_hash = b256(block.hash()); + if block_hash != message.block_hash { + return Err(ValidationError::BlockHashMismatch { + got: message.block_hash, + expected: block_hash, + }); + } + if b256(header.parent_hash) != message.parent_hash { + return Err(ValidationError::ParentHashMismatch { + got: message.parent_hash, + expected: b256(header.parent_hash), + }); + } + if header.gas_limit != message.gas_limit { + return Err(ValidationError::GasLimitMismatch { + got: message.gas_limit, + expected: header.gas_limit, + }); + } + if header.gas_used != message.gas_used { + return Err(ValidationError::GasUsedMismatch { + got: message.gas_used, + expected: header.gas_used, + }); + } + Ok(()) + } + + /// A parent past the window is refused: its state may be gone, and that store + /// error would reach the relay as an unclassifiable failure. + fn parent_header(&self, header: &BlockHeader) -> Result { + let parent = self + .store + .get_block_header_by_hash(header.parent_hash) + .map_err(|e| ValidationError::Store(e.to_string()))? + .ok_or(ValidationError::MissingParentBlock)?; + + let head = self.head.borrow().number; + if head.saturating_sub(parent.number) > self.validation_window { + return Err(ValidationError::BlockTooOld); + } + Ok(parent) + } +} diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs new file mode 100644 index 000000000..addc7ba14 --- /dev/null +++ b/crates/builder/src/validation/tests.rs @@ -0,0 +1,342 @@ +use std::sync::Arc; + +use alloy_primitives::{Address, B256, U256}; +use alloy_rpc_types::{ + beacon::{relay::BidTrace, requests::ExecutionRequestsV4}, + engine::ExecutionPayloadV3, +}; +use ethrex_blockchain::{ + Blockchain, BlockchainOptions, BlockchainType, + fork_choice::apply_fork_choice, + payload::{BuildPayloadArgs, create_payload}, +}; +use ethrex_common::{H256, types::ELASTICITY_MULTIPLIER}; +use ethrex_storage::Store; +use helix_common::simulator::BlockSimError; +use tokio::sync::watch; + +use crate::{ + engine::convert::{b256, block_to_payload_v3, eaddr, requests_to_v4}, + node::HeadInfo, + testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}, + validation::{BlockValidator, error::ValidationError}, +}; + +const WINDOW: u64 = 3; + +struct Built { + payload: ExecutionPayloadV3, + requests: ExecutionRequestsV4, +} + +impl Built { + fn block_hash(&self) -> B256 { + self.payload.payload_inner.payload_inner.block_hash + } +} + +struct Fixture { + store: Store, + blockchain: Arc, + genesis_hash: H256, + genesis_timestamp: u64, + chain_id: u64, + gas_limit: u64, + signers: Vec, + proposer: Address, + head: watch::Sender, +} + +impl Fixture { + async fn new() -> Self { + let (store, genesis) = dev_genesis_store().await; + let genesis_block = genesis.get_block(); + let blockchain: Arc = Blockchain::new(store.clone(), BlockchainOptions { + r#type: BlockchainType::L1, + ..Default::default() + }) + .into(); + + let (head, _) = watch::channel(HeadInfo { + number: 0, + hash: genesis_block.hash(), + timestamp: genesis_block.header.timestamp, + is_synced: true, + }); + + Self { + store, + blockchain, + head, + genesis_hash: genesis_block.hash(), + genesis_timestamp: genesis_block.header.timestamp, + chain_id: genesis.config.chain_id, + gas_limit: genesis_block.header.gas_limit, + signers: funded_signers(&genesis, 4), + proposer: Address::repeat_byte(0x77), + } + } + + fn validator(&self) -> BlockValidator { + BlockValidator::new(self.store.clone(), self.head.subscribe(), WINDOW) + } + + /// Builds a valid block on `parent`, paying `self.proposer` in its last tx. + fn build_on(&self, parent: H256, timestamp: u64, nonce: u64) -> Built { + let builder = &self.signers[0]; + let txs = vec![signed_transfer( + builder, + self.chain_id, + nonce, + self.proposer, + U256::from(ETH / 2), + 100 * GWEI, + 0, + )]; + let args = BuildPayloadArgs { + parent, + timestamp, + fee_recipient: eaddr(builder.address()), + random: H256::zero(), + withdrawals: Some(Vec::new()), + beacon_root: Some(H256::zero()), + slot_number: None, + version: 3, + elasticity_multiplier: ELASTICITY_MULTIPLIER, + gas_ceil: self.gas_limit, + }; + let template = create_payload(&args, &self.store, Default::default()).unwrap(); + let decoded = txs + .iter() + .map(|bytes| ethrex_common::types::Transaction::decode_canonical(bytes).unwrap()) + .collect(); + let built = self.blockchain.build_payload_with_transactions(template, decoded).unwrap(); + + Built { + payload: block_to_payload_v3(&built.payload), + requests: requests_to_v4(&built.requests).unwrap(), + } + } + + /// Appends `count` canonical blocks to the chain and returns the new head. + async fn extend_canonical(&self, count: usize) -> H256 { + let mut parent = self.genesis_hash; + let mut timestamp = self.genesis_timestamp; + for i in 0..count { + timestamp += 12; + let built = self.build_on(parent, timestamp, i as u64); + let block = self + .validator() + .to_block(&built.payload, B256::ZERO, &built.requests) + .expect("the fixture builds a convertible block"); + parent = block.hash(); + let number = block.header.number; + let timestamp = block.header.timestamp; + self.blockchain.add_block(block).unwrap(); + apply_fork_choice(&self.store, parent, parent, parent).await.unwrap(); + self.head.send_replace(HeadInfo { number, hash: parent, timestamp, is_synced: true }); + } + parent + } + + fn bid_trace(&self, built: &Built) -> BidTrace { + let header = &built.payload.payload_inner.payload_inner; + BidTrace { + slot: 1, + parent_hash: header.parent_hash, + block_hash: header.block_hash, + builder_pubkey: Default::default(), + proposer_pubkey: Default::default(), + proposer_fee_recipient: self.proposer, + gas_limit: header.gas_limit, + gas_used: header.gas_used, + value: U256::from(ETH / 2), + } + } +} + +#[tokio::test] +async fn a_valid_submission_prepares_its_block() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + let prepared = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a block the fixture built must prepare"); + + assert_eq!(b256(prepared.block.hash()), built.block_hash()); + assert_eq!(prepared.parent_header.hash(), fixture.genesis_hash); +} + +/// The payload carries a `block_hash`, but the validator recomputes it from the +/// converted header. The two must agree, or the conversion has lost a field. +#[tokio::test] +async fn the_payload_round_trips_to_the_same_block_hash() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + let block = fixture + .validator() + .to_block(&built.payload, B256::ZERO, &built.requests) + .expect("a block the fixture built must convert"); + + assert_eq!(b256(block.hash()), built.block_hash()); +} + +#[tokio::test] +async fn a_bid_trace_with_the_wrong_block_hash_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut message = fixture.bid_trace(&built); + message.block_hash = B256::repeat_byte(0xaa); + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a mismatched block hash must be rejected"); + + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); +} + +#[tokio::test] +async fn a_bid_trace_with_the_wrong_parent_hash_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut message = fixture.bid_trace(&built); + message.parent_hash = B256::repeat_byte(0xbb); + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a mismatched parent hash must be rejected"); + + assert!(matches!(error, ValidationError::ParentHashMismatch { .. }), "{error}"); +} + +#[tokio::test] +async fn a_bid_trace_with_the_wrong_gas_limit_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut message = fixture.bid_trace(&built); + message.gas_limit += 1; + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a mismatched gas limit must be rejected"); + + assert!(matches!(error, ValidationError::GasLimitMismatch { .. }), "{error}"); +} + +#[tokio::test] +async fn a_bid_trace_with_the_wrong_gas_used_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut message = fixture.bid_trace(&built); + message.gas_used += 1; + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a mismatched gas used must be rejected"); + + assert!(matches!(error, ValidationError::GasUsedMismatch { .. }), "{error}"); +} + +/// A payload edited after the bid was signed hashes to a different block, so it +/// fails the block hash check rather than any per-field check. +#[tokio::test] +async fn a_tampered_payload_fails_the_block_hash_check() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + built.payload.payload_inner.payload_inner.gas_used += 1; + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("an edited payload must be rejected"); + + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); +} + +#[tokio::test] +async fn an_undecodable_transaction_is_rejected() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + built.payload.payload_inner.payload_inner.transactions = vec![vec![0xde, 0xad].into()]; + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("an undecodable transaction must be rejected"); + + assert!(matches!(error, ValidationError::DecodeTransaction(_)), "{error}"); +} + +#[tokio::test] +async fn an_unknown_parent_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut message = fixture.bid_trace(&built); + let mut payload = built.payload.clone(); + payload.payload_inner.payload_inner.parent_hash = B256::repeat_byte(0xcc); + message.parent_hash = B256::repeat_byte(0xcc); + message.block_hash = + b256(fixture.validator().to_block(&payload, B256::ZERO, &built.requests).unwrap().hash()); + + let error = fixture + .validator() + .prepare(&payload, &message, B256::ZERO, &built.requests) + .expect_err("an unknown parent must be rejected"); + + assert!(matches!(error, ValidationError::MissingParentBlock), "{error}"); +} + +#[tokio::test] +async fn a_parent_inside_the_validation_window_is_accepted() { + let fixture = Fixture::new().await; + let head = fixture.extend_canonical(WINDOW as usize).await; + // extend_canonical spent one nonce per block it appended. + let built = fixture.build_on(head, fixture.genesis_timestamp + 1200, WINDOW); + let message = fixture.bid_trace(&built); + + fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a block built on the head must prepare"); +} + +/// The chain moves on while a submission is in flight. A parent further back +/// than the window is refused rather than validated against stale state. +#[tokio::test] +async fn a_parent_outside_the_validation_window_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + fixture.extend_canonical(WINDOW as usize + 1).await; + + let error = fixture + .validator() + .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a parent outside the window must be rejected"); + + assert!(matches!(error, ValidationError::BlockTooOld), "{error}"); +} + +/// The relay classifies a simulation failure by its message text. These two +/// must keep reaching `is_temporary` and `is_too_old`, or a transient failure +/// starts demoting builders. +#[test] +fn the_relay_classifies_the_parent_errors_it_must_retry() { + let missing = + BlockSimError::BlockValidationFailed(ValidationError::MissingParentBlock.to_string()); + assert!(missing.is_temporary(), "{missing}"); + + let too_old = BlockSimError::BlockValidationFailed(ValidationError::BlockTooOld.to_string()); + assert!(too_old.is_too_old(), "{too_old}"); + assert!(!too_old.is_demotable(), "{too_old}"); +} From 3824c9245ce0582a46dcc7ff4e3cf4dc0386a5d1 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 28 Aug 2026 21:25:32 +0100 Subject: [PATCH 18/29] Execute a submitted block and check it against its header Runs validate_block_pre_execution, executes on the parent state, then checks gas used, receipts root, requests hash and state root. The store is only read: the submitted block is never persisted. Step 4 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/src/validation/error.rs | 10 ++ crates/builder/src/validation/mod.rs | 71 ++++++++++- crates/builder/src/validation/tests.rs | 158 ++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 3 deletions(-) diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs index 15528d090..90392e0c5 100644 --- a/crates/builder/src/validation/error.rs +++ b/crates/builder/src/validation/error.rs @@ -22,4 +22,14 @@ pub enum ValidationError { BlockTooOld, #[error("store error: {0}")] Store(String), + #[error("invalid block: {0}")] + PreExecution(String), + #[error("execution failed: {0}")] + Execution(String), + #[error("invalid block: {0}")] + PostExecution(String), + #[error("block state root mismatch: got {got}, expected {expected}")] + StateRootMismatch { got: B256, expected: B256 }, + #[error("parent state is not available")] + MissingParentState, } diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index d3869c0bc..c250d14d8 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -10,7 +10,15 @@ use alloy_rpc_types::{ beacon::{relay::BidTrace, requests::ExecutionRequestsV4}, engine::ExecutionPayloadV3, }; -use ethrex_common::types::{Block, BlockHeader}; +use ethrex_blockchain::{BlockchainType, new_evm, vm::StoreVmDatabase}; +use ethrex_common::{ + types::{AccountUpdate, Block, BlockHeader, ELASTICITY_MULTIPLIER, Receipt}, + validation::{ + validate_block_pre_execution, validate_gas_used, validate_receipts_root_and_logs_bloom, + validate_requests_hash, + }, +}; +use ethrex_crypto::NativeCrypto; use ethrex_storage::Store; use tokio::sync::watch; @@ -26,6 +34,13 @@ pub struct PreparedBlock { pub parent_header: BlockHeader, } +#[derive(Debug)] +pub struct ExecutedBlock { + pub block: Block, + pub receipts: Vec, + pub account_updates: Vec, +} + #[derive(Clone)] pub struct BlockValidator { store: Store, @@ -51,6 +66,60 @@ impl BlockValidator { Ok(PreparedBlock { block, parent_header }) } + pub fn validate( + &self, + payload: &ExecutionPayloadV3, + message: &BidTrace, + parent_beacon_block_root: B256, + requests: &ExecutionRequestsV4, + ) -> Result { + let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + self.execute(prepared) + } + + /// Executes against the parent state and checks the header against what + /// execution produced. Writes nothing to the store. + pub fn execute(&self, prepared: PreparedBlock) -> Result { + let PreparedBlock { block, parent_header } = prepared; + let chain_config = self.store.get_chain_config(); + + validate_block_pre_execution(&block, &parent_header, &chain_config, ELASTICITY_MULTIPLIER) + .map_err(|e| ValidationError::PreExecution(e.to_string()))?; + + let vm_db = StoreVmDatabase::new(self.store.clone(), parent_header) + .map_err(|e| ValidationError::Execution(e.to_string()))?; + let mut vm = new_evm(&BlockchainType::L1, vm_db) + .map_err(|e| ValidationError::Execution(e.to_string()))?; + + let (result, _bal) = + vm.execute_block(&block).map_err(|e| ValidationError::Execution(e.to_string()))?; + + validate_gas_used(result.block_gas_used, &block.header) + .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + validate_receipts_root_and_logs_bloom(&block.header, &result.receipts, &NativeCrypto) + .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + validate_requests_hash(&block.header, &chain_config, &result.requests) + .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + + let account_updates = + vm.get_state_transitions().map_err(|e| ValidationError::Execution(e.to_string()))?; + let state_root = self + .store + .apply_account_updates_batch(block.header.parent_hash, &account_updates) + .map_err(|e| ValidationError::Store(e.to_string()))? + .ok_or(ValidationError::MissingParentState)? + .state_trie_hash; + + if state_root != block.header.state_root { + return Err(ValidationError::StateRootMismatch { + got: b256(block.header.state_root), + expected: b256(state_root), + }); + } + + Ok(ExecutedBlock { block, receipts: result.receipts, account_updates }) + } + fn to_block( &self, payload: &ExecutionPayloadV3, diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index addc7ba14..cb3c27811 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -16,7 +16,9 @@ use helix_common::simulator::BlockSimError; use tokio::sync::watch; use crate::{ - engine::convert::{b256, block_to_payload_v3, eaddr, requests_to_v4}, + engine::convert::{ + b256, block_to_payload_v3, eaddr, h256, payload_v3_to_block, requests_to_v4, + }, node::HeadInfo, testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}, validation::{BlockValidator, error::ValidationError}, @@ -141,10 +143,12 @@ impl Fixture { fn bid_trace(&self, built: &Built) -> BidTrace { let header = &built.payload.payload_inner.payload_inner; + let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests) + .expect("the fixture builds a convertible payload"); BidTrace { slot: 1, parent_hash: header.parent_hash, - block_hash: header.block_hash, + block_hash: b256(block.hash()), builder_pubkey: Default::default(), proposer_pubkey: Default::default(), proposer_fee_recipient: self.proposer, @@ -340,3 +344,153 @@ fn the_relay_classifies_the_parent_errors_it_must_retry() { assert!(too_old.is_too_old(), "{too_old}"); assert!(!too_old.is_demotable(), "{too_old}"); } + +fn withdrawal_request() -> ExecutionRequestsV4 { + ExecutionRequestsV4 { + withdrawals: vec![alloy_eips::eip7002::WithdrawalRequest { + source_address: Address::repeat_byte(0x11), + validator_pubkey: alloy_primitives::FixedBytes::repeat_byte(0x22), + amount: 1, + }], + ..Default::default() + } +} + +#[tokio::test] +async fn a_valid_block_passes_execution() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + let executed = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a block the fixture built must validate"); + + assert_eq!(executed.receipts.len(), 1); + assert!(!executed.account_updates.is_empty()); +} + +/// A simulator must never persist what it validates. The submitted block is not +/// stored, and the chain does not move. +#[tokio::test] +async fn validating_a_block_does_not_store_it() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a block the fixture built must validate"); + + assert_eq!(fixture.store.get_latest_block_number().await.unwrap(), 0); + assert!( + fixture.store.get_block_header_by_hash(h256(message.block_hash)).unwrap().is_none(), + "the validated block must not be in the store" + ); +} + +#[tokio::test] +async fn a_tampered_state_root_is_rejected() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + built.payload.payload_inner.payload_inner.state_root = B256::repeat_byte(0xaa); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a wrong state root must be rejected"); + + assert!(matches!(error, ValidationError::StateRootMismatch { .. }), "{error}"); +} + +#[tokio::test] +async fn a_tampered_gas_used_is_rejected() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + built.payload.payload_inner.payload_inner.gas_used += 1; + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a wrong gas used must be rejected"); + + assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); +} + +#[tokio::test] +async fn a_tampered_receipts_root_is_rejected() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + built.payload.payload_inner.payload_inner.receipts_root = B256::repeat_byte(0xbb); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a wrong receipts root must be rejected"); + + assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); +} + +/// The requests are submitted alongside the payload and commit into the header, +/// so a bundle execution did not produce must fail. +#[tokio::test] +async fn execution_requests_that_the_block_did_not_produce_are_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let requests = withdrawal_request(); + let tampered = Built { payload: built.payload.clone(), requests }; + let message = fixture.bid_trace(&tampered); + + let error = fixture + .validator() + .validate(&tampered.payload, &message, B256::ZERO, &tampered.requests) + .expect_err("unproduced requests must be rejected"); + + assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); +} + +/// Proves the pre-execution checks run: a bad base fee is caught by +/// `validate_block_pre_execution`, before any transaction executes. +#[tokio::test] +async fn a_tampered_base_fee_is_rejected_before_execution() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + built.payload.payload_inner.payload_inner.base_fee_per_gas += U256::from(1); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a wrong base fee must be rejected"); + + assert!(matches!(error, ValidationError::PreExecution(_)), "{error}"); +} + +#[tokio::test] +async fn a_block_with_an_unexecutable_transaction_is_rejected() { + let fixture = Fixture::new().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let bad_nonce = signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 99, + fixture.proposer, + U256::from(1), + 100 * GWEI, + 0, + ); + built.payload.payload_inner.payload_inner.transactions.push(bad_nonce.into()); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("an unexecutable transaction must be rejected"); + + assert!(matches!(error, ValidationError::Execution(_)), "{error}"); +} From 7f001d9a5b1cf812a5db327c5fb43fbfcbbac810 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 29 Aug 2026 15:40:35 +0100 Subject: [PATCH 19/29] Check the proposer payment on both the regular and merged paths The recipient's whole-block balance delta decides first. When the proposer also spends, the payment must be recognisable: a direct transfer, or a PAYMENT_FORWARDER call where its runtime is deployed. Merged blocks sum the base payment tx and the trailing distribution tx. helix-common becomes a runtime dependency for the payment constants. Ports the current reth behavior unchanged, including the multiSend gap that gattaca-com/helix#533 will close for both simulators at once. Step 5 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/Cargo.toml | 2 +- crates/builder/src/engine/convert.rs | 4 + crates/builder/src/testing.rs | 42 ++- crates/builder/src/validation/error.rs | 2 + crates/builder/src/validation/mod.rs | 196 +++++++++- crates/builder/src/validation/tests.rs | 479 ++++++++++++++++++++++++- 6 files changed, 714 insertions(+), 11 deletions(-) diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 26c989581..c368cd974 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -34,6 +34,7 @@ flux.workspace = true hex.workspace = true flux-network.workspace = true flux-utils.workspace = true +helix-common.workspace = true helix-tcp-types.workspace = true num_cpus.workspace = true rand.workspace = true @@ -53,4 +54,3 @@ uuid = { workspace = true, features = ["serde"] } zstd.workspace = true [dev-dependencies] -helix-common.workspace = true diff --git a/crates/builder/src/engine/convert.rs b/crates/builder/src/engine/convert.rs index 5719a17d1..43277e4db 100644 --- a/crates/builder/src/engine/convert.rs +++ b/crates/builder/src/engine/convert.rs @@ -40,6 +40,10 @@ pub fn au256(v: EU256) -> AU256 { AU256::from_be_bytes(v.to_big_endian()) } +pub fn eu256(v: AU256) -> EU256 { + EU256::from_big_endian(&v.to_be_bytes::<32>()) +} + pub fn ewithdrawal(w: &alloy_eips::eip4895::Withdrawal) -> Withdrawal { Withdrawal { index: w.index, diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index 8f791c6c3..c4a54ebe2 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -5,7 +5,7 @@ use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Address, U256}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; -use ethrex_common::types::Genesis; +use ethrex_common::types::{Genesis, GenesisAccount}; use ethrex_config::networks::Network; use ethrex_storage::{EngineType, Store}; @@ -65,12 +65,50 @@ pub fn signed_transfer( } pub async fn dev_genesis_store() -> (Store, Genesis) { - let genesis = Network::LocalDevnet.get_genesis().unwrap(); + dev_genesis_store_with(|_| {}).await +} + +/// `edit` may add allocations before the genesis state root is computed. +pub async fn dev_genesis_store_with(edit: impl FnOnce(&mut Genesis)) -> (Store, Genesis) { + let mut genesis = Network::LocalDevnet.get_genesis().unwrap(); + edit(&mut genesis); let mut store = Store::new("memory", EngineType::InMemory).unwrap(); store.add_initial_state(genesis.clone()).await.unwrap(); (store, genesis) } +/// The `PaymentForwarder` runtime, from contracts/README.md. +pub fn deploy_payment_forwarder(genesis: &mut Genesis) { + genesis.alloc.insert(eaddr(helix_common::PAYMENT_FORWARDER), GenesisAccount { + code: hex::decode("5f358060e01c4218600f5760401cff5b5f5ffd00").unwrap().into(), + storage: Default::default(), + balance: ethrex_common::U256::zero(), + nonce: 0, + }); +} + +/// A legacy transaction with no chain id, which EIP-155 replay protection does +/// not cover. +pub fn signed_unprotected_transfer( + signer: &PrivateKeySigner, + nonce: u64, + to: Address, + value: U256, + gas_price: u128, +) -> Vec { + let tx = alloy_consensus::TxLegacy { + chain_id: None, + nonce, + gas_price, + gas_limit: 21_000, + to: to.into(), + value, + input: Default::default(), + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718() +} + pub fn funded_signers(genesis: &Genesis, count: usize) -> Vec { let signers: Vec = KEYS .iter() diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs index 90392e0c5..093667ffd 100644 --- a/crates/builder/src/validation/error.rs +++ b/crates/builder/src/validation/error.rs @@ -32,4 +32,6 @@ pub enum ValidationError { StateRootMismatch { got: B256, expected: B256 }, #[error("parent state is not available")] MissingParentState, + #[error("could not verify proposer payment")] + ProposerPayment, } diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index c250d14d8..1634cc423 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -12,6 +12,7 @@ use alloy_rpc_types::{ }; use ethrex_blockchain::{BlockchainType, new_evm, vm::StoreVmDatabase}; use ethrex_common::{ + Address as EAddress, U256 as EU256, types::{AccountUpdate, Block, BlockHeader, ELASTICITY_MULTIPLIER, Receipt}, validation::{ validate_block_pre_execution, validate_gas_used, validate_receipts_root_and_logs_bloom, @@ -20,10 +21,15 @@ use ethrex_common::{ }; use ethrex_crypto::NativeCrypto; use ethrex_storage::Store; +use ethrex_vm::VmDatabase; +use helix_common::{ + PAYMENT_FORWARDER, PAYMENT_FORWARDER_CODE_HASH, payment::multisend_paid_amount, + payment_forwarder_recipient, +}; use tokio::sync::watch; use crate::{ - engine::convert::{b256, payload_v3_to_block}, + engine::convert::{b256, eaddr, eu256, h256, payload_v3_to_block}, node::HeadInfo, validation::error::ValidationError, }; @@ -37,6 +43,7 @@ pub struct PreparedBlock { #[derive(Debug)] pub struct ExecutedBlock { pub block: Block, + pub parent_header: BlockHeader, pub receipts: Vec, pub account_updates: Vec, } @@ -74,7 +81,25 @@ impl BlockValidator { requests: &ExecutionRequestsV4, ) -> Result { let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; - self.execute(prepared) + let executed = self.execute(prepared)?; + self.ensure_payment(&executed, message)?; + Ok(executed) + } + + /// Relay-internal merged-block path. A merged block's payment is split + /// across the base block's own payment tx and the appended distribution tx. + pub fn validate_merged( + &self, + payload: &ExecutionPayloadV3, + message: &BidTrace, + parent_beacon_block_root: B256, + requests: &ExecutionRequestsV4, + base_payment_tx_index: u64, + ) -> Result { + let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + let executed = self.execute(prepared)?; + self.ensure_merged_payment(&executed, message, base_payment_tx_index as usize)?; + Ok(executed) } /// Executes against the parent state and checks the header against what @@ -86,6 +111,7 @@ impl BlockValidator { validate_block_pre_execution(&block, &parent_header, &chain_config, ELASTICITY_MULTIPLIER) .map_err(|e| ValidationError::PreExecution(e.to_string()))?; + let parent_header_for_reads = parent_header.clone(); let vm_db = StoreVmDatabase::new(self.store.clone(), parent_header) .map_err(|e| ValidationError::Execution(e.to_string()))?; let mut vm = new_evm(&BlockchainType::L1, vm_db) @@ -117,7 +143,171 @@ impl BlockValidator { }); } - Ok(ExecutedBlock { block, receipts: result.receipts, account_updates }) + Ok(ExecutedBlock { + block, + parent_header: parent_header_for_reads, + receipts: result.receipts, + account_updates, + }) + } + + /// The balance delta is the ground truth. It falls short when the proposer + /// also spends, and then the last transaction must be a payment. + fn ensure_payment( + &self, + executed: &ExecutedBlock, + message: &BidTrace, + ) -> Result<(), ValidationError> { + if self.paid_by_balance(executed, message)? { + return Ok(()); + } + + let last_ix = executed + .block + .body + .transactions + .len() + .checked_sub(1) + .ok_or(ValidationError::ProposerPayment)?; + + let paid = self.recognized_payment_at(executed, message.proposer_fee_recipient, last_ix)?; + // The regular path is a single trailing payment for exactly the bid. + if paid != eu256(message.value) { + return Err(ValidationError::ProposerPayment); + } + Ok(()) + } + + /// Merged counterpart of [`Self::ensure_payment`]. `base_payment_tx_index` + /// comes from the relay; a wrong one finds no payment and fails closed. + fn ensure_merged_payment( + &self, + executed: &ExecutedBlock, + message: &BidTrace, + base_payment_tx_index: usize, + ) -> Result<(), ValidationError> { + if self.paid_by_balance(executed, message)? { + return Ok(()); + } + + let last_ix = executed + .block + .body + .transactions + .len() + .checked_sub(1) + .ok_or(ValidationError::ProposerPayment)?; + + let recipient = message.proposer_fee_recipient; + let mut total = self.recognized_payment_at(executed, recipient, last_ix)?; + if base_payment_tx_index != last_ix { + total += self.recognized_payment_at(executed, recipient, base_payment_tx_index)?; + } + + if total >= eu256(message.value) { + return Ok(()); + } + Err(ValidationError::ProposerPayment) + } + + /// Withdrawals are consensus-layer income, so they count against the rise + /// rather than towards it. + fn paid_by_balance( + &self, + executed: &ExecutedBlock, + message: &BidTrace, + ) -> Result { + let recipient = eaddr(message.proposer_fee_recipient); + let mut before = self.balance_at_parent(&executed.parent_header, recipient)?; + let after = executed + .account_updates + .iter() + .find(|update| update.address == recipient) + .and_then(|update| update.info.as_ref().map(|info| info.balance)) + .unwrap_or(before); + + for withdrawal in executed.block.body.withdrawals.iter().flatten() { + if withdrawal.address == recipient { + before += EU256::from(withdrawal.amount) * EU256::from(1_000_000_000u64); + } + } + + Ok(after >= before + eu256(message.value)) + } + + fn balance_at_parent( + &self, + parent_header: &BlockHeader, + address: EAddress, + ) -> Result { + let db = StoreVmDatabase::new(self.store.clone(), parent_header.clone()) + .map_err(|e| ValidationError::Store(e.to_string()))?; + Ok(db + .get_account_state(address) + .map_err(|e| ValidationError::Store(e.to_string()))? + .map(|account| account.balance) + .unwrap_or_default()) + } + + /// What the transaction at `ix` pays `recipient`. Zero rather than an error + /// for anything unrecognised: one bad position must not fail the block. + fn recognized_payment_at( + &self, + executed: &ExecutedBlock, + recipient: alloy_primitives::Address, + ix: usize, + ) -> Result { + let (Some(tx), Some(receipt)) = + (executed.block.body.transactions.get(ix), executed.receipts.get(ix)) + else { + return Ok(EU256::zero()); + }; + if !receipt.succeeded { + return Ok(EU256::zero()); + } + + let to = match tx.to() { + ethrex_common::types::TxKind::Call(to) => Some(to), + ethrex_common::types::TxKind::Create => None, + }; + let paid_directly = to == Some(eaddr(recipient)) && tx.data().is_empty(); + let paid_via_forwarder = to == Some(eaddr(PAYMENT_FORWARDER)) && + payment_forwarder_recipient(tx.data()) == Some(recipient) && + self.forwarder_is_deployed(&executed.parent_header)?; + + let contributed = if paid_directly || paid_via_forwarder { + tx.value() + } else { + eu256(multisend_paid_amount(tx.data(), recipient)) + }; + if contributed.is_zero() { + return Ok(EU256::zero()); + } + + // A legacy transaction with no chain id is replayable on another chain. + if tx.chain_id() != Some(self.store.get_chain_config().chain_id) { + return Ok(EU256::zero()); + } + if !tx + .effective_gas_tip(executed.block.header.base_fee_per_gas) + .unwrap_or_default() + .is_zero() + { + return Ok(EU256::zero()); + } + + Ok(contributed) + } + + /// A value call to an address with no code succeeds and keeps the value, so + /// the forwarder shape only pays where its runtime is present. + fn forwarder_is_deployed(&self, parent_header: &BlockHeader) -> Result { + let db = StoreVmDatabase::new(self.store.clone(), parent_header.clone()) + .map_err(|e| ValidationError::Store(e.to_string()))?; + Ok(db + .get_account_state(eaddr(PAYMENT_FORWARDER)) + .map_err(|e| ValidationError::Store(e.to_string()))? + .is_some_and(|account| account.code_hash == h256(PAYMENT_FORWARDER_CODE_HASH))) } fn to_block( diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index cb3c27811..118b70432 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -20,7 +20,10 @@ use crate::{ b256, block_to_payload_v3, eaddr, h256, payload_v3_to_block, requests_to_v4, }, node::HeadInfo, - testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}, + testing::{ + ETH, GWEI, deploy_payment_forwarder, dev_genesis_store_with, funded_signers, + signed_transfer, signed_unprotected_transfer, + }, validation::{BlockValidator, error::ValidationError}, }; @@ -51,7 +54,16 @@ struct Fixture { impl Fixture { async fn new() -> Self { - let (store, genesis) = dev_genesis_store().await; + Self::with_genesis(|_| {}).await + } + + async fn with_forwarder() -> Self { + Self::with_genesis(deploy_payment_forwarder).await + } + + async fn with_genesis(edit: impl FnOnce(&mut ethrex_common::types::Genesis)) -> Self { + let (store, genesis) = dev_genesis_store_with(edit).await; + let signers = funded_signers(&genesis, 4); let genesis_block = genesis.get_block(); let blockchain: Arc = Blockchain::new(store.clone(), BlockchainOptions { r#type: BlockchainType::L1, @@ -74,8 +86,8 @@ impl Fixture { genesis_timestamp: genesis_block.header.timestamp, chain_id: genesis.config.chain_id, gas_limit: genesis_block.header.gas_limit, - signers: funded_signers(&genesis, 4), - proposer: Address::repeat_byte(0x77), + proposer: signers[3].address(), + signers, } } @@ -95,12 +107,37 @@ impl Fixture { 100 * GWEI, 0, )]; + self.build_block(parent, timestamp, txs, Vec::new()) + } + + /// The proposer spends, so its whole-block balance delta falls short of the + /// bid value and the payment must be recognised from a transaction. + fn proposer_spend(&self, nonce: u64) -> Vec { + signed_transfer( + &self.signers[3], + self.chain_id, + nonce, + Address::repeat_byte(0x55), + U256::from(GWEI), + 100 * GWEI, + 0, + ) + } + + fn build_block( + &self, + parent: H256, + timestamp: u64, + txs: Vec>, + withdrawals: Vec, + ) -> Built { + let builder = &self.signers[0]; let args = BuildPayloadArgs { parent, timestamp, fee_recipient: eaddr(builder.address()), random: H256::zero(), - withdrawals: Some(Vec::new()), + withdrawals: Some(withdrawals), beacon_root: Some(H256::zero()), slot_number: None, version: 3, @@ -141,6 +178,33 @@ impl Fixture { parent } + fn signed_call( + &self, + signer: &alloy_signer_local::PrivateKeySigner, + nonce: u64, + to: Address, + value: U256, + input: Vec, + ) -> Vec { + use alloy_consensus::SignableTransaction; + use alloy_signer::SignerSync; + let tx = alloy_consensus::TxEip1559 { + chain_id: self.chain_id, + nonce, + gas_limit: 100_000, + max_fee_per_gas: 100 * GWEI, + max_priority_fee_per_gas: 0, + to: to.into(), + value, + access_list: Default::default(), + input: input.into(), + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + alloy_eips::eip2718::Encodable2718::encoded_2718(&alloy_consensus::TxEnvelope::from( + tx.into_signed(signature), + )) + } + fn bid_trace(&self, built: &Built) -> BidTrace { let header = &built.payload.payload_inner.payload_inner; let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests) @@ -494,3 +558,408 @@ async fn a_block_with_an_unexecutable_transaction_is_rejected() { assert!(matches!(error, ValidationError::Execution(_)), "{error}"); } + +/// A block whose transactions raise the proposer's balance by the bid value +/// needs no recognisable payment transaction. +#[tokio::test] +async fn a_payment_by_balance_delta_is_accepted() { + let fixture = Fixture::new().await; + let value = U256::from(ETH / 2); + let txs = vec![ + signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + fixture.proposer, + value, + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[2], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + U256::from(1), + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a balance delta covering the bid must be accepted"); +} + +#[tokio::test] +async fn a_trailing_direct_transfer_is_accepted() { + let fixture = Fixture::new().await; + let value = U256::from(ETH / 2); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + value, + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a trailing direct transfer must be accepted"); +} + +#[tokio::test] +async fn an_underpaid_block_is_rejected() { + let fixture = Fixture::new().await; + let paid = U256::from(ETH / 2); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + paid, + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = paid + U256::from(1); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("paying less than the bid must be rejected"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +/// A payment transaction that tips the builder is extracting value the bid did +/// not account for. +#[tokio::test] +async fn a_payment_tx_with_a_priority_fee_is_rejected() { + let fixture = Fixture::new().await; + let value = U256::from(ETH / 2); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + value, + 100 * GWEI, + GWEI, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a tipping payment tx must be rejected"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +/// EIP-155 replay protection binds the payment to this chain. A legacy +/// transaction carrying no chain id is replayable, so it is not a payment. +#[tokio::test] +async fn an_unprotected_payment_tx_is_rejected() { + let fixture = Fixture::new().await; + let value = U256::from(ETH / 2); + let txs = vec![ + fixture.proposer_spend(0), + signed_unprotected_transfer(&fixture.signers[0], 0, fixture.proposer, value, 100 * GWEI), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("an unprotected payment tx must be rejected"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +/// A withdrawal is consensus-layer income, not the builder's payment, so it is +/// added to the balance the payment is measured against. +#[tokio::test] +async fn a_withdrawal_does_not_pay_the_bid() { + let fixture = Fixture::new().await; + let value = U256::from(ETH / 2); + let withdrawal = ethrex_common::types::Withdrawal { + index: 0, + validator_index: 0, + address: eaddr(fixture.proposer), + amount: 500_000_000, // gwei, == ETH / 2 + }; + let built = fixture.build_block( + fixture.genesis_hash, + fixture.genesis_timestamp + 12, + vec![fixture.proposer_spend(0)], + vec![withdrawal], + ); + let mut message = fixture.bid_trace(&built); + message.value = value; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a withdrawal must not count as the bid payment"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +fn forwarder_calldata(timestamp: u64, recipient: Address) -> Vec { + let mut calldata = (timestamp as u32).to_be_bytes().to_vec(); + calldata.extend_from_slice(recipient.as_slice()); + calldata +} + +#[tokio::test] +async fn a_payment_through_the_forwarder_is_accepted() { + let fixture = Fixture::with_forwarder().await; + let value = U256::from(ETH / 2); + let timestamp = fixture.genesis_timestamp + 12; + let txs = vec![ + fixture.proposer_spend(0), + fixture.signed_call( + &fixture.signers[0], + 0, + helix_common::PAYMENT_FORWARDER, + value, + forwarder_calldata(timestamp, fixture.proposer), + ), + ]; + let built = fixture.build_block(fixture.genesis_hash, timestamp, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect("a forwarder payment must be accepted where the forwarder is deployed"); +} + +/// A value call to an address with no code succeeds and keeps the value, so the +/// forwarder shape only counts where the forwarder runtime is actually present. +#[tokio::test] +async fn a_forwarder_payment_is_rejected_where_the_forwarder_is_absent() { + let fixture = Fixture::new().await; + let value = U256::from(ETH / 2); + let timestamp = fixture.genesis_timestamp + 12; + let txs = vec![ + fixture.proposer_spend(0), + fixture.signed_call( + &fixture.signers[0], + 0, + helix_common::PAYMENT_FORWARDER, + value, + forwarder_calldata(timestamp, fixture.proposer), + ), + ]; + let built = fixture.build_block(fixture.genesis_hash, timestamp, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("an undeployed forwarder must not be trusted"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +/// The forwarder reverts unless the calldata timestamp matches the block, and a +/// reverted payment pays nothing. +#[tokio::test] +async fn a_reverted_payment_tx_is_rejected() { + let fixture = Fixture::with_forwarder().await; + let value = U256::from(ETH / 2); + let timestamp = fixture.genesis_timestamp + 12; + let txs = vec![ + fixture.proposer_spend(0), + fixture.signed_call( + &fixture.signers[0], + 0, + helix_common::PAYMENT_FORWARDER, + value, + forwarder_calldata(timestamp + 1, fixture.proposer), + ), + ]; + let built = fixture.build_block(fixture.genesis_hash, timestamp, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = value; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("a reverted payment must be rejected"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +/// A merged block pays in two places: the base block's own payment transaction, +/// kept at `base_payment_tx_index`, and the distribution transaction appended +/// last. +#[tokio::test] +async fn a_merged_payment_split_across_two_txs_is_accepted() { + let fixture = Fixture::new().await; + let base = U256::from(ETH / 4); + let added = U256::from(ETH / 4); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + base, + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + U256::from(1), + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[2], + fixture.chain_id, + 0, + fixture.proposer, + added, + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = base + added; + + fixture + .validator() + .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, 1) + .expect("both payment positions must count"); +} + +/// The relay supplies `base_payment_tx_index`. A wrong one must fail closed +/// rather than let an underpaid block through. +#[tokio::test] +async fn a_merged_payment_with_a_wrong_base_index_is_rejected() { + let fixture = Fixture::new().await; + let base = U256::from(ETH / 4); + let added = U256::from(ETH / 4); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + base, + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + U256::from(1), + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[2], + fixture.chain_id, + 0, + fixture.proposer, + added, + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = base + added; + + let error = fixture + .validator() + .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, 2) + .expect_err("a wrong base payment index must fail closed"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} + +/// The regular path only looks at the last transaction, so the same block that +/// passes as a merged submission fails as a regular one. +#[tokio::test] +async fn a_split_payment_is_not_accepted_on_the_regular_path() { + let fixture = Fixture::new().await; + let base = U256::from(ETH / 4); + let added = U256::from(ETH / 4); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + base, + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[2], + fixture.chain_id, + 0, + fixture.proposer, + added, + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = base + added; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests) + .expect_err("the regular path must not sum two positions"); + + assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); +} From ca5b60cb96e20ac33466044432a8986f525d543a Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 31 Aug 2026 10:08:22 +0100 Subject: [PATCH 20/29] Reject blocks that interact with a blacklisted address Interaction means effect: a state change, or a transaction addressed to the account. The coinbase and the proposer fee recipient are checked directly, since neither has to change state. Reading an account is not interaction. The reth simulator rejects such a block, so the two disagree; see the test that records it. Step 6 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/builder/Cargo.toml | 1 + crates/builder/src/testing.rs | 11 + crates/builder/src/validation/error.rs | 4 +- crates/builder/src/validation/mod.rs | 65 +++++- crates/builder/src/validation/tests.rs | 292 +++++++++++++++++++++++-- 6 files changed, 347 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac407dcbd..6f0619a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6458,6 +6458,7 @@ dependencies = [ "clap", "core_affinity", "crossbeam-channel", + "dashmap 5.5.3", "ethereum_ssz", "ethrex-blockchain", "ethrex-common", diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index c368cd974..382f656ec 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -18,6 +18,7 @@ bytes.workspace = true clap = { workspace = true, features = ["env"] } core_affinity.workspace = true crossbeam-channel.workspace = true +dashmap.workspace = true ethereum_ssz.workspace = true ethrex-blockchain.workspace = true ethrex-common.workspace = true diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index c4a54ebe2..2dcace78c 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -87,6 +87,17 @@ pub fn deploy_payment_forwarder(genesis: &mut Genesis) { }); } +/// Runtime that reads the balance of the address in its calldata and stops: +/// PUSH0 CALLDATALOAD BALANCE POP STOP. Reads an account without touching it. +pub fn deploy_balance_probe(genesis: &mut Genesis, address: Address) { + genesis.alloc.insert(eaddr(address), GenesisAccount { + code: hex::decode("5f35315000").unwrap().into(), + storage: Default::default(), + balance: ethrex_common::U256::zero(), + nonce: 0, + }); +} + /// A legacy transaction with no chain id, which EIP-155 replay protection does /// not cover. pub fn signed_unprotected_transfer( diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs index 093667ffd..ce716ac5a 100644 --- a/crates/builder/src/validation/error.rs +++ b/crates/builder/src/validation/error.rs @@ -1,4 +1,4 @@ -use alloy_primitives::B256; +use alloy_primitives::{Address, B256}; #[derive(Debug, thiserror::Error)] pub enum ValidationError { @@ -34,4 +34,6 @@ pub enum ValidationError { MissingParentState, #[error("could not verify proposer payment")] ProposerPayment, + #[error("block accesses blacklisted address: {0}")] + Blacklist(Address), } diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index 1634cc423..a185d6859 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -5,11 +5,14 @@ pub mod error; #[cfg(test)] mod tests; +use std::sync::Arc; + use alloy_primitives::B256; use alloy_rpc_types::{ beacon::{relay::BidTrace, requests::ExecutionRequestsV4}, engine::ExecutionPayloadV3, }; +use dashmap::DashSet; use ethrex_blockchain::{BlockchainType, new_evm, vm::StoreVmDatabase}; use ethrex_common::{ Address as EAddress, U256 as EU256, @@ -29,7 +32,7 @@ use helix_common::{ use tokio::sync::watch; use crate::{ - engine::convert::{b256, eaddr, eu256, h256, payload_v3_to_block}, + engine::convert::{aaddr, b256, eaddr, eu256, h256, payload_v3_to_block}, node::HeadInfo, validation::error::ValidationError, }; @@ -53,11 +56,17 @@ pub struct BlockValidator { store: Store, head: watch::Receiver, validation_window: u64, + disallow: Arc>, } impl BlockValidator { - pub fn new(store: Store, head: watch::Receiver, validation_window: u64) -> Self { - Self { store, head, validation_window } + pub fn new( + store: Store, + head: watch::Receiver, + validation_window: u64, + disallow: Arc>, + ) -> Self { + Self { store, head, validation_window, disallow } } pub fn prepare( @@ -79,9 +88,13 @@ impl BlockValidator { message: &BidTrace, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + apply_blacklist: bool, ) -> Result { let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; let executed = self.execute(prepared)?; + if apply_blacklist { + self.ensure_not_blacklisted(&executed, message)?; + } self.ensure_payment(&executed, message)?; Ok(executed) } @@ -94,14 +107,60 @@ impl BlockValidator { message: &BidTrace, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + apply_blacklist: bool, base_payment_tx_index: u64, ) -> Result { let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; let executed = self.execute(prepared)?; + if apply_blacklist { + self.ensure_not_blacklisted(&executed, message)?; + } self.ensure_merged_payment(&executed, message, base_payment_tx_index as usize)?; Ok(executed) } + /// Rejects a block that interacts with a listed address. Interaction means + /// effect: a state change, or a transaction addressed to it. Reading an + /// account is not interaction, which is where this parts company with the + /// reth simulator. + fn ensure_not_blacklisted( + &self, + executed: &ExecutedBlock, + message: &BidTrace, + ) -> Result<(), ValidationError> { + if self.disallow.is_empty() { + return Ok(()); + } + + // Neither is guaranteed to change state: a block with no priority fees + // leaves the coinbase untouched, and an unpaid recipient stays absent. + for address in [aaddr(executed.block.header.coinbase), message.proposer_fee_recipient] { + if self.disallow.contains(&address) { + return Err(ValidationError::Blacklist(address)); + } + } + + // A call that changes nothing leaves no account update behind. + for tx in &executed.block.body.transactions { + if let ethrex_common::types::TxKind::Call(to) = tx.to() { + let to = aaddr(to); + if self.disallow.contains(&to) { + return Err(ValidationError::Blacklist(to)); + } + } + } + + // Senders, created accounts, value recipients and storage writes. + for update in &executed.account_updates { + let address = aaddr(update.address); + if self.disallow.contains(&address) { + return Err(ValidationError::Blacklist(address)); + } + } + + Ok(()) + } + /// Executes against the parent state and checks the header against what /// execution produced. Writes nothing to the store. pub fn execute(&self, prepared: PreparedBlock) -> Result { diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index 118b70432..ce866482d 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -5,6 +5,7 @@ use alloy_rpc_types::{ beacon::{relay::BidTrace, requests::ExecutionRequestsV4}, engine::ExecutionPayloadV3, }; +use dashmap::DashSet; use ethrex_blockchain::{ Blockchain, BlockchainOptions, BlockchainType, fork_choice::apply_fork_choice, @@ -21,8 +22,8 @@ use crate::{ }, node::HeadInfo, testing::{ - ETH, GWEI, deploy_payment_forwarder, dev_genesis_store_with, funded_signers, - signed_transfer, signed_unprotected_transfer, + ETH, GWEI, deploy_balance_probe, deploy_payment_forwarder, dev_genesis_store_with, + funded_signers, signed_transfer, signed_unprotected_transfer, }, validation::{BlockValidator, error::ValidationError}, }; @@ -50,6 +51,7 @@ struct Fixture { signers: Vec, proposer: Address, head: watch::Sender, + disallow: Arc>, } impl Fixture { @@ -61,6 +63,28 @@ impl Fixture { Self::with_genesis(deploy_payment_forwarder).await } + async fn with_disallowed(listed: &[Address]) -> Self { + let fixture = Self::with_genesis(|_| {}).await; + fixture.disallow(listed) + } + + async fn with_forwarder_and_disallowed(listed: &[Address]) -> Self { + let fixture = Self::with_genesis(deploy_payment_forwarder).await; + fixture.disallow(listed) + } + + async fn with_probe_and_disallowed(listed: &[Address]) -> Self { + let fixture = Self::with_genesis(|genesis| deploy_balance_probe(genesis, PROBE)).await; + fixture.disallow(listed) + } + + fn disallow(self, listed: &[Address]) -> Self { + for address in listed { + self.disallow.insert(*address); + } + self + } + async fn with_genesis(edit: impl FnOnce(&mut ethrex_common::types::Genesis)) -> Self { let (store, genesis) = dev_genesis_store_with(edit).await; let signers = funded_signers(&genesis, 4); @@ -88,11 +112,17 @@ impl Fixture { gas_limit: genesis_block.header.gas_limit, proposer: signers[3].address(), signers, + disallow: Arc::new(DashSet::new()), } } fn validator(&self) -> BlockValidator { - BlockValidator::new(self.store.clone(), self.head.subscribe(), WINDOW) + BlockValidator::new( + self.store.clone(), + self.head.subscribe(), + WINDOW, + self.disallow.clone(), + ) } /// Builds a valid block on `parent`, paying `self.proposer` in its last tx. @@ -205,6 +235,27 @@ impl Fixture { )) } + /// Deploys empty code, so the created account exists in state. + fn signed_create(&self, signer: &alloy_signer_local::PrivateKeySigner, nonce: u64) -> Vec { + use alloy_consensus::SignableTransaction; + use alloy_signer::SignerSync; + let tx = alloy_consensus::TxEip1559 { + chain_id: self.chain_id, + nonce, + gas_limit: 100_000, + max_fee_per_gas: 100 * GWEI, + max_priority_fee_per_gas: 0, + to: alloy_primitives::TxKind::Create, + value: U256::ZERO, + access_list: Default::default(), + input: alloy_primitives::hex!("60006000f3").into(), + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + alloy_eips::eip2718::Encodable2718::encoded_2718(&alloy_consensus::TxEnvelope::from( + tx.into_signed(signature), + )) + } + fn bid_trace(&self, built: &Built) -> BidTrace { let header = &built.payload.payload_inner.payload_inner; let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests) @@ -428,7 +479,7 @@ async fn a_valid_block_passes_execution() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect("a block the fixture built must validate"); assert_eq!(executed.receipts.len(), 1); @@ -445,7 +496,7 @@ async fn validating_a_block_does_not_store_it() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect("a block the fixture built must validate"); assert_eq!(fixture.store.get_latest_block_number().await.unwrap(), 0); @@ -464,7 +515,7 @@ async fn a_tampered_state_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a wrong state root must be rejected"); assert!(matches!(error, ValidationError::StateRootMismatch { .. }), "{error}"); @@ -479,7 +530,7 @@ async fn a_tampered_gas_used_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a wrong gas used must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -494,7 +545,7 @@ async fn a_tampered_receipts_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a wrong receipts root must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -512,7 +563,7 @@ async fn execution_requests_that_the_block_did_not_produce_are_rejected() { let error = fixture .validator() - .validate(&tampered.payload, &message, B256::ZERO, &tampered.requests) + .validate(&tampered.payload, &message, B256::ZERO, &tampered.requests, false) .expect_err("unproduced requests must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -529,7 +580,7 @@ async fn a_tampered_base_fee_is_rejected_before_execution() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a wrong base fee must be rejected"); assert!(matches!(error, ValidationError::PreExecution(_)), "{error}"); @@ -553,7 +604,7 @@ async fn a_block_with_an_unexecutable_transaction_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("an unexecutable transaction must be rejected"); assert!(matches!(error, ValidationError::Execution(_)), "{error}"); @@ -592,7 +643,7 @@ async fn a_payment_by_balance_delta_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect("a balance delta covering the bid must be accepted"); } @@ -619,7 +670,7 @@ async fn a_trailing_direct_transfer_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect("a trailing direct transfer must be accepted"); } @@ -646,7 +697,7 @@ async fn an_underpaid_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("paying less than the bid must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -677,7 +728,7 @@ async fn a_payment_tx_with_a_priority_fee_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a tipping payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -700,7 +751,7 @@ async fn an_unprotected_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("an unprotected payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -729,7 +780,7 @@ async fn a_withdrawal_does_not_pay_the_bid() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a withdrawal must not count as the bid payment"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -762,7 +813,7 @@ async fn a_payment_through_the_forwarder_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect("a forwarder payment must be accepted where the forwarder is deployed"); } @@ -789,7 +840,7 @@ async fn a_forwarder_payment_is_rejected_where_the_forwarder_is_absent() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("an undeployed forwarder must not be trusted"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -818,7 +869,7 @@ async fn a_reverted_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("a reverted payment must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -869,7 +920,7 @@ async fn a_merged_payment_split_across_two_txs_is_accepted() { fixture .validator() - .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, 1) + .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, false, 1) .expect("both payment positions must count"); } @@ -917,7 +968,7 @@ async fn a_merged_payment_with_a_wrong_base_index_is_rejected() { let error = fixture .validator() - .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, 2) + .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, false, 2) .expect_err("a wrong base payment index must fail closed"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -958,8 +1009,203 @@ async fn a_split_payment_is_not_accepted_on_the_regular_path() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests) + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) .expect_err("the regular path must not sum two positions"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); } + +const LISTED: Address = Address::repeat_byte(0x9a); +const PROBE: Address = Address::repeat_byte(0x9b); + +#[tokio::test] +async fn a_blacklisted_sender_is_rejected() { + let fixture = Fixture::new().await; + let sender = fixture.signers[1].address(); + let fixture = fixture.disallow(&[sender]); + let txs = vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + U256::from(1), + 100 * GWEI, + 0, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect_err("a listed sender must be rejected"); + + assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); +} + +#[tokio::test] +async fn a_blacklisted_recipient_is_rejected() { + let fixture = Fixture::with_disallowed(&[LISTED]).await; + let txs = vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + LISTED, + U256::from(1), + 100 * GWEI, + 0, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect_err("a listed recipient must be rejected"); + + assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); +} + +#[tokio::test] +async fn a_blacklisted_coinbase_is_rejected() { + let fixture = Fixture::new().await; + let coinbase = fixture.signers[0].address(); + let fixture = fixture.disallow(&[coinbase]); + let txs = vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + U256::from(1), + 100 * GWEI, + 0, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect_err("a listed coinbase must be rejected"); + + assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); +} + +#[tokio::test] +async fn a_blacklisted_proposer_fee_recipient_is_rejected() { + let fixture = Fixture::with_disallowed(&[Address::repeat_byte(0x9c)]).await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut message = fixture.bid_trace(&built); + message.proposer_fee_recipient = Address::repeat_byte(0x9c); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect_err("a listed fee recipient must be rejected"); + + assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); +} + +/// The listed address is never a transaction's `to`: the forwarder sends it the +/// value internally. Only the state change reveals it. +#[tokio::test] +async fn a_blacklisted_internal_value_target_is_rejected() { + let fixture = Fixture::with_forwarder_and_disallowed(&[LISTED]).await; + let timestamp = fixture.genesis_timestamp + 12; + let txs = vec![fixture.signed_call( + &fixture.signers[1], + 0, + helix_common::PAYMENT_FORWARDER, + U256::from(GWEI), + forwarder_calldata(timestamp, LISTED), + )]; + let built = fixture.build_block(fixture.genesis_hash, timestamp, txs, Vec::new()); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect_err("a listed internal value target must be rejected"); + + assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); +} + +#[tokio::test] +async fn a_blacklisted_created_account_is_rejected() { + let fixture = Fixture::new().await; + let created = fixture.signers[1].address().create(0); + let fixture = fixture.disallow(&[created]); + let txs = vec![fixture.signed_create(&fixture.signers[1], 0)]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect_err("a listed created account must be rejected"); + + assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); +} + +/// Reading an account is not interacting with it. The reth simulator rejects +/// this block; this one does not. +#[tokio::test] +async fn an_account_that_is_only_read_is_not_blacklisted() { + let fixture = Fixture::with_probe_and_disallowed(&[LISTED]).await; + let mut calldata = [0u8; 32]; + calldata[12..].copy_from_slice(LISTED.as_slice()); + let txs = + vec![fixture.signed_call(&fixture.signers[1], 0, PROBE, U256::ZERO, calldata.to_vec())]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = U256::ZERO; + + let executed = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect("a read alone must not reject the block"); + + assert!(executed.receipts[0].succeeded, "the probe must have run for this to prove anything"); +} + +#[tokio::test] +async fn a_block_touching_no_listed_account_passes() { + let fixture = Fixture::with_disallowed(&[LISTED]).await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .expect("a block touching nothing listed must pass"); +} + +/// Filtering is a per-proposer preference, so a non-filtering proposer's block +/// is validated without it. +#[tokio::test] +async fn a_non_filtering_proposer_bypasses_the_blacklist() { + let fixture = Fixture::with_disallowed(&[LISTED]).await; + let txs = vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + LISTED, + U256::from(1), + 100 * GWEI, + 0, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = U256::ZERO; + + fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .expect("apply_blacklist = false must skip the check"); +} From 66cbbfc7c4f6c54c70007c996f16f5125c50da50 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 31 Aug 2026 10:37:25 +0100 Subject: [PATCH 21/29] Verify the blobs bundle against the block's blob hashes The submission carries one bundle for the whole block, so the block's blob versioned hashes must match its commitments in order. ethrex's BlobsBundle::validate is per transaction and does not fit that shape, so this checks the lengths and hashes and calls verify_cell_kzg_proof_batch directly. Blob gas accounting needs nothing: verify_blob_gas_usage already runs in validate_block_pre_execution. Step 8 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/src/testing.rs | 38 +++++ crates/builder/src/validation/error.rs | 2 + crates/builder/src/validation/mod.rs | 55 +++++- crates/builder/src/validation/tests.rs | 222 +++++++++++++++++++++---- 4 files changed, 285 insertions(+), 32 deletions(-) diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index 2dcace78c..39fe2ed03 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -130,3 +130,41 @@ pub fn funded_signers(genesis: &Genesis, count: usize) -> Vec assert_eq!(signers.len(), count, "dev genesis funds too few of the fixture keys"); signers } + +/// Blobs with real commitments and EIP-7594 cell proofs. Each blob differs, so +/// no two commitments collide. +pub fn blob_bundle(count: usize) -> ethrex_common::types::BlobsBundle { + let blobs = (0..count) + .map(|i| { + let mut blob = [0u8; ethrex_common::types::BYTES_PER_BLOB]; + blob[0] = i as u8 + 1; + blob + }) + .collect(); + ethrex_common::types::BlobsBundle::create_from_blobs(&blobs, Some(1)).unwrap() +} + +#[allow(clippy::too_many_arguments)] +pub fn signed_blob_transfer( + signer: &PrivateKeySigner, + chain_id: u64, + nonce: u64, + to: Address, + versioned_hashes: Vec, +) -> Vec { + let tx = alloy_consensus::TxEip4844 { + chain_id, + nonce, + gas_limit: 100_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 0, + to, + value: U256::ZERO, + access_list: Default::default(), + blob_versioned_hashes: versioned_hashes, + max_fee_per_blob_gas: 1_000_000_000, + input: Default::default(), + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718() +} diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs index ce716ac5a..04642dd7b 100644 --- a/crates/builder/src/validation/error.rs +++ b/crates/builder/src/validation/error.rs @@ -36,4 +36,6 @@ pub enum ValidationError { ProposerPayment, #[error("block accesses blacklisted address: {0}")] Blacklist(Address), + #[error("invalid blobs bundle")] + InvalidBlobsBundle, } diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index a185d6859..fe8b40f25 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -16,7 +16,10 @@ use dashmap::DashSet; use ethrex_blockchain::{BlockchainType, new_evm, vm::StoreVmDatabase}; use ethrex_common::{ Address as EAddress, U256 as EU256, - types::{AccountUpdate, Block, BlockHeader, ELASTICITY_MULTIPLIER, Receipt}, + types::{ + AccountUpdate, BlobsBundle, Block, BlockHeader, CELLS_PER_EXT_BLOB, ELASTICITY_MULTIPLIER, + Receipt, Transaction, + }, validation::{ validate_block_pre_execution, validate_gas_used, validate_receipts_root_and_logs_bloom, validate_requests_hash, @@ -88,9 +91,11 @@ impl BlockValidator { message: &BidTrace, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + blobs: &BlobsBundle, apply_blacklist: bool, ) -> Result { let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + self.validate_blobs_bundle(&prepared.block, blobs)?; let executed = self.execute(prepared)?; if apply_blacklist { self.ensure_not_blacklisted(&executed, message)?; @@ -107,10 +112,12 @@ impl BlockValidator { message: &BidTrace, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + blobs: &BlobsBundle, apply_blacklist: bool, base_payment_tx_index: u64, ) -> Result { let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + self.validate_blobs_bundle(&prepared.block, blobs)?; let executed = self.execute(prepared)?; if apply_blacklist { self.ensure_not_blacklisted(&executed, message)?; @@ -119,6 +126,52 @@ impl BlockValidator { Ok(executed) } + /// The submission carries one bundle for the whole block, so the block's + /// blob hashes must match its commitments in order. ethrex's own + /// `BlobsBundle::validate` is per transaction and does not fit that shape. + fn validate_blobs_bundle( + &self, + block: &Block, + blobs: &BlobsBundle, + ) -> Result<(), ValidationError> { + let versioned_hashes: Vec<_> = block + .body + .transactions + .iter() + .filter_map(|tx| match tx { + Transaction::EIP4844Transaction(tx) => Some(tx.blob_versioned_hashes.clone()), + _ => None, + }) + .flatten() + .collect(); + + if versioned_hashes.is_empty() && blobs.is_empty() { + return Ok(()); + } + + if blobs.blobs.len() != blobs.commitments.len() || + blobs.blobs.len() * CELLS_PER_EXT_BLOB != blobs.proofs.len() + { + return Err(ValidationError::InvalidBlobsBundle); + } + + blobs + .validate_blob_commitment_hashes(&versioned_hashes) + .map_err(|_| ValidationError::InvalidBlobsBundle)?; + + let valid = ethrex_crypto::kzg::verify_cell_kzg_proof_batch( + &blobs.blobs, + &blobs.commitments, + &blobs.proofs, + ) + .map_err(|_| ValidationError::InvalidBlobsBundle)?; + if !valid { + return Err(ValidationError::InvalidBlobsBundle); + } + + Ok(()) + } + /// Rejects a block that interacts with a listed address. Interaction means /// effect: a state change, or a transaction addressed to it. Reading an /// account is not interaction, which is where this parts company with the diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index ce866482d..e8cc0b9df 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -22,14 +22,19 @@ use crate::{ }, node::HeadInfo, testing::{ - ETH, GWEI, deploy_balance_probe, deploy_payment_forwarder, dev_genesis_store_with, - funded_signers, signed_transfer, signed_unprotected_transfer, + ETH, GWEI, blob_bundle, deploy_balance_probe, deploy_payment_forwarder, + dev_genesis_store_with, funded_signers, signed_blob_transfer, signed_transfer, + signed_unprotected_transfer, }, validation::{BlockValidator, error::ValidationError}, }; const WINDOW: u64 = 3; +fn empty_bundle() -> ethrex_common::types::BlobsBundle { + ethrex_common::types::BlobsBundle::default() +} + struct Built { payload: ExecutionPayloadV3, requests: ExecutionRequestsV4, @@ -479,7 +484,7 @@ async fn a_valid_block_passes_execution() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect("a block the fixture built must validate"); assert_eq!(executed.receipts.len(), 1); @@ -496,7 +501,7 @@ async fn validating_a_block_does_not_store_it() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect("a block the fixture built must validate"); assert_eq!(fixture.store.get_latest_block_number().await.unwrap(), 0); @@ -515,7 +520,7 @@ async fn a_tampered_state_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a wrong state root must be rejected"); assert!(matches!(error, ValidationError::StateRootMismatch { .. }), "{error}"); @@ -530,7 +535,7 @@ async fn a_tampered_gas_used_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a wrong gas used must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -545,7 +550,7 @@ async fn a_tampered_receipts_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a wrong receipts root must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -563,7 +568,14 @@ async fn execution_requests_that_the_block_did_not_produce_are_rejected() { let error = fixture .validator() - .validate(&tampered.payload, &message, B256::ZERO, &tampered.requests, false) + .validate( + &tampered.payload, + &message, + B256::ZERO, + &tampered.requests, + &empty_bundle(), + false, + ) .expect_err("unproduced requests must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -580,7 +592,7 @@ async fn a_tampered_base_fee_is_rejected_before_execution() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a wrong base fee must be rejected"); assert!(matches!(error, ValidationError::PreExecution(_)), "{error}"); @@ -604,7 +616,7 @@ async fn a_block_with_an_unexecutable_transaction_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("an unexecutable transaction must be rejected"); assert!(matches!(error, ValidationError::Execution(_)), "{error}"); @@ -643,7 +655,7 @@ async fn a_payment_by_balance_delta_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect("a balance delta covering the bid must be accepted"); } @@ -670,7 +682,7 @@ async fn a_trailing_direct_transfer_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect("a trailing direct transfer must be accepted"); } @@ -697,7 +709,7 @@ async fn an_underpaid_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("paying less than the bid must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -728,7 +740,7 @@ async fn a_payment_tx_with_a_priority_fee_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a tipping payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -751,7 +763,7 @@ async fn an_unprotected_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("an unprotected payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -780,7 +792,7 @@ async fn a_withdrawal_does_not_pay_the_bid() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a withdrawal must not count as the bid payment"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -813,7 +825,7 @@ async fn a_payment_through_the_forwarder_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect("a forwarder payment must be accepted where the forwarder is deployed"); } @@ -840,7 +852,7 @@ async fn a_forwarder_payment_is_rejected_where_the_forwarder_is_absent() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("an undeployed forwarder must not be trusted"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -869,7 +881,7 @@ async fn a_reverted_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("a reverted payment must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -920,7 +932,15 @@ async fn a_merged_payment_split_across_two_txs_is_accepted() { fixture .validator() - .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, false, 1) + .validate_merged( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + 1, + ) .expect("both payment positions must count"); } @@ -968,7 +988,15 @@ async fn a_merged_payment_with_a_wrong_base_index_is_rejected() { let error = fixture .validator() - .validate_merged(&built.payload, &message, B256::ZERO, &built.requests, false, 2) + .validate_merged( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + 2, + ) .expect_err("a wrong base payment index must fail closed"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -1009,7 +1037,7 @@ async fn a_split_payment_is_not_accepted_on_the_regular_path() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect_err("the regular path must not sum two positions"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -1038,7 +1066,7 @@ async fn a_blacklisted_sender_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect_err("a listed sender must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1062,7 +1090,7 @@ async fn a_blacklisted_recipient_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect_err("a listed recipient must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1088,7 +1116,7 @@ async fn a_blacklisted_coinbase_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect_err("a listed coinbase must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1103,7 +1131,7 @@ async fn a_blacklisted_proposer_fee_recipient_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect_err("a listed fee recipient must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1127,7 +1155,7 @@ async fn a_blacklisted_internal_value_target_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect_err("a listed internal value target must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1145,7 +1173,7 @@ async fn a_blacklisted_created_account_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect_err("a listed created account must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1167,7 +1195,7 @@ async fn an_account_that_is_only_read_is_not_blacklisted() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect("a read alone must not reject the block"); assert!(executed.receipts[0].succeeded, "the probe must have run for this to prove anything"); @@ -1181,7 +1209,7 @@ async fn a_block_touching_no_listed_account_passes() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, true) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) .expect("a block touching nothing listed must pass"); } @@ -1206,6 +1234,138 @@ async fn a_non_filtering_proposer_bypasses_the_blacklist() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, false) + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) .expect("apply_blacklist = false must skip the check"); } + +/// Proves a blob block builds and validates at all, before the negative cases +/// below rely on that. +#[tokio::test] +async fn a_block_with_a_valid_blobs_bundle_passes() { + let fixture = Fixture::new().await; + let bundle = blob_bundle(1); + let hashes: Vec = bundle.generate_versioned_hashes().iter().map(|h| b256(*h)).collect(); + let txs = vec![signed_blob_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + hashes, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = U256::ZERO; + + let executed = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .expect("a valid blobs bundle must pass"); + + assert!( + executed.block.header.blob_gas_used.unwrap_or_default() > 0, + "the blob tx must be in the block for this to prove anything" + ); +} + +/// Builds a one-blob block whose bundle the test then damages. +async fn blob_block(fixture: &Fixture) -> (Built, BidTrace, ethrex_common::types::BlobsBundle) { + let bundle = blob_bundle(1); + let hashes: Vec = bundle.generate_versioned_hashes().iter().map(|h| b256(*h)).collect(); + let txs = vec![signed_blob_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x66), + hashes, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut message = fixture.bid_trace(&built); + message.value = U256::ZERO; + (built, message, bundle) +} + +#[tokio::test] +async fn a_bundle_whose_commitments_do_not_match_the_block_is_rejected() { + let fixture = Fixture::new().await; + let (built, message, mut bundle) = blob_block(&fixture).await; + bundle.commitments[0] = blob_bundle(2).commitments[1]; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .expect_err("commitments not matching the block's hashes must be rejected"); + + assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); +} + +#[tokio::test] +async fn a_bundle_with_a_wrong_proof_count_is_rejected() { + let fixture = Fixture::new().await; + let (built, message, mut bundle) = blob_block(&fixture).await; + bundle.proofs.pop(); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .expect_err("a short proof list must be rejected"); + + assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); +} + +#[tokio::test] +async fn a_bundle_with_an_invalid_cell_proof_is_rejected() { + let fixture = Fixture::new().await; + let (built, message, mut bundle) = blob_block(&fixture).await; + bundle.proofs[0] = ethrex_common::types::Proof::from([0u8; 48]); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .expect_err("a corrupt cell proof must be rejected"); + + assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); +} + +#[tokio::test] +async fn a_bundle_carrying_more_blobs_than_the_block_is_rejected() { + let fixture = Fixture::new().await; + let (built, message, _) = blob_block(&fixture).await; + let bundle = blob_bundle(2); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .expect_err("a bundle with a spare blob must be rejected"); + + assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); +} + +#[tokio::test] +async fn a_blob_tx_with_an_empty_bundle_is_rejected() { + let fixture = Fixture::new().await; + let (built, message, _) = blob_block(&fixture).await; + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .expect_err("a blob tx without its blobs must be rejected"); + + assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); +} + +/// Blobs the block never referenced are not free to attach. +#[tokio::test] +async fn a_bundle_for_a_block_with_no_blob_txs_is_rejected() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + let error = fixture + .validator() + .validate(&built.payload, &message, B256::ZERO, &built.requests, &blob_bundle(1), false) + .expect_err("a bundle for a blobless block must be rejected"); + + assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); +} From feeee1212cde689a305c51aa0131b073813223d9 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 31 Aug 2026 10:54:54 +0100 Subject: [PATCH 22/29] Serve the SSZ validation routes from the simulation role /validate and /validate_merged decode the relay's SSZ requests, run the validator on a blocking thread under a semaphore, and answer 200 or 400 with the reason. A dehydrated submission answers 424 so the relay retries with full bytes. The disallow list refreshes from the configured endpoint. main now builds the validator and spawns both, so the module no longer needs allow(dead_code). Drops rpc_addr: no JSON-RPC is served, and an unused config field is a trap. The relay reaches this role through its simulator ssz_url. Step 9 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 4 + crates/builder/Cargo.toml | 4 + crates/builder/sim-config.example.yml | 5 +- crates/builder/src/config.rs | 16 +- crates/builder/src/engine/convert.rs | 25 +++ crates/builder/src/main.rs | 21 +- crates/builder/src/validation/mod.rs | 8 +- crates/builder/src/validation/server.rs | 187 ++++++++++++++++ crates/builder/src/validation/server_tests.rs | 206 ++++++++++++++++++ crates/builder/src/validation/tests.rs | 105 +++++++-- 10 files changed, 538 insertions(+), 43 deletions(-) create mode 100644 crates/builder/src/validation/server.rs create mode 100644 crates/builder/src/validation/server_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 6f0619a23..9b2147e1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6454,6 +6454,7 @@ dependencies = [ "alloy-signer", "alloy-signer-local", "alloy-sol-types", + "axum 0.8.9", "bytes", "clap", "core_affinity", @@ -6476,10 +6477,12 @@ dependencies = [ "flux-utils", "helix-common", "helix-tcp-types", + "helix-types", "hex", "num_cpus", "rand 0.9.5", "rayon", + "reqwest 0.13.4", "rustc-hash", "secp256k1 0.30.0", "serde", @@ -6489,6 +6492,7 @@ dependencies = [ "tikv-jemallocator", "tokio", "tokio-util", + "tower 0.5.3", "tracing", "tracing-subscriber 0.3.23", "uuid 1.24.1", diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 382f656ec..c11d70d22 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -35,8 +35,11 @@ flux.workspace = true hex.workspace = true flux-network.workspace = true flux-utils.workspace = true +axum.workspace = true helix-common.workspace = true helix-tcp-types.workspace = true +helix-types.workspace = true +reqwest.workspace = true num_cpus.workspace = true rand.workspace = true rayon.workspace = true @@ -55,3 +58,4 @@ uuid = { workspace = true, features = ["serde"] } zstd.workspace = true [dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/crates/builder/sim-config.example.yml b/crates/builder/sim-config.example.yml index f96804185..8c9f1d073 100644 --- a/crates/builder/sim-config.example.yml +++ b/crates/builder/sim-config.example.yml @@ -1,10 +1,9 @@ # helix-builder simulation configuration (--sim.config) +# SSZ validation server (/validate, /validate_merged). The relay reaches it +# through its simulator `ssz_url`. ssz_addr: "0.0.0.0:8552" -# Must differ from ssz_addr. -rpc_addr: "0.0.0.0:8553" - blacklist_endpoint: "http://localhost:3520/blacklist" # Maximum parent-to-head block distance a submission may build on. diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 064c0925d..69c620a7e 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -94,7 +94,6 @@ impl MergingConfig { #[serde(deny_unknown_fields)] pub struct SimulationConfig { pub ssz_addr: SocketAddr, - pub rpc_addr: SocketAddr, #[serde(default = "default_blacklist_endpoint")] pub blacklist_endpoint: String, /// Maximum parent-to-head block distance a submission may build on. @@ -116,9 +115,6 @@ impl SimulationConfig { } fn validate(&self) -> eyre::Result<()> { - if self.ssz_addr == self.rpc_addr { - eyre::bail!("simulation config: ssz_addr and rpc_addr must differ"); - } if self.blacklist_endpoint.is_empty() { eyre::bail!("simulation config: blacklist_endpoint must not be empty"); } @@ -245,7 +241,7 @@ mod simulation_config_tests { } fn minimal_simulation_config() -> SimulationConfig { - serde_yaml::from_str("ssz_addr: \"0.0.0.0:8552\"\nrpc_addr: \"0.0.0.0:8553\"\n") + serde_yaml::from_str("ssz_addr: \"0.0.0.0:8552\"\n") .expect("the minimal simulation config must parse") } @@ -256,7 +252,6 @@ mod simulation_config_tests { config.validate().unwrap(); assert_eq!(config.ssz_addr, "0.0.0.0:8552".parse::().unwrap()); - assert_eq!(config.rpc_addr, "0.0.0.0:8553".parse::().unwrap()); assert_eq!(config.blacklist_endpoint, "http://localhost:3520/blacklist"); assert_eq!(config.validation_window, 3); assert_eq!(config.max_concurrent_validations, 32); @@ -272,15 +267,6 @@ mod simulation_config_tests { assert_eq!(config.max_concurrent_validations, num_cpus::get()); } - #[test] - fn sim_config_rejects_one_address_for_both_servers() { - let config: SimulationConfig = - serde_yaml::from_str("ssz_addr: \"0.0.0.0:8552\"\nrpc_addr: \"0.0.0.0:8552\"\n") - .unwrap(); - - assert!(config.validate().is_err(), "ssz_addr must differ from rpc_addr"); - } - #[test] fn a_merging_config_alone_selects_the_merging_role() { let roles = Roles::resolve(Some(minimal_merging_config()), None).unwrap(); diff --git a/crates/builder/src/engine/convert.rs b/crates/builder/src/engine/convert.rs index 43277e4db..7eae71e72 100644 --- a/crates/builder/src/engine/convert.rs +++ b/crates/builder/src/engine/convert.rs @@ -146,6 +146,31 @@ pub fn payload_v3_to_block( Ok(Block::new(header, body)) } +/// The wire bundle carries EIP-7594 cell proofs, so the ethrex bundle is +/// version 1. +pub fn eblobs( + bundle: &alloy_rpc_types::engine::BlobsBundleV2, +) -> ethrex_common::types::BlobsBundle { + ethrex_common::types::BlobsBundle { + // A blob is 128 KiB. Fill the vector in place: collecting through an + // iterator moves each blob across the stack. + blobs: { + let mut blobs = vec![[0u8; ethrex_common::types::BYTES_PER_BLOB]; bundle.blobs.len()]; + for (out, blob) in blobs.iter_mut().zip(bundle.blobs.iter()) { + out.copy_from_slice(blob.as_slice()); + } + blobs + }, + commitments: bundle + .commitments + .iter() + .map(|c| ethrex_common::types::Commitment::from(c.0)) + .collect(), + proofs: bundle.proofs.iter().map(|p| ethrex_common::types::Proof::from(p.0)).collect(), + version: 1, + } +} + /// Inverse of [`requests_to_v4`]. `compute_requests_hash` skips type-byte-only /// entries, so the empty ones the wire format drops need not be restored. fn encoded_requests(requests: &ExecutionRequestsV4) -> Vec { diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index b1d5d2c45..d7879e512 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -23,6 +23,7 @@ use config::{MergingConfig, Roles, SimulationConfig}; use engine::{MergeEngine, types::EngineConfig}; use server::MergingServerTile; use spine::BuilderSpine; +use validation::{BlockValidator, server as validation_server}; #[global_allocator] static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; @@ -46,11 +47,23 @@ fn main() -> eyre::Result<()> { let node = runtime.block_on(node::start(&cli.node))?; if let Some(simulation_config) = roles.simulation() { - info!( - ssz_addr = %simulation_config.ssz_addr, - rpc_addr = %simulation_config.rpc_addr, - "Simulation role active" + let disallow = std::sync::Arc::new(dashmap::DashSet::new()); + let validator = BlockValidator::new( + node.store.clone(), + node.head.clone(), + simulation_config.validation_window, + disallow.clone(), ); + runtime.spawn(validation_server::refresh_blacklist( + simulation_config.blacklist_endpoint.clone(), + disallow, + )); + runtime.spawn(validation_server::run( + validator, + simulation_config.ssz_addr, + simulation_config.max_concurrent_validations, + )); + info!(ssz_addr = %simulation_config.ssz_addr, "Simulation role active"); } // `BuilderSpine::start` blocks until its tiles stop, so merging starts last. diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index fe8b40f25..ba45934df 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -1,9 +1,9 @@ -// Reached from main once the servers land in step 9 of #527. -#![allow(dead_code)] - pub mod error; +pub mod server; +#[cfg(test)] +mod server_tests; #[cfg(test)] -mod tests; +pub(crate) mod tests; use std::sync::Arc; diff --git a/crates/builder/src/validation/server.rs b/crates/builder/src/validation/server.rs new file mode 100644 index 000000000..aed6ca6cb --- /dev/null +++ b/crates/builder/src/validation/server.rs @@ -0,0 +1,187 @@ +use std::{net::SocketAddr, sync::Arc}; + +use alloy_primitives::Address; +use alloy_rpc_types::beacon::relay::SignedBidSubmissionV5; +use axum::{ + Router, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, +}; +use dashmap::DashSet; +use helix_common::{ + blacklist::{changed_disallow_hash, parse_disallow_list}, + decoder::{DecoderError, SubmissionDecoder, SubmissionDecoderParams}, + simulator::{SszMergedValidationRequest, SszValidationRequest}, +}; +use helix_types::Submission; +use ssz::Decode; +use tokio::{net::TcpListener, sync::Semaphore, time}; +use tracing::{error, info, warn}; + +use crate::{ + engine::convert::eblobs, + validation::{BlockValidator, error::ValidationError}, +}; + +#[derive(Clone)] +struct ServerState { + validator: BlockValidator, + permits: Arc, +} + +pub fn router(validator: BlockValidator, max_concurrent: usize) -> Router { + Router::new() + .route("/validate", post(validate)) + .route("/validate_merged", post(validate_merged)) + .with_state(ServerState { + validator, + permits: Arc::new(Semaphore::new(max_concurrent.max(1))), + }) +} + +pub async fn run(validator: BlockValidator, addr: SocketAddr, max_concurrent: usize) { + let listener = match TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(err) => { + error!(%err, %addr, "failed to bind the validation server"); + return; + } + }; + info!(%addr, "Validation server listening"); + if let Err(err) = axum::serve(listener, router(validator, max_concurrent)).await { + error!(%err, "validation server exited"); + } +} + +/// A dehydrated submission needs transactions this simulator does not cache. +/// The relay answers a 424 by retrying with full SSZ bytes. +fn decode_submission( + params: Option, + bytes: &[u8], +) -> Result, DecoderError> { + match params { + Some(params) => { + let mut buf = Vec::new(); + let (submission, _, _) = SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; + match submission { + Submission::Full(submission) => Ok(Some(submission.into())), + Submission::Dehydrated(_) => Ok(None), + } + } + None => Ok(Some(SignedBidSubmissionV5::from_ssz_bytes(bytes)?)), + } +} + +async fn validate(State(state): State, body: axum::body::Bytes) -> Response { + let request = match SszValidationRequest::from_ssz_bytes(&body) { + Ok(request) => request, + Err(err) => return bad_request(format!("{err:?}")), + }; + let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) + { + Ok(Some(submission)) => submission, + Ok(None) => return StatusCode::FAILED_DEPENDENCY.into_response(), + Err(err) => return bad_request(err.to_string()), + }; + + run_validation(state, move |validator| { + validator.validate( + &submission.execution_payload, + &submission.message, + request.parent_beacon_block_root, + &submission.execution_requests, + &eblobs(&submission.blobs_bundle), + request.apply_blacklist, + ) + }) + .await +} + +async fn validate_merged(State(state): State, body: axum::body::Bytes) -> Response { + let request = match SszMergedValidationRequest::from_ssz_bytes(&body) { + Ok(request) => request, + Err(err) => return bad_request(format!("{err:?}")), + }; + let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) + { + Ok(Some(submission)) => submission, + Ok(None) => return StatusCode::FAILED_DEPENDENCY.into_response(), + Err(err) => return bad_request(err.to_string()), + }; + + run_validation(state, move |validator| { + validator.validate_merged( + &submission.execution_payload, + &submission.message, + request.parent_beacon_block_root, + &submission.execution_requests, + &eblobs(&submission.blobs_bundle), + request.apply_blacklist, + request.base_payment_tx_index, + ) + }) + .await +} + +/// Validation is CPU-bound and synchronous, so it runs on a blocking thread. +/// The semaphore caps how many run at once. +async fn run_validation(state: ServerState, validate: F) -> Response +where + F: FnOnce(&BlockValidator) -> Result + + Send + + 'static, +{ + let Ok(_permit) = state.permits.clone().acquire_owned().await else { + return bad_request("validation server is shutting down".to_string()); + }; + let validator = state.validator.clone(); + let result = tokio::task::spawn_blocking(move || validate(&validator).map(|_| ())).await; + + match result { + Ok(Ok(())) => StatusCode::OK.into_response(), + Ok(Err(err)) => bad_request(err.to_string()), + Err(err) => { + error!(%err, "validation task panicked"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +fn bad_request(message: String) -> Response { + (StatusCode::BAD_REQUEST, message).into_response() +} + +/// Replaces the disallow list, returning its new digest when it changed. +pub fn refresh_disallow(disallow: &DashSet
, list: Vec) -> Option { + let previous = changed_disallow_hash(disallow, None); + let parsed = parse_disallow_list(list); + disallow.clear(); + for address in parsed { + disallow.insert(address); + } + changed_disallow_hash(disallow, previous.as_deref()) +} + +const REFRESH_INTERVAL: time::Duration = time::Duration::from_secs(300); + +pub async fn refresh_blacklist(endpoint: String, disallow: Arc>) { + let client = reqwest::Client::new(); + let mut interval = time::interval(REFRESH_INTERVAL); + loop { + interval.tick().await; + match client.get(&endpoint).send().await { + Ok(response) if response.status().is_success() => match response.json().await { + Ok(list) => { + if let Some(hash) = refresh_disallow(&disallow, list) { + info!(%hash, size = disallow.len(), "disallow list updated"); + } + } + Err(err) => warn!(%err, "could not read the disallow list"), + }, + Ok(response) => warn!(status = %response.status(), "disallow list fetch failed"), + Err(err) => warn!(%err, "disallow list fetch failed"), + } + } +} diff --git a/crates/builder/src/validation/server_tests.rs b/crates/builder/src/validation/server_tests.rs new file mode 100644 index 000000000..c32e6d14b --- /dev/null +++ b/crates/builder/src/validation/server_tests.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; + +use alloy_primitives::{Address, B256, U256}; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use helix_common::simulator::{SszMergedValidationRequest, SszValidationRequest}; +use ssz::Encode; +use tower::ServiceExt; + +use crate::{ + testing::{ETH, GWEI, signed_transfer}, + validation::{ + server::router, + tests::{Fixture, blob_bundle_v2}, + }, +}; + +async fn post(fixture: &Fixture, route: &str, body: Vec) -> (StatusCode, String) { + let response = router(fixture.validator(), 4) + .oneshot(Request::post(route).body(Body::from(body)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, String::from_utf8_lossy(&bytes).to_string()) +} + +#[tokio::test] +async fn a_valid_submission_is_accepted() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let request = fixture.ssz_request(&built, true); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +#[tokio::test] +async fn an_underpaid_submission_is_rejected_with_a_reason() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut request = fixture.ssz_request(&built, true); + let mut submission = fixture.submission(&built); + submission.message.value = U256::from(ETH); + request.signed_bid_submission = fixture.encode_submission(submission); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("proposer payment"), "{body}"); +} + +#[tokio::test] +async fn the_merged_route_accepts_a_split_payment() { + let fixture = Fixture::new().await; + let base = U256::from(ETH / 4); + let added = U256::from(ETH / 4); + let txs = vec![ + fixture.proposer_spend(0), + signed_transfer( + &fixture.signers[0], + fixture.chain_id, + 0, + fixture.proposer, + base, + 100 * GWEI, + 0, + ), + signed_transfer( + &fixture.signers[2], + fixture.chain_id, + 0, + fixture.proposer, + added, + 100 * GWEI, + 0, + ), + ]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut submission = fixture.submission(&built); + submission.message.value = base + added; + let request = SszMergedValidationRequest { + apply_blacklist: false, + registered_gas_limit: 0, + parent_beacon_block_root: B256::ZERO, + inclusion_list: Default::default(), + decoder_params: None, + signed_bid_submission: fixture.encode_submission(submission), + base_payment_tx_index: 1, + }; + + let (status, body) = post(&fixture, "/validate_merged", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +/// The request's `apply_blacklist` carries the proposer's filtering preference, +/// so the same block gets both answers. +#[tokio::test] +async fn the_request_decides_whether_the_blacklist_applies() { + let fixture = Fixture::new().await; + let listed = Address::repeat_byte(0x9a); + let fixture = fixture.disallow(&[listed]); + let txs = vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + listed, + U256::from(1), + 100 * GWEI, + 0, + )]; + let built = + fixture.build_block(fixture.genesis_hash, fixture.genesis_timestamp + 12, txs, Vec::new()); + let mut submission = fixture.submission(&built); + submission.message.value = U256::ZERO; + let encoded = fixture.encode_submission(submission); + + let mut filtering = fixture.ssz_request(&built, false); + filtering.apply_blacklist = true; + filtering.signed_bid_submission = encoded.clone(); + let (status, body) = post(&fixture, "/validate", filtering.as_ssz_bytes()).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("blacklisted"), "{body}"); + + let mut unfiltered = fixture.ssz_request(&built, false); + unfiltered.apply_blacklist = false; + unfiltered.signed_bid_submission = encoded; + let (status, body) = post(&fixture, "/validate", unfiltered.as_ssz_bytes()).await; + assert_eq!(status, StatusCode::OK, "{body}"); +} + +#[tokio::test] +async fn a_malformed_body_is_rejected() { + let fixture = Fixture::new().await; + + let (status, _) = post(&fixture, "/validate", vec![0xde, 0xad, 0xbe, 0xef]).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +/// A 128 KiB blob crosses the stack several times in a debug build, which +/// overflows tokio's 2 MiB worker stack. Release builds elide the copies. +fn with_large_stack(test: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(32 * 1024 * 1024).spawn(test).unwrap().join().unwrap(); +} + +#[test] +fn a_blobs_bundle_travels_through_the_wire_format() { + with_large_stack(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(blobs_bundle_wire_format()) + }); +} + +async fn blobs_bundle_wire_format() { + let fixture = Fixture::new().await; + let bundle = blob_bundle_v2(1); + let built = fixture.blob_block(&bundle); + let mut submission = fixture.submission(&built); + submission.message.value = U256::ZERO; + submission.blobs_bundle = bundle; + let mut request = fixture.ssz_request(&built, false); + request.signed_bid_submission = fixture.encode_submission(submission); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +#[test] +fn a_refreshed_list_replaces_the_previous_one() { + let disallow = Arc::new(dashmap::DashSet::new()); + disallow.insert(Address::repeat_byte(0x11)); + + let hash = crate::validation::server::refresh_disallow(&disallow, vec![ + "0x2222222222222222222222222222222222222222".into(), + ]); + + assert!(hash.is_some(), "a changed list must report a new digest"); + assert!(!disallow.contains(&Address::repeat_byte(0x11)), "stale entries must go"); + assert!(disallow.contains(&Address::repeat_byte(0x22))); +} + +#[test] +fn an_unchanged_list_reports_no_new_digest() { + let disallow = Arc::new(dashmap::DashSet::new()); + let entries = vec!["0x2222222222222222222222222222222222222222".to_string()]; + + let first = crate::validation::server::refresh_disallow(&disallow, entries.clone()) + .expect("a first list is always new"); + + assert!(!first.is_empty()); + assert_eq!( + crate::validation::server::refresh_disallow(&disallow, entries), + None, + "an unchanged list must report nothing" + ); +} diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index e8cc0b9df..6bb833c28 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -18,7 +18,7 @@ use tokio::sync::watch; use crate::{ engine::convert::{ - b256, block_to_payload_v3, eaddr, h256, payload_v3_to_block, requests_to_v4, + b256, block_to_payload_v3, eaddr, eblobs, h256, payload_v3_to_block, requests_to_v4, }, node::HeadInfo, testing::{ @@ -35,7 +35,7 @@ fn empty_bundle() -> ethrex_common::types::BlobsBundle { ethrex_common::types::BlobsBundle::default() } -struct Built { +pub(crate) struct Built { payload: ExecutionPayloadV3, requests: ExecutionRequestsV4, } @@ -46,25 +46,25 @@ impl Built { } } -struct Fixture { +pub(crate) struct Fixture { store: Store, blockchain: Arc, - genesis_hash: H256, - genesis_timestamp: u64, - chain_id: u64, + pub(crate) genesis_hash: H256, + pub(crate) genesis_timestamp: u64, + pub(crate) chain_id: u64, gas_limit: u64, - signers: Vec, - proposer: Address, + pub(crate) signers: Vec, + pub(crate) proposer: Address, head: watch::Sender, disallow: Arc>, } impl Fixture { - async fn new() -> Self { + pub(crate) async fn new() -> Self { Self::with_genesis(|_| {}).await } - async fn with_forwarder() -> Self { + pub(crate) async fn with_forwarder() -> Self { Self::with_genesis(deploy_payment_forwarder).await } @@ -83,7 +83,7 @@ impl Fixture { fixture.disallow(listed) } - fn disallow(self, listed: &[Address]) -> Self { + pub(crate) fn disallow(self, listed: &[Address]) -> Self { for address in listed { self.disallow.insert(*address); } @@ -121,7 +121,7 @@ impl Fixture { } } - fn validator(&self) -> BlockValidator { + pub(crate) fn validator(&self) -> BlockValidator { BlockValidator::new( self.store.clone(), self.head.subscribe(), @@ -131,7 +131,7 @@ impl Fixture { } /// Builds a valid block on `parent`, paying `self.proposer` in its last tx. - fn build_on(&self, parent: H256, timestamp: u64, nonce: u64) -> Built { + pub(crate) fn build_on(&self, parent: H256, timestamp: u64, nonce: u64) -> Built { let builder = &self.signers[0]; let txs = vec![signed_transfer( builder, @@ -147,7 +147,7 @@ impl Fixture { /// The proposer spends, so its whole-block balance delta falls short of the /// bid value and the payment must be recognised from a transaction. - fn proposer_spend(&self, nonce: u64) -> Vec { + pub(crate) fn proposer_spend(&self, nonce: u64) -> Vec { signed_transfer( &self.signers[3], self.chain_id, @@ -159,7 +159,7 @@ impl Fixture { ) } - fn build_block( + pub(crate) fn build_block( &self, parent: H256, timestamp: u64, @@ -213,7 +213,7 @@ impl Fixture { parent } - fn signed_call( + pub(crate) fn signed_call( &self, signer: &alloy_signer_local::PrivateKeySigner, nonce: u64, @@ -261,7 +261,7 @@ impl Fixture { )) } - fn bid_trace(&self, built: &Built) -> BidTrace { + pub(crate) fn bid_trace(&self, built: &Built) -> BidTrace { let header = &built.payload.payload_inner.payload_inner; let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests) .expect("the fixture builds a convertible payload"); @@ -1369,3 +1369,74 @@ async fn a_bundle_for_a_block_with_no_blob_txs_is_rejected() { assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); } + +/// The alloy bundle the wire format carries, mirroring [`blob_bundle`]. +pub(crate) fn blob_bundle_v2(count: usize) -> alloy_rpc_types::engine::BlobsBundleV2 { + let bundle = blob_bundle(count); + alloy_rpc_types::engine::BlobsBundleV2 { + blobs: bundle.blobs.iter().map(|blob| alloy_primitives::FixedBytes(*blob)).collect(), + commitments: bundle + .commitments + .iter() + .map(|c| alloy_primitives::FixedBytes(*c).into()) + .collect(), + proofs: bundle.proofs.iter().map(|p| alloy_primitives::FixedBytes(*p).into()).collect(), + } +} + +impl Fixture { + /// A block carrying one blob tx per blob in `bundle`. + pub(crate) fn blob_block(&self, bundle: &alloy_rpc_types::engine::BlobsBundleV2) -> Built { + let hashes: Vec = + eblobs(bundle).generate_versioned_hashes().iter().map(|h| b256(*h)).collect(); + let txs = vec![signed_blob_transfer( + &self.signers[1], + self.chain_id, + 0, + Address::repeat_byte(0x66), + hashes, + )]; + self.build_block(self.genesis_hash, self.genesis_timestamp + 12, txs, Vec::new()) + } + + pub(crate) fn submission( + &self, + built: &Built, + ) -> alloy_rpc_types::beacon::relay::SignedBidSubmissionV5 { + alloy_rpc_types::beacon::relay::SignedBidSubmissionV5 { + message: self.bid_trace(built), + execution_payload: built.payload.clone(), + blobs_bundle: Default::default(), + execution_requests: built.requests.clone(), + signature: Default::default(), + } + } + + /// SSZ bytes of the submission in the shape the relay sends when it has no + /// decoder params: a bare `SignedBidSubmissionV5`. + pub(crate) fn encode_submission( + &self, + submission: alloy_rpc_types::beacon::relay::SignedBidSubmissionV5, + ) -> Vec { + ssz::Encode::as_ssz_bytes(&submission) + } + + pub(crate) fn ssz_request( + &self, + built: &Built, + pays: bool, + ) -> helix_common::simulator::SszValidationRequest { + let mut submission = self.submission(built); + if !pays { + submission.message.value = U256::ZERO; + } + helix_common::simulator::SszValidationRequest { + apply_blacklist: false, + registered_gas_limit: 0, + parent_beacon_block_root: B256::ZERO, + inclusion_list: Default::default(), + decoder_params: None, + signed_bid_submission: self.encode_submission(submission), + } + } +} From ae1819536a992f76a2d33b8ea2ab3bbb5d952f22 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 31 Aug 2026 10:59:00 +0100 Subject: [PATCH 23/29] Document the builder's two roles and expose the validation port README covers both roles, how to run each, and where the simulation role differs from crates/simulator: no inclusion lists, a narrower disallow rule, SSZ only, and release builds only. Step 10 of gattaca-com/helix#527. Co-Authored-By: Claude Opus 5 (1M context) --- builder.Dockerfile | 3 +- crates/builder/README.md | 77 ++++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/builder.Dockerfile b/builder.Dockerfile index 7366d4934..8ed5ef6b7 100644 --- a/builder.Dockerfile +++ b/builder.Dockerfile @@ -37,10 +37,11 @@ RUN apt-get update && apt-get install -y \ COPY --from=builder /app/target/release/helix-builder ./ # 9876 merging TCP (relay connections) +# 8552 SSZ block validation (simulation role) # 8551 authrpc / Engine API (beacon node) # 8545 http json-rpc # 30303 devp2p tcp+udp # 9090 prometheus metrics -EXPOSE 9876 8551 8545 30303/tcp 30303/udp 9090 +EXPOSE 9876 8552 8551 8545 30303/tcp 30303/udp 9090 ENTRYPOINT ["/app/helix-builder"] diff --git a/crates/builder/README.md b/crates/builder/README.md index fabadd6cf..dbb280dbd 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -1,9 +1,20 @@ # helix-builder -An external block-merging builder: the TCP **server** counterpart of the -relay's block-merging tile (`crates/relay/src/block_merging/`), with an -embedded [ethrex](https://github.com/lambdaclass/ethrex) execution node -providing chain state and the EVM. +An embedded [ethrex](https://github.com/lambdaclass/ethrex) execution node +running one or both of two roles, selected by which config file is supplied: + +| role | config | what it does | +| --- | --- | --- | +| merging | `--merging.config` | external block-merging builder: the TCP server counterpart of the relay's block-merging tile (`crates/relay/src/block_merging/`) | +| simulation | `--sim.config` | block validator: the ethrex counterpart of `crates/simulator`, serving the relay's SSZ validation routes | + +Supplying neither is a startup error. Both share one node, and `RELAY_KEY` is +only needed for the merging role. + +## The merging role + +The TCP **server** counterpart of the relay's block-merging tile, with the +embedded node providing chain state and the EVM. The relay dials the builder and streams, per slot, the mergeable builder submissions it receives plus an activation for its current top bid. The @@ -16,22 +27,45 @@ origin builders, per the relay-supplied bps), and streams improved reth-based engine in `crates/simulator/src/block_merging/` onto ethrex's payload-building primitives. +## The simulation role + +Validates a submitted block against the node's own state: the payload converts +to an ethrex block, the bid trace must describe that block, the parent must be +within the validation window, then execution, the post-execution roots and +state root, the blobs bundle's KZG proofs, the disallow list and the proposer +payment. Nothing is written to the store. + +It serves the relay's SSZ routes only, `/validate` and `/validate_merged`, so +the relay must reach it through the simulator's `ssz_url`. Differences from +`crates/simulator` worth knowing before pointing traffic at it: + +- Inclusion lists are not enforced. A block that violates a submitted list + passes here and fails there. +- The disallow list rejects interaction by effect -- a state change, or a + transaction addressed to a listed account. `crates/simulator` also rejects a + block that merely *reads* one, so it rejects strictly more blocks. +- Only Fulu (V5) and the relay-internal merged method are served. + ## Architecture ``` tokio runtime embedded ethrex node: store (rocksdb), devp2p + snap sync, Engine API (authrpc) for the operator's beacon node, head watcher + simulation role: SSZ validation server, disallow-list refresh flux tile merging TCP server (listen, handshake, framing, routing) engine thread merge worker: order pool, base replay, presim (rayon), emission ``` The tile and engine communicate over bounded crossbeam channels; the engine -owns all merge state and never blocks the TCP thread. +owns all merge state and never blocks the TCP thread. Validation runs on the +tokio blocking pool, capped by `max_concurrent_validations`. ## Running -The builder is a full execution node and needs a **beacon node** driving its -Engine API to follow the chain: +Either role is a full execution node and needs a **beacon node** driving its +Engine API to follow the chain. + +Merging: ```sh RELAY_KEY=0x... helix-builder \ @@ -41,6 +75,16 @@ RELAY_KEY=0x... helix-builder \ --merging.config merging.yml ``` +Simulation: + +```sh +helix-builder \ + --network mainnet \ + --datadir /data/helix-sim \ + --authrpc.addr 0.0.0.0 --authrpc.jwtsecret /secrets/jwt.hex \ + --sim.config sim.yml +``` + - Node flags mirror the upstream `ethrex` binary (same names and `ETHREX_*` env vars). `--datadir memory --p2p.disabled` boots an ephemeral in-memory node for local testing. @@ -50,13 +94,26 @@ RELAY_KEY=0x... helix-builder \ [config.example.yml](config.example.yml). The `api_keys` allowlist must contain the key the relay presents in `MergerRegistrationV1`. -On the relay side, add the builder to `block_merging_config.tcp.builders` -(see the repo-root `config.example.yml`). +- `--sim.config` points at the simulation YAML; see + [sim-config.example.yml](sim-config.example.yml). Until + `blacklist_endpoint` answers, the list is empty and no block is filtered. + +On the relay side, add a merging builder to +`block_merging_config.tcp.builders`, and a simulator as a `simulators` entry +with `ssz_url` set to this role's `ssz_addr` (see the repo-root +`config.example.yml`). ## Limitations - Merging protocol v1 carries `ExecutionPayloadV3`; post-Amsterdam blocks - (EIP-7928 block access lists) are rejected as merge bases. + (EIP-7928 block access lists) are rejected as merge bases. The simulation + role has the same gap: the payload carries no block-access-list hash, so + Amsterdam needs a newer payload version. +- The simulation role serves no JSON-RPC, so a relay without `ssz_url` cannot + use it. +- A blob is 128 KiB and crosses the stack several times in a debug build, which + overflows tokio's default worker stack. Release builds elide the copies; run + the simulation role in release. - The base block's declared `block_hash` is trusted as the pool key; the wire format carries no `requests_hash` to fully recompute it. - P-256 (`P256VERIFY`) uses ethrex's portable fallback rather than the From 59ce6d31ede387b2c0f4835167cf1c4e64d82a7c Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 31 Aug 2026 17:48:13 +0100 Subject: [PATCH 24/29] Select the building role from a supplied config Turn `Roles` into a struct of optional configs. As an enum a third role needs seven variants. The building role takes its own two keys: `RELAY_KEY` is already read as secp256k1 by the merging role and as BLS by `load_keypair`. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/build-config.example.yml | 30 ++ crates/builder/src/building/keys.rs | 91 ++++++ crates/builder/src/building/mod.rs | 6 + crates/builder/src/cli.rs | 4 + crates/builder/src/config.rs | 268 ++++++++++++++++-- crates/builder/src/main.rs | 19 +- crates/builder/src/validation/server_tests.rs | 2 +- 7 files changed, 394 insertions(+), 26 deletions(-) create mode 100644 crates/builder/build-config.example.yml create mode 100644 crates/builder/src/building/keys.rs create mode 100644 crates/builder/src/building/mod.rs diff --git a/crates/builder/build-config.example.yml b/crates/builder/build-config.example.yml new file mode 100644 index 000000000..99a48d87d --- /dev/null +++ b/crates/builder/build-config.example.yml @@ -0,0 +1,30 @@ +# helix-builder building configuration (--build.config) +# +# Needs two keys in the environment: +# BUILDER_BLS_KEY signs the submission; register this pubkey with the relay +# BUILDER_PAYOUT_KEY pays the proposer; fund this address + +# Relay base URL. Serves proposer duties and accepts block submissions. +relay_url: "http://localhost:4040" + +# Sent as X-Api-Key on every submission. +api_key: "00000000-0000-0000-0000-000000000001" + +# Beacon node base URL. Its payload_attributes SSE topic drives building. +beacon_url: "http://localhost:3500" + +# Added to every bid, so an idle chain still produces a non-zero one. The relay +# rejects a zero-value block. Set to 0 to bid only what the block really earns. +subsidy_wei: 1000000000 + +# Gas held back from the fill for the trailing payout transaction. A contract +# fee recipient that needs more than this makes the payout fail. +payout_gas_reserve: 21000 + +extra_data: "helix-builder" + +# Points into the slot, in milliseconds, at which to build and submit. +submit_offsets_ms: [500, 2000] + +# Validate our own block before submitting it. +self_validate: true diff --git a/crates/builder/src/building/keys.rs b/crates/builder/src/building/keys.rs new file mode 100644 index 000000000..0d7d206d5 --- /dev/null +++ b/crates/builder/src/building/keys.rs @@ -0,0 +1,91 @@ +use alloy_primitives::Address; +use alloy_signer_local::PrivateKeySigner; +use helix_types::{BlsKeypair, BlsPublicKeyBytes, BlsSecretKey}; + +/// BLS secret that signs the submission. Distinct from the merging role's +/// `RELAY_KEY`, which is a secp256k1 secret. +pub const BUILDER_BLS_KEY_ENV: &str = "BUILDER_BLS_KEY"; +/// secp256k1 secret that signs the payout to the proposer. Must be funded. +pub const BUILDER_PAYOUT_KEY_ENV: &str = "BUILDER_PAYOUT_KEY"; + +#[derive(Debug)] +pub struct BuildingKeys { + pub bls: BlsKeypair, + pub payout: PrivateKeySigner, +} + +impl BuildingKeys { + pub fn load() -> eyre::Result { + let bls = std::env::var(BUILDER_BLS_KEY_ENV) + .map_err(|_| eyre::eyre!("{BUILDER_BLS_KEY_ENV} env var not set"))?; + let payout = std::env::var(BUILDER_PAYOUT_KEY_ENV) + .map_err(|_| eyre::eyre!("{BUILDER_PAYOUT_KEY_ENV} env var not set"))?; + Self::parse(&bls, &payout) + } + + /// Split from [`Self::load`] so tests never touch process-wide env vars. + pub fn parse(bls_hex: &str, payout_hex: &str) -> eyre::Result { + let bytes = hex::decode(bls_hex.trim().trim_start_matches("0x")) + .map_err(|e| eyre::eyre!("invalid {BUILDER_BLS_KEY_ENV}: {e}"))?; + let secret = BlsSecretKey::deserialize(&bytes) + .map_err(|e| eyre::eyre!("invalid {BUILDER_BLS_KEY_ENV}: {e:?}"))?; + let bls = BlsKeypair::from_components(secret.public_key(), secret); + + let payout: PrivateKeySigner = payout_hex + .trim() + .parse() + .map_err(|e| eyre::eyre!("invalid {BUILDER_PAYOUT_KEY_ENV}: {e}"))?; + + Ok(Self { bls, payout }) + } + + pub fn pubkey(&self) -> BlsPublicKeyBytes { + self.bls.pk.serialize().into() + } + + pub fn payout_address(&self) -> Address { + self.payout.address() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Non-zero, canonical-range scalars. + const BLS_HEX: &str = "3535353535353535353535353535353535353535353535353535353535353535"; + const PAYOUT_HEX: &str = "4646464646464646464646464646464646464646464646464646464646464646"; + + #[test] + fn parses_a_hex_keypair() { + let bare = BuildingKeys::parse(BLS_HEX, PAYOUT_HEX).expect("bare hex must parse"); + let prefixed = BuildingKeys::parse(&format!("0x{BLS_HEX}"), &format!("0x{PAYOUT_HEX}")) + .expect("0x-prefixed hex must parse"); + + assert_eq!(bare.pubkey(), prefixed.pubkey()); + assert_eq!(bare.payout_address(), prefixed.payout_address()); + } + + #[test] + fn rejects_a_malformed_bls_key() { + let err = BuildingKeys::parse("nothex", PAYOUT_HEX).expect_err("must not start"); + + assert!(err.to_string().contains(BUILDER_BLS_KEY_ENV), "got: {err}"); + } + + #[test] + fn rejects_a_malformed_payout_key() { + let err = BuildingKeys::parse(BLS_HEX, "nothex").expect_err("must not start"); + + assert!(err.to_string().contains(BUILDER_PAYOUT_KEY_ENV), "got: {err}"); + } + + #[test] + fn derives_the_pubkey_and_payout_address() { + let keys = BuildingKeys::parse(BLS_HEX, PAYOUT_HEX).unwrap(); + + // Both are logged at boot: the operator registers one and funds the other. + assert_ne!(keys.pubkey(), BlsPublicKeyBytes::default()); + assert_ne!(keys.payout_address(), Address::ZERO); + } +} diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs new file mode 100644 index 000000000..902ec8dac --- /dev/null +++ b/crates/builder/src/building/mod.rs @@ -0,0 +1,6 @@ +//! The building role: builds a block for the next slot and submits it to the +//! relay. Shares the embedded ethrex node with the other roles. + +mod keys; + +pub use keys::BuildingKeys; diff --git a/crates/builder/src/cli.rs b/crates/builder/src/cli.rs index 24e78d987..041d5398b 100644 --- a/crates/builder/src/cli.rs +++ b/crates/builder/src/cli.rs @@ -25,6 +25,10 @@ pub struct BuilderCli { /// Path to the simulation YAML config. Activates the simulation role. #[arg(long = "sim.config", env = "HELIX_BUILDER_SIM_CONFIG")] pub sim_config: Option, + + /// Path to the building YAML config. Activates the building role. + #[arg(long = "build.config", env = "HELIX_BUILDER_BUILD_CONFIG")] + pub build_config: Option, } /// Embedded-ethrex node options. Flag and env names match the upstream `ethrex` diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 69c620a7e..5c39036c5 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -3,6 +3,11 @@ use std::{net::SocketAddr, path::Path}; use serde::Deserialize; use uuid::Uuid; +/// Intrinsic gas of a plain transfer, the floor for the payout reserve. +const TX_GAS_COST: u64 = 21_000; +/// Consensus limit on the header's `extra_data`. +const MAX_EXTRA_DATA_BYTES: usize = 32; + /// Builder-owned merging configuration, loaded from YAML (`--merging.config`). #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] @@ -128,43 +133,125 @@ impl SimulationConfig { } } +/// Builder-owned building configuration, loaded from YAML (`--build.config`). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuildingConfig { + /// Relay base URL. Serves proposer duties and accepts submissions. + pub relay_url: String, + /// Sent as `X-Api-Key` on every submission. + pub api_key: String, + /// Beacon node base URL, for the `payload_attributes` SSE topic. + pub beacon_url: String, + /// Added to every bid. The relay rejects a zero-value block, so without + /// this an idle chain produces nothing. + // Read once the payout is built. + #[allow(dead_code)] + #[serde(default = "default_subsidy_wei")] + pub subsidy_wei: u128, + /// Gas held back from the fill for the trailing payout transaction. + #[serde(default = "default_payout_gas_reserve")] + pub payout_gas_reserve: u64, + #[serde(default = "default_extra_data")] + pub extra_data: String, + /// Points into the slot, in milliseconds, at which to build and submit. + #[serde(default = "default_submit_offsets_ms")] + pub submit_offsets_ms: Vec, + /// Validate our own block before submitting it. + // Read once the slot loop exists. + #[allow(dead_code)] + #[serde(default = "default_true")] + pub self_validate: bool, +} + +impl BuildingConfig { + pub fn load(path: &Path) -> eyre::Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| eyre::eyre!("failed to read building config {}: {e}", path.display()))?; + let config: Self = serde_yaml::from_str(&raw) + .map_err(|e| eyre::eyre!("failed to parse building config {}: {e}", path.display()))?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> eyre::Result<()> { + check_http_url("relay_url", &self.relay_url)?; + check_http_url("beacon_url", &self.beacon_url)?; + if self.api_key.is_empty() { + eyre::bail!("building config: api_key must not be empty"); + } + // A plain transfer cannot cost less than the intrinsic gas. + if self.payout_gas_reserve < TX_GAS_COST { + eyre::bail!("building config: payout_gas_reserve must be at least {TX_GAS_COST}"); + } + if self.submit_offsets_ms.is_empty() { + eyre::bail!("building config: submit_offsets_ms must not be empty"); + } + if self.extra_data.len() > MAX_EXTRA_DATA_BYTES { + eyre::bail!("building config: extra_data must be at most {MAX_EXTRA_DATA_BYTES} bytes"); + } + Ok(()) + } +} + +/// The roles the builder runs, one per supplied config. At least one is +/// required; any combination is allowed. #[derive(Debug, Clone)] -pub enum Roles { - Merging(MergingConfig), - Simulation(SimulationConfig), - Both { merging: MergingConfig, simulation: SimulationConfig }, +pub struct Roles { + merging: Option, + simulation: Option, + building: Option, } impl Roles { pub fn resolve( merging: Option, simulation: Option, + building: Option, ) -> eyre::Result { - match (merging, simulation) { - (Some(merging), Some(simulation)) => Ok(Self::Both { merging, simulation }), - (Some(merging), None) => Ok(Self::Merging(merging)), - (None, Some(simulation)) => Ok(Self::Simulation(simulation)), - (None, None) => { - eyre::bail!("no role selected: supply --merging.config, --sim.config, or both") - } + if merging.is_none() && simulation.is_none() && building.is_none() { + eyre::bail!( + "no role selected: supply --merging.config, --sim.config, --build.config, or any combination" + ); } + Ok(Self { merging, simulation, building }) } pub fn merging(&self) -> Option<&MergingConfig> { - match self { - Self::Merging(merging) | Self::Both { merging, .. } => Some(merging), - Self::Simulation(_) => None, - } + self.merging.as_ref() } pub fn simulation(&self) -> Option<&SimulationConfig> { - match self { - Self::Simulation(simulation) | Self::Both { simulation, .. } => Some(simulation), - Self::Merging(_) => None, - } + self.simulation.as_ref() + } + + pub fn building(&self) -> Option<&BuildingConfig> { + self.building.as_ref() + } +} + +/// `Url::parse` alone accepts `localhost:4040`, reading the host as a scheme. +fn check_http_url(field: &str, raw: &str) -> eyre::Result<()> { + let url = reqwest::Url::parse(raw) + .map_err(|e| eyre::eyre!("building config: {field} is not a URL: {e}"))?; + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() { + eyre::bail!("building config: {field} must be an http(s) URL with a host"); } + Ok(()) } +fn default_subsidy_wei() -> u128 { + 1_000_000_000 +} +fn default_payout_gas_reserve() -> u64 { + TX_GAS_COST +} +fn default_extra_data() -> String { + "helix-builder".to_string() +} +fn default_submit_offsets_ms() -> Vec { + vec![500, 2000] +} fn default_blacklist_endpoint() -> String { "http://localhost:3520/blacklist".to_string() } @@ -229,6 +316,13 @@ mod tests { } } +#[cfg(test)] +const MINIMAL_BUILDING_YAML: &str = concat!( + "relay_url: \"http://localhost:4040\"\n", + "api_key: \"key\"\n", + "beacon_url: \"http://localhost:3500\"\n", +); + #[cfg(test)] mod simulation_config_tests { use super::*; @@ -245,6 +339,10 @@ mod simulation_config_tests { .expect("the minimal simulation config must parse") } + fn minimal_building_config() -> BuildingConfig { + serde_yaml::from_str(MINIMAL_BUILDING_YAML).expect("the minimal building config must parse") + } + #[test] fn parses_the_example_sim_config() { let example = include_str!("../sim-config.example.yml"); @@ -269,32 +367,156 @@ mod simulation_config_tests { #[test] fn a_merging_config_alone_selects_the_merging_role() { - let roles = Roles::resolve(Some(minimal_merging_config()), None).unwrap(); + let roles = Roles::resolve(Some(minimal_merging_config()), None, None).unwrap(); assert!(roles.merging().is_some()); assert!(roles.simulation().is_none()); + assert!(roles.building().is_none()); } #[test] fn a_sim_config_alone_selects_the_simulation_role() { - let roles = Roles::resolve(None, Some(minimal_simulation_config())).unwrap(); + let roles = Roles::resolve(None, Some(minimal_simulation_config()), None).unwrap(); assert!(roles.simulation().is_some()); assert!(roles.merging().is_none(), "no merging role means no RELAY_KEY is needed"); + assert!(roles.building().is_none()); } #[test] fn both_configs_select_both_roles() { let roles = - Roles::resolve(Some(minimal_merging_config()), Some(minimal_simulation_config())) + Roles::resolve(Some(minimal_merging_config()), Some(minimal_simulation_config()), None) .unwrap(); assert!(roles.merging().is_some()); assert!(roles.simulation().is_some()); + assert!(roles.building().is_none()); + } + + #[test] + fn a_build_config_alone_selects_the_building_role() { + let roles = Roles::resolve(None, None, Some(minimal_building_config())).unwrap(); + + assert!(roles.building().is_some()); + assert!(roles.merging().is_none(), "no merging role means no RELAY_KEY is needed"); + assert!(roles.simulation().is_none()); + } + + #[test] + fn all_three_configs_select_all_three_roles() { + let roles = Roles::resolve( + Some(minimal_merging_config()), + Some(minimal_simulation_config()), + Some(minimal_building_config()), + ) + .unwrap(); + + assert!(roles.merging().is_some()); + assert!(roles.simulation().is_some()); + assert!(roles.building().is_some()); } #[test] fn neither_config_is_a_startup_error() { - assert!(Roles::resolve(None, None).is_err(), "the builder must run at least one role"); + assert!( + Roles::resolve(None, None, None).is_err(), + "the builder must run at least one role" + ); + } +} + +#[cfg(test)] +mod building_config_tests { + use super::*; + + fn with_line(extra: &str) -> BuildingConfig { + serde_yaml::from_str(&format!("{MINIMAL_BUILDING_YAML}{extra}\n")) + .expect("the config must parse") + } + + #[test] + fn parses_the_example_build_config() { + let example = include_str!("../build-config.example.yml"); + let config: BuildingConfig = serde_yaml::from_str(example).unwrap(); + config.validate().unwrap(); + + assert_eq!(config.relay_url, "http://localhost:4040"); + assert_eq!(config.beacon_url, "http://localhost:3500"); + assert_eq!(config.subsidy_wei, 1_000_000_000); + assert_eq!(config.payout_gas_reserve, 21_000); + assert_eq!(config.extra_data, "helix-builder"); + assert_eq!(config.submit_offsets_ms, vec![500, 2000]); + assert!(config.self_validate); + } + + #[test] + fn minimal_build_config_gets_defaults() { + let config: BuildingConfig = serde_yaml::from_str(MINIMAL_BUILDING_YAML).unwrap(); + config.validate().unwrap(); + + assert_eq!(config.subsidy_wei, 1_000_000_000, "an idle chain still gets a non-zero bid"); + assert_eq!(config.payout_gas_reserve, 21_000); + assert_eq!(config.extra_data, "helix-builder"); + assert_eq!(config.submit_offsets_ms, vec![500, 2000]); + assert!(config.self_validate); + } + + #[test] + fn rejects_an_unknown_field() { + let err = serde_yaml::from_str::(&format!( + "{MINIMAL_BUILDING_YAML}subsidy_we: 1\n" + )) + .expect_err("a misspelled field must not be silently ignored"); + + assert!(err.to_string().contains("unknown field"), "got: {err}"); + } + + #[test] + fn rejects_a_payout_gas_reserve_below_the_intrinsic_cost() { + let err = with_line("payout_gas_reserve: 20999") + .validate() + .expect_err("a reserve below the intrinsic gas can never pay for a transfer"); + + assert!(err.to_string().contains("payout_gas_reserve"), "got: {err}"); + } + + #[test] + fn rejects_empty_submit_offsets() { + let err = with_line("submit_offsets_ms: []") + .validate() + .expect_err("without an offset the role would start and never build"); + + assert!(err.to_string().contains("submit_offsets_ms"), "got: {err}"); + } + + #[test] + fn rejects_a_malformed_relay_url() { + let config: BuildingConfig = serde_yaml::from_str(concat!( + "relay_url: \"localhost:4040\"\n", + "api_key: \"key\"\n", + "beacon_url: \"http://localhost:3500\"\n", + )) + .unwrap(); + + let err = config.validate().expect_err("fail at boot, not on first submission"); + assert!(err.to_string().contains("relay_url"), "got: {err}"); + } + + #[test] + fn rejects_extra_data_over_32_bytes() { + let err = with_line(&format!("extra_data: \"{}\"", "x".repeat(33))) + .validate() + .expect_err("extra_data must fit the header field"); + + assert!(err.to_string().contains("extra_data"), "got: {err}"); + } + + #[test] + fn a_zero_subsidy_is_allowed() { + let config = with_line("subsidy_wei: 0"); + config.validate().expect("an operator may bid only what the block earns"); + + assert_eq!(config.subsidy_wei, 0); } } diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index d7879e512..9200f44de 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -7,6 +7,7 @@ use flux::{ use tracing::info; use tracing_subscriber::EnvFilter; +mod building; mod cli; mod config; mod engine; @@ -18,8 +19,9 @@ mod testing; mod utils; mod validation; +use building::BuildingKeys; use cli::BuilderCli; -use config::{MergingConfig, Roles, SimulationConfig}; +use config::{BuildingConfig, MergingConfig, Roles, SimulationConfig}; use engine::{MergeEngine, types::EngineConfig}; use server::MergingServerTile; use spine::BuilderSpine; @@ -34,7 +36,8 @@ fn main() -> eyre::Result<()> { let merging_config = cli.merging_config.as_deref().map(MergingConfig::load).transpose()?; let simulation_config = cli.sim_config.as_deref().map(SimulationConfig::load).transpose()?; - let roles = Roles::resolve(merging_config, simulation_config)?; + let building_config = cli.build_config.as_deref().map(BuildingConfig::load).transpose()?; + let roles = Roles::resolve(merging_config, simulation_config, building_config)?; // Fail fast on a missing/invalid RELAY_KEY, before the node boots. let relay_signer = roles.merging().map(|merging_config| { @@ -42,6 +45,18 @@ fn main() -> eyre::Result<()> { EngineConfig::load_relay_signer() }); + // Same, for the building role's own two keys. The role itself arrives in a + // later step; this only proves the keys are usable before the node boots. + if let Some(building_config) = roles.building() { + let keys = BuildingKeys::load()?; + info!( + relay_url = %building_config.relay_url, + builder_pubkey = %keys.pubkey(), + payout_address = %keys.payout_address(), + "Loaded building config; register the pubkey and fund the payout address", + ); + } + let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; let node = runtime.block_on(node::start(&cli.node))?; diff --git a/crates/builder/src/validation/server_tests.rs b/crates/builder/src/validation/server_tests.rs index c32e6d14b..8ab6aad06 100644 --- a/crates/builder/src/validation/server_tests.rs +++ b/crates/builder/src/validation/server_tests.rs @@ -5,7 +5,7 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use helix_common::simulator::{SszMergedValidationRequest, SszValidationRequest}; +use helix_common::simulator::SszMergedValidationRequest; use ssz::Encode; use tower::ServiceExt; From 86f7681ffcd35c7bc564cb60446fec406b8aec01 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 31 Aug 2026 18:19:54 +0100 Subject: [PATCH 25/29] Merge beacon payload attributes with relay duties into a slot context The beacon node supplies the consensus fields and the relay supplies the proposer. Neither alone is enough to build. Take the fee recipient and gas limit from the duty. The event carries a `suggested_fee_recipient`, but it is the local validator's. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 + crates/builder/Cargo.toml | 2 + crates/builder/src/building/mod.rs | 3 + crates/builder/src/building/slot.rs | 347 +++++++++++++++++++++++++ crates/builder/src/building/watcher.rs | 84 ++++++ crates/builder/src/main.rs | 12 +- 6 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 crates/builder/src/building/slot.rs create mode 100644 crates/builder/src/building/watcher.rs diff --git a/Cargo.lock b/Cargo.lock index 9b2147e1a..16661dfe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6475,6 +6475,7 @@ dependencies = [ "flux", "flux-network", "flux-utils", + "futures", "helix-common", "helix-tcp-types", "helix-types", @@ -6483,6 +6484,7 @@ dependencies = [ "rand 0.9.5", "rayon", "reqwest 0.13.4", + "reqwest-eventsource 0.5.0", "rustc-hash", "secp256k1 0.30.0", "serde", diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index c11d70d22..5b02895ce 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -40,6 +40,8 @@ helix-common.workspace = true helix-tcp-types.workspace = true helix-types.workspace = true reqwest.workspace = true +reqwest-eventsource.workspace = true +futures.workspace = true num_cpus.workspace = true rand.workspace = true rayon.workspace = true diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 902ec8dac..dc1868063 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -2,5 +2,8 @@ //! relay. Shares the embedded ethrex node with the other roles. mod keys; +mod slot; +mod watcher; pub use keys::BuildingKeys; +pub use watcher::run; diff --git a/crates/builder/src/building/slot.rs b/crates/builder/src/building/slot.rs new file mode 100644 index 000000000..444f3b929 --- /dev/null +++ b/crates/builder/src/building/slot.rs @@ -0,0 +1,347 @@ +use std::collections::{HashMap, HashSet}; + +use alloy_primitives::{Address, B256}; +use helix_common::{ + api::builder_api::BuilderGetValidatorsResponse, beacon::types::PayloadAttributesEvent, +}; +use helix_types::{BlsPublicKeyBytes, Withdrawals}; +use tracing::debug; + +/// The proposer's registration for one slot, as the relay reports it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposerDuty { + pub pubkey: BlsPublicKeyBytes, + pub fee_recipient: Address, + pub gas_limit: u64, +} + +/// Everything needed to build and bid for one slot. The consensus fields come +/// from the beacon node, the proposer fields from the relay. +// Read once the block is assembled. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct SlotContext { + pub slot: u64, + pub parent_hash: B256, + pub parent_block_number: u64, + pub timestamp: u64, + pub prev_randao: B256, + pub withdrawals: Withdrawals, + pub parent_beacon_block_root: B256, + pub proposer_pubkey: BlsPublicKeyBytes, + pub proposer_fee_recipient: Address, + pub registered_gas_limit: u64, +} + +/// Merges the beacon node's `payload_attributes` events with the relay's +/// proposer duties, and decides which events are worth building for. +#[derive(Debug, Default)] +pub struct SlotTracker { + duties: HashMap, + /// Highest slot seen, for discarding replays after an SSE reconnect. + latest_slot: u64, + /// Slot and parent pairs already built for. The beacon node repeats an + /// event whenever it recomputes the attributes. + built: HashSet<(u64, B256)>, +} + +impl SlotTracker { + pub fn on_duties(&mut self, duties: Vec) { + self.duties = duties + .into_iter() + .map(|duty| { + let registration = duty.entry.message; + (duty.slot.as_u64(), ProposerDuty { + pubkey: registration.pubkey, + fee_recipient: registration.fee_recipient, + gas_limit: registration.gas_limit, + }) + }) + .collect(); + let latest_slot = self.latest_slot; + self.duties.retain(|slot, _| *slot >= latest_slot); + } + + #[cfg(test)] + pub fn duty(&self, slot: u64) -> Option<&ProposerDuty> { + self.duties.get(&slot) + } + + pub fn on_payload_attributes(&mut self, event: PayloadAttributesEvent) -> Option { + let data = event.data; + let slot = data.proposal_slot.as_u64(); + + if slot < self.latest_slot { + debug!(slot, latest = self.latest_slot, "discarding a stale payload_attributes event"); + return None; + } + if slot > self.latest_slot { + self.latest_slot = slot; + self.built.retain(|(built_slot, _)| *built_slot >= slot); + self.duties.retain(|duty_slot, _| *duty_slot >= slot); + } + + // EIP-4788 needs the root, so a block cannot be built without it. + let Some(parent_beacon_block_root) = data.payload_attributes.parent_beacon_block_root + else { + debug!(slot, "skipping a slot with no parent_beacon_block_root"); + return None; + }; + + let Some(duty) = self.duties.get(&slot) else { + debug!(slot, "skipping a slot with no registered proposer"); + return None; + }; + + if !self.built.insert((slot, data.parent_block_hash)) { + return None; + } + + Some(SlotContext { + slot, + parent_hash: data.parent_block_hash, + parent_block_number: data.parent_block_number, + timestamp: data.payload_attributes.timestamp, + prev_randao: data.payload_attributes.prev_randao, + withdrawals: data.payload_attributes.withdrawals, + parent_beacon_block_root, + // Never the event's `suggested_fee_recipient`: that is the local + // validator's, not the proposer's. + proposer_pubkey: duty.pubkey, + proposer_fee_recipient: duty.fee_recipient, + registered_gas_limit: duty.gas_limit, + }) + } +} + +#[cfg(test)] +mod tests { + use helix_common::beacon::types::{PayloadAttributes, PayloadAttributesEventData}; + + use super::*; + + const PROPOSER_FEE_RECIPIENT: Address = Address::repeat_byte(0xaa); + const LOCAL_FEE_RECIPIENT: &str = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const REGISTERED_GAS_LIMIT: u64 = 30_000_000; + + fn duty_response(slot: u64) -> BuilderGetValidatorsResponse { + let mut entry = helix_types::SignedValidatorRegistration::default(); + entry.message.fee_recipient = PROPOSER_FEE_RECIPIENT; + entry.message.gas_limit = REGISTERED_GAS_LIMIT; + entry.message.pubkey = BlsPublicKeyBytes::from([7u8; 48]); + + BuilderGetValidatorsResponse { + slot: slot.into(), + validator_index: 1, + entry, + preferences: Default::default(), + } + } + + fn event(slot: u64, parent: B256) -> PayloadAttributesEvent { + PayloadAttributesEvent { + version: "fulu".to_string(), + data: PayloadAttributesEventData { + proposer_index: 1, + proposal_slot: slot.into(), + parent_block_number: slot - 1, + parent_block_root: String::new(), + parent_block_hash: parent, + payload_attributes: PayloadAttributes { + timestamp: 1_700_000_000 + slot * 12, + prev_randao: B256::repeat_byte(0xcc), + suggested_fee_recipient: LOCAL_FEE_RECIPIENT.to_string(), + withdrawals: Withdrawals::default(), + parent_beacon_block_root: Some(B256::repeat_byte(0xdd)), + }, + }, + } + } + + fn tracker_with_duty(slot: u64) -> SlotTracker { + let mut tracker = SlotTracker::default(); + tracker.on_duties(vec![duty_response(slot)]); + tracker + } + + #[test] + fn an_event_with_a_matching_duty_yields_a_context() { + let mut tracker = tracker_with_duty(10); + + let context = tracker + .on_payload_attributes(event(10, B256::repeat_byte(0x11))) + .expect("a registered proposer and a complete event must build"); + + assert_eq!(context.slot, 10); + assert_eq!(context.parent_hash, B256::repeat_byte(0x11)); + assert_eq!(context.parent_block_number, 9); + assert_eq!(context.timestamp, 1_700_000_000 + 120); + assert_eq!(context.prev_randao, B256::repeat_byte(0xcc)); + assert_eq!(context.parent_beacon_block_root, B256::repeat_byte(0xdd)); + assert_eq!(context.proposer_pubkey, BlsPublicKeyBytes::from([7u8; 48])); + } + + #[test] + fn the_fee_recipient_and_gas_limit_come_from_the_duty() { + let mut tracker = tracker_with_duty(10); + + let context = tracker.on_payload_attributes(event(10, B256::repeat_byte(0x11))).unwrap(); + + // The event's `suggested_fee_recipient` is the local validator's. Paying + // it would produce a block the relay rejects. + assert_eq!(context.proposer_fee_recipient, PROPOSER_FEE_RECIPIENT); + assert_eq!(context.registered_gas_limit, REGISTERED_GAS_LIMIT); + } + + #[test] + fn an_event_without_a_duty_is_skipped() { + let mut tracker = SlotTracker::default(); + + assert!( + tracker.on_payload_attributes(event(10, B256::repeat_byte(0x11))).is_none(), + "an unregistered proposer cannot be bid for" + ); + } + + #[test] + fn a_repeated_event_is_skipped() { + let mut tracker = tracker_with_duty(10); + let parent = B256::repeat_byte(0x11); + + assert!(tracker.on_payload_attributes(event(10, parent)).is_some()); + assert!( + tracker.on_payload_attributes(event(10, parent)).is_none(), + "the beacon node repeats an event whenever it recomputes the attributes" + ); + } + + #[test] + fn a_new_parent_for_the_same_slot_yields_a_fresh_context() { + let mut tracker = tracker_with_duty(10); + + assert!(tracker.on_payload_attributes(event(10, B256::repeat_byte(0x11))).is_some()); + let reorged = tracker + .on_payload_attributes(event(10, B256::repeat_byte(0x22))) + .expect("a late or re-orged parent must rebuild, not count as a duplicate"); + + assert_eq!(reorged.parent_hash, B256::repeat_byte(0x22)); + } + + #[test] + fn an_event_for_an_older_slot_is_skipped() { + let mut tracker = SlotTracker::default(); + tracker.on_duties(vec![duty_response(9), duty_response(10)]); + + assert!(tracker.on_payload_attributes(event(10, B256::repeat_byte(0x11))).is_some()); + assert!( + tracker.on_payload_attributes(event(9, B256::repeat_byte(0x99))).is_none(), + "an SSE reconnect can replay older events" + ); + } + + #[test] + fn an_event_without_a_parent_beacon_block_root_is_skipped() { + let mut tracker = tracker_with_duty(10); + let mut incomplete = event(10, B256::repeat_byte(0x11)); + incomplete.data.payload_attributes.parent_beacon_block_root = None; + + assert!( + tracker.on_payload_attributes(incomplete).is_none(), + "EIP-4788 makes the root mandatory" + ); + } + + #[test] + fn refreshed_duties_replace_the_previous_set() { + let mut tracker = tracker_with_duty(10); + assert!(tracker.duty(10).is_some()); + + tracker.on_duties(vec![duty_response(11)]); + + assert!(tracker.duty(10).is_none(), "a duty dropped by the relay must not linger"); + assert!(tracker.duty(11).is_some()); + } + + #[test] + fn duties_for_past_slots_are_pruned() { + let mut tracker = SlotTracker::default(); + tracker.on_duties(vec![duty_response(10)]); + tracker.on_payload_attributes(event(10, B256::repeat_byte(0x11))); + + tracker.on_duties(vec![duty_response(5), duty_response(20)]); + + assert!(tracker.duty(5).is_none(), "duties must not accumulate across epochs"); + assert!(tracker.duty(20).is_some()); + } + + #[test] + fn parses_a_relay_duties_response() { + let json = r#"[{ + "slot": "11111", + "validator_index": "222", + "entry": { + "message": { + "fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "gas_limit": "36000000", + "timestamp": "1700000000", + "pubkey": "0x933ad9491b62059dd065b560d256d8957a8c402cc6e8d8ee7290ae11e8f7329267a8811c397529dac52ae1342ba58c95" + }, + "signature": "0xa8e4f8ff4e9f2ea1f1a0f1e7f61b2a26eefbf2ae0cd2b3a2bcbb2c0b6a09ad8d3e33fdb0a1a5f8c99d1b0f4a9b04e6a20b9a2b6d43cd0f5cb61ecebba38a9d3e93a8b6c0e5b3d33da0bb2a9d0c9ee1b8b5f8f1f92d2ce7c4a6e6c4bb1a3f1c2d" + }, + "preferences": { + "censoring": false, + "filtering": "global", + "trusted_builders": null, + "disable_optimistic": false + } + }]"#; + + let duties: Vec = serde_json::from_str(json).unwrap(); + let mut tracker = SlotTracker::default(); + tracker.on_duties(duties); + + let duty = tracker.duty(11111).expect("the quoted slot must round-trip"); + assert_eq!(duty.gas_limit, 36_000_000, "gas_limit is a quoted u64"); + assert_eq!(duty.fee_recipient, Address::repeat_byte(0xaa)); + } + + #[test] + fn parses_a_payload_attributes_event() { + let json = r#"{ + "version": "fulu", + "data": { + "proposer_index": "123", + "proposal_slot": "11111", + "parent_block_number": "999", + "parent_block_root": "0x1111111111111111111111111111111111111111111111111111111111111111", + "parent_block_hash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "payload_attributes": { + "timestamp": "1700000000", + "prev_randao": "0x3333333333333333333333333333333333333333333333333333333333333333", + "suggested_fee_recipient": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "withdrawals": [{ + "index": "1", + "validator_index": "2", + "address": "0xcccccccccccccccccccccccccccccccccccccccc", + "amount": "32000000000" + }], + "parent_beacon_block_root": "0x4444444444444444444444444444444444444444444444444444444444444444" + } + } + }"#; + + let event: PayloadAttributesEvent = serde_json::from_str(json).unwrap(); + let mut tracker = SlotTracker::default(); + tracker.on_duties(vec![duty_response(11111)]); + + let context = tracker.on_payload_attributes(event).expect("a complete event must build"); + + assert_eq!(context.slot, 11111); + assert_eq!(context.parent_block_number, 999); + assert_eq!(context.timestamp, 1_700_000_000); + assert_eq!(context.parent_hash, B256::repeat_byte(0x22)); + assert_eq!(context.parent_beacon_block_root, B256::repeat_byte(0x44)); + assert_eq!(context.withdrawals.len(), 1); + assert_eq!(context.withdrawals[0].amount, 32_000_000_000); + } +} diff --git a/crates/builder/src/building/watcher.rs b/crates/builder/src/building/watcher.rs new file mode 100644 index 000000000..32dbe4134 --- /dev/null +++ b/crates/builder/src/building/watcher.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use eyre::Context; +use futures::StreamExt; +use helix_common::{ + api::{PATH_BUILDER_API, PATH_GET_VALIDATORS, builder_api::BuilderGetValidatorsResponse}, + beacon::types::PayloadAttributesEvent, +}; +use reqwest_eventsource::{Event, EventSource}; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use crate::{ + building::slot::{SlotContext, SlotTracker}, + config::BuildingConfig, +}; + +/// Proposer duties cover the current and next epoch, so this only has to beat +/// an epoch boundary. +const DUTIES_REFRESH: Duration = Duration::from_secs(12); + +pub async fn fetch_duties( + http: &reqwest::Client, + relay_url: &str, +) -> eyre::Result> { + let url = format!("{}{PATH_BUILDER_API}{PATH_GET_VALIDATORS}", relay_url.trim_end_matches('/')); + let response = http.get(&url).send().await.wrap_err("get_validators request failed")?; + let status = response.status(); + if !status.is_success() { + eyre::bail!("get_validators returned {status}"); + } + response.json().await.wrap_err("get_validators returned malformed JSON") +} + +/// Merges the beacon node's `payload_attributes` events with the relay's +/// proposer duties, and publishes one [`SlotContext`] per slot worth building. +pub async fn run(config: BuildingConfig, contexts: mpsc::Sender) { + let http = reqwest::Client::new(); + let mut tracker = SlotTracker::default(); + + let events_url = format!( + "{}/eth/v1/events?topics=payload_attributes", + config.beacon_url.trim_end_matches('/') + ); + // EventSource reconnects on its own; a replayed event is discarded by the + // tracker's staleness check. + let mut events = EventSource::get(&events_url); + + let mut refresh = tokio::time::interval(DUTIES_REFRESH); + + loop { + tokio::select! { + _ = refresh.tick() => match fetch_duties(&http, &config.relay_url).await { + Ok(duties) => { + debug!(count = duties.len(), "refreshed proposer duties"); + tracker.on_duties(duties); + } + Err(e) => warn!(err = %e, "failed to refresh proposer duties"), + }, + Some(event) = events.next() => match event { + Ok(Event::Open) => info!(url = %events_url, "subscribed to payload_attributes"), + Ok(Event::Message(message)) => { + match serde_json::from_str::(&message.data) { + Ok(event) => { + if let Some(context) = tracker.on_payload_attributes(event) { + info!( + slot = context.slot, + parent_hash = %context.parent_hash, + gas_limit = context.registered_gas_limit, + "building for slot", + ); + if contexts.send(context).await.is_err() { + return; + } + } + } + Err(e) => warn!(err = %e, "malformed payload_attributes event"), + } + } + Err(e) => warn!(err = %e, "payload_attributes stream error"), + }, + } + } +} diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index 9200f44de..173de628e 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -45,8 +45,8 @@ fn main() -> eyre::Result<()> { EngineConfig::load_relay_signer() }); - // Same, for the building role's own two keys. The role itself arrives in a - // later step; this only proves the keys are usable before the node boots. + // Same, for the building role's own two keys. They are consumed once the + // role signs and pays; loading here fails fast on a bad key. if let Some(building_config) = roles.building() { let keys = BuildingKeys::load()?; info!( @@ -81,6 +81,14 @@ fn main() -> eyre::Result<()> { info!(ssz_addr = %simulation_config.ssz_addr, "Simulation role active"); } + if let Some(building_config) = roles.building() { + let (contexts, mut rx) = tokio::sync::mpsc::channel(4); + runtime.spawn(building::run(building_config.clone(), contexts)); + // Steps 3 to 5 build and submit from these. + runtime.spawn(async move { while rx.recv().await.is_some() {} }); + info!("Building role active"); + } + // `BuilderSpine::start` blocks until its tiles stop, so merging starts last. if let Some(merging_config) = roles.merging() { let relay_signer = relay_signer.expect("the merging role loads a relay signer"); From 54ae1fc9d8ae1a7ad6aba0e5bc56b6e37ea6379d Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 1 Sep 2026 14:36:51 +0100 Subject: [PATCH 26/29] Build a block for each slot from the node's mempool Reserve the payout gas by lowering `remaining_gas` before the fill and restoring it after. `fill_transactions` spends the whole budget. Raise the default subsidy to 0.001 ETH. At 1 gwei the old default could not cover the payout's own gas, so every idle slot was skipped. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/build-config.example.yml | 6 +- crates/builder/src/building/assemble.rs | 237 ++++++++++++ crates/builder/src/building/assemble/tests.rs | 338 ++++++++++++++++++ crates/builder/src/building/mod.rs | 45 ++- crates/builder/src/config.rs | 9 +- crates/builder/src/main.rs | 42 ++- 6 files changed, 656 insertions(+), 21 deletions(-) create mode 100644 crates/builder/src/building/assemble.rs create mode 100644 crates/builder/src/building/assemble/tests.rs diff --git a/crates/builder/build-config.example.yml b/crates/builder/build-config.example.yml index 99a48d87d..154ef4003 100644 --- a/crates/builder/build-config.example.yml +++ b/crates/builder/build-config.example.yml @@ -14,8 +14,10 @@ api_key: "00000000-0000-0000-0000-000000000001" beacon_url: "http://localhost:3500" # Added to every bid, so an idle chain still produces a non-zero one. The relay -# rejects a zero-value block. Set to 0 to bid only what the block really earns. -subsidy_wei: 1000000000 +# rejects a zero-value block. Must exceed the payout's own gas +# (payout_gas_reserve * base fee) or the slot is skipped. Set to 0 to bid only +# what the block really earns. +subsidy_wei: 1000000000000000 # Gas held back from the fill for the trailing payout transaction. A contract # fee recipient that needs more than this makes the payout fail. diff --git a/crates/builder/src/building/assemble.rs b/crates/builder/src/building/assemble.rs new file mode 100644 index 000000000..ccaa12de7 --- /dev/null +++ b/crates/builder/src/building/assemble.rs @@ -0,0 +1,237 @@ +use alloy_consensus::{SignableTransaction, TxEip1559}; +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::{Address, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use ethrex_blockchain::{ + Blockchain, + payload::{BuildPayloadArgs, HeadTransaction, PayloadBuildContext, create_payload}, +}; +use ethrex_common::{ + U256 as EU256, + types::{ + AccountUpdate, BlobsBundle, Block, ELASTICITY_MULTIPLIER, MempoolTransaction, Transaction, + requests::EncodedRequests, + }, +}; +use ethrex_crypto::native::NativeCrypto; +use ethrex_storage::Store; +use thiserror::Error; + +use crate::{ + building::slot::SlotContext, + config::BuildingConfig, + engine::convert::{au256, eaddr, eu256, h256}, +}; + +#[derive(Debug, Error)] +pub enum BuildError { + #[error("parent block not found")] + MissingParent, + /// Nothing to bid: the relay rejects a zero-value block. + #[error("no payout: tips and subsidy do not cover the payout gas")] + NoPayout, + #[error("the builder cannot afford the payout")] + PayoutUnaffordable, + #[error("the payout transaction reverted")] + PayoutReverted, + #[error("the payout recipient is the builder itself")] + PayoutToSelf, + #[error("build failed: {0}")] + Internal(String), +} + +/// A finalized block and the bid it backs. +#[derive(Debug)] +pub struct BuiltBlock { + pub block: Block, + // These three are read by the submission. + #[allow(dead_code)] + pub blobs_bundle: BlobsBundle, + #[allow(dead_code)] + pub requests: Vec, + /// The changed accounts, for checking the payment the way the relay does. + #[allow(dead_code)] + pub account_updates: Vec, + /// Paid to the proposer by the trailing transaction, and the value the + /// `BidTrace` claims. + pub value: U256, +} + +/// Builds a block for `slot` from the node's mempool, ending with a transfer +/// that pays the proposer. +/// +/// The coinbase is the builder, so tips accrue here and the bid is funded from +/// them plus the configured subsidy. +pub fn build( + store: &Store, + blockchain: &Blockchain, + slot: &SlotContext, + config: &BuildingConfig, + payout_signer: &PrivateKeySigner, + chain_id: u64, +) -> Result { + let builder = payout_signer.address(); + if builder == slot.proposer_fee_recipient { + return Err(BuildError::PayoutToSelf); + } + if store + .get_block_header_by_hash(h256(slot.parent_hash)) + .map_err(|e| BuildError::Internal(e.to_string()))? + .is_none() + { + return Err(BuildError::MissingParent); + } + + let args = BuildPayloadArgs { + parent: h256(slot.parent_hash), + timestamp: slot.timestamp, + fee_recipient: eaddr(builder), + random: h256(slot.prev_randao), + withdrawals: Some(slot.withdrawals.iter().map(ewithdrawal_lh).collect()), + beacon_root: Some(h256(slot.parent_beacon_block_root)), + slot_number: None, + version: 3, + elasticity_multiplier: ELASTICITY_MULTIPLIER, + // `create_payload` runs this through `calc_gas_limit`, which applies + // the 1/1024 clamp against the parent. + gas_ceil: slot.registered_gas_limit, + }; + let template = create_payload(&args, store, config.extra_data.clone().into()) + .map_err(|e| BuildError::Internal(format!("create_payload: {e}")))?; + + let mut ctx = PayloadBuildContext::new(template, store, &blockchain.options.r#type) + .map_err(|e| BuildError::Internal(format!("payload context: {e}")))?; + blockchain + .apply_system_operations(&mut ctx) + .map_err(|e| BuildError::Internal(format!("system operations: {e}")))?; + + // `fill_transactions` spends every last drop of `remaining_gas`, so hold + // the payout's share back and restore it once the fill is done. + let reserve = config.payout_gas_reserve.min(ctx.remaining_gas); + ctx.remaining_gas -= reserve; + blockchain + .fill_transactions(&mut ctx) + .map_err(|e| BuildError::Internal(format!("fill transactions: {e}")))?; + ctx.remaining_gas += reserve; + + let base_fee = ctx.payload.header.base_fee_per_gas.unwrap_or_default(); + let payout = payout_value(&ctx, config, base_fee)?; + + let nonce = ctx + .vm + .db + .get_account(eaddr(builder)) + .map_err(|e| BuildError::Internal(e.to_string()))? + .info + .nonce; + let balance = ctx + .vm + .db + .get_account(eaddr(builder)) + .map_err(|e| BuildError::Internal(e.to_string()))? + .info + .balance; + let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + if balance < eu256(payout) + gas_cost { + return Err(BuildError::PayoutUnaffordable); + } + + let payout_tx = signed_payout( + payout_signer, + chain_id, + nonce, + slot.proposer_fee_recipient, + payout, + config.payout_gas_reserve, + base_fee as u128, + )?; + let sender = payout_tx + .sender(&NativeCrypto) + .map_err(|e| BuildError::Internal(format!("payout sender: {e}")))?; + blockchain + .apply_tx_to_payload( + HeadTransaction { tx: MempoolTransaction::new(payout_tx, sender), tip: EU256::zero() }, + &mut ctx, + ) + .map_err(|e| BuildError::Internal(format!("payout tx: {e}")))?; + if !ctx.receipts.last().is_some_and(|receipt| receipt.succeeded) { + return Err(BuildError::PayoutReverted); + } + + blockchain + .extract_requests(&mut ctx) + .map_err(|e| BuildError::Internal(format!("extract requests: {e}")))?; + blockchain + .apply_withdrawals(&mut ctx) + .map_err(|e| BuildError::Internal(format!("apply withdrawals: {e}")))?; + blockchain + .finalize_payload(&mut ctx) + .map_err(|e| BuildError::Internal(format!("finalize: {e}")))?; + + Ok(BuiltBlock { + block: ctx.payload, + blobs_bundle: ctx.blobs_bundle, + requests: ctx.requests.unwrap_or_default(), + account_updates: ctx.account_updates, + value: payout, + }) +} + +/// Tips earned plus the subsidy, less the gas the payout itself will burn. +fn payout_value( + ctx: &PayloadBuildContext, + config: &BuildingConfig, + base_fee: u64, +) -> Result { + let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + let funded = ctx.block_value + EU256::from(config.subsidy_wei); + let payout = funded.checked_sub(gas_cost).ok_or(BuildError::NoPayout)?; + if payout.is_zero() { + return Err(BuildError::NoPayout); + } + Ok(au256(payout)) +} + +fn signed_payout( + signer: &PrivateKeySigner, + chain_id: u64, + nonce: u64, + to: Address, + value: U256, + gas_limit: u64, + base_fee: u128, +) -> Result { + let tx = TxEip1559 { + chain_id, + nonce, + gas_limit, + max_fee_per_gas: base_fee, + // A tip would pay the builder out of its own payout, and the relay's + // payment check refuses one. + max_priority_fee_per_gas: 0, + to: to.into(), + value, + access_list: Default::default(), + input: Default::default(), + }; + let signature = signer + .sign_hash_sync(&tx.signature_hash()) + .map_err(|e| BuildError::Internal(format!("payout signature: {e}")))?; + let encoded = alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718(); + Transaction::decode_canonical(&encoded) + .map_err(|e| BuildError::Internal(format!("payout decode: {e}"))) +} + +/// The consensus withdrawal type, not the alloy one `convert` bridges. +fn ewithdrawal_lh(w: &helix_types::Withdrawal) -> ethrex_common::types::Withdrawal { + ethrex_common::types::Withdrawal { + index: w.index, + validator_index: w.validator_index, + address: eaddr(w.address), + amount: w.amount, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/builder/src/building/assemble/tests.rs b/crates/builder/src/building/assemble/tests.rs new file mode 100644 index 000000000..6abb8178e --- /dev/null +++ b/crates/builder/src/building/assemble/tests.rs @@ -0,0 +1,338 @@ +use std::sync::Arc; + +use alloy_primitives::{Address, B256, U256}; +use ethrex_blockchain::{Blockchain, BlockchainOptions, BlockchainType}; +use ethrex_common::types::Transaction; +use ethrex_storage::Store; +use ethrex_vm::VmDatabase; +use helix_types::{BlsPublicKeyBytes, Withdrawal, Withdrawals}; + +use super::*; +use crate::testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}; + +const PROPOSER: Address = Address::repeat_byte(0x77); +/// Must clear the payout's own gas cost, ~21000 * base fee. +const SUBSIDY: u128 = ETH / 1000; + +struct Fixture { + store: Store, + blockchain: Arc, + signers: Vec, + parent_hash: B256, + parent_timestamp: u64, + parent_gas_limit: u64, + chain_id: u64, +} + +impl Fixture { + async fn new() -> Self { + let (store, genesis) = dev_genesis_store().await; + let genesis_block = genesis.get_block(); + let blockchain = Arc::new(Blockchain::new(store.clone(), BlockchainOptions { + r#type: BlockchainType::L1, + ..Default::default() + })); + Self { + signers: funded_signers(&genesis, 8), + parent_hash: crate::engine::convert::b256(genesis_block.hash()), + parent_timestamp: genesis_block.header.timestamp, + parent_gas_limit: genesis_block.header.gas_limit, + chain_id: genesis.config.chain_id, + store, + blockchain, + } + } + + /// `signers[0]` is the builder: it holds the coinbase and signs the payout. + fn builder(&self) -> &alloy_signer_local::PrivateKeySigner { + &self.signers[0] + } + + fn config(&self) -> BuildingConfig { + serde_yaml::from_str(&format!( + "relay_url: \"http://localhost:4040\"\napi_key: \"key\"\n\ + beacon_url: \"http://localhost:3500\"\nsubsidy_wei: {SUBSIDY}\n" + )) + .unwrap() + } + + fn slot(&self) -> SlotContext { + SlotContext { + slot: 1, + parent_hash: self.parent_hash, + parent_block_number: 0, + timestamp: self.parent_timestamp + 12, + prev_randao: B256::repeat_byte(0xcc), + withdrawals: Withdrawals::default(), + parent_beacon_block_root: B256::repeat_byte(0xdd), + proposer_pubkey: BlsPublicKeyBytes::default(), + proposer_fee_recipient: PROPOSER, + registered_gas_limit: self.parent_gas_limit, + } + } + + /// Admits a transfer from `signers[index]` to the mempool. + async fn pool_transfer(&self, index: usize, nonce: u64, tip: u128) { + let encoded = signed_transfer( + &self.signers[index], + self.chain_id, + nonce, + Address::repeat_byte(0x55), + U256::from(GWEI), + 100 * GWEI, + tip, + ); + let tx = Transaction::decode_canonical(&encoded).unwrap(); + self.blockchain.add_transaction_to_pool(tx).await.unwrap(); + } + + fn build(&self, slot: &SlotContext, config: &BuildingConfig) -> Result { + build(&self.store, &self.blockchain, slot, config, self.builder(), self.chain_id) + } + + fn build_default(&self) -> Result { + self.build(&self.slot(), &self.config()) + } + + fn parent_header(&self) -> ethrex_common::types::BlockHeader { + self.store + .get_block_header_by_hash(crate::engine::convert::h256(self.parent_hash)) + .unwrap() + .unwrap() + } +} + +/// The trailing transaction, decoded. +fn payout_tx(built: &BuiltBlock) -> &Transaction { + built.block.body.transactions.last().expect("a block always ends with the payout") +} + +#[tokio::test] +async fn mempool_transactions_are_included() { + let fixture = Fixture::new().await; + fixture.pool_transfer(1, 0, GWEI).await; + fixture.pool_transfer(2, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + assert_eq!(built.block.body.transactions.len(), 3, "two mempool txs plus the payout"); +} + +#[tokio::test] +async fn the_block_extends_the_parent() { + let fixture = Fixture::new().await; + let slot = fixture.slot(); + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + let header = &built.block.header; + assert_eq!(crate::engine::convert::b256(header.parent_hash), fixture.parent_hash); + assert_eq!(header.number, 1); + assert_eq!(header.timestamp, slot.timestamp); + assert_eq!(crate::engine::convert::b256(header.prev_randao), slot.prev_randao); + assert_eq!(header.extra_data.as_ref(), b"helix-builder"); + assert_eq!( + header.coinbase, + eaddr(fixture.builder().address()), + "the builder is the coinbase, so tips fund the bid" + ); +} + +#[tokio::test] +async fn the_header_gas_limit_follows_the_registered_limit() { + let fixture = Fixture::new().await; + let mut slot = fixture.slot(); + // Far below the parent, so the 1/1024 clamp binds. + slot.registered_gas_limit = 1; + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + let delta = fixture.parent_gas_limit / 1024 - 1; + assert_eq!( + built.block.header.gas_limit, + fixture.parent_gas_limit - delta, + "ethrex's calc_gas_limit clamps the registered limit against the parent" + ); +} + +#[tokio::test] +async fn the_payout_is_the_last_transaction_and_pays_the_bid() { + let fixture = Fixture::new().await; + fixture.pool_transfer(1, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + let payout = payout_tx(&built); + assert_eq!(payout.to(), ethrex_common::types::TxKind::Call(eaddr(PROPOSER))); + assert_eq!(payout.value(), eu256(built.value)); + assert!(payout.data().is_empty(), "a plain transfer, which the relay recognises"); + assert_eq!(payout.max_priority_fee(), Some(0), "a tip would be refused"); +} + +#[tokio::test] +async fn the_bid_equals_tips_plus_subsidy_less_the_payout_gas() { + let fixture = Fixture::new().await; + fixture.pool_transfer(1, 0, GWEI).await; + let config = fixture.config(); + + let built = fixture.build_default().unwrap(); + + let base_fee = built.block.header.base_fee_per_gas.unwrap(); + let tips = EU256::from(21_000u64) * EU256::from(GWEI); + let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + let expected = tips + EU256::from(SUBSIDY) - gas_cost; + + assert_eq!(eu256(built.value), expected); +} + +#[tokio::test] +async fn the_fee_recipient_balance_rises_by_the_bid() { + let fixture = Fixture::new().await; + fixture.pool_transfer(1, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + // The check the relay's simulator makes in `paid_by_balance`. + let db = + ethrex_blockchain::vm::StoreVmDatabase::new(fixture.store.clone(), fixture.parent_header()) + .unwrap(); + let before = db + .get_account_state(eaddr(PROPOSER)) + .unwrap() + .map(|account| account.balance) + .unwrap_or_default(); + let after = built + .account_updates + .iter() + .find(|update| update.address == eaddr(PROPOSER)) + .and_then(|update| update.info.as_ref().map(|info| info.balance)) + .expect("the payout must touch the fee recipient"); + + assert_eq!(after, before + eu256(built.value)); + assert!(!built.value.is_zero(), "the relay rejects a zero-value block"); +} + +#[tokio::test] +async fn the_payout_gas_reserve_survives_a_full_block() { + let fixture = Fixture::new().await; + let mut slot = fixture.slot(); + // Room for a handful of transfers, so the fill exhausts the block. + slot.registered_gas_limit = fixture.parent_gas_limit; + for index in 1..8 { + for nonce in 0..40 { + fixture.pool_transfer(index, nonce, GWEI).await; + } + } + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + let payout = payout_tx(&built); + assert_eq!( + payout.to(), + ethrex_common::types::TxKind::Call(eaddr(PROPOSER)), + "the reserve must outlast the fill", + ); + assert!( + built.block.header.gas_used <= built.block.header.gas_limit, + "the restored reserve must not overrun the limit", + ); +} + +#[tokio::test] +async fn an_empty_mempool_still_bids_the_subsidy() { + let fixture = Fixture::new().await; + + let built = fixture.build_default().unwrap(); + + assert_eq!(built.block.body.transactions.len(), 1, "the payout alone"); + assert!(!built.value.is_zero(), "an idle testnet must still produce a bid"); +} + +#[tokio::test] +async fn a_zero_subsidy_and_no_tips_is_refused() { + let fixture = Fixture::new().await; + let mut config = fixture.config(); + config.subsidy_wei = 0; + + let err = fixture.build(&fixture.slot(), &config).expect_err("there is nothing to bid"); + + assert!(matches!(err, BuildError::NoPayout), "got: {err}"); +} + +#[tokio::test] +async fn a_payout_the_builder_cannot_afford_is_refused() { + let fixture = Fixture::new().await; + let mut config = fixture.config(); + // Beyond anything the dev genesis funds. + config.subsidy_wei = u128::MAX; + + let err = fixture.build(&fixture.slot(), &config).expect_err("the builder is not that rich"); + + assert!(matches!(err, BuildError::PayoutUnaffordable), "got: {err}"); +} + +#[tokio::test] +async fn a_payout_to_the_builder_itself_is_refused() { + let fixture = Fixture::new().await; + let mut slot = fixture.slot(); + slot.proposer_fee_recipient = fixture.builder().address(); + + let err = fixture.build(&slot, &fixture.config()).expect_err("paying ourselves proves nothing"); + + assert!(matches!(err, BuildError::PayoutToSelf), "got: {err}"); +} + +#[tokio::test] +async fn withdrawals_are_applied() { + let fixture = Fixture::new().await; + let mut slot = fixture.slot(); + let recipient = Address::repeat_byte(0x66); + slot.withdrawals = Withdrawals::new(vec![Withdrawal { + index: 1, + validator_index: 2, + address: recipient, + amount: 32_000_000_000, + }]) + .unwrap(); + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + assert!(built.block.header.withdrawals_root.is_some()); + assert_eq!(built.block.body.withdrawals.as_ref().unwrap().len(), 1); +} + +#[tokio::test] +async fn blob_transactions_carry_their_sidecar() { + let fixture = Fixture::new().await; + let bundle = crate::testing::blob_bundle(1); + let hashes: Vec = bundle + .generate_versioned_hashes() + .iter() + .map(|hash| crate::engine::convert::b256(*hash)) + .collect(); + let encoded = crate::testing::signed_blob_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x55), + hashes, + ); + let Transaction::EIP4844Transaction(blob_tx) = Transaction::decode_canonical(&encoded).unwrap() + else { + panic!("expected a blob transaction"); + }; + fixture.blockchain.add_blob_transaction_to_pool(blob_tx, bundle).await.unwrap(); + + let built = fixture.build_default().unwrap(); + + assert_eq!(built.block.body.transactions.len(), 2, "the blob tx plus the payout"); + assert_eq!( + built.blobs_bundle.blobs.len(), + 1, + "the sidecar must come through the mempool, not be rebuilt", + ); + assert_eq!( + built.block.header.blob_gas_used, + Some(u64::from(ethrex_common::constants::GAS_PER_BLOB)) + ); +} diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index dc1868063..3b930c0cc 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -1,9 +1,52 @@ //! The building role: builds a block for the next slot and submits it to the //! relay. Shares the embedded ethrex node with the other roles. +mod assemble; mod keys; mod slot; mod watcher; +use std::sync::Arc; + +use alloy_signer_local::PrivateKeySigner; +use ethrex_blockchain::Blockchain; +use ethrex_storage::Store; pub use keys::BuildingKeys; -pub use watcher::run; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; +pub use watcher::run as watch_slots; + +use crate::{building::slot::SlotContext, config::BuildingConfig}; + +/// Builds a block for every slot the watcher publishes. +pub async fn build_blocks( + config: BuildingConfig, + store: Store, + blockchain: Arc, + payout_signer: PrivateKeySigner, + chain_id: u64, + mut contexts: mpsc::Receiver, +) { + while let Some(slot) = contexts.recv().await { + let (config, store, blockchain, signer) = + (config.clone(), store.clone(), blockchain.clone(), payout_signer.clone()); + // Building is CPU-bound and must not stall the runtime. + let built = tokio::task::spawn_blocking(move || { + assemble::build(&store, &blockchain, &slot, &config, &signer, chain_id) + }) + .await; + + match built { + Ok(Ok(block)) => info!( + number = block.block.header.number, + txs = block.block.body.transactions.len(), + gas_used = block.block.header.gas_used, + value = %block.value, + "built a block", + ), + // Step 4 submits; until then a built block is only reported. + Ok(Err(e)) => warn!(err = %e, "skipping slot"), + Err(e) => error!(err = %e, "build task panicked"), + } + } +} diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 5c39036c5..cbe9da1e8 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -241,7 +241,7 @@ fn check_http_url(field: &str, raw: &str) -> eyre::Result<()> { } fn default_subsidy_wei() -> u128 { - 1_000_000_000 + 1_000_000_000_000_000 } fn default_payout_gas_reserve() -> u64 { TX_GAS_COST @@ -443,7 +443,7 @@ mod building_config_tests { assert_eq!(config.relay_url, "http://localhost:4040"); assert_eq!(config.beacon_url, "http://localhost:3500"); - assert_eq!(config.subsidy_wei, 1_000_000_000); + assert_eq!(config.subsidy_wei, 1_000_000_000_000_000); assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); assert_eq!(config.submit_offsets_ms, vec![500, 2000]); @@ -455,7 +455,10 @@ mod building_config_tests { let config: BuildingConfig = serde_yaml::from_str(MINIMAL_BUILDING_YAML).unwrap(); config.validate().unwrap(); - assert_eq!(config.subsidy_wei, 1_000_000_000, "an idle chain still gets a non-zero bid"); + assert_eq!( + config.subsidy_wei, 1_000_000_000_000_000, + "an idle chain still gets a non-zero bid" + ); assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); assert_eq!(config.submit_offsets_ms, vec![500, 2000]); diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index 173de628e..b5f6ca8c3 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -45,17 +45,20 @@ fn main() -> eyre::Result<()> { EngineConfig::load_relay_signer() }); - // Same, for the building role's own two keys. They are consumed once the - // role signs and pays; loading here fails fast on a bad key. - if let Some(building_config) = roles.building() { - let keys = BuildingKeys::load()?; - info!( - relay_url = %building_config.relay_url, - builder_pubkey = %keys.pubkey(), - payout_address = %keys.payout_address(), - "Loaded building config; register the pubkey and fund the payout address", - ); - } + // Same, for the building role's own two keys. + let building_keys = match roles.building() { + Some(building_config) => { + let keys = BuildingKeys::load()?; + info!( + relay_url = %building_config.relay_url, + builder_pubkey = %keys.pubkey(), + payout_address = %keys.payout_address(), + "Loaded building config; register the pubkey and fund the payout address", + ); + Some(keys) + } + None => None, + }; let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; @@ -82,10 +85,19 @@ fn main() -> eyre::Result<()> { } if let Some(building_config) = roles.building() { - let (contexts, mut rx) = tokio::sync::mpsc::channel(4); - runtime.spawn(building::run(building_config.clone(), contexts)); - // Steps 3 to 5 build and submit from these. - runtime.spawn(async move { while rx.recv().await.is_some() {} }); + let keys = building_keys.expect("the building role loads its keys"); + let chain_id = node.store.get_chain_config().chain_id; + + let (contexts, rx) = tokio::sync::mpsc::channel(4); + runtime.spawn(building::watch_slots(building_config.clone(), contexts)); + runtime.spawn(building::build_blocks( + building_config.clone(), + node.store.clone(), + node.blockchain.clone(), + keys.payout, + chain_id, + rx, + )); info!("Building role active"); } From d2f4d8a233e18d2d6bef8b85fbe90ca47a5f913a Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 1 Sep 2026 15:09:25 +0100 Subject: [PATCH 27/29] Sign the built block and submit it to the relay Take the builder domain from the beacon node's own spec and genesis. A hardcoded genesis fork version makes the relay drop every bid, silently. Check the blobs bundle by proof count, not by `version`. ethrex's `AddAssign` does not propagate it, so an aggregate always reads 0. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/src/building/mod.rs | 71 ++++-- crates/builder/src/building/submit.rs | 156 +++++++++++++ crates/builder/src/building/submit/tests.rs | 242 ++++++++++++++++++++ crates/builder/src/main.rs | 10 + 4 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 crates/builder/src/building/submit.rs create mode 100644 crates/builder/src/building/submit/tests.rs diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 3b930c0cc..131fd6a11 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -4,6 +4,7 @@ mod assemble; mod keys; mod slot; +mod submit; mod watcher; use std::sync::Arc; @@ -11,6 +12,7 @@ use std::sync::Arc; use alloy_signer_local::PrivateKeySigner; use ethrex_blockchain::Blockchain; use ethrex_storage::Store; +use helix_common::signing::RelaySigningContext; pub use keys::BuildingKeys; use tokio::sync::mpsc; use tracing::{error, info, warn}; @@ -18,35 +20,76 @@ pub use watcher::run as watch_slots; use crate::{building::slot::SlotContext, config::BuildingConfig}; -/// Builds a block for every slot the watcher publishes. +/// Reads the network's spec and genesis from the beacon node, so the builder +/// domain is never a hardcoded per-network constant. +pub async fn signing_context(beacon_url: &str) -> eyre::Result { + let url = beacon_url + .parse() + .map_err(|e| eyre::eyre!("building config: beacon_url is not a URL: {e}"))?; + let chain_info = + helix_common::beacon::BeaconClient::new(helix_common::config::BeaconClientConfig { url }) + .get_chain_info() + .await + .map_err(|e| eyre::eyre!("cannot read the chain spec from the beacon node: {e:?}"))?; + Ok(RelaySigningContext::new(BuildingKeys::load()?.bls, Arc::new(chain_info))) +} + +/// Builds and submits a block for every slot the watcher publishes. pub async fn build_blocks( config: BuildingConfig, store: Store, blockchain: Arc, payout_signer: PrivateKeySigner, + signing: RelaySigningContext, chain_id: u64, mut contexts: mpsc::Receiver, ) { + let submitter = submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing); + while let Some(slot) = contexts.recv().await { - let (config, store, blockchain, signer) = - (config.clone(), store.clone(), blockchain.clone(), payout_signer.clone()); + let (build_config, store, blockchain, signer, build_slot) = ( + config.clone(), + store.clone(), + blockchain.clone(), + payout_signer.clone(), + slot.clone(), + ); // Building is CPU-bound and must not stall the runtime. let built = tokio::task::spawn_blocking(move || { - assemble::build(&store, &blockchain, &slot, &config, &signer, chain_id) + assemble::build(&store, &blockchain, &build_slot, &build_config, &signer, chain_id) }) .await; - match built { - Ok(Ok(block)) => info!( - number = block.block.header.number, - txs = block.block.body.transactions.len(), - gas_used = block.block.header.gas_used, - value = %block.value, - "built a block", + let built = match built { + Ok(Ok(built)) => built, + Ok(Err(e)) => { + warn!(slot = slot.slot, err = %e, "skipping slot"); + continue; + } + Err(e) => { + error!(slot = slot.slot, err = %e, "build task panicked"); + continue; + } + }; + + let submission = match submitter.sign(&built, &slot) { + Ok(submission) => submission, + Err(e) => { + warn!(slot = slot.slot, err = %e, "cannot sign the block"); + continue; + } + }; + + match submitter.submit(&submission).await { + Ok(()) => info!( + slot = slot.slot, + block_hash = %submission.message.block_hash, + txs = built.block.body.transactions.len(), + value = %built.value, + "submitted a block", ), - // Step 4 submits; until then a built block is only reported. - Ok(Err(e)) => warn!(err = %e, "skipping slot"), - Err(e) => error!(err = %e, "build task panicked"), + // The relay's reason is how an operator learns the blocks are bad. + Err(e) => warn!(slot = slot.slot, err = %e, "the relay refused the block"), } } } diff --git a/crates/builder/src/building/submit.rs b/crates/builder/src/building/submit.rs new file mode 100644 index 000000000..578b0ce36 --- /dev/null +++ b/crates/builder/src/building/submit.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use alloy_eips::eip7594::CELLS_PER_EXT_BLOB; +use helix_common::{ + api::{PATH_BUILDER_API, PATH_SUBMIT_BLOCK}, + signing::RelaySigningContext, +}; +use helix_types::{ + BidTrace, BlobsBundle, KzgCommitments, SignedBidSubmission, payload_from_v3, requests_from_v4, +}; +use ssz::Encode; +use thiserror::Error; + +use crate::{ + building::{assemble::BuiltBlock, slot::SlotContext}, + engine::convert::{block_to_payload_v3, requests_to_v4}, +}; + +#[derive(Debug, Error)] +pub enum SubmitError { + #[error("blobs bundle: {0}")] + Blobs(String), + #[error("payload exceeds the consensus limits")] + OversizedPayload, + #[error("requests: {0}")] + Requests(String), + #[error("relay rejected the submission ({status}): {body}")] + Rejected { status: u16, body: String }, + #[error("relay request failed: {0}")] + Transport(String), +} + +pub struct Submitter { + http: reqwest::Client, + url: String, + api_key: String, + signing: RelaySigningContext, +} + +impl Submitter { + pub fn new(relay_url: &str, api_key: String, signing: RelaySigningContext) -> Self { + Self { + http: reqwest::Client::new(), + url: format!( + "{}{PATH_BUILDER_API}{PATH_SUBMIT_BLOCK}", + relay_url.trim_end_matches('/') + ), + api_key, + signing, + } + } + + /// Builds the `BidTrace` and signs it under the builder domain. + pub fn sign( + &self, + built: &BuiltBlock, + slot: &SlotContext, + ) -> Result { + let payload_v3 = block_to_payload_v3(&built.block); + let payload = payload_from_v3(payload_v3).ok_or(SubmitError::OversizedPayload)?; + + let requests_v4 = requests_to_v4(&built.requests).map_err(SubmitError::Requests)?; + let requests = requests_from_v4(requests_v4) + .ok_or_else(|| SubmitError::Requests("exceeds the consensus limits".into()))?; + + let blobs = wire_blobs(&built.blobs_bundle)?; + + // Every field the relay cross-checks against the payload comes from the + // payload itself, not from the build. + let message = BidTrace { + slot: slot.slot, + parent_hash: payload.parent_hash, + block_hash: payload.block_hash, + builder_pubkey: *self.signing.pubkey(), + proposer_pubkey: slot.proposer_pubkey, + proposer_fee_recipient: slot.proposer_fee_recipient, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + value: built.value, + }; + let signature = self.signing.sign_builder_message(&message); + + Ok(SignedBidSubmission { + message, + execution_payload: Arc::new(payload), + blobs_bundle: Arc::new(blobs), + execution_requests: Arc::new(requests), + signature: signature.serialize().into(), + }) + } + + pub async fn submit(&self, submission: &SignedBidSubmission) -> Result<(), SubmitError> { + let response = self + .http + .post(&self.url) + .header("content-type", "application/octet-stream") + .header("x-api-key", &self.api_key) + .body(submission.as_ssz_bytes()) + .send() + .await + .map_err(|e| SubmitError::Transport(e.to_string()))?; + + let status = response.status(); + if status.is_success() { + return Ok(()); + } + // The relay's message is how an operator learns the blocks are bad. + let body = response.text().await.unwrap_or_default(); + Err(SubmitError::Rejected { status: status.as_u16(), body }) + } +} + +/// Inverse of [`crate::engine::convert::eblobs`]. +/// +/// ethrex's `AddAssign` for `BlobsBundle` does not propagate `version`, so an +/// aggregated bundle always claims version 0. The proof count is the only +/// trustworthy signal that these are EIP-7594 cell proofs. +fn wire_blobs(bundle: ðrex_common::types::BlobsBundle) -> Result { + let expected = bundle.blobs.len() * CELLS_PER_EXT_BLOB; + if bundle.proofs.len() != expected { + return Err(SubmitError::Blobs(format!( + "expected {expected} cell proofs for {} blobs, got {}", + bundle.blobs.len(), + bundle.proofs.len() + ))); + } + if bundle.commitments.len() != bundle.blobs.len() { + return Err(SubmitError::Blobs(format!( + "expected {} commitments, got {}", + bundle.blobs.len(), + bundle.commitments.len() + ))); + } + + // A blob is 128 KiB. Write into the allocation rather than returning one + // by value, which would move it across the stack. + let mut blobs = Vec::with_capacity(bundle.blobs.len()); + for blob in &bundle.blobs { + let mut out = Box::new(alloy_consensus::Blob::ZERO); + out.0.copy_from_slice(blob.as_slice()); + blobs.push(Arc::from(out)); + } + + // `helix_types::fields::Kzg*` are `Bytes48`, not lighthouse's same-named types. + let commitments: Vec = + bundle.commitments.iter().map(|c| alloy_consensus::Bytes48::from(*c)).collect(); + Ok(BlobsBundle { + commitments: KzgCommitments::new(commitments) + .map_err(|e| SubmitError::Blobs(format!("too many commitments: {e:?}")))?, + proofs: bundle.proofs.iter().map(|p| alloy_consensus::Bytes48::from(*p)).collect(), + blobs, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/builder/src/building/submit/tests.rs b/crates/builder/src/building/submit/tests.rs new file mode 100644 index 000000000..aa6bfdb89 --- /dev/null +++ b/crates/builder/src/building/submit/tests.rs @@ -0,0 +1,242 @@ +use std::sync::Arc; + +use alloy_primitives::{Address, B256, U256}; +use ethrex_common::types::BlobsBundle as EthrexBlobsBundle; +use helix_common::chain_info::ChainInfo; +use helix_types::{BlsKeypair, BlsPublicKeyBytes, Withdrawals}; +use ssz::Decode; + +use super::*; +use crate::testing::blob_bundle; + +const PROPOSER: Address = Address::repeat_byte(0x77); + +fn signing() -> RelaySigningContext { + RelaySigningContext::new(BlsKeypair::random(), Arc::new(ChainInfo::default())) +} + +fn submitter(url: &str) -> Submitter { + Submitter::new(url, "test-key".to_string(), signing()) +} + +fn slot_context() -> SlotContext { + SlotContext { + slot: 42, + parent_hash: B256::repeat_byte(0x11), + parent_block_number: 41, + timestamp: 1_700_000_000, + prev_randao: B256::repeat_byte(0xcc), + withdrawals: Withdrawals::default(), + parent_beacon_block_root: B256::repeat_byte(0xdd), + proposer_pubkey: BlsPublicKeyBytes::from([7u8; 48]), + proposer_fee_recipient: PROPOSER, + registered_gas_limit: 30_000_000, + } +} + +/// A minimal finalized block, enough to convert and sign. +fn built_block(bundle: EthrexBlobsBundle) -> BuiltBlock { + let mut block = ethrex_common::types::Block::default(); + block.header.gas_limit = 30_000_000; + block.header.gas_used = 21_000; + block.header.base_fee_per_gas = Some(7); + block.body.withdrawals = Some(Vec::new()); + BuiltBlock { + block, + blobs_bundle: bundle, + requests: Vec::new(), + account_updates: Vec::new(), + value: U256::from(1_234_567_u64), + } +} + +// --- blobs conversion --- + +#[test] +fn a_blobs_bundle_converts_to_the_wire_type() { + let bundle = blob_bundle(1); + + let wire = wire_blobs(&bundle).expect("a cell-proof bundle must convert"); + + assert_eq!(wire.blobs.len(), 1); + assert_eq!(wire.commitments.len(), 1); + assert_eq!(wire.proofs.len(), CELLS_PER_EXT_BLOB, "128 cell proofs per blob"); + assert_eq!(wire.commitments[0].0, bundle.commitments[0]); + assert_eq!(wire.blobs[0].as_slice(), bundle.blobs[0].as_slice()); +} + +#[test] +fn a_bundle_without_cell_proofs_is_refused() { + let mut bundle = blob_bundle(1); + // A pre-EIP-7594 bundle: one proof per blob. `version` still reads 0 either + // way, so only the count distinguishes them. + bundle.proofs.truncate(1); + + let err = wire_blobs(&bundle).expect_err("the relay requires cell proofs"); + + assert!(err.to_string().contains("cell proofs"), "got: {err}"); +} + +#[test] +fn a_bundle_with_mismatched_commitments_is_refused() { + let mut bundle = blob_bundle(1); + bundle.commitments.clear(); + + let err = wire_blobs(&bundle).expect_err("commitments must match the blobs"); + + assert!(err.to_string().contains("commitments"), "got: {err}"); +} + +#[test] +fn an_empty_bundle_converts() { + let wire = wire_blobs(&EthrexBlobsBundle::default()).expect("the common case"); + + assert!(wire.blobs.is_empty()); + assert!(wire.proofs.is_empty()); +} + +// --- the submission --- + +#[test] +fn the_bid_trace_mirrors_the_payload() { + let submitter = submitter("http://localhost:1"); + let built = built_block(EthrexBlobsBundle::default()); + + let submission = submitter.sign(&built, &slot_context()).unwrap(); + + // `payload.validate()` on the relay rejects any disagreement here. + let payload = &submission.execution_payload; + assert_eq!(submission.message.parent_hash, payload.parent_hash); + assert_eq!(submission.message.block_hash, payload.block_hash); + assert_eq!(submission.message.gas_limit, payload.gas_limit); + assert_eq!(submission.message.gas_used, payload.gas_used); +} + +#[test] +fn the_bid_trace_carries_the_slot_and_proposer() { + let submitter = submitter("http://localhost:1"); + let built = built_block(EthrexBlobsBundle::default()); + let slot = slot_context(); + + let submission = submitter.sign(&built, &slot).unwrap(); + + assert_eq!(submission.message.slot, 42); + assert_eq!(submission.message.proposer_pubkey, slot.proposer_pubkey); + assert_eq!(submission.message.proposer_fee_recipient, PROPOSER); + assert_eq!(submission.message.value, built.value); + assert!(!submission.message.value.is_zero(), "the relay rejects a zero-value block"); +} + +#[test] +fn the_signature_verifies_under_the_builder_domain() { + let signing = signing(); + let domain = signing.chain_info.builder_domain; + let submitter = Submitter::new("http://localhost:1", "key".to_string(), signing); + let built = built_block(EthrexBlobsBundle::default()); + + let submission = submitter.sign(&built, &slot_context()).unwrap(); + + // The exact check the relay's decoder tile makes. + submission.verify_signature(domain).expect("the relay must accept our signature"); +} + +#[test] +fn the_submission_round_trips_through_ssz() { + let submitter = submitter("http://localhost:1"); + let built = built_block(EthrexBlobsBundle::default()); + let submission = submitter.sign(&built, &slot_context()).unwrap(); + + let encoded = submission.as_ssz_bytes(); + let decoded = SignedBidSubmission::from_ssz_bytes(&encoded).expect("the wire format"); + + assert_eq!(decoded.message.block_hash, submission.message.block_hash); + assert_eq!(decoded.message.value, submission.message.value); +} + +#[test] +fn a_submission_with_blobs_round_trips() { + with_large_stack(|| { + let submitter = submitter("http://localhost:1"); + let built = built_block(blob_bundle(1)); + + let submission = submitter.sign(&built, &slot_context()).unwrap(); + let encoded = submission.as_ssz_bytes(); + let decoded = SignedBidSubmission::from_ssz_bytes(&encoded) + .expect("a bundle decodes only with 128 proofs per blob"); + + assert_eq!(decoded.blobs_bundle.blobs.len(), 1); + assert_eq!(decoded.blobs_bundle.proofs.len(), CELLS_PER_EXT_BLOB); + }); +} + +/// 128 KiB blobs overflow the default stack in debug builds. +fn with_large_stack(test: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(32 * 1024 * 1024).spawn(test).unwrap().join().unwrap(); +} + +// --- the HTTP call --- + +/// Serves one canned response and records what the builder sent. +async fn stub_relay( + status: axum::http::StatusCode, + body: &'static str, +) -> (String, tokio::sync::oneshot::Receiver) { + let (tx, rx) = tokio::sync::oneshot::channel(); + let seen = Arc::new(std::sync::Mutex::new(Some(tx))); + let app = axum::Router::new().route( + &format!("{PATH_BUILDER_API}{PATH_SUBMIT_BLOCK}"), + axum::routing::post(move |headers: axum::http::HeaderMap| { + let seen = seen.clone(); + async move { + if let Some(tx) = seen.lock().unwrap().take() { + let _ = tx.send(headers); + } + (status, body) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), rx) +} + +#[tokio::test] +async fn the_request_carries_the_ssz_headers() { + let (url, headers) = stub_relay(axum::http::StatusCode::OK, "").await; + let submitter = submitter(&url); + let submission = + submitter.sign(&built_block(EthrexBlobsBundle::default()), &slot_context()).unwrap(); + + submitter.submit(&submission).await.unwrap(); + + let headers = headers.await.unwrap(); + assert_eq!(headers["content-type"], "application/octet-stream", "SSZ, not JSON"); + assert_eq!(headers["x-api-key"], "test-key"); +} + +#[tokio::test] +async fn a_success_is_reported() { + let (url, _headers) = stub_relay(axum::http::StatusCode::OK, "").await; + let submitter = submitter(&url); + let submission = + submitter.sign(&built_block(EthrexBlobsBundle::default()), &slot_context()).unwrap(); + + submitter.submit(&submission).await.expect("a 200 is success"); +} + +#[tokio::test] +async fn a_relay_rejection_is_reported_with_its_body() { + let (url, _headers) = + stub_relay(axum::http::StatusCode::BAD_REQUEST, "simulation failed: invalid state root") + .await; + let submitter = submitter(&url); + let submission = + submitter.sign(&built_block(EthrexBlobsBundle::default()), &slot_context()).unwrap(); + + let err = submitter.submit(&submission).await.expect_err("a 400 is not success"); + + // This text is how an operator learns the builder is producing bad blocks. + assert!(err.to_string().contains("invalid state root"), "got: {err}"); + assert!(matches!(err, SubmitError::Rejected { status: 400, .. }), "got: {err}"); +} diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index b5f6ca8c3..8e27f6290 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -88,6 +88,15 @@ fn main() -> eyre::Result<()> { let keys = building_keys.expect("the building role loads its keys"); let chain_id = node.store.get_chain_config().chain_id; + // The builder domain must come from the network's own spec. A wrong + // genesis fork version makes the relay drop every bid, silently. + let signing = runtime.block_on(building::signing_context(&building_config.beacon_url))?; + info!( + chain = %signing.chain_info.name, + builder_domain = %signing.chain_info.builder_domain, + "Resolved the builder signing domain", + ); + let (contexts, rx) = tokio::sync::mpsc::channel(4); runtime.spawn(building::watch_slots(building_config.clone(), contexts)); runtime.spawn(building::build_blocks( @@ -95,6 +104,7 @@ fn main() -> eyre::Result<()> { node.store.clone(), node.blockchain.clone(), keys.payout, + signing, chain_id, rx, )); From a5dba0ea94bb5f1ac5ec01444227bb5dfb111dcc Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 1 Sep 2026 15:38:04 +0100 Subject: [PATCH 28/29] Build at each configured offset and resubmit only on a higher value Sleep to an absolute deadline. The offsets share one origin, so sleeping them end to end would land every attempt after the last. Key the best bid by slot and parent. After a re-org the earlier bid sits on a dead parent, so a lower value must still go out. Drop `self_validate`. Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/build-config.example.yml | 3 - crates/builder/src/building/mod.rs | 106 +++++++++++------- crates/builder/src/building/schedule.rs | 140 ++++++++++++++++++++++++ crates/builder/src/config.rs | 7 -- 4 files changed, 206 insertions(+), 50 deletions(-) create mode 100644 crates/builder/src/building/schedule.rs diff --git a/crates/builder/build-config.example.yml b/crates/builder/build-config.example.yml index 154ef4003..2df42ef41 100644 --- a/crates/builder/build-config.example.yml +++ b/crates/builder/build-config.example.yml @@ -27,6 +27,3 @@ extra_data: "helix-builder" # Points into the slot, in milliseconds, at which to build and submit. submit_offsets_ms: [500, 2000] - -# Validate our own block before submitting it. -self_validate: true diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 131fd6a11..16c578c48 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -3,6 +3,7 @@ mod assemble; mod keys; +mod schedule; mod slot; mod submit; mod watcher; @@ -15,10 +16,13 @@ use ethrex_storage::Store; use helix_common::signing::RelaySigningContext; pub use keys::BuildingKeys; use tokio::sync::mpsc; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; pub use watcher::run as watch_slots; -use crate::{building::slot::SlotContext, config::BuildingConfig}; +use crate::{ + building::{schedule::BestBid, slot::SlotContext}, + config::BuildingConfig, +}; /// Reads the network's spec and genesis from the beacon node, so the builder /// domain is never a hardcoded per-network constant. @@ -45,51 +49,73 @@ pub async fn build_blocks( mut contexts: mpsc::Receiver, ) { let submitter = submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing); + let mut best = BestBid::default(); while let Some(slot) = contexts.recv().await { - let (build_config, store, blockchain, signer, build_slot) = ( - config.clone(), - store.clone(), - blockchain.clone(), - payout_signer.clone(), - slot.clone(), - ); - // Building is CPU-bound and must not stall the runtime. - let built = tokio::task::spawn_blocking(move || { - assemble::build(&store, &blockchain, &build_slot, &build_config, &signer, chain_id) - }) - .await; + best.prune(slot.slot); - let built = match built { - Ok(Ok(built)) => built, - Ok(Err(e)) => { - warn!(slot = slot.slot, err = %e, "skipping slot"); - continue; - } - Err(e) => { - error!(slot = slot.slot, err = %e, "build task panicked"); - continue; - } - }; + // Each delay is measured from the same instant, so they must not be + // slept end to end. + let base = tokio::time::Instant::now(); + for delay in schedule::delays(slot.timestamp, &config.submit_offsets_ms, now_ms()) { + tokio::time::sleep_until(base + delay).await; + + let (build_config, store, blockchain, signer, build_slot) = ( + config.clone(), + store.clone(), + blockchain.clone(), + payout_signer.clone(), + slot.clone(), + ); + // Building is CPU-bound and must not stall the runtime. + let built = tokio::task::spawn_blocking(move || { + assemble::build(&store, &blockchain, &build_slot, &build_config, &signer, chain_id) + }) + .await; - let submission = match submitter.sign(&built, &slot) { - Ok(submission) => submission, - Err(e) => { - warn!(slot = slot.slot, err = %e, "cannot sign the block"); + let built = match built { + Ok(Ok(built)) => built, + Ok(Err(e)) => { + warn!(slot = slot.slot, err = %e, "skipping slot"); + continue; + } + Err(e) => { + error!(slot = slot.slot, err = %e, "build task panicked"); + continue; + } + }; + + if !best.improves(slot.slot, slot.parent_hash, built.value) { + debug!(slot = slot.slot, value = %built.value, "not an improvement"); continue; } - }; - match submitter.submit(&submission).await { - Ok(()) => info!( - slot = slot.slot, - block_hash = %submission.message.block_hash, - txs = built.block.body.transactions.len(), - value = %built.value, - "submitted a block", - ), - // The relay's reason is how an operator learns the blocks are bad. - Err(e) => warn!(slot = slot.slot, err = %e, "the relay refused the block"), + let submission = match submitter.sign(&built, &slot) { + Ok(submission) => submission, + Err(e) => { + warn!(slot = slot.slot, err = %e, "cannot sign the block"); + continue; + } + }; + + match submitter.submit(&submission).await { + Ok(()) => info!( + slot = slot.slot, + block_hash = %submission.message.block_hash, + txs = built.block.body.transactions.len(), + value = %built.value, + "submitted a block", + ), + // The relay's reason is how an operator learns the blocks are bad. + Err(e) => warn!(slot = slot.slot, err = %e, "the relay refused the block"), + } } } } + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the clock is after the unix epoch") + .as_millis() as u64 +} diff --git a/crates/builder/src/building/schedule.rs b/crates/builder/src/building/schedule.rs new file mode 100644 index 000000000..1e5358331 --- /dev/null +++ b/crates/builder/src/building/schedule.rs @@ -0,0 +1,140 @@ +use std::{collections::HashMap, time::Duration}; + +use alloy_primitives::{B256, U256}; + +/// How long to wait before each build attempt, measured from `now_ms`. +/// +/// Every entry is relative to the same instant, so a caller must sleep to an +/// absolute deadline rather than sleeping each in turn. +/// +/// A slot learned about late still gets one immediate attempt: dropping it +/// would mean no bid at all for that slot. +pub fn delays(slot_timestamp: u64, offsets: &[u64], now_ms: u64) -> Vec { + let start_ms = slot_timestamp * 1_000; + let mut sorted: Vec = offsets.to_vec(); + sorted.sort_unstable(); + + let upcoming: Vec = sorted + .iter() + .filter_map(|offset| start_ms.checked_add(*offset)?.checked_sub(now_ms)) + .map(Duration::from_millis) + .collect(); + + if upcoming.is_empty() { vec![Duration::ZERO] } else { upcoming } +} + +/// The best bid already sent, per slot and parent. +/// +/// The relay treats every submission as a new bid, so resending a lower value +/// would replace a better one. +#[derive(Debug, Default)] +pub struct BestBid { + best: HashMap<(u64, B256), U256>, +} + +impl BestBid { + /// Records `value` and reports whether it is worth submitting. + pub fn improves(&mut self, slot: u64, parent: B256, value: U256) -> bool { + match self.best.entry((slot, parent)) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + if value <= *entry.get() { + return false; + } + entry.insert(value); + true + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(value); + true + } + } + } + + /// Drops slots below `slot`, so the map does not grow without bound. + pub fn prune(&mut self, slot: u64) { + self.best.retain(|(best_slot, _), _| *best_slot >= slot); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SLOT_TIMESTAMP: u64 = 1_700_000_000; + const START_MS: u64 = SLOT_TIMESTAMP * 1_000; + + #[test] + fn offsets_become_delays_from_the_slot_start() { + let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS); + + assert_eq!(delays, vec![Duration::from_millis(500), Duration::from_millis(2000)]); + } + + #[test] + fn unsorted_offsets_are_ordered() { + let delays = delays(SLOT_TIMESTAMP, &[2000, 500], START_MS); + + assert_eq!( + delays, + vec![Duration::from_millis(500), Duration::from_millis(2000)], + "the config is a plain list and nothing else sorts it", + ); + } + + #[test] + fn an_offset_already_past_is_skipped() { + let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 1_000); + + assert_eq!(delays, vec![Duration::from_millis(1000)], "only the 2000ms offset is ahead"); + } + + #[test] + fn a_late_event_still_gets_one_attempt() { + let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 5_000); + + assert_eq!( + delays, + vec![Duration::ZERO], + "a late payload_attributes must not mean no bid for the slot", + ); + } + + #[test] + fn a_higher_value_is_submitted() { + let mut best = BestBid::default(); + let parent = B256::repeat_byte(0x11); + + assert!(best.improves(1, parent, U256::from(10))); + assert!(best.improves(1, parent, U256::from(11))); + } + + #[test] + fn an_equal_or_lower_value_is_not_resubmitted() { + let mut best = BestBid::default(); + let parent = B256::repeat_byte(0x11); + assert!(best.improves(1, parent, U256::from(10))); + + assert!(!best.improves(1, parent, U256::from(10)), "an equal bid replaces a good one"); + assert!(!best.improves(1, parent, U256::from(9))); + } + + #[test] + fn a_new_slot_resets_the_best_value() { + let mut best = BestBid::default(); + let parent = B256::repeat_byte(0x11); + assert!(best.improves(1, parent, U256::from(10))); + + assert!(best.improves(2, parent, U256::from(1)), "a new slot starts a new auction"); + } + + #[test] + fn a_new_parent_for_the_same_slot_resets_the_best_value() { + let mut best = BestBid::default(); + assert!(best.improves(1, B256::repeat_byte(0x11), U256::from(10))); + + assert!( + best.improves(1, B256::repeat_byte(0x22), U256::from(1)), + "after a re-org the earlier bid sits on a dead parent", + ); + } +} diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index cbe9da1e8..68e2d1fd6 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -157,11 +157,6 @@ pub struct BuildingConfig { /// Points into the slot, in milliseconds, at which to build and submit. #[serde(default = "default_submit_offsets_ms")] pub submit_offsets_ms: Vec, - /// Validate our own block before submitting it. - // Read once the slot loop exists. - #[allow(dead_code)] - #[serde(default = "default_true")] - pub self_validate: bool, } impl BuildingConfig { @@ -447,7 +442,6 @@ mod building_config_tests { assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); assert_eq!(config.submit_offsets_ms, vec![500, 2000]); - assert!(config.self_validate); } #[test] @@ -462,7 +456,6 @@ mod building_config_tests { assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); assert_eq!(config.submit_offsets_ms, vec![500, 2000]); - assert!(config.self_validate); } #[test] From 21e58b4a44e4f0c2953c563d8e7e41dc8c40c026 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 1 Sep 2026 16:16:52 +0100 Subject: [PATCH 29/29] Document the builder's third role Co-Authored-By: Claude Opus 5 (1M context) --- crates/builder/README.md | 74 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/builder/README.md b/crates/builder/README.md index dbb280dbd..45d556d53 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -1,15 +1,18 @@ # helix-builder An embedded [ethrex](https://github.com/lambdaclass/ethrex) execution node -running one or both of two roles, selected by which config file is supplied: +running any combination of three roles, selected by which config files are +supplied: | role | config | what it does | | --- | --- | --- | | merging | `--merging.config` | external block-merging builder: the TCP server counterpart of the relay's block-merging tile (`crates/relay/src/block_merging/`) | | simulation | `--sim.config` | block validator: the ethrex counterpart of `crates/simulator`, serving the relay's SSZ validation routes | +| building | `--build.config` | block builder: fills a block from the node's mempool and submits it to the relay | -Supplying neither is a startup error. Both share one node, and `RELAY_KEY` is -only needed for the merging role. +Supplying none is a startup error. The roles share one node. `RELAY_KEY` is +needed only for merging, and `BUILDER_BLS_KEY` / `BUILDER_PAYOUT_KEY` only for +building. ## The merging role @@ -46,12 +49,50 @@ the relay must reach it through the simulator's `ssz_url`. Differences from block that merely *reads* one, so it rejects strictly more blocks. - Only Fulu (V5) and the relay-internal merged method are served. +## The building role + +Builds a block for the next slot and submits it to the relay. It is meant for +testnets: it lets the relay's whole path -- submit, simulate, `get_header`, +`get_payload`, publish -- be exercised with a builder you control. + +Two sources are merged into one slot context, because neither is sufficient +alone. The beacon node's `payload_attributes` SSE topic gives the parent, +timestamp, `prev_randao`, withdrawals and `parent_beacon_block_root`; the +relay's `get_validators` gives the proposer's pubkey, fee recipient and +registered gas limit. A slot whose proposer has not registered is skipped. + +The block itself is ethrex's own payload machinery, with the builder as the +coinbase, so tips accrue to the builder and the bid is funded from them: + +``` +payout = tips + subsidy_wei - payout_gas_reserve * base_fee +``` + +The block ends with a plain transfer of `payout` to the proposer's registered +fee recipient. `subsidy_wei` exists because the relay rejects a zero-value +block: without it an idle testnet would produce no bids at all, which is when +the builder is least useful. Set it to 0 to bid only what the block earns. + +Gas for that transfer is held back from the fill by lowering ethrex's +`remaining_gas` before `fill_transactions` and restoring it afterwards. + +The builder signs under the domain read from the beacon node's own spec and +genesis, never a compiled-in fork version. It builds at each +`submit_offsets_ms` point in the slot and submits only when the value beats +what it already sent for that slot and parent -- a new parent, after a re-org, +starts a fresh auction. + +What it does not do: no bundles or `eth_sendBundle`, no ordering of its own +(ethrex's tip-sorted fill), no cancellations, no bidding strategy (it always +bids the full block value), and one relay only. + ## Architecture ``` tokio runtime embedded ethrex node: store (rocksdb), devp2p + snap sync, Engine API (authrpc) for the operator's beacon node, head watcher simulation role: SSZ validation server, disallow-list refresh + building role: payload_attributes SSE, duty poll, build + submit flux tile merging TCP server (listen, handshake, framing, routing) engine thread merge worker: order pool, base replay, presim (rayon), emission ``` @@ -98,6 +139,26 @@ helix-builder \ [sim-config.example.yml](sim-config.example.yml). Until `blacklist_endpoint` answers, the list is empty and no block is filtered. +Building: + +```sh +BUILDER_BLS_KEY=0x... BUILDER_PAYOUT_KEY=0x... helix-builder \ + --network hoodi \ + --datadir /data/helix-builder \ + --authrpc.addr 0.0.0.0 --authrpc.jwtsecret /secrets/jwt.hex \ + --build.config build.yml +``` + +- `--build.config` points at the building YAML; see + [build-config.example.yml](build-config.example.yml). +- `BUILDER_BLS_KEY` signs the submission. Register its pubkey with the relay, + under `builders`, with the `api_key` the config sends. +- `BUILDER_PAYOUT_KEY` signs the payment to the proposer and **must be + funded**: every bid is paid from this account. Both the pubkey and the + address are logged at startup. +- These are separate from `RELAY_KEY`, which the merging role reads as a + secp256k1 key and `helix-common` reads as a BLS one. + On the relay side, add a merging builder to `block_merging_config.tcp.builders`, and a simulator as a `simulators` entry with `ssz_url` set to this role's `ssz_addr` (see the repo-root @@ -113,7 +174,12 @@ with `ssz_url` set to this role's `ssz_addr` (see the repo-root use it. - A blob is 128 KiB and crosses the stack several times in a debug build, which overflows tokio's default worker stack. Release builds elide the copies; run - the simulation role in release. + the simulation and building roles in release. +- The building role's payout uses a fixed `payout_gas_reserve`, 21000 by + default. A contract fee recipient needing more makes the payout fail and the + slot is skipped. +- The building role includes a blob transaction only when its sidecar reached + the node's mempool over devp2p; it does not rebuild sidecars. - The base block's declared `block_hash` is trusted as the pool key; the wire format carries no `requests_hash` to fully recompute it. - P-256 (`P256VERIFY`) uses ethrex's portable fallback rather than the