Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3d563e0
feat(engine): read shouldOverrideBuilder from getPayloadV5
MegaRedHand Oct 26, 2026
951ff73
feat(rpc): remember the registrations forwarded to the builder
MegaRedHand Oct 26, 2026
c9f4fe0
feat(rpc): add the builder flow's circuit breaker
MegaRedHand Oct 26, 2026
87a9851
feat(rpc): validate a builder's bid before it competes
MegaRedHand Oct 26, 2026
15bc10e
feat(rpc): choose between the local payload and a builder's
MegaRedHand Oct 26, 2026
52a6b84
feat(rpc): share the builder's state and remember registrations
MegaRedHand Oct 26, 2026
0731823
feat(rpc): serve a builder's blinded block from produceBlockV3
MegaRedHand Oct 26, 2026
5cd832c
feat(rpc): hand signed blinded blocks to the builder
MegaRedHand Oct 26, 2026
706e33d
docs: describe the fulu builder flow
MegaRedHand Oct 26, 2026
2e25eb8
fix(rpc): close the slot-claim and bid-check gaps in the builder flow
MegaRedHand Oct 26, 2026
2aae2a7
docs: note the bid block number check and the publish guard's limits
MegaRedHand Oct 26, 2026
f4c9078
test(rpc): pin the blinded_blocks body limit and the produce fallback
MegaRedHand Oct 26, 2026
3ade3a4
docs: point at the fulu builder flow from the README and CLAUDE.md
MegaRedHand Oct 29, 2026
49eb716
Merge feat/beacon-builder-registration into feat/beacon-builder-blocks
MegaRedHand Nov 4, 2026
074b459
fix(rpc): address the security review of the fulu builder flow
MegaRedHand Nov 5, 2026
086f50c
refactor(rpc): count the breaker's consecutive skips from the head bl…
MegaRedHand Nov 5, 2026
d0b6752
fix(rpc): scale the boost factor before rounding, and remember regist…
MegaRedHand Nov 5, 2026
dc7e26f
feat(rpc): warn when the builder's block is served under shouldOverri…
MegaRedHand Nov 5, 2026
33daa27
Merge feat/beacon-builder-registration into feat/beacon-builder-blocks
MegaRedHand Nov 5, 2026
0d4dadf
Merge feat/beacon-builder-registration into feat/beacon-builder-blocks
MegaRedHand Nov 5, 2026
60c697a
fix(rpc): bound the local build against a valid bid, refuse publishes…
MegaRedHand Nov 5, 2026
ebd338a
style(rpc): format the merged shown_location
MegaRedHand Nov 5, 2026
4ca75de
Merge feat/beacon-builder-registration into feat/beacon-builder-blocks
MegaRedHand Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,17 @@ default** on its two SSZ endpoints and bare integers in JSON, because
`checkpoint_sync.rs` and other clients' lean sync read those bytes and may send no
`Accept` at all; `/lean/v0/blocks/finalized` now answers JSON when asked for it by name.

With `--builder`, the beacon surface also runs the **fulu builder flow
(MEV-Boost)**. `register_validator` forwards registrations to the builder
(`beacon/registrations.rs`, client in `beacon/builder_client/`).
`produceBlockV3` asks for the builder's bid alongside the local build and
serves a blinded block when the bid passes its checks and wins on value
(`beacon/builder_flow/`: bid checks, circuit breaker, boost-factor choice).
`blinded_blocks` hands the signed block back for the builder to publish, with
no local fallback, since a second block for the slot would be an equivocation. Gloas
has no blinded blocks; its builder market is separate (the **Gloas** bullet
under Beacon Chain STF).

See [`docs/rpc.md`](docs/rpc.md) for the full reference: CLI flags and defaults, the lean API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the Beacon API endpoints and the three ids they refuse, the metrics/debug endpoints (Prometheus `/metrics`, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types.

## Beacon Chain types (`crates/common/types/src/beacon/`)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ what they sign.
| `--checkpoint-sync-url` | Where the anchor comes from. Required on a fresh data directory for a built-in network, since those never start from genesis. A resumable data directory is used before it; a network loaded from a directory anchors at its own `genesis.ssz` when no URL is given |
| `--node-key` | Optional, but worth persisting: the columns this node custodies are a function of its node id, so without a key file it changes identity, and custody set, on every restart |
| `--execution-endpoint`, `--execution-jwt-secret` | Optional Engine API pairing, given together. Without them, blocks import without payload validation |
| `--builder` | Optional builder API endpoint, normally MEV-Boost. With it, a fulu proposal from a validator registered through this node gets a blinded block when the builder's bid wins; without it every block is built locally |

A built-in network needs nothing else on disk. Its `eth-clients` `config.yaml`
and bootnode list, and the two genesis values the fork digest is computed from,
Expand Down
67 changes: 67 additions & 0 deletions bin/ethlambda/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,21 @@ pub(crate) struct MainnetOptions {
value_parser = clap::value_parser!(u64).range(1..),
)]
pub(crate) builder_header_timeout: u64,

/// Build locally, skipping the builder, after more than this many
/// consecutive empty slots right before the proposal (the circuit breaker).
#[arg(long, default_value_t = ethlambda_rpc::FallbackThresholds::DEFAULT.skips, requires = "builder")]
pub(crate) builder_fallback_skips: u64,

/// Build locally after more than this many empty slots in the epoch's
/// worth of slots before the proposal.
#[arg(long, default_value_t = ethlambda_rpc::FallbackThresholds::DEFAULT.skips_per_epoch, requires = "builder")]
pub(crate) builder_fallback_skips_per_epoch: u64,

/// Build locally when the proposal is more than this many epochs past the
/// finalized checkpoint.
#[arg(long, default_value_t = ethlambda_rpc::FallbackThresholds::DEFAULT.epochs_since_finalization, requires = "builder")]
pub(crate) builder_fallback_epochs_since_finalization: u64,
}

/// `--builder`'s value, refused while the flags are parsed unless it is an
Expand Down Expand Up @@ -1087,6 +1102,58 @@ mod tests {
}
}

#[test]
fn the_fallback_flags_parse_and_default_to_lighthouses() {
let beacon = parse_beacon(beacon_args());
assert_eq!(beacon.mainnet.builder_fallback_skips, 3);
assert_eq!(beacon.mainnet.builder_fallback_skips_per_epoch, 8);
assert_eq!(beacon.mainnet.builder_fallback_epochs_since_finalization, 3);

let mut args = beacon_args();
args.extend([
"--builder",
"http://127.0.0.1:18550",
"--builder-fallback-skips",
"5",
"--builder-fallback-skips-per-epoch",
"12",
"--builder-fallback-epochs-since-finalization",
"2",
]);
let beacon = parse_beacon(args);
assert_eq!(beacon.mainnet.builder_fallback_skips, 5);
assert_eq!(beacon.mainnet.builder_fallback_skips_per_epoch, 12);
assert_eq!(beacon.mainnet.builder_fallback_epochs_since_finalization, 2);
}

#[test]
fn each_fallback_flag_needs_a_builder() {
for flag in [
"--builder-fallback-skips",
"--builder-fallback-skips-per-epoch",
"--builder-fallback-epochs-since-finalization",
] {
let mut args = beacon_args();
args.extend([flag, "5"]);
try_parse_from(args).expect_err(flag);
}
}

#[test]
fn a_bad_fallback_value_is_refused() {
for flag in [
"--builder-fallback-skips",
"--builder-fallback-skips-per-epoch",
"--builder-fallback-epochs-since-finalization",
] {
for value in ["-1", "many"] {
let mut args = beacon_args();
args.extend(["--builder", "http://127.0.0.1:18550", flag, value]);
try_parse_from(args).expect_err(flag);
}
}
}

#[test]
fn a_zero_header_timeout_is_refused() {
let mut args = beacon_args();
Expand Down
9 changes: 9 additions & 0 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,14 @@ async fn run_node(options: Options) -> eyre::Result<()> {
})
.transpose()?,
};
let builder_fallback = match &network {
Network::Lean(_) => ethlambda_rpc::FallbackThresholds::DEFAULT,
Network::Mainnet { mainnet, .. } => ethlambda_rpc::FallbackThresholds {
skips: mainnet.builder_fallback_skips,
skips_per_epoch: mainnet.builder_fallback_skips_per_epoch,
epochs_since_finalization: mainnet.builder_fallback_epochs_since_finalization,
},
};
// A one-off probe, for the log only: nothing waits on it or reads its
// answer, and every later call to the builder is tried on its own.
if let Some(builder) = builder.clone() {
Expand Down Expand Up @@ -881,6 +889,7 @@ async fn run_node(options: Options) -> eyre::Result<()> {
client_version: version::engine_client_version(),
identity: rpc_identity,
builder,
builder_fallback,
},
local_peer_id,
rpc_shutdown,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ pub(crate) fn slot_start_ms(config: &Config, slot: Slot) -> u64 {

/// The specification's `is_future_slot`: `slot` starts later than `now_ms`
/// plus the gossip clock disparity allowance.
pub(crate) fn is_future_slot(config: &Config, slot: Slot, now_ms: u64) -> bool {
pub fn is_future_slot(config: &Config, slot: Slot, now_ms: u64) -> bool {
slot_start_ms(config, slot) > now_ms.saturating_add(MAXIMUM_GOSSIP_CLOCK_DISPARITY)
}

Expand Down
27 changes: 23 additions & 4 deletions crates/blockchain/state_transition/src/beacon/stf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ use crate::beacon::helpers::accessors::{ActiveBalanceCache, CommitteeCache, get_
use crate::beacon::helpers::misc::compute_signing_root;
use crate::beacon::lean_state_unreachable;
use crate::beacon::preset;
use crate::beacon::primitives::{HashTreeRoot as _, Slot};
use crate::beacon::primitives::{BlsSignature, HashTreeRoot as _, Root, Slot, ValidatorIndex};
use crate::beacon::{bls, config::Config, constants};

/// What the execution layer would answer for a block's payload.
Expand Down Expand Up @@ -212,12 +212,31 @@ pub fn verify_block_signature(
state: &BeaconState,
signed_block: &containers::SignedBeaconBlock,
) -> bool {
let Ok(proposer) = state.validator(signed_block.proposer_index()) else {
verify_proposer_signature(
state,
signed_block.proposer_index(),
signed_block.message_hash_tree_root(),
&signed_block.signature(),
)
}

/// Whether `signature` is `proposer_index`'s over the block whose root is
/// `block_root`, under `state`'s proposer domain.
///
/// A blinded block has the root of its full block, so one check serves
/// both.
pub fn verify_proposer_signature(
state: &BeaconState,
proposer_index: ValidatorIndex,
block_root: Root,
signature: &BlsSignature,
) -> bool {
let Ok(proposer) = state.validator(proposer_index) else {
return false;
};
let domain = get_domain(state, constants::DOMAIN_BEACON_PROPOSER, None);
let signing_root = compute_signing_root(signed_block.message_hash_tree_root(), domain);
bls::verify(&proposer.pubkey, signing_root, &signed_block.signature())
let signing_root = compute_signing_root(block_root, domain);
bls::verify(&proposer.pubkey, signing_root, signature)
}

/// Advances the state to `slot`, running epoch processing at each boundary and
Expand Down
29 changes: 29 additions & 0 deletions crates/net/engine/src/building.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ pub struct BuiltPayload {
pub blobs_bundle: BlobsBundle,
/// The EIP-7685 request list, each entry its type byte then its data.
pub execution_requests: Vec<Vec<u8>>,
/// The engine's `shouldOverrideBuilder`: it wants this payload built
/// locally whatever a builder bid pays. Absent from an answer means false.
pub should_override_builder: bool,
}

/// `engine_getPayloadV6`'s answer, decoded: [`BuiltPayload`] with Amsterdam's
Expand Down Expand Up @@ -151,6 +154,8 @@ pub(crate) struct GetPayloadV5Response {
block_value: String,
blobs_bundle: BlobsBundleJson,
#[serde(default)]
should_override_builder: bool,
#[serde(default)]
execution_requests: Vec<String>,
}

Expand Down Expand Up @@ -259,6 +264,7 @@ impl TryFrom<GetPayloadV5Response> for BuiltPayload {
block_value: parse_uint256(&response.block_value)?,
blobs_bundle,
execution_requests: decode_requests(&response.execution_requests)?,
should_override_builder: response.should_override_builder,
})
}
}
Expand Down Expand Up @@ -454,6 +460,29 @@ mod tests {
assert_eq!(built.execution_requests, vec![vec![0x00, 0xaa]]);
}

#[test]
fn a_v5_answer_carries_should_override_builder_and_defaults_it_to_false() {
let mut json = response_json();
json["shouldOverrideBuilder"] = true.into();
let response: GetPayloadV5Response = serde_json::from_value(json).unwrap();
assert!(
BuiltPayload::try_from(response)
.unwrap()
.should_override_builder
);

let mut json = response_json();
json.as_object_mut()
.unwrap()
.remove("shouldOverrideBuilder");
let response: GetPayloadV5Response = serde_json::from_value(json).unwrap();
assert!(
!BuiltPayload::try_from(response)
.unwrap()
.should_override_builder
);
}

#[test]
fn a_wrong_width_hash_is_a_decode_error() {
let mut json = response_json();
Expand Down
50 changes: 45 additions & 5 deletions crates/net/engine/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,26 @@ pub const ENGINE_TIMEOUT: Duration = Duration::from_secs(8);
/// for the older methods.
pub const ENGINE_NEW_PAYLOAD_V5_TIMEOUT: Duration = Duration::from_secs(6);

/// How many times one call is attempted before it is given up on.
/// Timeout for `engine_getPayloadV5` and `engine_getPayloadV6`, which get this
/// one attempt and no retry.
///
/// execution-apis #958 (merged 2026-10-26) dropped `getPayload`'s timeout: the
/// method "MUST respond immediately", and the consensus client "MAY abort such
/// a call at its own discretion". One second is the value it replaced, and the
/// one Nimbus uses (`GETPAYLOAD_TIMEOUT`, `el/el_manager.nim:53`) and Prysm
/// uses before gloas (300 ms from gloas, `execution/engine_jsonrpc.go:116-118`
/// and `:300-304`). Lighthouse (`engine_api/http.rs:52`) and Teku
/// (`AbstractExecutionEngineClient.java:85`) allow 2 s.
///
/// Not retried, unlike every other method: the payload is due at the start of
/// the slot, so an answer a retry ladder could still get (seconds later) is
/// one the block cannot use, and an execution client may stop the build
/// process once it has served the call, so a retry after a lost answer can
/// find the payload gone.
pub const ENGINE_GET_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(1);

/// How many times one call is attempted before it is given up on, for every
/// method but `getPayload` (see [`ENGINE_GET_PAYLOAD_TIMEOUT`]).
///
/// Three, not the ten `ethlambda-p2p` uses for a peer fetch. Those are different
/// numbers measuring different things: a peer ladder is bounded by a millisecond
Expand Down Expand Up @@ -101,11 +120,23 @@ impl EngineClient {
method: &str,
params: serde_json::Value,
timeout: Duration,
) -> Result<T, EngineError> {
self.call_with_attempts(method, params, timeout, ENGINE_MAX_ATTEMPTS)
.await
}

/// [`Self::call_with_timeout`], attempted at most `attempts` times.
async fn call_with_attempts<T: serde::de::DeserializeOwned>(
&self,
method: &str,
params: serde_json::Value,
timeout: Duration,
attempts: u32,
) -> Result<T, EngineError> {
let mut backoff = ENGINE_INITIAL_BACKOFF;
let mut last = EngineError::Transport("no attempt was made".to_string());

for attempt in 1..=ENGINE_MAX_ATTEMPTS {
for attempt in 1..=attempts {
match self.call_once(method, &params, timeout).await {
Ok(value) => return Ok(value),
// An answer, not a failure to get one. Do not retry.
Expand All @@ -115,7 +146,7 @@ impl EngineClient {
last = err;
}
}
if attempt < ENGINE_MAX_ATTEMPTS {
if attempt < attempts {
tokio::time::sleep(backoff).await;
backoff *= BACKOFF_MULTIPLIER;
}
Expand Down Expand Up @@ -301,12 +332,16 @@ impl EngineClient {

/// `engine_getPayloadV5`: the payload a build process has assembled so
/// far, with its blobs bundle and execution requests.
///
/// One attempt at [`ENGINE_GET_PAYLOAD_TIMEOUT`], never retried; see there
/// for why.
pub async fn get_payload(
&self,
payload_id: crate::building::PayloadId,
) -> Result<crate::building::BuiltPayload, EngineError> {
let params = json!([payload_id]);
let response: crate::building::GetPayloadV5Response = self
.call("engine_getPayloadV5", json!([payload_id]))
.call_with_attempts("engine_getPayloadV5", params, ENGINE_GET_PAYLOAD_TIMEOUT, 1)
.await?;
response.try_into()
}
Expand All @@ -331,12 +366,17 @@ impl EngineClient {

/// `engine_getPayloadV6`: [`Self::get_payload`] for Amsterdam, answering
/// with `ExecutionPayloadV4` (`blockAccessList` and `slotNumber` included).
///
/// One attempt at [`ENGINE_GET_PAYLOAD_TIMEOUT`], like V5. Prysm tightens
/// its own to 300 ms from gloas; this keeps the second until an execution
/// client's gloas build times are known.
pub async fn get_payload_v6(
&self,
payload_id: crate::building::PayloadId,
) -> Result<crate::building::BuiltGloasPayload, EngineError> {
let params = json!([payload_id]);
let response: crate::building::GetPayloadV6Response = self
.call("engine_getPayloadV6", json!([payload_id]))
.call_with_attempts("engine_getPayloadV6", params, ENGINE_GET_PAYLOAD_TIMEOUT, 1)
.await?;
response.try_into()
}
Expand Down
Loading
Loading