From c2ce0cd3a48792cb671448b025aa70647954a2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:40:21 -0300 Subject: [PATCH 01/12] fix(types): quote Builder integers and accept it, and proposer preferences, from JSON `Builder` serialized `version`, `balance`, `deposit_epoch` and `withdrawable_epoch` as bare numbers, which the Beacon API wants quoted (this also affected the gloas state's JSON). Give it the same `quoted_or_bare` adapters the other containers use, and `Deserialize`, along with the proposer preferences containers a builder-market endpoint takes in a request body. Round-trip tests cover the bid, the preferences and the builder, and the example bid and preferences events from beacon-APIs' event stream parse. --- .../types/src/beacon/containers/gloas.rs | 37 ++++++++- .../types/src/beacon/containers/json_tests.rs | 81 +++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/crates/common/types/src/beacon/containers/gloas.rs b/crates/common/types/src/beacon/containers/gloas.rs index 09838fa0..8284480a 100644 --- a/crates/common/types/src/beacon/containers/gloas.rs +++ b/crates/common/types/src/beacon/containers/gloas.rs @@ -180,23 +180,36 @@ pub type CellsBitList = ProgressiveBitlist; /// A registered builder's record in the builder registry (EIP-7732), the /// builder-side counterpart of [`super::shared::Validator`]. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct Builder { pub pubkey: BlsPubkey, /// Which shape this record is in. Only [`crate::beacon::constants::PAYLOAD_BUILDER_VERSION`] /// exists today; the field exists so a future format change has /// somewhere to record it. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub version: u8, pub execution_address: ExecutionAddress, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub balance: Gwei, /// The epoch this builder's deposit was placed, which /// `is_active_builder` compares against the finalized checkpoint before /// treating the builder as eligible. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub deposit_epoch: Epoch, /// `FAR_FUTURE_EPOCH` until this builder initiates an exit, the same /// sentinel convention [`super::shared::Validator::withdrawable_epoch`] /// uses. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub withdrawable_epoch: Epoch, } @@ -1115,7 +1128,16 @@ pub struct PartialDataColumnGroupID { /// A proposer's broadcast, ahead of its slot, of the fee recipient and gas /// limit it wants a builder's bid to target (EIP-7732 p2p-interface.md). #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct ProposerPreferences { /// The root of the beacon state the proposer duty this message announces @@ -1132,7 +1154,16 @@ pub struct ProposerPreferences { } #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SignedProposerPreferences { pub message: ProposerPreferences, diff --git a/crates/common/types/src/beacon/containers/json_tests.rs b/crates/common/types/src/beacon/containers/json_tests.rs index 62c6ca3e..5506897b 100644 --- a/crates/common/types/src/beacon/containers/json_tests.rs +++ b/crates/common/types/src/beacon/containers/json_tests.rs @@ -403,3 +403,84 @@ fn an_oversized_list_is_refused_rather_than_truncated() { ]); assert!(serde_json::from_value::(json).is_err()); } + +fn bid() -> gloas::SignedExecutionPayloadBid { + gloas::SignedExecutionPayloadBid { + message: gloas::ExecutionPayloadBid { + parent_block_hash: [1; 32].into(), + parent_block_root: [2; 32].into(), + block_hash: [3; 32].into(), + prev_randao: [4; 32].into(), + fee_recipient: [5; 20].into(), + gas_limit: 30_000_000, + builder_index: 7, + slot: 33, + value: 8, + execution_payment: 9, + execution_requests_root: [7; 32].into(), + blob_kzg_commitments: vec![KzgCommitment([6; 48])].try_into().unwrap(), + }, + signature: signature(3), + } +} + +#[test] +fn a_bid_round_trips_through_json() { + round_trip(&bid()); +} + +#[test] +fn proposer_preferences_round_trip_through_json() { + round_trip(&gloas::SignedProposerPreferences { + message: gloas::ProposerPreferences { + dependent_root: [1; 32].into(), + proposal_slot: 32, + validator_index: 123, + fee_recipient: [5; 20].into(), + target_gas_limit: 60_000_000, + }, + signature: signature(4), + }); +} + +#[test] +fn a_builder_round_trips_and_quotes_its_integers() { + let builder = gloas::Builder { + pubkey: pubkey(1), + version: 3, + execution_address: [5; 20].into(), + balance: 32_000_000_000, + deposit_epoch: 4, + withdrawable_epoch: u64::MAX, + }; + round_trip(&builder); + let json = serde_json::to_value(&builder).unwrap(); + for field in ["version", "balance", "deposit_epoch", "withdrawable_epoch"] { + assert!(json[field].is_string(), "{field} must be quoted: {json}"); + } +} + +/// The example `execution_payload_bid` and `proposer_preferences` events in +/// beacon-APIs' event stream, byte for byte. +#[test] +fn the_beacon_apis_example_messages_parse() { + let bid = r#"{"message": {"parent_block_hash": "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", "parent_block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "block_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "fee_recipient": "0x0000000000000000000000000000000000000000", "gas_limit": "30000000", "builder_index": "42", "slot": "10", "value": "1000000000", "execution_payment": "0", "blob_kzg_commitments": ["0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2"], "execution_requests_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}, "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}"#; + let parsed: gloas::SignedExecutionPayloadBid = serde_json::from_str(bid).unwrap(); + assert_eq!(parsed.message.builder_index, 42); + assert_eq!(parsed.message.slot, 10); + assert_eq!(parsed.message.value, 1_000_000_000); + assert_eq!(parsed.message.blob_kzg_commitments.len(), 1); + + let preferences = r#"{"message": {"dependent_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "proposal_slot": "32", "validator_index": "123", "fee_recipient": "0x0000000000000000000000000000000000000000", "target_gas_limit": "60000000"}, "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}"#; + let parsed: gloas::SignedProposerPreferences = serde_json::from_str(preferences).unwrap(); + assert_eq!(parsed.message.proposal_slot, 32); + assert_eq!(parsed.message.validator_index, 123); + assert_eq!(parsed.message.target_gas_limit, 60_000_000); +} + +#[test] +fn signed_proposer_preferences_are_a_fixed_172_bytes() { + use libssz::SszEncode as _; + let bytes = gloas::SignedProposerPreferences::default().to_ssz(); + assert_eq!(bytes.len(), 172); +} From 86db22da77b387b2824bb450ae66856696910d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:40:28 -0300 Subject: [PATCH 02/12] feat(beacon): stub the gloas builder market so its three halves can land in parallel Adds the shared `BuilderMarket`, the `execution_payload_bid` and `proposer_preferences` gossip rule modules, the p2p triage, verdict and publish plumbing, the `RpcToP2P` publish methods, and the Beacon API route modules, all with the final signatures and placeholder bodies: every rule answers `Ignore(NoConsumer)`, the market holds nothing, and no route is registered yet. Splitting the wiring from the logic lets the rules and market, the p2p handling and the Beacon API be filled in on separate branches without touching each other's files. New reason variants, `Validated` arms and `RpcToP2P` methods sit beside the envelope ones, and everything else is a new file, so a parallel sync committee branch merges cleanly. --- bin/ethlambda/src/main.rs | 6 + .../src/beacon/builder_market.rs | 130 ++++++++++++++++++ .../src/beacon/gloas_block_production.rs | 36 +++++ .../beacon/gossip/execution_payload_bid.rs | 92 +++++++++++++ .../state_transition/src/beacon/gossip/mod.rs | 62 +++++++++ .../src/beacon/gossip/proposer_preferences.rs | 79 +++++++++++ .../state_transition/src/beacon/mod.rs | 1 + crates/net/api/src/lib.rs | 18 ++- crates/net/p2p/src/beacon/builder_market.rs | 57 ++++++++ crates/net/p2p/src/beacon/mod.rs | 1 + crates/net/p2p/src/beacon/topics.rs | 4 + crates/net/p2p/src/beacon/verdict.rs | 35 ++++- crates/net/p2p/src/gossipsub/handler.rs | 4 + crates/net/p2p/src/gossipsub/mod.rs | 2 + crates/net/p2p/src/lib.rs | 42 +++++- crates/net/p2p/src/req_resp/handlers.rs | 4 + crates/net/rpc/src/beacon/bid_selection.rs | 45 ++++++ crates/net/rpc/src/beacon/bids.rs | 13 ++ crates/net/rpc/src/beacon/builder_config.rs | 53 +++++++ crates/net/rpc/src/beacon/builders.rs | 12 ++ crates/net/rpc/src/beacon/mod.rs | 8 ++ .../rpc/src/beacon/proposer_preferences.rs | 13 ++ crates/net/rpc/src/lib.rs | 29 +++- 23 files changed, 739 insertions(+), 7 deletions(-) create mode 100644 crates/blockchain/state_transition/src/beacon/builder_market.rs create mode 100644 crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs create mode 100644 crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs create mode 100644 crates/net/p2p/src/beacon/builder_market.rs create mode 100644 crates/net/rpc/src/beacon/bid_selection.rs create mode 100644 crates/net/rpc/src/beacon/bids.rs create mode 100644 crates/net/rpc/src/beacon/builder_config.rs create mode 100644 crates/net/rpc/src/beacon/builders.rs create mode 100644 crates/net/rpc/src/beacon/proposer_preferences.rs diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index f5037732..c08ce4b6 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -721,6 +721,10 @@ async fn run_node(options: Options) -> eyre::Result<()> { // by block production and `GET .../pool/payload_attestations`. let payload_attestation_pool = ethlambda_state_transition::beacon::payload_attestation_pool::SharedPayloadAttestationPool::default(); + // Bids, proposer preferences and known payloads: filled by gossip and the + // Beacon API's bid and preferences endpoints, read by block production. + let builder_market = + ethlambda_state_transition::beacon::builder_market::SharedBuilderMarket::default(); let p2p = P2P::spawn( built, setup.store.clone(), @@ -728,6 +732,7 @@ async fn run_node(options: Options) -> eyre::Result<()> { discovery, attestation_pool.clone(), payload_attestation_pool.clone(), + builder_market.clone(), ) .await .wrap_err("failed to start discv5 discovery")?; @@ -769,6 +774,7 @@ async fn run_node(options: Options) -> eyre::Result<()> { p2p: rpc_p2p, attestation_pool: attestation_pool.clone(), payload_attestation_pool: payload_attestation_pool.clone(), + builder_market: builder_market.clone(), custody_columns: rpc_custody_columns, engine: rpc_engine, }, diff --git a/crates/blockchain/state_transition/src/beacon/builder_market.rs b/crates/blockchain/state_transition/src/beacon/builder_market.rs new file mode 100644 index 00000000..a39c7973 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/builder_market.rs @@ -0,0 +1,130 @@ +//! The shared state behind the gloas builder market: bids seen on +//! `execution_payload_bid` (or posted to the Beacon API) and pooled for block +//! production, the proposer preferences those bids are judged against, and the +//! execution payloads gossip has revealed. +//! +//! One [`SharedBuilderMarket`] exists per node. p2p validates against it and +//! the Beacon API reads it, like [`super::payload_attestation_pool`]. Gossip's +//! stateful checks run on blocking threads, so none of this can be owned by the +//! chain actor. +//! +//! The method signatures are the contract between the gossip rules, p2p and the +//! Beacon API; the bodies are placeholders until they are filled. + +use std::{ + num::NonZeroUsize, + sync::{Arc, Mutex}, +}; + +use lru::LruCache; + +use super::containers::gloas; +use super::gossip::IgnoreReason; +use super::primitives::{BlsPubkey, ExecutionAddress, ExecutionBlockHash, Root, Slot}; + +/// Exactly one per node. +pub type SharedBuilderMarket = Arc; + +/// Bids pooled per `(slot, parent hash, parent root)`, top values kept. +pub const MAX_BIDS_PER_PARENT: usize = 16; +/// A full slot refuses new keys: `record_bid` answers `false`. +pub const MAX_SEEN_BID_KEYS_PER_SLOT: usize = 4096; +/// Over the cap, the lowest `proposal_slot` is dropped first. +pub const MAX_PREFERENCES: usize = 1024; +/// Known payloads, evicted least recently used first, by block hash. +pub const KNOWN_PAYLOADS_CAPACITY: NonZeroUsize = NonZeroUsize::new(256).unwrap(); + +/// What gossip learned about an execution payload from its envelope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KnownPayload { + pub gas_limit: u64, + /// The block whose envelope revealed it. + pub beacon_block_root: Root, + /// `(pubkey, source_address)` of every builder exit request it carries. + pub builder_exits: Vec<(BlsPubkey, ExecutionAddress)>, +} + +#[derive(Debug)] +pub struct BuilderMarket { + // Filled by Agent A: the bid pool and the preferences cache join this. + #[allow(dead_code)] + payloads: Mutex>, +} + +impl Default for BuilderMarket { + fn default() -> Self { + Self { + payloads: Mutex::new(LruCache::new(KNOWN_PAYLOADS_CAPACITY)), + } + } +} + +#[allow(dead_code, unused_variables)] // filled by Agent A +impl BuilderMarket { + // Bids: seen.execution_payload_bids + seen.best_execution_payload_bid + pool. + + /// The spec's two seen rules, in order: `(slot, parent_hash, parent_root, + /// builder)` recorded -> `AlreadySeen`; value <= best for `(slot, + /// parent_hash, parent_root)` -> `NotHighestBid`. + pub fn check_bid_seen(&self, bid: &gloas::ExecutionPayloadBid) -> Result<(), IgnoreReason> { + Ok(()) + } + + /// Re-runs [`Self::check_bid_seen`] under the lock. If it passes, records + /// both seen keys and pools the bid. `false` = not recorded (race, or the + /// per-slot key cap). Prunes slots below `bid.slot - 1`. + pub fn record_bid(&self, signed: gloas::SignedExecutionPayloadBid) -> bool { + false + } + + /// The identical message and signature is pooled (API idempotency). + pub fn contains_bid(&self, signed: &gloas::SignedExecutionPayloadBid) -> bool { + false + } + + /// Pooled bids for the key: value descending, then builder index ascending. + pub fn bids_for( + &self, + slot: Slot, + parent_block_root: Root, + parent_block_hash: ExecutionBlockHash, + ) -> Vec { + Vec::new() + } + + pub fn has_bids_for_slot(&self, slot: Slot) -> bool { + false + } + + pub fn prune_bids_before(&self, slot: Slot) {} + + // Proposer preferences: seen.proposer_preferences. + + pub fn preferences( + &self, + proposal_slot: Slot, + dependent_root: Root, + ) -> Option { + None + } + + /// The first valid preferences per key win. `false` if one is held. Prunes + /// `proposal_slot < current_slot`. + pub fn record_preferences( + &self, + signed: gloas::SignedProposerPreferences, + current_slot: Slot, + ) -> bool { + false + } + + pub fn prune_preferences_before(&self, slot: Slot) {} + + // Known payloads: seen.execution_payloads. + + pub fn record_execution_payload(&self, envelope: &gloas::ExecutionPayloadEnvelope) {} + + pub fn known_payload(&self, block_hash: ExecutionBlockHash) -> Option { + None + } +} diff --git a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs index a9800cba..6b3af3aa 100644 --- a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs @@ -347,6 +347,42 @@ pub struct GloasBlockInputs { pub execution_requests: ExecutionRequests, } +/// What a gloas block body carries when it commits to another builder's bid +/// rather than to a payload this node built. +#[derive(Debug, Clone)] +pub struct GloasBidBlockInputs { + pub randao_reveal: BlsSignature, + pub graffiti: Bytes32, + pub attestations: Vec, + pub payload_attestations: Vec, + pub parent_execution_requests: ExecutionRequests, + pub signed_bid: SignedExecutionPayloadBid, +} + +/// The unsigned block for `state.slot()` committing to `inputs.signed_bid`. +/// `process_block` on a copy fills `state_root` and enforces +/// `process_execution_payload_bid` (active, cover, signature, slot, parent +/// hash and root, randao). No envelope: the builder reveals it. +#[allow(dead_code)] // filled by Agent A +pub fn assemble_gloas_block_on_bid( + _state: &BeaconState, + _inputs: GloasBidBlockInputs, + _config: &Config, +) -> Result { + Err(Error::SpecAssert("unimplemented")) +} + +/// Cheap pre-filter on the state advanced to the slot: whether the bid could +/// be packed into a block on `state`. The signature is not checked here. +#[allow(dead_code)] // filled by Agent A +pub fn bid_is_includable( + _state: &BeaconState, + _signed_bid: &SignedExecutionPayloadBid, + _parent_requests: &ExecutionRequests, +) -> bool { + false +} + /// A produced block and the unsigned envelope that reveals its payload. #[derive(Debug, Clone)] pub struct GloasProduced { diff --git a/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs new file mode 100644 index 00000000..9cda084b --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs @@ -0,0 +1,92 @@ +//! Gossip validation for the gloas `execution_payload_bid` topic: a builder's +//! `SignedExecutionPayloadBid`, the commitment a proposer may choose in place +//! of building its own payload. +//! +//! Split like [`super::envelope`]: [`cheap_checks`] reads only the message, the +//! market's seen state and the clock, so the p2p actor runs it inline; +//! [`stateful_checks`] reads cached states and verifies the signature, so it +//! runs on a blocking thread. Never queues: every "MAY be queued" is IGNORE. +//! +//! Stubs until filled: every rule answers `Ignore(NoConsumer)`. + +use std::sync::Arc; + +use super::{IgnoreReason, Outcome}; +use crate::beacon::builder_market::BuilderMarket; +use crate::beacon::config::Config; +use crate::beacon::containers::{BeaconState, gloas}; +use crate::beacon::fork_choice::Store; +use crate::beacon::primitives::{Epoch, Root, Slot}; + +/// Gloas p2p preset: the largest decompressed `SignedExecutionPayloadBid`. +pub const MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE: usize = 196_932; + +/// The spec's `is_gas_limit_target_compatible`. +/// `max_diff = (parent / 1024).saturating_sub(1)`, `min = parent - max_diff`, +/// `max = parent.saturating_add(max_diff)`. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub fn is_gas_limit_target_compatible( + parent_gas_limit: u64, + gas_limit: u64, + target_gas_limit: u64, +) -> bool { + false +} + +/// `is_current_slot(slot) || slot.checked_sub(1).is_some_and(is_current_slot)`. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub(crate) fn is_current_or_next_slot(config: &Config, slot: Slot, now_ms: u64) -> bool { + false +} + +#[allow(unused_variables)] // filled by Agent A +pub fn cheap_checks( + market: &BuilderMarket, + store: &Store, + signed: &gloas::SignedExecutionPayloadBid, + now_ms: u64, +) -> Result<(), Outcome> { + Err(Outcome::Ignore(IgnoreReason::NoConsumer)) +} + +#[allow(unused_variables)] // filled by Agent A +pub fn stateful_checks( + store: &Store, + market: &BuilderMarket, + signed: &gloas::SignedExecutionPayloadBid, +) -> Outcome { + Outcome::Ignore(IgnoreReason::NoConsumer) +} + +/// Both halves. The caller records the bid on `Accept`. +pub fn validate( + market: &BuilderMarket, + store: &Store, + signed: &gloas::SignedExecutionPayloadBid, + now_ms: u64, +) -> Outcome { + if let Err(outcome) = cheap_checks(market, store, signed, now_ms) { + return outcome; + } + stateful_checks(store, market, signed) +} + +/// The spec's `is_bid_compatible_with_head`, against the recorded head. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub fn is_bid_compatible_with_head( + store: &Store, + bid: &gloas::ExecutionPayloadBid, +) -> Result { + Err(Outcome::Ignore(IgnoreReason::NoConsumer)) +} + +/// Cached-only: a `CheckpointState{epoch, root}` hit; else the cached +/// `BlockState(root)` advanced to the epoch start and cached. `None` = miss. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub(crate) fn cached_checkpoint_state( + store: &Store, + epoch: Epoch, + root: Root, +) -> Option> { + None +} diff --git a/crates/blockchain/state_transition/src/beacon/gossip/mod.rs b/crates/blockchain/state_transition/src/beacon/gossip/mod.rs index 187f612b..748df0ce 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/mod.rs @@ -11,7 +11,9 @@ pub mod attestation; pub mod block; pub mod column; pub mod envelope; +pub mod execution_payload_bid; pub mod payload_attestation; +pub mod proposer_preferences; #[cfg(test)] pub(crate) mod test_support; @@ -138,6 +140,32 @@ pub enum IgnoreReason { FinalizedNotAncestor, /// An ancestor lies outside what the state's `block_roots` can answer. AncestryUnknown, + /// A gloas bid or proposer preferences names a slot before the fork. + PreGloasSlot, + /// A bid's slot is neither the current nor the next slot. + NotCurrentOrNextSlot, + /// A bid's value does not beat the best already seen for its slot and parent. + NotHighestBid, + /// A bid or preferences name a slot beyond the proposer lookahead. + BeyondLookahead, + /// No proposer preferences are known for a bid's slot and dependent root. + PreferencesUnseen, + /// A bid's fee recipient is not the one in the proposer's preferences. + FeeRecipientMismatch, + /// A bid's parent block hash is not a known execution payload. + ParentPayloadUnknown, + /// A bid's gas limit cannot reach the preferences' target from the parent's. + GasLimitIncompatible, + /// A bid is not compatible with this node's head branch. + NotOnHeadBranch, + /// A bid's builder cannot cover its value. + BuilderCannotCover, + /// A bid's builder exits in the parent payload. + BuilderMayExit, + /// Proposer preferences arrived after their proposal slot began. + SlotStarted, + /// Proposer preferences name a dependent root no chain here can have. + ImpossibleDependentRoot, /// A gloas block builds on its parent's full payload branch, but the /// parent's envelope has not been seen and verified (the specification /// lets it be queued until it is). @@ -174,6 +202,19 @@ impl IgnoreReason { Self::StateUnavailable => "state_unavailable", Self::FinalizedNotAncestor => "finalized_not_ancestor", Self::AncestryUnknown => "ancestry_unknown", + Self::PreGloasSlot => "pre_gloas_slot", + Self::NotCurrentOrNextSlot => "not_current_or_next_slot", + Self::NotHighestBid => "not_highest_bid", + Self::BeyondLookahead => "beyond_lookahead", + Self::PreferencesUnseen => "preferences_unseen", + Self::FeeRecipientMismatch => "fee_recipient_mismatch", + Self::ParentPayloadUnknown => "parent_payload_unknown", + Self::GasLimitIncompatible => "gas_limit_incompatible", + Self::NotOnHeadBranch => "not_on_head_branch", + Self::BuilderCannotCover => "builder_cannot_cover", + Self::BuilderMayExit => "builder_may_exit", + Self::SlotStarted => "slot_started", + Self::ImpossibleDependentRoot => "impossible_dependent_root", Self::ParentPayloadUnverified => "parent_payload_unverified", Self::PayloadEnvelopeUnseen => "payload_envelope_unseen", Self::PayloadOptimistic => "payload_optimistic", @@ -231,6 +272,20 @@ pub enum RejectReason { AggregateSignature, /// The target is not the voted block's ancestor at the target epoch. TargetNotAncestor, + /// A bid promises a payment outside the bid value. + ExecutionPaymentNonZero, + /// A bid's block hash is its parent's. + BlockHashEqualsParent, + /// A bid's `prev_randao` is not the parent state's current mix. + PrevRandao, + /// A bid's builder index is not in the registry. + UnknownBuilder, + /// A bid's builder is not a payload builder. + NotPayloadBuilder, + /// A bid's builder is not active. + InactiveBuilder, + /// Preferences' dependent block is later than the shuffling's dependent slot. + DependentRootTooLate, /// A gloas block body (or its parent execution requests) carries more of /// an operation than its limit, or any deposit. OperationLimit, @@ -291,6 +346,13 @@ impl RejectReason { Self::AggregatorSignature => "aggregator_signature", Self::AggregateSignature => "aggregate_signature", Self::TargetNotAncestor => "target_not_ancestor", + Self::ExecutionPaymentNonZero => "execution_payment_nonzero", + Self::BlockHashEqualsParent => "block_hash_equals_parent", + Self::PrevRandao => "prev_randao", + Self::UnknownBuilder => "unknown_builder", + Self::NotPayloadBuilder => "not_payload_builder", + Self::InactiveBuilder => "inactive_builder", + Self::DependentRootTooLate => "dependent_root_too_late", Self::OperationLimit => "operation_limit", Self::BidParentMismatch => "bid_parent_mismatch", Self::BidNotOnParentHead => "bid_not_on_parent_head", diff --git a/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs new file mode 100644 index 00000000..50c73650 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs @@ -0,0 +1,79 @@ +//! Gossip validation for the gloas `proposer_preferences` topic: a proposer's +//! `SignedProposerPreferences` (fee recipient and gas target) for a slot, which +//! bids on that slot are judged against. +//! +//! Split like [`super::envelope`]: [`cheap_checks`] inline in the p2p actor, +//! [`stateful_checks`] on a blocking thread, reading cached states only. +//! +//! Stubs until filled: every rule answers `Ignore(NoConsumer)`. + +use super::{IgnoreReason, Outcome}; +use crate::beacon::builder_market::BuilderMarket; +use crate::beacon::config::Config; +use crate::beacon::containers::{BeaconState, gloas}; +use crate::beacon::fork_choice::Store; +use crate::beacon::primitives::{Domain, Epoch, Root, Slot}; + +#[allow(unused_variables)] // filled by Agent A +pub fn cheap_checks( + market: &BuilderMarket, + store: &Store, + signed: &gloas::SignedProposerPreferences, + now_ms: u64, +) -> Result<(), Outcome> { + Err(Outcome::Ignore(IgnoreReason::NoConsumer)) +} + +#[allow(unused_variables)] // filled by Agent A +pub fn stateful_checks(store: &Store, signed: &gloas::SignedProposerPreferences) -> Outcome { + Outcome::Ignore(IgnoreReason::NoConsumer) +} + +/// Both halves. The caller records the preferences on `Accept`. +pub fn validate( + market: &BuilderMarket, + store: &Store, + signed: &gloas::SignedProposerPreferences, + now_ms: u64, +) -> Outcome { + if let Err(outcome) = cheap_checks(market, store, signed, now_ms) { + return outcome; + } + stateful_checks(store, signed) +} + +/// The spec's `is_valid_dependent_root`: `root == store.head()`, or some block +/// in the index has `parent_root == root` and `slot > dependent_slot`. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub fn is_valid_dependent_root(store: &Store, root: Root, dependent_slot: Slot) -> bool { + false +} + +/// `ancestor_at(state, state_block_root, compute_shuffling_dependent_slot( +/// epoch(proposal_slot)))`. Used by `produceBlockV4` and the validator client. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub fn dependent_root_at( + state: &BeaconState, + state_block_root: Root, + proposal_slot: Slot, +) -> Option { + None +} + +/// The distinct candidate signing domains, tried in order: `get_domain( +/// lookahead_state, DOMAIN_PROPOSER_PREFERENCES, Some(P))`; the schedule's fork +/// version at `P - MIN_SEED_LOOKAHEAD` (saturating); the schedule's at `P`. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub fn proposer_preferences_domains( + lookahead_state: &BeaconState, + config: &Config, + proposal_epoch: Epoch, +) -> Vec { + Vec::new() +} + +/// `now > slot_start + 500 ms`. +#[allow(dead_code, unused_variables)] // filled by Agent A +pub(crate) fn is_past_slot(config: &Config, slot: Slot, now_ms: u64) -> bool { + false +} diff --git a/crates/blockchain/state_transition/src/beacon/mod.rs b/crates/blockchain/state_transition/src/beacon/mod.rs index 1a4471fa..6a5457c8 100644 --- a/crates/blockchain/state_transition/src/beacon/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/mod.rs @@ -72,6 +72,7 @@ pub mod aggregate; pub mod attestation_pool; pub mod block_production; pub mod bls; +pub mod builder_market; pub mod das; pub mod fork_choice; pub mod genesis; diff --git a/crates/net/api/src/lib.rs b/crates/net/api/src/lib.rs index 7c3c3305..2d961591 100644 --- a/crates/net/api/src/lib.rs +++ b/crates/net/api/src/lib.rs @@ -5,7 +5,10 @@ use ethlambda_types::{ beacon::containers::{ DataColumnSidecar, SignedAggregateAndProof, SignedBeaconBlock, electra::SingleAttestation, - gloas::{PayloadAttestationMessage, SignedExecutionPayloadEnvelope}, + gloas::{ + PayloadAttestationMessage, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedProposerPreferences, + }, }, beacon::primitives::ValidatorIndex, block::SignedBlock, @@ -366,6 +369,19 @@ pub trait RpcToP2P: Send + Sync { envelope: Box, sidecars: Vec, ) -> Result<(), ActorError>; + /// Gossip a signed execution payload bid on `execution_payload_bid`. The + /// caller ran the gossip rules and recorded it in the shared + /// `BuilderMarket` (seen keys and pool), so the p2p actor only publishes. + fn publish_execution_payload_bid( + &self, + bid: SignedExecutionPayloadBid, + ) -> Result<(), ActorError>; + /// Gossip signed proposer preferences on `proposer_preferences`, under the + /// proposal slot's digest. Validated and cached by the caller. + fn publish_proposer_preferences( + &self, + preferences: SignedProposerPreferences, + ) -> Result<(), ActorError>; /// Gossip a payload timeliness committee member's vote on /// `payload_attestation_message` and hand it to the chain actor. Checked by /// the caller as above. diff --git a/crates/net/p2p/src/beacon/builder_market.rs b/crates/net/p2p/src/beacon/builder_market.rs new file mode 100644 index 00000000..44e78bdc --- /dev/null +++ b/crates/net/p2p/src/beacon/builder_market.rs @@ -0,0 +1,57 @@ +//! Gloas builder market gossip: the `execution_payload_bid` and +//! `proposer_preferences` topics. +//! +//! Stubs until filled. Neither message type ever reaches the chain actor: the +//! rules live in `ethlambda_state_transition::beacon::gossip::{ +//! execution_payload_bid, proposer_preferences}`, and what they accept is +//! recorded in the node's shared `BuilderMarket` by the verdict. + +use ethlambda_state_transition::beacon::gossip::{IgnoreReason, Outcome}; +use ethlambda_types::beacon::containers::gloas::{ + SignedExecutionPayloadBid, SignedProposerPreferences, +}; +use ethlambda_types::beacon::primitives::Slot; +use ethlambda_types::time::unix_now_ms; + +use crate::P2PServer; +use crate::beacon::verdict::Dispatch; + +/// Size cap -> `Reject(Malformed)`; decode -> `Reject(Decode)`; +/// `inc_beacon_gossip(KIND, "decoded")`; +/// `gossip::execution_payload_bid::cheap_checks`; then +/// `Validate(Validated::ExecutionPayloadBid { .. })`. +#[allow(dead_code, unused_variables)] // filled by Agent B +pub(crate) fn triage_execution_payload_bid(server: &P2PServer, payload: &[u8]) -> Dispatch { + Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)) +} + +/// As [`triage_execution_payload_bid`], for `proposer_preferences`. +#[allow(dead_code, unused_variables)] // filled by Agent B +pub(crate) fn triage_proposer_preferences(server: &P2PServer, payload: &[u8]) -> Dispatch { + Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)) +} + +/// Publish on `publish_digest(bid.slot)` / `execution_payload_bid`. +/// Precondition: the caller already recorded it in the market. +#[allow(dead_code, unused_variables)] // filled by Agent B +pub(crate) fn publish_execution_payload_bid( + server: &mut P2PServer, + bid: SignedExecutionPayloadBid, +) { +} + +/// Publish on `publish_digest(proposal_slot)`: during the epoch before gloas +/// this is the gloas digest, which is held. +#[allow(dead_code, unused_variables)] // filled by Agent B +pub(crate) fn publish_proposer_preferences( + server: &mut P2PServer, + preferences: SignedProposerPreferences, +) { +} + +/// The wall-clock slot, from the store's config. +pub(crate) fn wall_slot(server: &P2PServer) -> Slot { + let config = server.store.config(); + let genesis_ms = config.genesis_time_ms(); + unix_now_ms().saturating_sub(genesis_ms) / config.slot_duration_ms.max(1) +} diff --git a/crates/net/p2p/src/beacon/mod.rs b/crates/net/p2p/src/beacon/mod.rs index 8ebc3721..03bd60fa 100644 --- a/crates/net/p2p/src/beacon/mod.rs +++ b/crates/net/p2p/src/beacon/mod.rs @@ -13,6 +13,7 @@ //! written once and handed the two things the chains disagree about: how wide //! the `` field is, and how a chunk body becomes a block. +pub mod builder_market; pub mod column_checks; pub mod decode; pub mod encoding; diff --git a/crates/net/p2p/src/beacon/topics.rs b/crates/net/p2p/src/beacon/topics.rs index ff4e6da6..7605d3d9 100644 --- a/crates/net/p2p/src/beacon/topics.rs +++ b/crates/net/p2p/src/beacon/topics.rs @@ -51,6 +51,10 @@ pub const SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF: &str = "sync_committee_contribu pub const EXECUTION_PAYLOAD: &str = "execution_payload"; /// Topic kind for gloas payload timeliness committee votes. pub const PAYLOAD_ATTESTATION_MESSAGE: &str = "payload_attestation_message"; +/// Topic kind for gloas builder bids. +pub const EXECUTION_PAYLOAD_BID: &str = "execution_payload_bid"; +/// Topic kind for gloas proposer preferences. +pub const PROPOSER_PREFERENCES: &str = "proposer_preferences"; /// The topic kinds gloas adds to [`SUBSCRIBED_TOPIC_KINDS`], subscribed from /// the gloas digest on and never under an earlier one. diff --git a/crates/net/p2p/src/beacon/verdict.rs b/crates/net/p2p/src/beacon/verdict.rs index 5d820eed..62591872 100644 --- a/crates/net/p2p/src/beacon/verdict.rs +++ b/crates/net/p2p/src/beacon/verdict.rs @@ -15,12 +15,14 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::time::Instant; use ethlambda_network_api::{AggregateArrival, BlockArrival, BlockSource}; +use ethlambda_state_transition::beacon::builder_market::SharedBuilderMarket; use ethlambda_state_transition::beacon::gossip::{self, IgnoreReason, Outcome}; use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCacheExt as _; use ethlambda_storage::{CacheKey, Store}; use ethlambda_types::beacon::containers::electra::{self, SingleAttestation}; use ethlambda_types::beacon::containers::gloas::{ - PayloadAttestationMessage, SignedExecutionPayloadEnvelope, + PayloadAttestationMessage, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedProposerPreferences, }; use ethlambda_types::beacon::containers::{ DataColumnSidecar, SignedAggregateAndProof, SignedBeaconBlock, @@ -32,7 +34,7 @@ use spawned_concurrency::message::Message; use spawned_concurrency::tasks::{Context, Handler}; use tracing::{error, warn}; -use crate::beacon::column_checks; +use crate::beacon::{builder_market, column_checks}; use crate::{P2PServer, metrics}; /// Which gossip message a verdict is for. @@ -78,6 +80,16 @@ pub(crate) enum Validated { /// A gloas `execution_payload`. Boxed for the reason `Block` is: it /// carries a whole execution payload. Envelope(Box), + /// A gloas `execution_payload_bid`. Carries the market because + /// `stateful_checks` receives only the store. + #[allow(dead_code)] // constructed by Agent B's triage + ExecutionPayloadBid { + bid: Box, + market: SharedBuilderMarket, + }, + /// A gloas `proposer_preferences`. + #[allow(dead_code)] // constructed by Agent B's triage + ProposerPreferences(Box), /// A gloas `payload_attestation_message`. PayloadAttestation(PayloadAttestationMessage), } @@ -115,6 +127,12 @@ impl Validated { subnet_id, } => gossip::attestation::stateful_checks(store, attestation, *subnet_id), Self::Envelope(envelope) => gossip::envelope::stateful_checks(store, envelope), + Self::ExecutionPayloadBid { bid, market } => { + gossip::execution_payload_bid::stateful_checks(store, market, bid) + } + Self::ProposerPreferences(preferences) => { + gossip::proposer_preferences::stateful_checks(store, preferences) + } Self::PayloadAttestation(message) => { gossip::payload_attestation::stateful_checks(store, message) } @@ -147,6 +165,12 @@ impl Validated { envelope.message.beacon_block_root, envelope.message.builder_index, ), + Self::ExecutionPayloadBid { bid, .. } => { + server.builder_market.record_bid((**bid).clone()) + } + Self::ProposerPreferences(preferences) => server + .builder_market + .record_preferences((**preferences).clone(), builder_market::wall_slot(server)), Self::PayloadAttestation(message) => server .seen_payload_attestations .record(message.data.slot, message.validator_index), @@ -267,6 +291,10 @@ impl Validated { Self::Aggregate { .. } | Self::Attestation { .. } | Self::Envelope(_) + // The SSE `execution_payload_bid` and `proposer_preferences` events + // hook in here once the events endpoint lands. + | Self::ExecutionPayloadBid { .. } + | Self::ProposerPreferences(_) | Self::PayloadAttestation(_) => {} } } @@ -445,6 +473,9 @@ fn permits_for<'a>( Validated::Aggregate { .. } | Validated::Attestation { .. } | Validated::PayloadAttestation(_) => &server.attestation_validation_permits, + Validated::ExecutionPayloadBid { .. } | Validated::ProposerPreferences(_) => { + &server.builder_validation_permits + } } } diff --git a/crates/net/p2p/src/gossipsub/handler.rs b/crates/net/p2p/src/gossipsub/handler.rs index 1b664a47..ac1a0c47 100644 --- a/crates/net/p2p/src/gossipsub/handler.rs +++ b/crates/net/p2p/src/gossipsub/handler.rs @@ -253,6 +253,10 @@ fn handle_beacon_gossip( triage_envelope(server, payload) } else if kind == beacon_topics::PAYLOAD_ATTESTATION_MESSAGE { triage_payload_attestation(server, payload) + } else if kind == beacon_topics::EXECUTION_PAYLOAD_BID { + crate::beacon::builder_market::triage_execution_payload_bid(server, payload) + } else if kind == beacon_topics::PROPOSER_PREFERENCES { + crate::beacon::builder_market::triage_proposer_preferences(server, payload) } else { triage_other(wire, kind, payload) }; diff --git a/crates/net/p2p/src/gossipsub/mod.rs b/crates/net/p2p/src/gossipsub/mod.rs index 1701c5f3..8c558016 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -2,6 +2,8 @@ mod encoding; mod handler; mod messages; +#[allow(unused_imports)] // used by Agent B's publish functions +pub(crate) use encoding::compress_message; pub use encoding::decompress_message; pub use handler::{ handle_gossip_message, join_aggregator_subnets, leave_expired_aggregator_subnets, diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index ff52d419..1d8bf971 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -45,8 +45,8 @@ use ethlambda_network_api::{ }, rpc_to_p2p::{ PublishBeaconAggregate, PublishBeaconAttestation, PublishBeaconBlock, - PublishExecutionPayloadEnvelope, PublishPayloadAttestationMessage, - SubscribeAttestationSubnets, + PublishExecutionPayloadBid, PublishExecutionPayloadEnvelope, + PublishPayloadAttestationMessage, PublishProposerPreferences, SubscribeAttestationSubnets, }, }; use ethlambda_state_transition::beacon::aggregate::MAX_AGGREGATES_PER_SLOT; @@ -56,7 +56,8 @@ use ethlambda_state_transition::beacon::gossip::{ payload_attestation::SeenPayloadAttestations, }; use ethlambda_state_transition::beacon::{ - attestation_pool::SharedAttestationPool, payload_attestation_pool::SharedPayloadAttestationPool, + attestation_pool::SharedAttestationPool, builder_market::SharedBuilderMarket, + payload_attestation_pool::SharedPayloadAttestationPool, }; use ethlambda_storage::{Chain, Store}; use ethlambda_types::beacon::preset::{MAX_VALIDATORS_PER_COMMITTEE, SLOTS_PER_EPOCH}; @@ -244,6 +245,11 @@ const COLUMN_CHECK_PERMITS: usize = 16; /// has data from a follower. const ATTESTATION_VALIDATION_PERMITS: usize = 128; +/// How many `execution_payload_bid` and `proposer_preferences` stateful checks +/// may run at once. A pool of its own, so a burst of bids (each costs a BLS +/// verification) cannot starve blocks, columns or attestations of permits. +const BUILDER_VALIDATION_PERMITS: usize = 32; + /// Capacity of the first-valid-block cache, keyed by `(slot, proposer)`. /// How often to leave aggregator subnets whose slot has passed. One slot's /// worth: a subnet outlives its need by at most this, which costs a little @@ -1093,6 +1099,7 @@ impl P2P { discovery: Option, attestation_pool: SharedAttestationPool, payload_attestation_pool: SharedPayloadAttestationPool, + builder_market: SharedBuilderMarket, ) -> Result { let discovery = match discovery { Some(config) => Some(spawn_discovery(config).await?), @@ -1156,6 +1163,10 @@ impl P2P { )), attestation_pool, payload_attestation_pool, + builder_market, + builder_validation_permits: Arc::new(tokio::sync::Semaphore::new( + BUILDER_VALIDATION_PERMITS, + )), aggregator_subnets: HashMap::new(), }; let discovery_enabled = server.discovery.is_some(); @@ -1296,6 +1307,15 @@ pub struct P2PServer { /// lean never touches it. pub(crate) payload_attestation_pool: SharedPayloadAttestationPool, + /// Bids, proposer preferences and known payloads, shared with the Beacon + /// API (which posts bids and preferences, and builds blocks from the pool). + /// Gossip's stateful checks read it from blocking threads, so it cannot + /// live in the chain actor; lean never touches it. + pub(crate) builder_market: SharedBuilderMarket, + /// Permits for `execution_payload_bid` and `proposer_preferences` stateful + /// checks, see [`BUILDER_VALIDATION_PERMITS`]. + pub(crate) builder_validation_permits: Arc, + /// The attestation subnets joined for a validator client's aggregators, /// each with the last slot it is needed for. Short-lived by design: never /// advertised in `attnets`, and left once the slot has passed. The @@ -1606,6 +1626,18 @@ impl Handler for P2PServer { } } +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishExecutionPayloadBid, _ctx: &Context) { + beacon::builder_market::publish_execution_payload_bid(self, msg.bid); + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishProposerPreferences, _ctx: &Context) { + beacon::builder_market::publish_proposer_preferences(self, msg.preferences); + } +} + impl Handler for P2PServer { async fn handle(&mut self, msg: PublishPayloadAttestationMessage, _ctx: &Context) { gossipsub::publish_payload_attestation_message(self, msg.message).await; @@ -2783,6 +2815,10 @@ pub(crate) mod test_support { )), attestation_pool: Default::default(), payload_attestation_pool: Default::default(), + builder_market: Default::default(), + builder_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::BUILDER_VALIDATION_PERMITS, + )), aggregator_subnets: HashMap::new(), } } diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index daad0659..580c3643 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -2790,6 +2790,10 @@ pub(crate) mod tests { )), attestation_pool: Default::default(), payload_attestation_pool: Default::default(), + builder_market: Default::default(), + builder_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::BUILDER_VALIDATION_PERMITS, + )), aggregator_subnets: HashMap::new(), } } diff --git a/crates/net/rpc/src/beacon/bid_selection.rs b/crates/net/rpc/src/beacon/bid_selection.rs new file mode 100644 index 00000000..0fd43838 --- /dev/null +++ b/crates/net/rpc/src/beacon/bid_selection.rs @@ -0,0 +1,45 @@ +//! Choosing between this node's own build and a pooled builder bid for +//! `produceBlockV4`. Pure: no store, no clock. +//! +//! Stubs until filled. + +use ethlambda_types::beacon::containers::gloas::{ExecutionPayloadBid, SignedExecutionPayloadBid}; +use ethlambda_types::beacon::primitives::Uint256; + +/// The local build, as the choice sees it. +#[allow(dead_code)] // filled by Agent C +pub(crate) struct LocalCandidate { + pub(crate) value_wei: u128, + pub(crate) should_override_builder: bool, +} + +// The enum is short-lived (one per block production), so boxing the bid buys +// nothing. +#[allow(dead_code, clippy::large_enum_variant)] // filled by Agent C +pub(crate) enum PayloadChoice { + Local, + Bid(SignedExecutionPayloadBid), +} + +/// `value.saturating_add(execution_payment)`; a p2p bid's payment is zero. +#[allow(dead_code, unused_variables)] // filled by Agent C +pub(crate) fn bid_total_gwei(bid: &ExecutionPayloadBid) -> u64 { + 0 +} + +/// Saturating conversion of a wei amount. +#[allow(dead_code, unused_variables)] // filled by Agent C +pub(crate) fn wei_u128(value: &Uint256) -> u128 { + 0 +} + +/// `None` when there is neither a local build nor a bid at or above `min_bid`. +#[allow(dead_code, unused_variables)] // filled by Agent C +pub(crate) fn choose_payload( + local: Option<&LocalCandidate>, + bids_desc: &[SignedExecutionPayloadBid], + min_bid: u64, + builder_boost_factor: u64, +) -> Option { + None +} diff --git a/crates/net/rpc/src/beacon/bids.rs b/crates/net/rpc/src/beacon/bids.rs new file mode 100644 index 00000000..21d3ecac --- /dev/null +++ b/crates/net/rpc/src/beacon/bids.rs @@ -0,0 +1,13 @@ +//! `POST /eth/v1/beacon/execution_payload_bids`: a builder's bid handed to this +//! node, validated with the gossip rules, pooled in the shared +//! `BuilderMarket` and gossiped on `execution_payload_bid`. +//! +//! Stub: no routes until filled. + +use axum::Router; +use ethlambda_storage::Store; + +#[allow(dead_code)] // filled by Agent C +pub(crate) fn routes() -> Router { + Router::new() +} diff --git a/crates/net/rpc/src/beacon/builder_config.rs b/crates/net/rpc/src/beacon/builder_config.rs new file mode 100644 index 00000000..5408aa84 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_config.rs @@ -0,0 +1,53 @@ +//! The `BuilderConfig` a validator client sends with `produceBlockV4`. +//! +//! Stubs until filled. Field types below are placeholders; the SSZ and JSON +//! forms are added with the real containers. + +use axum::http::HeaderMap; + +use crate::beacon::ApiError; + +#[allow(dead_code)] // filled by Agent C +pub(crate) const MAX_BUILDER_ENTRIES: usize = 64; +#[allow(dead_code)] // filled by Agent C +pub(crate) const MAX_BUILDER_URL_SIZE: usize = 2048; +#[allow(dead_code)] // filled by Agent C +pub(crate) const MAX_BUILDER_PUBKEYS: usize = 64; +#[allow(dead_code)] // filled by Agent C +pub(crate) const MAX_BUILDER_AUTH_DATA_SIZE: usize = 4096; + +#[derive(Debug, Clone, Default)] +#[allow(dead_code)] // filled by Agent C +pub(crate) struct BuilderEntry { + pub(crate) url: String, + pub(crate) auth_data: Vec, + pub(crate) auth_slot: u64, + pub(crate) min_bid: u64, + pub(crate) builder_boost_factor: u64, +} + +#[derive(Debug, Clone, Default)] +#[allow(dead_code)] // filled by Agent C +pub(crate) struct BuilderConfig { + pub(crate) min_bid: u64, + pub(crate) builder_boost_factor: u64, + pub(crate) builders: Vec, +} + +#[allow(dead_code)] // filled by Agent C +impl BuilderEntry { + /// A non-empty url, non-empty `auth.data` and `auth.message.slot == slot`. + /// An unusable entry never fails the request. + pub(crate) fn is_usable_for(&self, _slot: u64) -> bool { + false + } +} + +/// A missing or undecodable body is a 400. +#[allow(dead_code)] // filled by Agent C +pub(crate) fn decode_builder_config( + _headers: &HeaderMap, + _body: &[u8], +) -> Result { + Err(ApiError::BadRequest("the body is not a BuilderConfig")) +} diff --git a/crates/net/rpc/src/beacon/builders.rs b/crates/net/rpc/src/beacon/builders.rs new file mode 100644 index 00000000..59995e4e --- /dev/null +++ b/crates/net/rpc/src/beacon/builders.rs @@ -0,0 +1,12 @@ +//! `POST /eth/v1/beacon/states/{state_id}/builders`: the builder registry of a +//! gloas state, filtered by id and status. +//! +//! Stub: no routes until filled. + +use axum::Router; +use ethlambda_storage::Store; + +#[allow(dead_code)] // filled by Agent C +pub(crate) fn routes() -> Router { + Router::new() +} diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs index 728a2087..922a1867 100644 --- a/crates/net/rpc/src/beacon/mod.rs +++ b/crates/net/rpc/src/beacon/mod.rs @@ -15,7 +15,11 @@ use serde::Serialize; use crate::shared::block_id::IdError; +pub(crate) mod bid_selection; +pub(crate) mod bids; pub(crate) mod blocks; +pub(crate) mod builder_config; +pub(crate) mod builders; pub(crate) mod config; pub(crate) mod envelopes; pub(crate) mod genesis; @@ -24,6 +28,7 @@ pub(crate) mod headers; pub(crate) mod node; pub(crate) mod pool; pub(crate) mod proposal; +pub(crate) mod proposer_preferences; pub(crate) mod ptc; pub(crate) mod states; pub(crate) mod validator; @@ -180,6 +185,9 @@ pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { .merge(proposal::routes()) .merge(gloas_proposal::routes()) .merge(ptc::routes()) + .merge(bids::routes()) + .merge(proposer_preferences::routes()) + .merge(builders::routes()) } #[cfg(test)] diff --git a/crates/net/rpc/src/beacon/proposer_preferences.rs b/crates/net/rpc/src/beacon/proposer_preferences.rs new file mode 100644 index 00000000..6dd90008 --- /dev/null +++ b/crates/net/rpc/src/beacon/proposer_preferences.rs @@ -0,0 +1,13 @@ +//! `POST /eth/v1/validator/proposer_preferences`: signed proposer preferences +//! from a validator client, validated with the gossip rules, cached in the +//! shared `BuilderMarket` and gossiped on `proposer_preferences`. +//! +//! Stub: no routes until filled. + +use axum::Router; +use ethlambda_storage::Store; + +#[allow(dead_code)] // filled by Agent C +pub(crate) fn routes() -> Router { + Router::new() +} diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index e03ae786..7844f941 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -4,7 +4,8 @@ use axum::{Extension, Router}; use ethlambda_blockchain::{EventBus, SyncStatusController}; use ethlambda_network_api::RpcToP2PRef; use ethlambda_state_transition::beacon::{ - attestation_pool::SharedAttestationPool, payload_attestation_pool::SharedPayloadAttestationPool, + attestation_pool::SharedAttestationPool, builder_market::SharedBuilderMarket, + payload_attestation_pool::SharedPayloadAttestationPool, }; use ethlambda_storage::Store; use ethlambda_types::aggregator::AggregatorController; @@ -202,6 +203,9 @@ pub struct BeaconApiHandles { /// Filled by gossip and the payload attestation pool endpoint, read by /// block production and the pool's GET. pub payload_attestation_pool: SharedPayloadAttestationPool, + /// Bids, proposer preferences and known payloads: filled by the bid and + /// preferences endpoints and by gossip, read by block production. + pub builder_market: SharedBuilderMarket, /// The columns this node custodies: what `payload_attestation_data` checks /// a block's blob availability against, and what block production tells /// the execution client it samples for when asking it to build a gloas @@ -231,6 +235,7 @@ pub async fn start_beacon_rpc_server( .layer(Extension(handles.p2p)) .layer(Extension(handles.attestation_pool)) .layer(Extension(handles.payload_attestation_pool)) + .layer(Extension(handles.builder_market)) .layer(Extension(handles.custody_columns)) .layer(Extension(beacon::validator::FeeRecipients::default())) .layer(Extension(handles.engine)); @@ -443,6 +448,12 @@ pub(crate) mod test_utils { pub(crate) payload_attestations: std::sync::Mutex< Vec, >, + pub(crate) bids: std::sync::Mutex< + Vec, + >, + pub(crate) proposer_preferences: std::sync::Mutex< + Vec, + >, } impl ethlambda_network_api::RpcToP2P for RecordingNetwork { @@ -493,6 +504,22 @@ pub(crate) mod test_utils { Ok(()) } + fn publish_execution_payload_bid( + &self, + bid: ethlambda_types::beacon::containers::gloas::SignedExecutionPayloadBid, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.bids.lock().unwrap().push(bid); + Ok(()) + } + + fn publish_proposer_preferences( + &self, + preferences: ethlambda_types::beacon::containers::gloas::SignedProposerPreferences, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.proposer_preferences.lock().unwrap().push(preferences); + Ok(()) + } + fn publish_payload_attestation_message( &self, message: ethlambda_types::beacon::containers::gloas::PayloadAttestationMessage, From 47b11564b88c3262b338797a9baf50783286fed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:48:17 -0300 Subject: [PATCH 03/12] feat(state-transition): implement the gloas builder market state Bids, proposer preferences and revealed execution payloads are judged and consumed from three places (gossip rules on blocking threads, the Beacon API and block production), so they live in one shared object rather than in the chain actor. The market keeps the spec's seen sets and best-bid bar apart from the pooled bids: the pool is truncated to the top values per parent, but the bar a new bid must strictly beat is not. Known payloads are a bounded LRU, and every collection is bounded so spam cannot grow memory. Also adds the test-utils fixtures (builder registry state, bid and preference signing, envelopes) the gossip rules, p2p and rpc tests build on. --- .../src/beacon/builder_market.rs | 529 +++++++++++++++++- 1 file changed, 511 insertions(+), 18 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/builder_market.rs b/crates/blockchain/state_transition/src/beacon/builder_market.rs index a39c7973..6eda031b 100644 --- a/crates/blockchain/state_transition/src/beacon/builder_market.rs +++ b/crates/blockchain/state_transition/src/beacon/builder_market.rs @@ -7,13 +7,11 @@ //! the Beacon API reads it, like [`super::payload_attestation_pool`]. Gossip's //! stateful checks run on blocking threads, so none of this can be owned by the //! chain actor. -//! -//! The method signatures are the contract between the gossip rules, p2p and the -//! Beacon API; the bodies are placeholders until they are filled. use std::{ + collections::{BTreeMap, BTreeSet}, num::NonZeroUsize, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, MutexGuard}, }; use lru::LruCache; @@ -44,22 +42,78 @@ pub struct KnownPayload { pub builder_exits: Vec<(BlsPubkey, ExecutionAddress)>, } +/// Bids for one `(parent_block_hash, parent_block_root)` of a slot. +#[derive(Debug, Default)] +struct ParentBids { + /// The highest value recorded, which a later bid must strictly beat. Kept + /// apart from `bids` because the pool is truncated and the bar is not. + best_value: Option, + /// Value descending, then builder index ascending. + bids: Vec, +} + +type ParentKey = (ExecutionBlockHash, Root); + +#[derive(Debug, Default)] +struct SlotBids { + /// The spec's `seen.execution_payload_bids`: one bid per builder per + /// `(slot, parent_hash, parent_root)`. + seen: BTreeSet<(ParentKey, u64)>, + parents: BTreeMap, +} + +#[derive(Debug, Default)] +struct BidPool { + slots: BTreeMap, +} + +impl BidPool { + fn check(&self, bid: &gloas::ExecutionPayloadBid) -> Result<(), IgnoreReason> { + let Some(slot) = self.slots.get(&bid.slot) else { + return Ok(()); + }; + let parent = (bid.parent_block_hash, bid.parent_block_root); + if slot.seen.contains(&(parent, bid.builder_index)) { + return Err(IgnoreReason::AlreadySeen); + } + if let Some(best) = slot.parents.get(&parent).and_then(|p| p.best_value) + && bid.value <= best + { + return Err(IgnoreReason::NotHighestBid); + } + Ok(()) + } +} + +/// Preferences by `(proposal_slot, dependent_root)`: the first valid one wins. +type PreferencesCache = BTreeMap<(Slot, Root), gloas::SignedProposerPreferences>; + #[derive(Debug)] pub struct BuilderMarket { - // Filled by Agent A: the bid pool and the preferences cache join this. - #[allow(dead_code)] + bids: Mutex, + preferences: Mutex, payloads: Mutex>, } impl Default for BuilderMarket { fn default() -> Self { Self { + bids: Mutex::default(), + preferences: Mutex::default(), payloads: Mutex::new(LruCache::new(KNOWN_PAYLOADS_CAPACITY)), } } } -#[allow(dead_code, unused_variables)] // filled by Agent A +/// A poisoned lock means a panic elsewhere interrupted an update, but every +/// mutation here is a single insert or remove, so the data is still consistent +/// and gossip should keep working. +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + impl BuilderMarket { // Bids: seen.execution_payload_bids + seen.best_execution_payload_bid + pool. @@ -67,19 +121,54 @@ impl BuilderMarket { /// builder)` recorded -> `AlreadySeen`; value <= best for `(slot, /// parent_hash, parent_root)` -> `NotHighestBid`. pub fn check_bid_seen(&self, bid: &gloas::ExecutionPayloadBid) -> Result<(), IgnoreReason> { - Ok(()) + lock(&self.bids).check(bid) } /// Re-runs [`Self::check_bid_seen`] under the lock. If it passes, records /// both seen keys and pools the bid. `false` = not recorded (race, or the /// per-slot key cap). Prunes slots below `bid.slot - 1`. pub fn record_bid(&self, signed: gloas::SignedExecutionPayloadBid) -> bool { - false + let mut pool = lock(&self.bids); + if pool.check(&signed.message).is_err() { + return false; + } + let bid_slot = signed.message.slot; + let slot = pool.slots.entry(bid_slot).or_default(); + if slot.seen.len() >= MAX_SEEN_BID_KEYS_PER_SLOT { + return false; + } + let parent = ( + signed.message.parent_block_hash, + signed.message.parent_block_root, + ); + slot.seen.insert((parent, signed.message.builder_index)); + let entry = slot.parents.entry(parent).or_default(); + entry.best_value = Some(signed.message.value); + entry.bids.push(signed); + entry.bids.sort_by(|a, b| { + b.message + .value + .cmp(&a.message.value) + .then(a.message.builder_index.cmp(&b.message.builder_index)) + }); + entry.bids.truncate(MAX_BIDS_PER_PARENT); + let keep_from = bid_slot.saturating_sub(1); + pool.slots = pool.slots.split_off(&keep_from); + true } /// The identical message and signature is pooled (API idempotency). pub fn contains_bid(&self, signed: &gloas::SignedExecutionPayloadBid) -> bool { - false + let pool = lock(&self.bids); + pool.slots + .get(&signed.message.slot) + .and_then(|slot| { + slot.parents.get(&( + signed.message.parent_block_hash, + signed.message.parent_block_root, + )) + }) + .is_some_and(|parent| parent.bids.contains(signed)) } /// Pooled bids for the key: value descending, then builder index ascending. @@ -89,14 +178,25 @@ impl BuilderMarket { parent_block_root: Root, parent_block_hash: ExecutionBlockHash, ) -> Vec { - Vec::new() + lock(&self.bids) + .slots + .get(&slot) + .and_then(|s| s.parents.get(&(parent_block_hash, parent_block_root))) + .map(|p| p.bids.clone()) + .unwrap_or_default() } pub fn has_bids_for_slot(&self, slot: Slot) -> bool { - false + lock(&self.bids) + .slots + .get(&slot) + .is_some_and(|s| s.parents.values().any(|p| !p.bids.is_empty())) } - pub fn prune_bids_before(&self, slot: Slot) {} + pub fn prune_bids_before(&self, slot: Slot) { + let mut pool = lock(&self.bids); + pool.slots = pool.slots.split_off(&slot); + } // Proposer preferences: seen.proposer_preferences. @@ -105,7 +205,9 @@ impl BuilderMarket { proposal_slot: Slot, dependent_root: Root, ) -> Option { - None + lock(&self.preferences) + .get(&(proposal_slot, dependent_root)) + .cloned() } /// The first valid preferences per key win. `false` if one is held. Prunes @@ -115,16 +217,407 @@ impl BuilderMarket { signed: gloas::SignedProposerPreferences, current_slot: Slot, ) -> bool { - false + let mut cache = lock(&self.preferences); + let key = (signed.message.proposal_slot, signed.message.dependent_root); + if cache.contains_key(&key) { + return false; + } + *cache = cache.split_off(&(current_slot, Root::ZERO)); + cache.insert(key, signed); + while cache.len() > MAX_PREFERENCES { + cache.pop_first(); + } + cache.contains_key(&key) } - pub fn prune_preferences_before(&self, slot: Slot) {} + pub fn prune_preferences_before(&self, slot: Slot) { + let mut cache = lock(&self.preferences); + *cache = cache.split_off(&(slot, Root::ZERO)); + } // Known payloads: seen.execution_payloads. - pub fn record_execution_payload(&self, envelope: &gloas::ExecutionPayloadEnvelope) {} + pub fn record_execution_payload(&self, envelope: &gloas::ExecutionPayloadEnvelope) { + let builder_exits = envelope + .execution_requests + .builder_exits + .iter() + .map(|exit| (exit.pubkey, exit.source_address)) + .collect(); + let known = KnownPayload { + gas_limit: envelope.payload.gas_limit, + beacon_block_root: envelope.beacon_block_root, + builder_exits, + }; + lock(&self.payloads).put(envelope.payload.block_hash, known); + } pub fn known_payload(&self, block_hash: ExecutionBlockHash) -> Option { - None + lock(&self.payloads).get(&block_hash).cloned() + } +} + +/// Builder-market fixtures shared by this module's tests, the gossip rules' +/// and (`test-utils` feature) the Beacon API's and p2p's: a gloas state with a +/// registered builder, and signatures that state verifies. +#[cfg(any(test, feature = "test-utils"))] +pub mod test_support { + use ethlambda_types::beacon::containers::bellatrix::{ExtraData, LogsBloom}; + use ethlambda_types::beacon::primitives::{BlsSignature, Bytes32, Uint256}; + + use super::*; + use crate::beacon::containers::BeaconState; + use crate::beacon::gloas_block_production::test_support::parent_state; + use crate::beacon::helpers::accessors::get_domain; + use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_signing_root}; + use crate::beacon::helpers::test_state::{secret_key_for, sign_for}; + use crate::beacon::primitives::HashTreeRoot as _; + use crate::beacon::{constants, preset}; + + /// The secret key behind builder `index`'s registered pubkey. Offset from + /// the validators' keys so the two never collide. + pub fn builder_secret(index: usize) -> blst::min_pk::SecretKey { + secret_key_for(1000 + index) + } + + /// `gloas_block_production::test_support::parent_state` with builders + /// `0..=builder_index` registered (each funded with `balance`, deposited at + /// `deposit_epoch`), and the finalized checkpoint one epoch past + /// `deposit_epoch` so they are active. + pub fn gloas_state_with_builder( + builder_index: u64, + balance: u64, + deposit_epoch: u64, + ) -> BeaconState { + let mut state = parent_state(); + let BeaconState::Gloas(inner) = &mut state else { + unreachable!("built as gloas") + }; + for index in 0..=builder_index { + let builder = gloas::Builder { + pubkey: BlsPubkey(builder_secret(index as usize).sk_to_pk().to_bytes()), + version: constants::PAYLOAD_BUILDER_VERSION, + execution_address: ExecutionAddress::repeat_byte(index as u8 + 1), + balance, + deposit_epoch, + withdrawable_epoch: constants::FAR_FUTURE_EPOCH, + }; + inner.builders.push(builder); + } + inner.finalized_checkpoint.epoch = deposit_epoch + 1; + state + } + + /// `bid` signed by builder `builder_index` under `state`'s builder domain. + pub fn sign_bid( + state: &BeaconState, + bid: gloas::ExecutionPayloadBid, + builder_index: u64, + ) -> gloas::SignedExecutionPayloadBid { + let domain = get_domain(state, constants::DOMAIN_BEACON_BUILDER, None); + let root = compute_signing_root(bid.hash_tree_root(), domain); + let signature = builder_secret(builder_index as usize).sign( + root.as_slice(), + crate::beacon::bls::DST, + &[], + ); + gloas::SignedExecutionPayloadBid { + message: bid, + signature: BlsSignature(signature.to_bytes()), + } + } + + /// `prefs` signed by its own `validator_index` under `state`'s preferences + /// domain at the proposal slot's epoch (the spec's). + pub fn sign_preferences( + state: &BeaconState, + prefs: gloas::ProposerPreferences, + ) -> gloas::SignedProposerPreferences { + let epoch = compute_epoch_at_slot(prefs.proposal_slot); + let domain = get_domain(state, constants::DOMAIN_PROPOSER_PREFERENCES, Some(epoch)); + let root = compute_signing_root(prefs.hash_tree_root(), domain); + let signature = sign_for(prefs.validator_index as usize, root); + gloas::SignedProposerPreferences { + message: prefs, + signature, + } + } + + /// An envelope revealing payload `block_hash` with `gas_limit`, for the + /// block `beacon_block_root`, carrying one exit request per `exits` entry. + pub fn envelope_with_gas_limit( + block_hash: ExecutionBlockHash, + gas_limit: u64, + beacon_block_root: Root, + exits: Vec<(BlsPubkey, ExecutionAddress)>, + ) -> gloas::ExecutionPayloadEnvelope { + let payload = gloas::ExecutionPayload { + parent_hash: ExecutionBlockHash::ZERO, + fee_recipient: Default::default(), + state_root: Bytes32::repeat_byte(1), + receipts_root: Bytes32::repeat_byte(2), + logs_bloom: LogsBloom::try_from(vec![0u8; preset::BYTES_PER_LOGS_BLOOM]).unwrap(), + prev_randao: Default::default(), + block_number: 7, + gas_limit, + gas_used: 0, + timestamp: 0, + extra_data: ExtraData::default(), + base_fee_per_gas: Uint256::from_u128(7), + block_hash, + transactions: Default::default(), + withdrawals: Default::default(), + blob_gas_used: 0, + excess_blob_gas: 0, + block_access_list: Default::default(), + slot_number: 0, + }; + let builder_exits: Vec<_> = exits + .into_iter() + .map(|(pubkey, source_address)| gloas::BuilderExitRequest { + source_address, + pubkey, + }) + .collect(); + let execution_requests = gloas::ExecutionRequests { + builder_exits: builder_exits.into(), + ..Default::default() + }; + gloas::ExecutionPayloadEnvelope { + payload, + execution_requests, + builder_index: 0, + beacon_block_root, + parent_beacon_block_root: Root::ZERO, + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::envelope_with_gas_limit; + use super::*; + + fn hash(byte: u8) -> ExecutionBlockHash { + ExecutionBlockHash::repeat_byte(byte) + } + + fn bid(slot: Slot, builder: u64, value: u64) -> gloas::SignedExecutionPayloadBid { + gloas::SignedExecutionPayloadBid { + message: gloas::ExecutionPayloadBid { + slot, + builder_index: builder, + value, + parent_block_hash: hash(1), + parent_block_root: Root::repeat_byte(2), + block_hash: hash(3), + ..Default::default() + }, + signature: Default::default(), + } + } + + fn prefs(slot: Slot, dependent: u8, validator: u64) -> gloas::SignedProposerPreferences { + gloas::SignedProposerPreferences { + message: gloas::ProposerPreferences { + dependent_root: Root::repeat_byte(dependent), + proposal_slot: slot, + validator_index: validator, + ..Default::default() + }, + signature: Default::default(), + } + } + + #[test] + fn a_builder_bids_once_per_parent() { + let market = BuilderMarket::default(); + assert!(market.record_bid(bid(10, 1, 5))); + assert_eq!( + market.check_bid_seen(&bid(10, 1, 9).message), + Err(IgnoreReason::AlreadySeen) + ); + assert!(!market.record_bid(bid(10, 1, 9))); + // Another parent hash is another key. + let mut other = bid(10, 1, 9); + other.message.parent_block_hash = hash(9); + assert!(market.record_bid(other)); + } + + #[test] + fn a_bid_must_strictly_beat_the_best() { + let market = BuilderMarket::default(); + assert!(market.record_bid(bid(10, 1, 5))); + assert_eq!( + market.check_bid_seen(&bid(10, 2, 5).message), + Err(IgnoreReason::NotHighestBid) + ); + assert_eq!( + market.check_bid_seen(&bid(10, 2, 4).message), + Err(IgnoreReason::NotHighestBid) + ); + assert_eq!(market.check_bid_seen(&bid(10, 2, 6).message), Ok(())); + assert!(market.record_bid(bid(10, 2, 6))); + // A lower bid never reached the pool, the bar stays at the best. + assert!(!market.record_bid(bid(10, 3, 6))); + } + + #[test] + fn a_stale_check_fails_the_record() { + let market = BuilderMarket::default(); + let racing = bid(10, 2, 7); + assert_eq!(market.check_bid_seen(&racing.message), Ok(())); + assert!(market.record_bid(bid(10, 1, 8))); + assert!(!market.record_bid(racing)); + } + + #[test] + fn a_full_slot_refuses_new_keys() { + let market = BuilderMarket::default(); + for builder in 0..MAX_SEEN_BID_KEYS_PER_SLOT as u64 { + assert!(market.record_bid(bid(10, builder, builder + 1))); + } + let next = MAX_SEEN_BID_KEYS_PER_SLOT as u64; + assert!(!market.record_bid(bid(10, next, next + 1))); + // Other slots are unaffected. + assert!(market.record_bid(bid(11, next, 1))); + } + + #[test] + fn bids_for_orders_by_value_and_keeps_the_top() { + let market = BuilderMarket::default(); + let total = MAX_BIDS_PER_PARENT as u64 + 4; + for builder in 0..total { + assert!(market.record_bid(bid(10, builder, builder + 1))); + } + let pooled = market.bids_for(10, Root::repeat_byte(2), hash(1)); + assert_eq!(pooled.len(), MAX_BIDS_PER_PARENT); + let values: Vec = pooled.iter().map(|b| b.message.value).collect(); + let expected: Vec = (5..=total).rev().collect(); + assert_eq!(values, expected); + // The truncated bar still holds: the best value is the latest. + assert_eq!( + market.check_bid_seen(&bid(10, 99, total).message), + Err(IgnoreReason::NotHighestBid) + ); + assert!(market.bids_for(10, Root::ZERO, hash(1)).is_empty()); + } + + #[test] + fn contains_bid_matches_the_exact_message() { + let market = BuilderMarket::default(); + let signed = bid(10, 1, 5); + assert!(!market.contains_bid(&signed)); + assert!(market.record_bid(signed.clone())); + assert!(market.contains_bid(&signed)); + let mut other = signed.clone(); + other.signature.0[0] = 1; + assert!(!market.contains_bid(&other)); + assert!(market.has_bids_for_slot(10)); + assert!(!market.has_bids_for_slot(11)); + } + + #[test] + fn bids_prune_below_the_previous_slot() { + let market = BuilderMarket::default(); + assert!(market.record_bid(bid(10, 1, 5))); + assert!(market.record_bid(bid(11, 1, 5))); + assert!(market.has_bids_for_slot(10)); + assert!(market.record_bid(bid(12, 1, 5))); + assert!(!market.has_bids_for_slot(10)); + assert!(market.has_bids_for_slot(11)); + market.prune_bids_before(12); + assert!(!market.has_bids_for_slot(11)); + assert!(market.has_bids_for_slot(12)); + } + + #[test] + fn the_first_preferences_win() { + let market = BuilderMarket::default(); + let first = prefs(40, 1, 3); + assert!(market.record_preferences(first.clone(), 38)); + assert!(!market.record_preferences(prefs(40, 1, 4), 38)); + assert_eq!(market.preferences(40, Root::repeat_byte(1)), Some(first)); + // Another dependent root is another key. + assert!(market.record_preferences(prefs(40, 2, 3), 38)); + assert_eq!(market.preferences(41, Root::repeat_byte(1)), None); + } + + #[test] + fn preferences_prune_by_proposal_slot() { + let market = BuilderMarket::default(); + assert!(market.record_preferences(prefs(40, 1, 3), 38)); + assert!(market.record_preferences(prefs(41, 1, 3), 41)); + assert!(market.preferences(40, Root::repeat_byte(1)).is_none()); + assert!(market.preferences(41, Root::repeat_byte(1)).is_some()); + market.prune_preferences_before(42); + assert!(market.preferences(41, Root::repeat_byte(1)).is_none()); + } + + #[test] + fn preferences_over_the_cap_evict_the_lowest_slot() { + let market = BuilderMarket::default(); + for slot in 0..MAX_PREFERENCES as u64 { + assert!(market.record_preferences(prefs(100 + slot, 1, 3), 0)); + } + let high = 100 + MAX_PREFERENCES as u64; + assert!(market.record_preferences(prefs(high, 1, 3), 0)); + assert!(market.preferences(100, Root::repeat_byte(1)).is_none()); + assert!(market.preferences(101, Root::repeat_byte(1)).is_some()); + // A message below everything held is itself the one dropped. + assert!(!market.record_preferences(prefs(50, 1, 3), 0)); + } + + #[test] + fn a_revealed_payload_is_known_with_its_exits() { + let market = BuilderMarket::default(); + let pubkey = BlsPubkey([7; 48]); + let source = ExecutionAddress::repeat_byte(9); + let envelope = envelope_with_gas_limit( + hash(5), + 36_000_000, + Root::repeat_byte(4), + vec![(pubkey, source)], + ); + assert!(market.known_payload(hash(5)).is_none()); + market.record_execution_payload(&envelope); + assert_eq!( + market.known_payload(hash(5)), + Some(KnownPayload { + gas_limit: 36_000_000, + beacon_block_root: Root::repeat_byte(4), + builder_exits: vec![(pubkey, source)], + }) + ); + } + + #[test] + fn known_payloads_are_a_bounded_lru() { + let market = BuilderMarket::default(); + let capacity = KNOWN_PAYLOADS_CAPACITY.get(); + for index in 0..capacity { + let mut block_hash = ExecutionBlockHash::ZERO; + block_hash.0[..8].copy_from_slice(&(index as u64).to_le_bytes()); + market.record_execution_payload(&envelope_with_gas_limit( + block_hash, + 1, + Root::ZERO, + vec![], + )); + } + let mut first = ExecutionBlockHash::ZERO; + first.0[..8].copy_from_slice(&0u64.to_le_bytes()); + // Touch the oldest so the second becomes the least recently used. + assert!(market.known_payload(first).is_some()); + market.record_execution_payload(&envelope_with_gas_limit( + hash(0xff), + 1, + Root::ZERO, + vec![], + )); + let mut second = ExecutionBlockHash::ZERO; + second.0[..8].copy_from_slice(&1u64.to_le_bytes()); + assert!(market.known_payload(first).is_some()); + assert!(market.known_payload(second).is_none()); } } From 2cf871d5914ba84eb1afb851382180de012adae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:48:58 -0300 Subject: [PATCH 04/12] feat(p2p): gossip and publish gloas builder bids and proposer preferences Subscribe the execution_payload_bid and proposer_preferences topics under gloas digests only, decode them, triage them (size cap, decode, cheap rules) and hand the rest to the blocking pool on a permit pool of their own so a bid burst cannot starve blocks, columns or attestations. Neither type reaches the chain actor; accepted ones are recorded in the shared builder market. Envelopes accepted from gossip, and envelopes this node publishes itself, are recorded as known payloads, since bid validation reads them and gossip never echoes a node's own message. Bids and preferences are published on the digest of their own slot, so preferences for the first gloas epoch go out on the gloas digest during the epoch before the fork. --- crates/net/p2p/src/beacon/builder_market.rs | 389 +++++++++++++++++++- crates/net/p2p/src/beacon/decode.rs | 53 +++ crates/net/p2p/src/beacon/topics.rs | 24 +- crates/net/p2p/src/beacon/verdict.rs | 215 ++++++++++- crates/net/p2p/src/gossipsub/handler.rs | 5 + crates/net/p2p/src/gossipsub/mod.rs | 1 - docs/beacon_wire.md | 24 +- docs/metrics.md | 29 +- 8 files changed, 700 insertions(+), 40 deletions(-) diff --git a/crates/net/p2p/src/beacon/builder_market.rs b/crates/net/p2p/src/beacon/builder_market.rs index 44e78bdc..f44ce4bb 100644 --- a/crates/net/p2p/src/beacon/builder_market.rs +++ b/crates/net/p2p/src/beacon/builder_market.rs @@ -1,52 +1,183 @@ //! Gloas builder market gossip: the `execution_payload_bid` and //! `proposer_preferences` topics. //! -//! Stubs until filled. Neither message type ever reaches the chain actor: the +//! Neither message type ever reaches the chain actor: the //! rules live in `ethlambda_state_transition::beacon::gossip::{ //! execution_payload_bid, proposer_preferences}`, and what they accept is //! recorded in the node's shared `BuilderMarket` by the verdict. -use ethlambda_state_transition::beacon::gossip::{IgnoreReason, Outcome}; +use ethlambda_state_transition::beacon::gossip::{ + self, Outcome, RejectReason, execution_payload_bid::MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE, +}; use ethlambda_types::beacon::containers::gloas::{ SignedExecutionPayloadBid, SignedProposerPreferences, }; use ethlambda_types::beacon::primitives::Slot; use ethlambda_types::time::unix_now_ms; +use libp2p::gossipsub::IdentTopic; +use libssz::SszEncode; +use tracing::{debug, error, info, warn}; -use crate::P2PServer; -use crate::beacon::verdict::Dispatch; +use crate::beacon::verdict::{Dispatch, Validated}; +use crate::beacon::{decode as beacon_decode, topics as beacon_topics}; +use crate::gossipsub::compress_message; +use crate::{P2PServer, metrics}; -/// Size cap -> `Reject(Malformed)`; decode -> `Reject(Decode)`; -/// `inc_beacon_gossip(KIND, "decoded")`; -/// `gossip::execution_payload_bid::cheap_checks`; then -/// `Validate(Validated::ExecutionPayloadBid { .. })`. -#[allow(dead_code, unused_variables)] // filled by Agent B +/// Decode a gloas execution payload bid and run its cheap gossip checks. +/// +/// The size cap is the specification's decompressed bound, applied before any +/// decode so an oversized payload costs nothing. Same shape as the envelope's +/// triage, except the seen state lives in the shared +/// [`BuilderMarket`](ethlambda_state_transition::beacon::builder_market::BuilderMarket), +/// and the object carries the market on to its stateful checks. pub(crate) fn triage_execution_payload_bid(server: &P2PServer, payload: &[u8]) -> Dispatch { - Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)) + const KIND: &str = beacon_topics::EXECUTION_PAYLOAD_BID; + if payload.len() > MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!( + kind = KIND, + bytes = payload.len(), + "Beacon gossip payload over the size cap" + ); + return Dispatch::Report(Outcome::Reject(RejectReason::Malformed)); + } + let bid = match beacon_decode::decode_execution_payload_bid(payload) { + Ok(bid) => bid, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); + } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + debug!( + slot = bid.message.slot, + builder_index = bid.message.builder_index, + value = bid.message.value, + bytes = payload.len(), + "Beacon execution payload bid decoded" + ); + if let Err(outcome) = gossip::execution_payload_bid::cheap_checks( + &server.builder_market, + &server.store, + &bid, + unix_now_ms(), + ) { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::ExecutionPayloadBid { + bid: Box::new(bid), + market: server.builder_market.clone(), + }) } -/// As [`triage_execution_payload_bid`], for `proposer_preferences`. -#[allow(dead_code, unused_variables)] // filled by Agent B +/// As [`triage_execution_payload_bid`], for `proposer_preferences`. The +/// container is fixed-size, so decode is its own size check. pub(crate) fn triage_proposer_preferences(server: &P2PServer, payload: &[u8]) -> Dispatch { - Dispatch::Report(Outcome::Ignore(IgnoreReason::NoConsumer)) + const KIND: &str = beacon_topics::PROPOSER_PREFERENCES; + let preferences = match beacon_decode::decode_proposer_preferences(payload) { + Ok(preferences) => preferences, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); + } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + debug!( + proposal_slot = preferences.message.proposal_slot, + validator_index = preferences.message.validator_index, + bytes = payload.len(), + "Beacon proposer preferences decoded" + ); + if let Err(outcome) = gossip::proposer_preferences::cheap_checks( + &server.builder_market, + &server.store, + &preferences, + unix_now_ms(), + ) { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::ProposerPreferences(Box::new(preferences))) +} + +/// Where and what to publish for one of this module's messages: the topic of +/// `kind` under the digest `slot` names, and the compressed SSZ. `None` when +/// this node is not on the beacon wire or holds no digest covering `slot`. +/// +/// Split from the publish functions so the topic and digest choice can be +/// checked without a swarm to read the command back from. +fn publication( + server: &P2PServer, + kind: &'static str, + slot: Slot, + ssz: &[u8], +) -> Option<(IdentTopic, Vec)> { + let Some(beacon) = server.wire.beacon() else { + error!( + kind, + slot, "A builder market message reached a lean node; dropping it" + ); + return None; + }; + let Some(digest) = beacon.publish_digest(slot) else { + warn!( + kind, + slot, "No held fork digest covers this message's slot; not publishing" + ); + return None; + }; + let topic = IdentTopic::new(beacon_topics::topic_name(digest, kind)); + Some((topic, compress_message(ssz))) } /// Publish on `publish_digest(bid.slot)` / `execution_payload_bid`. -/// Precondition: the caller already recorded it in the market. -#[allow(dead_code, unused_variables)] // filled by Agent B +/// +/// Precondition: the caller already recorded it in the market, since +/// gossipsub never delivers a node its own messages. pub(crate) fn publish_execution_payload_bid( server: &mut P2PServer, bid: SignedExecutionPayloadBid, ) { + let slot = bid.message.slot; + let Some((topic, data)) = publication( + server, + beacon_topics::EXECUTION_PAYLOAD_BID, + slot, + &bid.to_ssz(), + ) else { + return; + }; + server.swarm_handle.publish(topic, data); + info!( + slot, + builder_index = bid.message.builder_index, + value = bid.message.value, + "Published execution payload bid to gossipsub" + ); } /// Publish on `publish_digest(proposal_slot)`: during the epoch before gloas /// this is the gloas digest, which is held. -#[allow(dead_code, unused_variables)] // filled by Agent B pub(crate) fn publish_proposer_preferences( server: &mut P2PServer, preferences: SignedProposerPreferences, ) { + let proposal_slot = preferences.message.proposal_slot; + let Some((topic, data)) = publication( + server, + beacon_topics::PROPOSER_PREFERENCES, + proposal_slot, + &preferences.to_ssz(), + ) else { + return; + }; + server.swarm_handle.publish(topic, data); + info!( + proposal_slot, + validator_index = preferences.message.validator_index, + "Published proposer preferences to gossipsub" + ); } /// The wall-clock slot, from the store's config. @@ -55,3 +186,229 @@ pub(crate) fn wall_slot(server: &P2PServer) -> Slot { let genesis_ms = config.genesis_time_ms(); unix_now_ms().saturating_sub(genesis_ms) / config.slot_duration_ms.max(1) } + +#[cfg(test)] +mod tests { + use ethlambda_state_transition::beacon::gossip::{IgnoreReason, Outcome, RejectReason}; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; + use ethlambda_types::beacon::primitives::{Epoch, ExecutionBlockHash}; + use libssz::SszEncode; + + use super::*; + use crate::beacon::transition::apply; + use crate::test_support::unconnected_beacon_server; + + const FULU: Epoch = 1_000; + const GLOAS: Epoch = 5_000; + + /// Fulu, then gloas at [`GLOAS`], so the rollover window can be opened. + fn rollover_config() -> Config { + Config::mainnet() + .with_fork_epoch(ForkName::Altair, 1) + .with_fork_epoch(ForkName::Bellatrix, 2) + .with_fork_epoch(ForkName::Capella, 3) + .with_fork_epoch(ForkName::Deneb, 4) + .with_fork_epoch(ForkName::Electra, 5) + .with_fork_epoch(ForkName::Fulu, FULU) + .with_fork_epoch(ForkName::Gloas, GLOAS) + } + + fn gloas_from_genesis() -> Config { + Config::mainnet().with_fork_epoch(ForkName::Gloas, 0) + } + + /// A bid the cheap checks have nothing to say against at `slot`. + fn bid_at(slot: Slot) -> SignedExecutionPayloadBid { + let mut bid = SignedExecutionPayloadBid::default(); + bid.message.slot = slot; + bid.message.builder_index = 3; + bid.message.value = 10; + bid.message.block_hash = ExecutionBlockHash::repeat_byte(1); + bid + } + + fn preferences_at(proposal_slot: Slot) -> SignedProposerPreferences { + let mut preferences = SignedProposerPreferences::default(); + preferences.message.proposal_slot = proposal_slot; + preferences.message.validator_index = 5; + preferences + } + + // -- Triage, independent of the gossip rules -- + + #[tokio::test] + async fn an_oversized_bid_is_malformed_before_it_is_decoded() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + let payload = vec![0xff; MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE + 1]; + assert!(matches!( + triage_execution_payload_bid(&server, &payload), + Dispatch::Report(Outcome::Reject(RejectReason::Malformed)) + )); + } + + #[tokio::test] + async fn garbage_on_either_builder_topic_is_undecodable() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + assert!(matches!( + triage_execution_payload_bid(&server, &[0xff; 3]), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + assert!(matches!( + triage_proposer_preferences(&server, &[0xff; 3]), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + // Preferences are fixed-size, so one byte over is not a preference. + let mut bytes = preferences_at(1).to_ssz(); + bytes.push(0); + assert!(matches!( + triage_proposer_preferences(&server, &bytes), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + // -- Triage, which needs the real cheap checks (Agent A) -- + + #[tokio::test] + async fn a_far_slot_bid_is_not_current_or_next() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + let bid = bid_at(wall_slot(&server) + 1_000); + assert!(matches!( + triage_execution_payload_bid(&server, &bid.to_ssz()), + Dispatch::Report(Outcome::Ignore(IgnoreReason::NotCurrentOrNextSlot)) + )); + } + + #[tokio::test] + async fn a_bid_with_a_nonzero_payment_is_rejected() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + let mut bid = bid_at(wall_slot(&server) + 1); + bid.message.execution_payment = 1; + assert!(matches!( + triage_execution_payload_bid(&server, &bid.to_ssz()), + Dispatch::Report(Outcome::Reject(RejectReason::ExecutionPaymentNonZero)) + )); + } + + /// A valid-shaped bid goes on to the stateful checks carrying the shared + /// market, and once the market holds its builder's key a second is ignored + /// before them. + #[tokio::test] + async fn a_valid_shaped_bid_is_validated_unless_its_builder_key_was_seen() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + let bid = bid_at(wall_slot(&server) + 1); + let payload = bid.to_ssz(); + match triage_execution_payload_bid(&server, &payload) { + Dispatch::Validate(Validated::ExecutionPayloadBid { + bid: decoded, + market, + }) => { + assert_eq!(*decoded, bid); + assert!(std::sync::Arc::ptr_eq(&market, &server.builder_market)); + } + _ => panic!("expected the bid to go to its stateful checks"), + } + + assert!(server.builder_market.record_bid(bid)); + assert!(matches!( + triage_execution_payload_bid(&server, &payload), + Dispatch::Report(Outcome::Ignore(IgnoreReason::AlreadySeen)) + )); + } + + #[tokio::test] + async fn preferences_are_judged_on_the_clock() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + let wall = wall_slot(&server); + let past = preferences_at(wall.saturating_sub(5)); + assert!(matches!( + triage_proposer_preferences(&server, &past.to_ssz()), + Dispatch::Report(Outcome::Ignore(IgnoreReason::SlotStarted)) + )); + let two_epochs_ahead = preferences_at((wall / SLOTS_PER_EPOCH + 2) * SLOTS_PER_EPOCH + 1); + assert!(matches!( + triage_proposer_preferences(&server, &two_epochs_ahead.to_ssz()), + Dispatch::Report(Outcome::Ignore(IgnoreReason::BeyondLookahead)) + )); + } + + #[tokio::test] + async fn preferences_for_a_cached_key_are_already_seen() { + let server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + let wall = wall_slot(&server); + let preferences = preferences_at(wall + 2); + let payload = preferences.to_ssz(); + assert!(matches!( + triage_proposer_preferences(&server, &payload), + Dispatch::Validate(Validated::ProposerPreferences(decoded)) if *decoded == preferences + )); + assert!(server.builder_market.record_preferences(preferences, wall)); + assert!(matches!( + triage_proposer_preferences(&server, &payload), + Dispatch::Report(Outcome::Ignore(IgnoreReason::AlreadySeen)) + )); + } + + // -- Publishing -- + + fn topic_of(server: &P2PServer, kind: &'static str, slot: Slot) -> Option { + publication(server, kind, slot, b"payload").map(|(topic, _)| topic.to_string()) + } + + #[tokio::test] + async fn a_bid_is_published_on_its_slots_digest_and_compressed() { + let mut server = unconnected_beacon_server(gloas_from_genesis(), 0).await; + // The test server starts on a placeholder digest; this is the startup + // call that puts the schedule's own digest in. + apply(&mut server, 0); + let wire = server.wire.beacon().expect("a beacon wire"); + let slot = 7; + let expected = beacon_topics::topic_name( + wire.publish_digest(slot).expect("held"), + beacon_topics::EXECUTION_PAYLOAD_BID, + ); + let ssz = bid_at(slot).to_ssz(); + let (topic, data) = + publication(&server, beacon_topics::EXECUTION_PAYLOAD_BID, slot, &ssz).unwrap(); + assert_eq!(topic.to_string(), expected); + assert_eq!(data, compress_message(&ssz)); + assert!(expected.ends_with("/execution_payload_bid/ssz_snappy")); + } + + /// During the epoch before gloas, preferences for a gloas slot go out on + /// the gloas digest the node has already joined, and a slot nobody listens + /// to is not published at all. + #[tokio::test] + async fn preferences_for_a_next_fork_slot_use_the_next_forks_digest() { + let mut server = unconnected_beacon_server(rollover_config(), 0).await; + apply(&mut server, GLOAS - 1); + let wire = server.wire.beacon().expect("a beacon wire"); + let gloas_digest = wire.schedule.digest_at(GLOAS); + let fulu_digest = wire.schedule.digest_at(GLOAS - 1); + assert_ne!(gloas_digest, fulu_digest); + + let kind = beacon_topics::PROPOSER_PREFERENCES; + let first_gloas_slot = GLOAS * SLOTS_PER_EPOCH; + assert_eq!( + topic_of(&server, kind, first_gloas_slot), + Some(beacon_topics::topic_name(gloas_digest, kind)) + ); + assert_eq!( + topic_of(&server, kind, first_gloas_slot - 1), + Some(beacon_topics::topic_name(fulu_digest, kind)) + ); + // Long past: its digest is no longer held. + assert_eq!(topic_of(&server, kind, 0), None); + } + + /// Before the window opens the gloas digest is not held, so there is + /// nowhere to publish a bid for a gloas slot. + #[tokio::test] + async fn nothing_is_published_for_a_digest_that_is_not_held() { + let mut server = unconnected_beacon_server(rollover_config(), 0).await; + apply(&mut server, GLOAS - 3); + let kind = beacon_topics::EXECUTION_PAYLOAD_BID; + assert_eq!(topic_of(&server, kind, GLOAS * SLOTS_PER_EPOCH), None); + } +} diff --git a/crates/net/p2p/src/beacon/decode.rs b/crates/net/p2p/src/beacon/decode.rs index b4fac1c0..4547d52a 100644 --- a/crates/net/p2p/src/beacon/decode.rs +++ b/crates/net/p2p/src/beacon/decode.rs @@ -24,6 +24,7 @@ //! | `sync_committee_contribution_and_proof` | No, altair onward | //! | `data_column_sidecar_{subnet_id}` | Yes, at gloas, by topic digest | //! | `execution_payload`, `payload_attestation_message` | No, gloas onward | +//! | `execution_payload_bid`, `proposer_preferences` | No, gloas onward | use ethlambda_types::beacon::config::Config; use ethlambda_types::beacon::containers::{ @@ -237,6 +238,22 @@ pub fn decode_payload_attestation_message( gloas::PayloadAttestationMessage::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) } +/// Decode an `execution_payload_bid` payload. Gloas on, like +/// [`decode_execution_payload_envelope`]. +pub fn decode_execution_payload_bid( + bytes: &[u8], +) -> Result { + gloas::SignedExecutionPayloadBid::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) +} + +/// Decode a `proposer_preferences` payload. Gloas on, like +/// [`decode_execution_payload_envelope`]. +pub fn decode_proposer_preferences( + bytes: &[u8], +) -> Result { + gloas::SignedProposerPreferences::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) +} + /// Decode a `beacon_aggregate_and_proof` payload, at the fork its slot names. pub fn decode_aggregate_and_proof( config: &Config, @@ -653,6 +670,42 @@ mod tests { } } + #[test] + fn an_execution_payload_bid_round_trips() { + let mut bid = gloas::SignedExecutionPayloadBid::default(); + bid.message.slot = slot_of(10); + bid.message.builder_index = 4; + bid.message.value = 99; + bid.message.parent_block_root = Root::repeat_byte(7); + let bytes = bid.to_ssz(); + assert_eq!(decode_execution_payload_bid(&bytes), Ok(bid)); + assert!(decode_execution_payload_bid(&bytes[..bytes.len() - 1]).is_err()); + assert_eq!( + decode_execution_payload_bid(&[0xff; 3]), + Err(DecodeError::Ssz) + ); + } + + #[test] + fn proposer_preferences_round_trip() { + let mut preferences = gloas::SignedProposerPreferences::default(); + preferences.message.proposal_slot = slot_of(10); + preferences.message.validator_index = 9; + preferences.message.dependent_root = Root::repeat_byte(2); + let bytes = preferences.to_ssz(); + assert_eq!(decode_proposer_preferences(&bytes), Ok(preferences)); + for length in 0..bytes.len() { + assert!(decode_proposer_preferences(&bytes[..length]).is_err()); + } + let mut longer = bytes; + longer.push(0); + assert!(decode_proposer_preferences(&longer).is_err()); + assert_eq!( + decode_proposer_preferences(&[0xff; 3]), + Err(DecodeError::Ssz) + ); + } + #[test] fn an_execution_payload_envelope_round_trips() { let envelope = crate::test_support::envelope(5, 9); diff --git a/crates/net/p2p/src/beacon/topics.rs b/crates/net/p2p/src/beacon/topics.rs index 7605d3d9..2fe68e72 100644 --- a/crates/net/p2p/src/beacon/topics.rs +++ b/crates/net/p2p/src/beacon/topics.rs @@ -1,6 +1,8 @@ //! The gossipsub topics `ethlambda beacon` subscribes to. //! -//! Seven global topics, plus two families this node's own node id selects a +//! Seven global topics (four more from gloas: the execution payload envelope, +//! the payload attestation message, the builder bid and the proposer +//! preferences), plus two families this node's own node id selects a //! narrow slice of: the data column subnets it custodies, and the //! `SUBNETS_PER_NODE` attestation subnets it backbones. //! @@ -58,7 +60,12 @@ pub const PROPOSER_PREFERENCES: &str = "proposer_preferences"; /// The topic kinds gloas adds to [`SUBSCRIBED_TOPIC_KINDS`], subscribed from /// the gloas digest on and never under an earlier one. -pub const GLOAS_TOPIC_KINDS: [&str; 2] = [EXECUTION_PAYLOAD, PAYLOAD_ATTESTATION_MESSAGE]; +pub const GLOAS_TOPIC_KINDS: [&str; 4] = [ + EXECUTION_PAYLOAD, + PAYLOAD_ATTESTATION_MESSAGE, + EXECUTION_PAYLOAD_BID, + PROPOSER_PREFERENCES, +]; /// Every topic kind this node subscribes to at every fork, in the order they /// are subscribed. [`GLOAS_TOPIC_KINDS`] follow from gloas. @@ -348,7 +355,7 @@ mod tests { } #[test] - fn gloas_adds_the_envelope_and_payload_attestation_topics() { + fn gloas_adds_the_envelope_vote_bid_and_preferences_topics() { let fork_topics = |fork| { BeaconTopics::for_fork(fork, MAINNET, &[], &[]) .topics @@ -358,8 +365,17 @@ mod tests { }; let gloas = fork_topics(ForkName::Gloas); let fulu = fork_topics(ForkName::Fulu); - assert_eq!(gloas.len(), SUBSCRIBED_TOPIC_KINDS.len() + 2); + assert_eq!(gloas.len(), SUBSCRIBED_TOPIC_KINDS.len() + 4); assert_eq!(fulu.len(), SUBSCRIBED_TOPIC_KINDS.len()); + assert_eq!( + GLOAS_TOPIC_KINDS, + [ + "execution_payload", + "payload_attestation_message", + "execution_payload_bid", + "proposer_preferences", + ] + ); for kind in GLOAS_TOPIC_KINDS { let name = topic_name(MAINNET, kind); assert!(gloas.contains(&name), "gloas lacks {name}"); diff --git a/crates/net/p2p/src/beacon/verdict.rs b/crates/net/p2p/src/beacon/verdict.rs index 62591872..ef263492 100644 --- a/crates/net/p2p/src/beacon/verdict.rs +++ b/crates/net/p2p/src/beacon/verdict.rs @@ -82,13 +82,11 @@ pub(crate) enum Validated { Envelope(Box), /// A gloas `execution_payload_bid`. Carries the market because /// `stateful_checks` receives only the store. - #[allow(dead_code)] // constructed by Agent B's triage ExecutionPayloadBid { bid: Box, market: SharedBuilderMarket, }, /// A gloas `proposer_preferences`. - #[allow(dead_code)] // constructed by Agent B's triage ProposerPreferences(Box), /// A gloas `payload_attestation_message`. PayloadAttestation(PayloadAttestationMessage), @@ -161,10 +159,20 @@ impl Validated { }, Self::Aggregate { aggregate, .. } => server.seen_aggregates.record(aggregate), Self::Attestation { attestation, .. } => server.seen_attestations.record(attestation), - Self::Envelope(envelope) => server.seen_envelopes.record( - envelope.message.beacon_block_root, - envelope.message.builder_index, - ), + Self::Envelope(envelope) => { + let recorded = server.seen_envelopes.record( + envelope.message.beacon_block_root, + envelope.message.builder_index, + ); + if recorded { + // A bid's parent payload is known once its envelope passed + // gossip; the builder market judges bids against this. + server + .builder_market + .record_execution_payload(&envelope.message); + } + recorded + } Self::ExecutionPayloadBid { bid, .. } => { server.builder_market.record_bid((**bid).clone()) } @@ -1180,6 +1188,201 @@ mod tests { assert!(server.attestation_validation_permits.try_acquire().is_ok()); } + fn bid(slot: u64, builder_index: u64, value: u64) -> SignedExecutionPayloadBid { + let mut bid = SignedExecutionPayloadBid::default(); + bid.message.slot = slot; + bid.message.builder_index = builder_index; + bid.message.value = value; + bid + } + + fn preferences(proposal_slot: u64, validator: u64) -> SignedProposerPreferences { + let mut preferences = SignedProposerPreferences::default(); + preferences.message.proposal_slot = proposal_slot; + preferences.message.validator_index = validator; + preferences + } + + fn bid_object(server: &P2PServer, bid: SignedExecutionPayloadBid) -> Validated { + Validated::ExecutionPayloadBid { + bid: Box::new(bid), + market: server.builder_market.clone(), + } + } + + /// Needs the real market (Agent A): its stub never records. + #[tokio::test] + async fn the_first_accept_for_a_bid_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = bid_object(&server, bid(5, 3, 10)); + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + // Anything but an accept records nothing. + let other = bid_object(&server, bid(5, 4, 10)); + assert_eq!( + settle( + &mut server, + Outcome::Ignore(IgnoreReason::StateUnavailable), + &other + ), + Outcome::Ignore(IgnoreReason::StateUnavailable) + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &other), + Outcome::Accept + ); + } + + /// An accepted bid is pooled in the market the API and `produceBlockV4` + /// read. Needs the real market (Agent A). + #[tokio::test] + async fn an_accepted_bid_is_pooled_in_the_shared_market() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let signed = bid(5, 3, 10); + let object = bid_object(&server, signed.clone()); + assert!(!server.builder_market.contains_bid(&signed)); + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert!(server.builder_market.contains_bid(&signed)); + assert_eq!( + server.builder_market.bids_for( + 5, + signed.message.parent_block_root, + signed.message.parent_block_hash + ), + vec![signed] + ); + } + + /// Needs the real market (Agent A). + #[tokio::test] + async fn the_first_accept_for_a_preferences_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::ProposerPreferences(Box::new(preferences(40, 5))); + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + assert!(server.builder_market.preferences(40, Root::ZERO).is_some()); + } + + /// Neither builder market type has a consumer on the chain actor, on any + /// outcome. + #[tokio::test] + async fn bids_and_preferences_never_reach_the_chain_actor() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let chain = Arc::new(RecordingChain(AtomicBool::new(false))); + server.blockchain = Some(chain.clone()); + + for outcome in [ + Outcome::Accept, + Outcome::Queue(QueueReason::BlockUnknown), + Outcome::Ignore(IgnoreReason::Overloaded), + ] { + bid_object(&server, bid(5, 3, 10)).forward(&server, Instant::now(), outcome); + Validated::ProposerPreferences(Box::new(preferences(40, 5))).forward( + &server, + Instant::now(), + outcome, + ); + } + + assert!(!chain.0.load(Ordering::SeqCst)); + } + + /// Bids and preferences draw from a pool of their own, so a burst of them + /// cannot starve blocks, columns or attestations, and the reverse. + #[tokio::test] + async fn the_builder_permit_pool_is_independent_of_the_others() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let bid = bid_object(&server, bid(5, 3, 10)); + let preferences = Validated::ProposerPreferences(Box::new(preferences(40, 5))); + for object in [&bid, &preferences] { + assert!(Arc::ptr_eq( + permits_for(&server, object), + &server.builder_validation_permits + )); + } + + let gossip_before = server.gossip_validation_permits.available_permits(); + let attestation_before = server.attestation_validation_permits.available_permits(); + let mut held = Vec::new(); + while let Ok(permit) = server + .builder_validation_permits + .clone() + .try_acquire_owned() + { + held.push(permit); + } + // Exhausted: a bid's stateful checks would answer `Ignore(Overloaded)`. + assert_eq!(server.builder_validation_permits.available_permits(), 0); + assert!( + permits_for(&server, &bid) + .clone() + .try_acquire_owned() + .is_err() + ); + assert_eq!( + server.gossip_validation_permits.available_permits(), + gossip_before + ); + assert_eq!( + server.attestation_validation_permits.available_permits(), + attestation_before + ); + } + + /// An accepted envelope is a known payload for bid validation. Needs the + /// real market (Agent A). + #[tokio::test] + async fn an_accepted_envelope_becomes_a_known_payload() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let signed = envelope(1, 3); + let hash = signed.message.payload.block_hash; + assert!(server.builder_market.known_payload(hash).is_none()); + let object = Validated::Envelope(Box::new(signed)); + + // A queued envelope has not been judged, so it is not known. + settle( + &mut server, + Outcome::Queue(QueueReason::BlockUnknown), + &object, + ); + assert!(server.builder_market.known_payload(hash).is_none()); + + settle(&mut server, Outcome::Accept, &object); + assert!(server.builder_market.known_payload(hash).is_some()); + } + + /// The node's own envelope is known too, since gossip never echoes it. + /// Needs the real market (Agent A). + #[tokio::test] + async fn a_published_envelope_becomes_a_known_payload() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let signed = envelope(2, 3); + let hash = signed.message.payload.block_hash; + assert!(server.builder_market.known_payload(hash).is_none()); + + crate::gossipsub::publish_execution_payload_envelope(&mut server, signed, Vec::new()).await; + + assert!(server.builder_market.known_payload(hash).is_some()); + } + #[test] fn only_accept_propagates_and_only_accept_or_queue_reaches_the_chain() { assert!(matches!( diff --git a/crates/net/p2p/src/gossipsub/handler.rs b/crates/net/p2p/src/gossipsub/handler.rs index ac1a0c47..49915f73 100644 --- a/crates/net/p2p/src/gossipsub/handler.rs +++ b/crates/net/p2p/src/gossipsub/handler.rs @@ -818,6 +818,11 @@ pub async fn publish_execution_payload_envelope( ) { let slot = envelope.message.payload.slot_number; let block_root = envelope.message.beacon_block_root; + // Gossip never echoes a node's own message, so its own envelope is a + // known payload for bid validation only because it is recorded here. + server + .builder_market + .record_execution_payload(&envelope.message); let Some(beacon) = server.wire.beacon() else { error!( slot, diff --git a/crates/net/p2p/src/gossipsub/mod.rs b/crates/net/p2p/src/gossipsub/mod.rs index 8c558016..1e2b402f 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -2,7 +2,6 @@ mod encoding; mod handler; mod messages; -#[allow(unused_imports)] // used by Agent B's publish functions pub(crate) use encoding::compress_message; pub use encoding::decompress_message; pub use handler::{ diff --git a/docs/beacon_wire.md b/docs/beacon_wire.md index c7cb6e8f..fd05ac9e 100644 --- a/docs/beacon_wire.md +++ b/docs/beacon_wire.md @@ -85,14 +85,28 @@ after a boundary read the stale digest and reject the record. Seven global topics, `/eth2/{digest}/{name}/ssz_snappy`, plus two subnet families this node's own node id selects a narrow slice of: the data column subnets it custodies and the attestation subnets it backbones, both described -below. A gloas digest adds two more, `execution_payload` and -`payload_attestation_message`, which `BeaconTopics::for_fork` subscribes under -gloas digests and not under earlier ones. An envelope is validated like a block +below. A gloas digest adds four more, `execution_payload`, +`payload_attestation_message`, `execution_payload_bid` and +`proposer_preferences`, which `BeaconTopics::for_fork` subscribes under gloas +digests and not under earlier ones (so one epoch before the fork, when the +digest is joined). An envelope is validated like a block (its stateful half on the blocking pool) and goes to the chain actor on `Accept` and on `Queue`, since the actor holds an envelope whose block is not imported yet. A payload attestation is validated on the attestation permit pool and goes to the actor on `Accept` only: a vote for a block not imported here is dropped, -since it is valid only within its own slot. +since it is valid only within its own slot. Bids and preferences are the +builder market: they never reach the chain actor. Their rules live in +`state_transition::beacon::gossip::{execution_payload_bid, proposer_preferences}`, +the cheap half runs in p2p's triage (`p2p/src/beacon/builder_market.rs`) and the +stateful half on a permit pool of their own (`builder_validation_permits`), so +a bid burst cannot starve blocks, columns or attestations. Whatever is accepted +goes into the node's one shared `BuilderMarket`, which the Beacon API and block +production read. Neither is ever queued: a message whose parent or dependent +block is unknown is ignored. A bid is capped at 196,932 decompressed bytes +(`Reject(Malformed)` above that). The node publishes both: a bid on the digest of +its slot, preferences on the digest of the proposal slot, which in the epoch +before gloas is the gloas digest already joined. Envelopes the node publishes +itself are recorded as known payloads, since gossip never echoes them. | Topic | Decoded as | | --- | --- | @@ -106,6 +120,8 @@ since it is valid only within its own slot. | `beacon_attestation_{subnet_id}` | `Attestation`, phase0 or electra's `SingleAttestation` (gloas keeps the latter) | | `execution_payload` | `SignedExecutionPayloadEnvelope`, gloas digests only | | `payload_attestation_message` | `PayloadAttestationMessage`, gloas digests only | +| `execution_payload_bid` | `SignedExecutionPayloadBid`, gloas digests only | +| `proposer_preferences` | `SignedProposerPreferences`, gloas digests only | `beacon_attestation_{0..63}` is no longer wholly unsubscribed. This node holds `SUBNETS_PER_NODE` (2 on mainnet) long-lived subscriptions from that family, diff --git a/docs/metrics.md b/docs/metrics.md index cbdc72d6..6d6f7b9e 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -363,15 +363,17 @@ own section. These are ethlambda-specific, not part of the leanMetrics spec. `kind` is the topic kind, with every `data_column_sidecar_{subnet}` sharing the label `data_column_sidecar` and every `beacon_attestation_{subnet_id}` sharing -`beacon_attestation`. The gloas topics `execution_payload` and -`payload_attestation_message` are labelled by their own name. `queue` means +`beacon_attestation`. The gloas topics `execution_payload`, +`payload_attestation_message`, `execution_payload_bid` and +`proposer_preferences` are labelled by their own name. `queue` means IGNORE to gossipsub while the chain actor still receives the object and parks it; of the gloas topics only `execution_payload` answers it, for an envelope whose block is not known yet (`block_unknown`), has no post-state yet (`block_not_ready`), or has one that is not in the cache the checks read (`state_not_cached`, as when the block was imported moments ago; the actor -verifies the signature itself, so the envelope is forwarded rather than dropped). The aggregate, attestation and -`payload_attestation_message` topics never answer `queue`, since the vote +verifies the signature itself, so the envelope is forwarded rather than dropped). The aggregate, attestation, +`payload_attestation_message`, `execution_payload_bid` and `proposer_preferences` +topics never answer `queue`, since the vote block's post-state is either cached or it is not (`IgnoreReason::UnknownBlock`/`StateUnavailable`), with nothing to hold the message for. **`verdict_expired_total` should stay at @@ -390,16 +392,25 @@ only), `not_aggregator` (aggregate only), `not_in_committee`, `aggregator_signature` (aggregate only), `aggregate_signature` (aggregate only), `target_not_ancestor`, `wrong_subnet` (attestation only), and gloas's `data_index_out_of_range`, `same_slot_payload_flag` and `payload_invalid` on the -reject side. `already_seen`, `overloaded` and `unsupported_fork` are shared with the +reject side. The builder market topics add, on the ignore side, +`pre_gloas_slot`, `not_current_or_next_slot`, `not_highest_bid`, +`beyond_lookahead`, `preferences_unseen`, `fee_recipient_mismatch`, +`parent_payload_unknown`, `gas_limit_incompatible`, `not_on_head_branch`, +`builder_cannot_cover`, `builder_may_exit`, `slot_started` and +`impossible_dependent_root`, and on the reject side `execution_payment_nonzero`, +`block_hash_equals_parent`, `prev_randao`, `unknown_builder`, +`not_payload_builder`, `inactive_builder` and `dependent_root_too_late`; a bid +over its size cap is `malformed`. `already_seen`, `overloaded` and `unsupported_fork` are shared with the other topics: `unsupported_fork` is an ignore reason for a message of a fork this build has no gossip rules for, so an honest peer past the fork epoch is not scored as a bad decoder. Gloas blocks, data columns, aggregates and attestations are validated, so they carry their own verdict reasons instead. -Two permit pools bound the blocking-thread half of validation: -`gossip_validation_permits` for blocks and columns, -`attestation_validation_permits` for aggregates and subnet attestations. They -are deliberately separate: a mainnet slot's worth of aggregates and backbone +Three permit pools bound the blocking-thread half of validation: +`gossip_validation_permits` for blocks, columns and envelopes, +`attestation_validation_permits` for aggregates, subnet attestations and +payload votes, and `builder_validation_permits` for bids and proposer +preferences. They are deliberately separate: a mainnet slot's worth of aggregates and backbone attestations arrives every slot, not only during a range sync, and sharing one pool would let that burst answer `Ignore(Overloaded)` for a block or a column instead. Neither pool has a metric of its own yet; a permit exhausted on From b0d142d00a8af3a91874ec39d169a86dd011ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:54:43 -0300 Subject: [PATCH 05/12] feat(engine): keep shouldOverrideBuilder from getPayloadV6 An engine that wants its own payload used whatever a builder pays says so with this flag; dropping it would let a bid comparison override that. --- crates/net/engine/src/building.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/net/engine/src/building.rs b/crates/net/engine/src/building.rs index b407c932..6ff10924 100644 --- a/crates/net/engine/src/building.rs +++ b/crates/net/engine/src/building.rs @@ -130,6 +130,9 @@ pub struct BuiltGloasPayload { pub blobs_bundle: BlobsBundle, /// The EIP-7685 request list, each entry its type byte then its data. pub execution_requests: Vec>, + /// 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, } /// `BlobsBundleV2`: the payload's blobs, their commitments, and every blob's @@ -159,6 +162,8 @@ pub(crate) struct GetPayloadV6Response { block_value: String, blobs_bundle: BlobsBundleJson, #[serde(default)] + should_override_builder: bool, + #[serde(default)] execution_requests: Vec, } @@ -307,6 +312,7 @@ impl TryFrom for BuiltGloasPayload { block_value: parse_uint256(&response.block_value)?, blobs_bundle: decode_blobs_bundle(response.blobs_bundle)?, execution_requests: decode_requests(&response.execution_requests)?, + should_override_builder: response.should_override_builder, }) } } @@ -527,6 +533,30 @@ mod tests { built.execution_requests, vec![vec![0x00, 0x11], vec![0x02, 0xff]] ); + assert!(!built.should_override_builder); + } + + #[test] + fn a_v6_answer_carries_should_override_builder_and_defaults_it_to_false() { + let mut json = v6_json(); + json["shouldOverrideBuilder"] = true.into(); + let response: GetPayloadV6Response = serde_json::from_value(json).unwrap(); + assert!( + BuiltGloasPayload::try_from(response) + .unwrap() + .should_override_builder + ); + + let mut json = v6_json(); + json.as_object_mut() + .unwrap() + .remove("shouldOverrideBuilder"); + let response: GetPayloadV6Response = serde_json::from_value(json).unwrap(); + assert!( + !BuiltGloasPayload::try_from(response) + .unwrap() + .should_override_builder + ); } #[test] From 1a0c34779f815dbaf0fa61431bf0404dd8789d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:54:43 -0300 Subject: [PATCH 06/12] feat(rpc): serve the builder market endpoints and build on bids Add POST execution_payload_bids, POST proposer_preferences and POST states/{id}/builders. Bids and preferences run through the gossip rules and land in the shared builder market that p2p also fills. produceBlockV4 now reads the real BuilderConfig, builds locally when it has an engine and takes the best pooled bid when the config's min_bid and boost factor say so (the local payload wins ties, and shouldOverrideBuilder keeps it). A bid-won block comes back bare and caches nothing, since the builder reveals the payload. A self-built payload takes its fee recipient and gas target from the proposer's signed preferences when the market holds them. --- crates/net/rpc/src/beacon/bid_selection.rs | 207 ++++++- crates/net/rpc/src/beacon/bids.rs | 149 ++++- crates/net/rpc/src/beacon/builder_config.rs | 270 +++++++++- crates/net/rpc/src/beacon/builders.rs | 332 +++++++++++- crates/net/rpc/src/beacon/gloas_proposal.rs | 507 +++++++++++++----- .../rpc/src/beacon/proposer_preferences.rs | 139 ++++- crates/net/rpc/src/beacon/states.rs | 5 +- .../rpc/src/beacon/validator_client_tests.rs | 3 + docs/rpc.md | 87 ++- 9 files changed, 1499 insertions(+), 200 deletions(-) diff --git a/crates/net/rpc/src/beacon/bid_selection.rs b/crates/net/rpc/src/beacon/bid_selection.rs index 0fd43838..82b890c5 100644 --- a/crates/net/rpc/src/beacon/bid_selection.rs +++ b/crates/net/rpc/src/beacon/bid_selection.rs @@ -1,45 +1,230 @@ //! Choosing between this node's own build and a pooled builder bid for //! `produceBlockV4`. Pure: no store, no clock. //! -//! Stubs until filled. +//! Units differ on the two sides: a bid's value is in Gwei and the execution +//! client's `blockValue` in Wei, and the specification weights the local value +//! by 100 against a bid weighted by `builder_boost_factor`. Everything that +//! compares the two goes through [`choose_payload`], so the conversion lives in +//! one place. use ethlambda_types::beacon::containers::gloas::{ExecutionPayloadBid, SignedExecutionPayloadBid}; use ethlambda_types::beacon::primitives::Uint256; +/// Dividing a wei amount by this yields the amount in Gwei times 100, the +/// local value's weight: `wei / 1e9 * 100 == wei / 1e7`. +const WEI_PER_WEIGHTED_GWEI: u128 = 10_000_000; + /// The local build, as the choice sees it. -#[allow(dead_code)] // filled by Agent C pub(crate) struct LocalCandidate { pub(crate) value_wei: u128, + /// The execution client's `shouldOverrideBuilder`. pub(crate) should_override_builder: bool, } // The enum is short-lived (one per block production), so boxing the bid buys // nothing. -#[allow(dead_code, clippy::large_enum_variant)] // filled by Agent C +#[allow(clippy::large_enum_variant)] +#[derive(Debug, PartialEq, Eq)] pub(crate) enum PayloadChoice { Local, Bid(SignedExecutionPayloadBid), } /// `value.saturating_add(execution_payment)`; a p2p bid's payment is zero. -#[allow(dead_code, unused_variables)] // filled by Agent C pub(crate) fn bid_total_gwei(bid: &ExecutionPayloadBid) -> u64 { - 0 + bid.value.saturating_add(bid.execution_payment) } -/// Saturating conversion of a wei amount. -#[allow(dead_code, unused_variables)] // filled by Agent C +/// Saturating conversion of a wei amount: the 32 little-endian bytes of a +/// `uint256` clamped to `u128::MAX`. pub(crate) fn wei_u128(value: &Uint256) -> u128 { - 0 + let (low, high) = value.0.split_at(16); + if high.iter().any(|byte| *byte != 0) { + return u128::MAX; + } + u128::from_le_bytes(low.try_into().expect("sixteen bytes")) } -/// `None` when there is neither a local build nor a bid at or above `min_bid`. -#[allow(dead_code, unused_variables)] // filled by Agent C +/// The bid to build on, or the local build. +/// +/// 1. The best bid is the first of `bids_desc` (value descending) whose total +/// is at least `min_bid`. +/// 2. With no local build, that bid, or `None` if there is none. +/// 3. A local build that asks to override builders wins. +/// 4. Otherwise the bid wins iff `builder_boost_factor * bid_gwei` exceeds the +/// local value in the same weighting: `factor * gwei * 1e9 > 100 * wei`, +/// which for integers is `factor * gwei > floor(wei / 1e7)`. The left side +/// is a product of two `u64`s, so it cannot overflow `u128`. +/// 5. The local build wins a tie, so a factor of `0` prefers it and `u64::MAX` +/// prefers the bid, each unless step 2 or 3 says otherwise. pub(crate) fn choose_payload( local: Option<&LocalCandidate>, bids_desc: &[SignedExecutionPayloadBid], min_bid: u64, builder_boost_factor: u64, ) -> Option { - None + let best = bids_desc + .iter() + .find(|signed| bid_total_gwei(&signed.message) >= min_bid); + let Some(local) = local else { + return best.cloned().map(PayloadChoice::Bid); + }; + let Some(best) = best else { + return Some(PayloadChoice::Local); + }; + if local.should_override_builder { + return Some(PayloadChoice::Local); + } + let weighted_bid = u128::from(builder_boost_factor) * u128::from(bid_total_gwei(&best.message)); + let weighted_local = local.value_wei / WEI_PER_WEIGHTED_GWEI; + if weighted_bid > weighted_local { + Some(PayloadChoice::Bid(best.clone())) + } else { + Some(PayloadChoice::Local) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bid(builder: u64, value: u64) -> SignedExecutionPayloadBid { + let mut signed = SignedExecutionPayloadBid::default(); + signed.message.builder_index = builder; + signed.message.value = value; + signed + } + + fn local(value_wei: u128) -> LocalCandidate { + LocalCandidate { + value_wei, + should_override_builder: false, + } + } + + fn picks_bid(choice: Option) -> bool { + matches!(choice, Some(PayloadChoice::Bid(_))) + } + + #[test] + fn a_tie_goes_to_the_local_build() { + // 1e9 wei is 1 gwei; factor 100 weights both sides to 100. + let bids = [bid(1, 1)]; + assert_eq!( + choose_payload(Some(&local(1_000_000_000)), &bids, 0, 100), + Some(PayloadChoice::Local) + ); + } + + #[test] + fn the_floor_at_ten_million_wei_decides_the_boundary() { + let bids = [bid(1, 1)]; + // One wei short of the tie: the local weighted value floors to 99. + assert!(picks_bid(choose_payload( + Some(&local(999_999_999)), + &bids, + 0, + 100 + ))); + // Exactly the tie, and above it. + for wei in [1_000_000_000, 1_000_000_001, 1_000_000_000_000] { + assert_eq!( + choose_payload(Some(&local(wei)), &bids, 0, 100), + Some(PayloadChoice::Local), + "{wei}" + ); + } + } + + #[test] + fn a_factor_of_zero_prefers_local_and_the_maximum_prefers_the_bid() { + let bids = [bid(1, 5)]; + assert_eq!( + choose_payload(Some(&local(1)), &bids, 0, 0), + Some(PayloadChoice::Local) + ); + assert!(picks_bid(choose_payload( + Some(&local(1_000_000_000_000_000)), + &bids, + 0, + u64::MAX + ))); + } + + #[test] + fn a_factor_of_zero_still_takes_the_bid_when_the_local_build_failed() { + let bids = [bid(1, 5)]; + assert!(picks_bid(choose_payload(None, &bids, 0, 0))); + } + + #[test] + fn the_min_bid_floor_skips_bids_below_it() { + let bids = [bid(1, 9), bid(2, 5), bid(3, 1)]; + // The best bid is below the floor, and so are the rest. + assert_eq!( + choose_payload(Some(&local(0)), &bids, 10, u64::MAX), + Some(PayloadChoice::Local) + ); + assert_eq!(choose_payload(None, &bids, 10, u64::MAX), None); + // The floor is inclusive. + let Some(PayloadChoice::Bid(chosen)) = choose_payload(None, &bids, 9, 0) else { + panic!("the bid at the floor is eligible") + }; + assert_eq!(chosen.message.builder_index, 1); + } + + #[test] + fn the_override_flag_keeps_the_local_build() { + let bids = [bid(1, u64::MAX)]; + let overriding = LocalCandidate { + value_wei: 0, + should_override_builder: true, + }; + assert_eq!( + choose_payload(Some(&overriding), &bids, 0, u64::MAX), + Some(PayloadChoice::Local) + ); + } + + #[test] + fn no_local_build_and_no_bid_is_nothing() { + assert_eq!(choose_payload(None, &[], 0, 100), None); + assert_eq!( + choose_payload(Some(&local(1)), &[], 0, 100), + Some(PayloadChoice::Local) + ); + } + + #[test] + fn the_total_counts_execution_payment_and_saturates() { + let mut signed = bid(1, u64::MAX); + signed.message.execution_payment = 5; + assert_eq!(bid_total_gwei(&signed.message), u64::MAX); + signed.message.value = 3; + assert_eq!(bid_total_gwei(&signed.message), 8); + } + + #[test] + fn the_weighted_comparison_cannot_overflow() { + let bids = [bid(1, u64::MAX)]; + assert!(picks_bid(choose_payload( + Some(&local(u128::MAX)), + &bids, + 0, + u64::MAX + ))); + } + + #[test] + fn wei_saturates_at_u128() { + assert_eq!(wei_u128(&Uint256::from_u128(42)), 42); + assert_eq!(wei_u128(&Uint256::from_u128(u128::MAX)), u128::MAX); + assert_eq!(wei_u128(&Uint256::MAX), u128::MAX); + let mut bytes = [0u8; 32]; + bytes[16] = 1; + assert_eq!( + wei_u128(ðlambda_types::beacon::primitives::U256(bytes)), + u128::MAX + ); + } } diff --git a/crates/net/rpc/src/beacon/bids.rs b/crates/net/rpc/src/beacon/bids.rs index 21d3ecac..e7da89e7 100644 --- a/crates/net/rpc/src/beacon/bids.rs +++ b/crates/net/rpc/src/beacon/bids.rs @@ -2,12 +2,153 @@ //! node, validated with the gossip rules, pooled in the shared //! `BuilderMarket` and gossiped on `execution_payload_bid`. //! -//! Stub: no routes until filled. +//! The rules are the topic's own (`gossip::execution_payload_bid`), so a bid +//! that would draw a peer's penalty is refused here instead of being relayed. +//! A refusal is a 400 whatever the verdict: the specification has no 202 for +//! "valid but not forwarded", and a bid this node would ignore on gossip is one +//! it cannot vouch for to the network either. -use axum::Router; +use axum::{ + Extension, Router, + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::post, +}; +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::{ + builder_market::SharedBuilderMarket, + gossip::{ + IgnoreReason, Outcome, + execution_payload_bid::{ + MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE, cheap_checks, stateful_checks, + }, + }, +}; use ethlambda_storage::Store; +use ethlambda_types::beacon::{containers::gloas::SignedExecutionPayloadBid, fork::ForkName}; +use tracing::{debug, warn}; + +use crate::beacon::{ApiError, BodyEncoding}; -#[allow(dead_code)] // filled by Agent C pub(crate) fn routes() -> Router { - Router::new() + Router::new().route("/eth/v1/beacon/execution_payload_bids", post(post_bid)) +} + +/// A 400 whose message names the verdict, in the Beacon API's error shape. +pub(crate) fn bad_request(message: String) -> Response { + let body = serde_json::json!({ "code": 400, "message": message }); + let mut response = crate::json_response(body); + *response.status_mut() = StatusCode::BAD_REQUEST; + response +} + +/// `"{outcome}: {reason}"`, the label pair of a verdict. +pub(crate) fn describe(outcome: &Outcome) -> String { + let (outcome, reason) = outcome.labels(); + format!("{outcome}: {reason}") +} + +pub(crate) fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// `Eth-Consensus-Version` is optional, and must name `allowed` when given. +pub(crate) fn require_version(headers: &HeaderMap, allowed: &[ForkName]) -> Result<(), ApiError> { + let Some(value) = headers.get("eth-consensus-version") else { + return Ok(()); + }; + match value.to_str().ok().and_then(ForkName::parse) { + Some(fork) if allowed.contains(&fork) => Ok(()), + _ => Err(ApiError::BadRequest( + "Eth-Consensus-Version names a fork this endpoint does not take", + )), + } +} + +/// `POST /eth/v1/beacon/execution_payload_bids`. +/// +/// 1. An identical bid already pooled is a success without republishing, so a +/// builder that retries does not flood the topic. +/// 2. The cheap rules run inline and the stateful ones (cached states, the +/// signature) on a blocking thread. +/// 3. An accepted bid is recorded in the market, which is where block +/// production reads it and where gossip's own seen rules look, then gossiped. +async fn post_bid( + State(store): State, + Extension(p2p): Extension, + Extension(market): Extension, + headers: HeaderMap, + body: Bytes, +) -> Response { + if let Err(err) = require_version(&headers, &[ForkName::Gloas]) { + return err.into_response(); + } + let encoding = match BodyEncoding::from_headers(&headers) { + Ok(encoding) => encoding, + Err(err) => return err.into_response(), + }; + if encoding == BodyEncoding::Ssz && body.len() > MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE { + return ApiError::BadRequest("the SignedExecutionPayloadBid exceeds its size bound") + .into_response(); + } + let Some(bid) = encoding.decode::(&body) else { + return ApiError::BadRequest("the body is not a gloas SignedExecutionPayloadBid") + .into_response(); + }; + + if market.contains_bid(&bid) { + debug!( + slot = bid.message.slot, + builder_index = bid.message.builder_index, + "Execution payload bid already pooled; not republishing" + ); + return StatusCode::OK.into_response(); + } + let verdict = match cheap_checks(&market, &store, &bid, unix_ms()) { + Ok(()) => { + let (store, market, bid) = (store.clone(), market.clone(), bid.clone()); + match tokio::task::spawn_blocking(move || stateful_checks(&store, &market, &bid)).await + { + Ok(verdict) => verdict, + Err(_) => { + return ApiError::Internal("validating the bid failed").into_response(); + } + } + } + Err(outcome) => outcome, + }; + if verdict != Outcome::Accept { + let (outcome, reason) = verdict.labels(); + warn!( + slot = bid.message.slot, + builder_index = bid.message.builder_index, + outcome, + reason, + "Refused a submitted execution payload bid" + ); + return bad_request(describe(&verdict)); + } + // The state a stateful check read can have moved on, and another bid can + // have taken the key meanwhile: recording re-runs the seen rules under the + // market's lock. + let slot = bid.message.slot; + let builder_index = bid.message.builder_index; + if !market.record_bid(bid.clone()) { + return bad_request(describe(&Outcome::Ignore(IgnoreReason::AlreadySeen))); + } + match p2p.publish_execution_payload_bid(bid) { + Ok(()) => { + debug!( + slot, + builder_index, "Accepted execution payload bid for gossip" + ); + StatusCode::OK.into_response() + } + Err(_) => ApiError::Internal("the network actor is not running").into_response(), + } } diff --git a/crates/net/rpc/src/beacon/builder_config.rs b/crates/net/rpc/src/beacon/builder_config.rs index 5408aa84..ebea79e1 100644 --- a/crates/net/rpc/src/beacon/builder_config.rs +++ b/crates/net/rpc/src/beacon/builder_config.rs @@ -1,53 +1,275 @@ //! The `BuilderConfig` a validator client sends with `produceBlockV4`. //! -//! Stubs until filled. Field types below are placeholders; the SSZ and JSON -//! forms are added with the real containers. +//! Its containers are the Beacon API's own (`types/gloas/builder_entry.yaml` +//! and `request_auth.yaml`), not consensus containers, so they live with the +//! API. Each has the SSZ form the specification gives and the JSON form with +//! quoted integers and hex byte strings. +//! +//! Only the top-level `min_bid` and `builder_boost_factor` are used today: they +//! govern the bids this node sees over p2p. The `builders` entries (bid +//! requests to a builder's URL) are decoded and left alone. -use axum::http::HeaderMap; +use axum::http::{HeaderMap, header}; +use ethlambda_types::beacon::primitives::{BlsPubkey, BlsSignature}; +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; +use serde::{Deserialize, Serialize}; use crate::beacon::ApiError; -#[allow(dead_code)] // filled by Agent C pub(crate) const MAX_BUILDER_ENTRIES: usize = 64; -#[allow(dead_code)] // filled by Agent C pub(crate) const MAX_BUILDER_URL_SIZE: usize = 2048; -#[allow(dead_code)] // filled by Agent C pub(crate) const MAX_BUILDER_PUBKEYS: usize = 64; -#[allow(dead_code)] // filled by Agent C pub(crate) const MAX_BUILDER_AUTH_DATA_SIZE: usize = 4096; -#[derive(Debug, Clone, Default)] -#[allow(dead_code)] // filled by Agent C +/// The builder-specs' `BuilderRequestAuth`: opaque authentication bytes and the +/// slot they authorize. +#[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, Serialize, Deserialize)] +pub(crate) struct BuilderRequestAuth { + #[serde(with = "ethlambda_types::beacon::serde_helpers::ssz_hex")] + pub(crate) data: SszList, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + pub(crate) slot: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, Serialize, Deserialize)] +pub(crate) struct SignedBuilderRequestAuth { + pub(crate) message: BuilderRequestAuth, + pub(crate) signature: BlsSignature, +} + +/// The URL as the SSZ container holds it (UTF-8 bytes) and JSON writes it (a +/// string). +mod url_text { + use super::{MAX_BUILDER_URL_SIZE, SszList}; + + pub fn serialize( + value: &SszList, + serializer: S, + ) -> Result { + serializer.serialize_str(&String::from_utf8_lossy(value)) + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let text = ::deserialize(deserializer)?; + SszList::try_from(text.into_bytes()) + .map_err(|_| serde::de::Error::custom("url exceeds MAX_BUILDER_URL_SIZE")) + } +} + +/// A per-builder bid request a validator client supplies. +#[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, Serialize, Deserialize)] pub(crate) struct BuilderEntry { - pub(crate) url: String, - pub(crate) auth_data: Vec, - pub(crate) auth_slot: u64, + #[serde(with = "url_text")] + pub(crate) url: SszList, + pub(crate) auth: SignedBuilderRequestAuth, + #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")] + pub(crate) builder_pubkeys: SszList, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + pub(crate) max_execution_payment: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] pub(crate) min_bid: u64, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] pub(crate) builder_boost_factor: u64, } -#[derive(Debug, Clone, Default)] -#[allow(dead_code)] // filled by Agent C +/// The resolved per-key builder config of one block-production request. +#[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, Serialize, Deserialize)] pub(crate) struct BuilderConfig { + /// Minimum total payment, in Gwei, accepted from a p2p bid. + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] pub(crate) min_bid: u64, + /// Percentage multiplier applied to a p2p bid against the local build. + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] pub(crate) builder_boost_factor: u64, - pub(crate) builders: Vec, + #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")] + pub(crate) builders: SszList, } -#[allow(dead_code)] // filled by Agent C impl BuilderEntry { /// A non-empty url, non-empty `auth.data` and `auth.message.slot == slot`. - /// An unusable entry never fails the request. - pub(crate) fn is_usable_for(&self, _slot: u64) -> bool { - false + /// An unusable entry never fails the request: it yields no bid. + pub(crate) fn is_usable_for(&self, slot: u64) -> bool { + !self.url.is_empty() && !self.auth.message.data.is_empty() && self.auth.message.slot == slot + } +} + +impl BuilderConfig { + /// How many entries a builder request could be made for at `slot`. + pub(crate) fn usable_entries(&self, slot: u64) -> usize { + self.builders + .iter() + .filter(|entry| entry.is_usable_for(slot)) + .count() } } -/// A missing or undecodable body is a 400. -#[allow(dead_code)] // filled by Agent C +/// Decodes the request body as a `BuilderConfig`: SSZ for an +/// `application/octet-stream` body, JSON otherwise (what a client with no +/// `Content-Type` sends). A missing or undecodable body is a 400. pub(crate) fn decode_builder_config( - _headers: &HeaderMap, - _body: &[u8], + headers: &HeaderMap, + body: &[u8], ) -> Result { - Err(ApiError::BadRequest("the body is not a BuilderConfig")) + let invalid = || ApiError::BadRequest("the body is not a BuilderConfig"); + let ssz = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)); + if ssz { + ::from_ssz_bytes(body).map_err(|_| invalid()) + } else { + serde_json::from_slice(body).map_err(|_| invalid()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use libssz::SszEncode as _; + + fn entry(url: &str, data: &[u8], slot: u64) -> BuilderEntry { + BuilderEntry { + url: SszList::try_from(url.as_bytes().to_vec()).unwrap(), + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: SszList::try_from(data.to_vec()).unwrap(), + slot, + }, + signature: BlsSignature::default(), + }, + builder_pubkeys: vec![BlsPubkey::default()].try_into().unwrap(), + max_execution_payment: 7, + min_bid: 5, + builder_boost_factor: 120, + } + } + + fn config(entries: Vec) -> BuilderConfig { + BuilderConfig { + min_bid: 10_000_000, + builder_boost_factor: 100, + builders: entries.try_into().unwrap(), + } + } + + fn ssz_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + crate::SSZ_CONTENT_TYPE.parse().unwrap(), + ); + headers + } + + #[test] + fn json_and_ssz_round_trip_with_entries() { + let original = config(vec![ + entry("https://builder.example.com", b"auth", 9), + entry("https://other.example.com", b"x", 9), + ]); + let json = serde_json::to_vec(&original).unwrap(); + assert_eq!( + decode_builder_config(&HeaderMap::new(), &json).unwrap(), + original + ); + let ssz = original.to_ssz(); + assert_eq!( + decode_builder_config(&ssz_headers(), &ssz).unwrap(), + original + ); + } + + #[test] + fn the_json_form_quotes_integers_and_writes_bytes_as_hex() { + let json = + serde_json::to_value(config(vec![entry("https://b.example", b"\x12\x34", 9)])).unwrap(); + assert_eq!(json["min_bid"], "10000000"); + assert_eq!(json["builder_boost_factor"], "100"); + let builder = &json["builders"][0]; + assert_eq!(builder["url"], "https://b.example"); + assert_eq!(builder["auth"]["message"]["data"], "0x1234"); + assert_eq!(builder["auth"]["message"]["slot"], "9"); + assert_eq!(builder["max_execution_payment"], "7"); + assert_eq!(builder["min_bid"], "5"); + assert_eq!(builder["builder_boost_factor"], "120"); + } + + #[test] + fn an_unusable_entry_still_decodes() { + let slot = 9; + let unusable = [ + entry("", b"auth", slot), + entry("https://b.example", b"", slot), + entry("https://b.example", b"auth", slot + 1), + ]; + for bad in &unusable { + assert!(!bad.is_usable_for(slot)); + } + let usable = entry("https://b.example", b"auth", slot); + assert!(usable.is_usable_for(slot)); + let original = config(vec![ + unusable[0].clone(), + unusable[1].clone(), + unusable[2].clone(), + usable, + ]); + let json = serde_json::to_vec(&original).unwrap(); + let decoded = decode_builder_config(&HeaderMap::new(), &json).unwrap(); + assert_eq!(decoded, original); + assert_eq!(decoded.usable_entries(slot), 1); + let ssz = decode_builder_config(&ssz_headers(), &original.to_ssz()).unwrap(); + assert_eq!(ssz, original); + } + + #[test] + fn an_undecodable_or_oversized_body_is_a_400() { + for body in [ + &b""[..], + b"not json", + br#"{"min_bid": "1"}"#, + br#"{"min_bid": "x", "builder_boost_factor": "1", "builders": []}"#, + ] { + assert!(matches!( + decode_builder_config(&HeaderMap::new(), body), + Err(ApiError::BadRequest(_)) + )); + } + // SSZ: too short, and an offset that points nowhere. + for body in [&[][..], &[0u8; 19][..], &[1u8; 20][..]] { + assert!(decode_builder_config(&ssz_headers(), body).is_err()); + } + // More than MAX_BUILDER_ENTRIES entries. + let many = serde_json::json!({ + "min_bid": "0", + "builder_boost_factor": "0", + "builders": vec![ + serde_json::to_value(entry("https://b.example", b"a", 1)).unwrap(); + MAX_BUILDER_ENTRIES + 1 + ], + }); + assert!( + decode_builder_config(&HeaderMap::new(), &serde_json::to_vec(&many).unwrap()).is_err() + ); + // A url above the bound. + let long = "a".repeat(MAX_BUILDER_URL_SIZE + 1); + let mut json = serde_json::to_value(config(vec![entry("https://b", b"a", 1)])).unwrap(); + json["builders"][0]["url"] = long.into(); + assert!( + decode_builder_config(&HeaderMap::new(), &serde_json::to_vec(&json).unwrap()).is_err() + ); + } + + #[test] + fn the_empty_local_preferred_config_decodes() { + let body = br#"{"min_bid":"0","builder_boost_factor":"0","builders":[]}"#; + let decoded = decode_builder_config(&HeaderMap::new(), body).unwrap(); + assert_eq!(decoded, BuilderConfig::default()); + // Twenty bytes: the two integers and the offset of the empty list. + let ssz = + decode_builder_config(&ssz_headers(), &BuilderConfig::default().to_ssz()).unwrap(); + assert_eq!(ssz, decoded); + } } diff --git a/crates/net/rpc/src/beacon/builders.rs b/crates/net/rpc/src/beacon/builders.rs index 59995e4e..36a5d854 100644 --- a/crates/net/rpc/src/beacon/builders.rs +++ b/crates/net/rpc/src/beacon/builders.rs @@ -1,12 +1,336 @@ //! `POST /eth/v1/beacon/states/{state_id}/builders`: the builder registry of a //! gloas state, filtered by id and status. //! -//! Stub: no routes until filled. +//! The Beacon API defines only the POST form, so a builder list is a request +//! with a body rather than a query string. -use axum::Router; +use axum::{ + Router, + body::Bytes, + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::post, +}; +use ethlambda_state_transition::beacon::helpers::gloas::is_active_builder; use ethlambda_storage::Store; +use ethlambda_types::beacon::{ + constants::FAR_FUTURE_EPOCH, + containers::{BeaconState, gloas::Builder}, + primitives::{BlsPubkey, ValidatorIndex}, +}; +use serde::{Deserialize, Serialize}; + +use crate::beacon::{ApiError, blocks::is_finalized, states::load}; -#[allow(dead_code)] // filled by Agent C pub(crate) fn routes() -> Router { - Router::new() + Router::new().route( + "/eth/v1/beacon/states/{state_id}/builders", + post(post_builders), + ) +} + +/// `api.yaml#BuilderStatus`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BuilderStatus { + Pending, + Active, + Exited, +} + +impl BuilderStatus { + fn name(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Exited => "exited", + } + } + + fn parse(text: &str) -> Option { + match text { + "pending" => Some(Self::Pending), + "active" => Some(Self::Active), + "exited" => Some(Self::Exited), + _ => None, + } + } +} + +/// A builder id: a registry index or a 48-byte public key. +enum BuilderId { + Index(u64), + Pubkey(BlsPubkey), +} + +impl BuilderId { + fn parse(text: &str) -> Result { + let invalid = || ApiError::BadRequest("invalid builder id"); + if let Some(digits) = text.strip_prefix("0x") { + let bytes: [u8; 48] = hex::decode(digits) + .map_err(|_| invalid())? + .try_into() + .map_err(|_| invalid())?; + return Ok(Self::Pubkey(BlsPubkey(bytes))); + } + text.parse().map(Self::Index).map_err(|_| invalid()) + } + + fn selects(&self, index: u64, builder: &Builder) -> bool { + match self { + Self::Index(wanted) => *wanted == index, + Self::Pubkey(wanted) => *wanted == builder.pubkey, + } + } +} + +/// The optional request body. Empty or absent filters select everything. +#[derive(Debug, Default, Deserialize)] +struct BuildersRequest { + #[serde(default)] + ids: Vec, + #[serde(default)] + statuses: Vec, +} + +#[derive(Debug, Serialize)] +struct BuilderEntry<'a> { + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + index: ValidatorIndex, + status: &'static str, + builder: &'a Builder, +} + +/// `POST /eth/v1/beacon/states/{state_id}/builders`. +/// +/// An id naming no builder is omitted rather than failing the request. The +/// answer is in registry order, which the specification leaves unspecified. +async fn post_builders( + Path(state_id): Path, + State(store): State, + body: Bytes, +) -> Response { + let request = if body.is_empty() { + BuildersRequest::default() + } else { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return ApiError::BadRequest("invalid request body").into_response(), + } + }; + let ids = match request + .ids + .iter() + .map(|id| BuilderId::parse(id)) + .collect::, _>>() + { + Ok(ids) => ids, + Err(err) => return err.into_response(), + }; + let statuses = match request + .statuses + .iter() + .map(|status| BuilderStatus::parse(status).ok_or(ApiError::BadRequest("invalid status"))) + .collect::, _>>() + { + Ok(statuses) => statuses, + Err(err) => return err.into_response(), + }; + let (root, state) = match load(&store, &state_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + let BeaconState::Gloas(inner) = state.as_ref() else { + return ApiError::BadRequest("the requested state is prior to Gloas").into_response(); + }; + + let mut entries = Vec::new(); + for (index, builder) in inner.builders.iter().enumerate() { + let index = index as u64; + if !(ids.is_empty() || ids.iter().any(|id| id.selects(index, builder))) { + continue; + } + let status = if builder.withdrawable_epoch != FAR_FUTURE_EPOCH { + BuilderStatus::Exited + } else if is_active_builder(inner, index).unwrap_or(false) { + BuilderStatus::Active + } else { + BuilderStatus::Pending + }; + if !(statuses.is_empty() || statuses.contains(&status)) { + continue; + } + entries.push(BuilderEntry { + index, + status: status.name(), + builder, + }); + } + + crate::json_response(serde_json::json!({ + "execution_optimistic": crate::shared::optimistic::block_is_optimistic(&store, root), + "finalized": is_finalized(&store, state.slot()), + "data": entries, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::beacon_store_with_config; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use ethlambda_state_transition::beacon::helpers::test_state::with_signing_validators_at; + use ethlambda_types::beacon::{config::Config, fork::ForkName}; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + fn builder(seed: u8, deposit_epoch: u64, withdrawable_epoch: u64) -> Builder { + Builder { + pubkey: BlsPubkey([seed; 48]), + version: 3, + execution_address: Default::default(), + balance: 32_000_000_000 + u64::from(seed), + deposit_epoch, + withdrawable_epoch, + } + } + + /// A gloas head state whose registry holds an active builder (0), a + /// pending one (1, deposited at the finalized epoch) and an exited one (2). + fn gloas_state_with_builders() -> BeaconState { + let mut state = with_signing_validators_at(ForkName::Gloas, 64); + let BeaconState::Gloas(inner) = &mut state else { + unreachable!("built as gloas") + }; + inner.finalized_checkpoint.epoch = 10; + for builder in [ + builder(1, 3, FAR_FUTURE_EPOCH), + builder(2, 10, FAR_FUTURE_EPOCH), + builder(3, 3, 20), + ] { + inner.builders.push(builder); + } + state + } + + async fn post_to(store: Store, state_id: &str, body: &str) -> (StatusCode, serde_json::Value) { + let request = Request::post(format!("/eth/v1/beacon/states/{state_id}/builders")) + .body(Body::from(body.to_string())) + .unwrap(); + let response = routes().with_state(store).oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or_default()) + } + + fn gloas_store() -> Store { + let config = Config::mainnet().with_fork_epoch(ForkName::Gloas, 0); + beacon_store_with_config(gloas_state_with_builders(), config).0 + } + + fn indices(json: &serde_json::Value) -> Vec { + json["data"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["index"].as_str().unwrap().to_string()) + .collect() + } + + #[tokio::test] + async fn every_builder_is_listed_in_registry_order_with_its_status() { + for body in ["", "{}", r#"{"ids":[],"statuses":[]}"#] { + let (status, json) = post_to(gloas_store(), "head", body).await; + assert_eq!(status, StatusCode::OK, "{body:?}"); + assert_eq!(indices(&json), ["0", "1", "2"]); + let statuses: Vec<_> = json["data"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["status"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(statuses, ["active", "pending", "exited"]); + assert_eq!(json["execution_optimistic"], false); + assert!(json["finalized"].is_boolean()); + } + } + + #[tokio::test] + async fn numbers_are_quoted() { + let (_, json) = post_to(gloas_store(), "head", "").await; + let first = &json["data"][0]; + assert_eq!(first["index"], "0"); + assert_eq!(first["builder"]["version"], "3"); + assert_eq!(first["builder"]["balance"], "32000000001"); + assert_eq!(first["builder"]["deposit_epoch"], "3"); + assert_eq!( + first["builder"]["withdrawable_epoch"], + FAR_FUTURE_EPOCH.to_string() + ); + assert!( + first["builder"]["pubkey"] + .as_str() + .unwrap() + .starts_with("0x") + ); + } + + #[tokio::test] + async fn builders_are_selected_by_index_or_public_key_and_unknown_ids_are_omitted() { + let pubkey = format!("0x{}", "03".repeat(48)); + let body = format!(r#"{{"ids":["1","{pubkey}","99","0x{}"]}}"#, "ff".repeat(48)); + let (status, json) = post_to(gloas_store(), "head", &body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(indices(&json), ["1", "2"]); + } + + #[tokio::test] + async fn the_status_filter_narrows_the_list() { + let (_, json) = post_to(gloas_store(), "head", r#"{"statuses":["active"]}"#).await; + assert_eq!(indices(&json), ["0"]); + let (_, json) = post_to( + gloas_store(), + "head", + r#"{"statuses":["pending","exited"]}"#, + ) + .await; + assert_eq!(indices(&json), ["1", "2"]); + let (_, json) = post_to( + gloas_store(), + "head", + r#"{"ids":["0","1"],"statuses":["exited"]}"#, + ) + .await; + assert!(indices(&json).is_empty()); + } + + #[tokio::test] + async fn a_bad_body_id_or_status_is_a_400() { + for body in [ + "not json", + r#"{"ids":["zero"]}"#, + r#"{"ids":["0x1234"]}"#, + r#"{"statuses":["retired"]}"#, + r#"{"ids":"0"}"#, + ] { + let (status, _) = post_to(gloas_store(), "head", body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + } + } + + #[tokio::test] + async fn a_pre_gloas_state_is_a_400_and_an_unknown_one_a_404() { + let state = with_signing_validators_at(ForkName::Fulu, 64); + let (store, _) = beacon_store_with_config(state, Config::mainnet()); + let (status, json) = post_to(store, "head", "").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["message"], "the requested state is prior to Gloas"); + + let (status, _) = post_to(gloas_store(), "999999", "").await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _) = post_to(gloas_store(), "nonsense", "").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } } diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs index 12a3a507..d12245d0 100644 --- a/crates/net/rpc/src/beacon/gloas_proposal.rs +++ b/crates/net/rpc/src/beacon/gloas_proposal.rs @@ -15,9 +15,15 @@ //! serves the envelope back for a validator client that asked for the block //! with `include_payload=false`. //! -//! This node never takes a builder's bid: the `BuilderConfig` request body is -//! decoded, as the specification requires of a body that cannot be, and -//! otherwise ignored. +//! The node always builds a local payload when it has an execution client, and +//! may instead build on a bid it saw over p2p (`bid_selection` decides, from the +//! request's `BuilderConfig`). A bid-won block comes back bare: the builder +//! reveals the payload, so nothing is cached here and this node never signs or +//! publishes an envelope for it. The `builders` entries of the config (bid +//! requests to a builder's URL) are decoded and not consulted. +//! +//! A self-built payload's fee recipient and gas target come from the signed +//! proposer preferences for the slot when this node holds them. //! //! What `produceBlockV4` builds is kept in a small cache, keyed by slot and //! block root and holding the current and the previous slot only, so the @@ -48,15 +54,18 @@ use ethlambda_state_transition::beacon::{ attestation_pool::SharedAttestationPool, block_production::advance_to_slot, bls, + builder_market::SharedBuilderMarket, fork_choice::{ get_head_node, gloas_verify_data_column_sidecar, gloas_verify_data_column_sidecar_kzg_proofs, should_build_on_full, }, gloas_block_production::{ - GloasBlockInputs, GloasPayloadInputs, GloasProduced, assemble_gloas_block, + GloasBidBlockInputs, GloasBlockInputs, GloasPayloadInputs, GloasProduced, + assemble_gloas_block, assemble_gloas_block_on_bid, bid_is_includable, gloas_data_column_sidecars, gloas_payload_inputs, pack_gloas_attestations, pack_payload_attestations, parse_gloas_execution_requests, }, + gossip::proposer_preferences::dependent_root_at, helpers::{ accessors::{get_beacon_proposer_index, get_domain}, misc::compute_signing_root, @@ -74,7 +83,8 @@ use ethlambda_types::{ deneb::Blob, gloas::{ BeaconBlock, ExecutionPayloadEnvelope, ExecutionRequests, - SignedExecutionPayloadEnvelope, + SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedProposerPreferences, }, }, fork::ForkName, @@ -91,6 +101,8 @@ use tracing::{debug, info, warn}; use crate::beacon::{ ApiError, BodyEncoding, + bid_selection::{LocalCandidate, PayloadChoice, bid_total_gwei, choose_payload, wei_u128}, + builder_config::{BuilderConfig, decode_builder_config}, proposal::{Blobs, CellKzgProofs, decimal, require_gloas_slot}, validator::{FeeRecipients, head}, }; @@ -175,13 +187,6 @@ pub(crate) fn routes() -> Router { .layer(Extension(PayloadCache::default())) } -fn is_ssz(headers: &HeaderMap) -> bool { - headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)) -} - fn consensus_version(headers: &HeaderMap) -> Option { headers .get("eth-consensus-version") @@ -189,41 +194,6 @@ fn consensus_version(headers: &HeaderMap) -> Option { .and_then(ForkName::parse) } -/// `BuilderConfig` as it arrives as JSON. Only decoded: this node takes no -/// builder bids, so nothing past the shape is read. -#[derive(Debug, Deserialize)] -struct BuilderConfigJson { - #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] - #[allow(dead_code)] - min_bid: u64, - #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] - #[allow(dead_code)] - builder_boost_factor: u64, - builders: Vec, -} - -/// Decodes the request body as a `BuilderConfig`, returning how many builder -/// entries it names. A body that does not decode is invalid per the -/// specification. -/// -/// The SSZ form is `min_bid`, `builder_boost_factor` and the offset of the -/// `builders` list, so a decodable body is at least that long and the offset -/// points just past those three fields; the entries themselves are not parsed. -fn decode_builder_config(headers: &HeaderMap, body: &[u8]) -> Result { - if is_ssz(headers) { - let offset = body - .get(16..20) - .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes"))); - return match offset { - Some(20) => Ok(usize::from(body.len() > 20)), - _ => Err(ApiError::BadRequest("the body is not a BuilderConfig")), - }; - } - serde_json::from_slice::(body) - .map(|config| config.builders.len()) - .map_err(|_| ApiError::BadRequest("the body is not a BuilderConfig")) -} - #[derive(Debug, Deserialize)] struct ProduceQuery { randao_reveal: BlsSignature, @@ -246,6 +216,7 @@ async fn post_produce_block( Extension(engine): Extension>, Extension(pool): Extension, Extension(ptc_pool): Extension, + Extension(market): Extension, Extension(fee_recipients): Extension, Extension(custody): Extension, Extension(cache): Extension, @@ -273,24 +244,29 @@ async fn post_produce_block( ) { return err.into_response(); } - let builders = match decode_builder_config(&headers, &body) { - Ok(builders) => builders, + let builder_config = match decode_builder_config(&headers, &body) { + Ok(config) => config, Err(err) => return err.into_response(), }; + let builders = builder_config.usable_entries(slot); if builders > 0 { - debug!(%slot, builders, "Ignoring the builders of the block production request"); + debug!(%slot, builders, "Not consulting the builder entries of the block production request"); } - let Some(engine) = engine else { + // Without an execution client a block can only be built on a bid, so a node + // holding none for the slot has nothing to offer. + if engine.is_none() && !market.has_bids_for_slot(slot) { return ApiError::ServiceUnavailable( "no execution client configured to build a payload with", ) .into_response(); - }; + } let graffiti = query.graffiti.unwrap_or(Bytes32::ZERO); let produced = produce( &store, - &engine, + engine.as_ref(), + &market, + &builder_config, &pool, &ptc_pool, &fee_recipients, @@ -304,49 +280,59 @@ async fn post_produce_block( Ok(produced) => produced, Err(err) => return err.into_response(), }; - let Produced { - built, - block, - envelope, - payload_value, - } = produced; - - let block_root = block.hash_tree_root(); - cache.insert( - slot, - block_root, - CachedPayload { - envelope: envelope.clone(), - blobs: built.blobs_bundle.blobs.clone(), - cell_proofs: built.blobs_bundle.proofs.clone(), - }, - ); - let accept = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()); let encoding = Encoding::from_accept(accept); - let included = query.include_payload; - let mut response = if included { - let (Ok(kzg_proofs), Ok(blobs)) = ( - CellKzgProofs::try_from(built.blobs_bundle.proofs), - blobs_list(built.blobs_bundle.blobs), - ) else { - return ApiError::Internal("the blobs bundle exceeds the block's bounds") - .into_response(); - }; - let contents = GloasBlockContents { - block, - execution_payload_envelope: envelope, - kzg_proofs, - blobs, - }; - match encoding { - Encoding::Ssz => ssz_response(contents.to_ssz()), - Encoding::Json => block_json(&payload_value, true, &contents), + let (mut response, included, payload_value) = match produced { + Production::Bid { block, bid } => { + let payload_value = bid_payload_value(&bid); + let response = match encoding { + Encoding::Ssz => ssz_response(block.to_ssz()), + Encoding::Json => block_json(&payload_value, false, &block), + }; + (response, false, payload_value) } - } else { - match encoding { - Encoding::Ssz => ssz_response(block.to_ssz()), - Encoding::Json => block_json(&payload_value, false, &block), + Production::Local(Produced { + built, + block, + envelope, + payload_value, + }) => { + let block_root = block.hash_tree_root(); + cache.insert( + slot, + block_root, + CachedPayload { + envelope: envelope.clone(), + blobs: built.blobs_bundle.blobs.clone(), + cell_proofs: built.blobs_bundle.proofs.clone(), + }, + ); + let included = query.include_payload; + let response = if included { + let (Ok(kzg_proofs), Ok(blobs)) = ( + CellKzgProofs::try_from(built.blobs_bundle.proofs), + blobs_list(built.blobs_bundle.blobs), + ) else { + return ApiError::Internal("the blobs bundle exceeds the block's bounds") + .into_response(); + }; + let contents = GloasBlockContents { + block, + execution_payload_envelope: envelope, + kzg_proofs, + blobs, + }; + match encoding { + Encoding::Ssz => ssz_response(contents.to_ssz()), + Encoding::Json => block_json(&payload_value, true, &contents), + } + } else { + match encoding { + Encoding::Ssz => ssz_response(block.to_ssz()), + Encoding::Json => block_json(&payload_value, false, &block), + } + }; + (response, included, payload_value) } }; let response_headers = response.headers_mut(); @@ -357,12 +343,17 @@ async fn post_produce_block( if let Ok(value) = HeaderValue::from_str(&payload_value) { response_headers.insert("eth-execution-payload-value", value); } - // Not computed: nothing here reads it, and the builder comparison it - // exists for does not happen on this node. + // Not computed: nothing here reads it, and the bid comparison compares + // execution payload values only. response_headers.insert("eth-consensus-block-value", HeaderValue::from_static("0")); with_consensus_version(response, ForkName::Gloas) } +/// A bid's total payment in Wei, the unit `Eth-Execution-Payload-Value` is in. +fn bid_payload_value(bid: &SignedExecutionPayloadBid) -> String { + (u128::from(bid_total_gwei(&bid.message)) * 1_000_000_000).to_string() +} + fn block_json(payload_value: &str, included: bool, data: &T) -> Response { crate::json_response(serde_json::json!({ "version": ForkName::Gloas.as_str(), @@ -381,7 +372,19 @@ fn blobs_list(blobs: Vec>) -> Result { Blobs::try_from(blobs).map_err(|_| ()) } -/// Everything `produceBlockV4` built. +/// What `produceBlockV4` built for one block. +enum Production { + /// A block on this node's own payload, with the envelope that reveals it. + Local(Produced), + /// A block committing to a builder's bid. The builder reveals the payload, + /// so there is no envelope and nothing to cache. + Bid { + block: BeaconBlock, + bid: SignedExecutionPayloadBid, + }, +} + +/// A self-built block. struct Produced { built: BuiltGloasPayload, block: BeaconBlock, @@ -389,6 +392,12 @@ struct Produced { payload_value: String, } +/// This node's own payload for the slot, checked and ready to assemble. +struct LocalBuild { + built: BuiltGloasPayload, + execution_requests: ExecutionRequests, +} + /// What the build needs from the chain, read and advanced off the runtime. struct Prepared { state: BeaconState, @@ -399,12 +408,17 @@ struct Prepared { /// The parent envelope's requests when building on its full payload and /// the parent is gloas; empty otherwise. parent_requests: ExecutionRequests, + /// The proposer's signed preferences for the slot, when this node holds + /// them under the slot's dependent root. + preferences: Option, } #[allow(clippy::too_many_arguments)] async fn produce( store: &Store, - engine: &EngineClient, + engine: Option<&EngineClient>, + market: &SharedBuilderMarket, + builder_config: &BuilderConfig, pool: &SharedAttestationPool, ptc_pool: &SharedPayloadAttestationPool, fee_recipients: &FeeRecipients, @@ -412,27 +426,39 @@ async fn produce( slot: Slot, randao_reveal: BlsSignature, graffiti: Bytes32, -) -> Result { +) -> Result { let prepare_store = store.clone(); - let prepared = - tokio::task::spawn_blocking(move || prepare(&prepare_store, slot, randao_reveal)) - .await - .map_err(|_| ApiError::Internal("preparing the block failed"))??; - - let built = build_payload(store, engine, fee_recipients, custody, &prepared).await?; - let bundle = &built.blobs_bundle; - if bundle.commitments.len() != bundle.blobs.len() - || bundle.proofs.len() - != bundle.blobs.len() * ethlambda_types::beacon::preset::CELLS_PER_EXT_BLOB - { - warn!(%slot, "The execution client's blobs bundle is inconsistent"); - return Err(ApiError::ServiceUnavailable( - "the execution client returned an inconsistent blobs bundle", - )); - } - let execution_requests = parse_gloas_execution_requests(&built.execution_requests) - .map_err(|_| ApiError::Internal("the execution client's request list is malformed"))?; - let payload_value = decimal(&built.block_value); + let prepare_market = market.clone(); + let prepared = tokio::task::spawn_blocking(move || { + prepare(&prepare_store, &prepare_market, slot, randao_reveal) + }) + .await + .map_err(|_| ApiError::Internal("preparing the block failed"))??; + + // The local build first: bids keep arriving while the engine works, and + // are read once it is done. + let local = match engine { + Some(engine) => { + Some(build_local(store, engine, fee_recipients, custody, &prepared, slot).await) + } + None => None, + }; + let (local, local_error) = match local { + Some(Ok(local)) => (Some(local), None), + Some(Err(err)) => (None, Some(err)), + None => (None, None), + }; + let bids = candidate_bids(market, &prepared); + let candidate = local.as_ref().map(|local| LocalCandidate { + value_wei: wei_u128(&local.built.block_value), + should_override_builder: local.built.should_override_builder, + }); + let choice = choose_payload( + candidate.as_ref(), + &bids, + builder_config.min_bid, + builder_config.builder_boost_factor, + ); let candidates = pool .lock() @@ -443,12 +469,64 @@ async fn produce( .expect("payload attestation pool lock poisoned") .messages_for(slot.saturating_sub(1), prepared.head_root); let config = store.config(); + let prepared = Arc::new(prepared); + + if let Some(PayloadChoice::Bid(bid)) = choice { + let builder_index = bid.message.builder_index; + let value_gwei = bid_total_gwei(&bid.message); + let assembled = { + let (config, prepared, bid) = (config.clone(), prepared.clone(), bid.clone()); + let (candidates, messages) = (candidates.clone(), messages.clone()); + tokio::task::spawn_blocking(move || { + assemble_on_bid( + &config, + &prepared, + bid, + candidates, + messages, + randao_reveal, + graffiti, + ) + }) + .await + }; + match assembled { + Ok(Ok(block)) => { + info!( + %slot, + builder_index, + value_gwei, + "Produced gloas block on a builder bid" + ); + return Ok(Production::Bid { block, bid }); + } + Ok(Err(_)) | Err(_) if local.is_some() => { + warn!(%slot, builder_index, "The winning bid failed to build; using the local payload"); + } + Ok(Err(_)) | Err(_) => { + return Err(ApiError::ServiceUnavailable( + "the winning bid failed to build", + )); + } + } + } + + let Some(local) = local else { + return Err(local_error.unwrap_or(ApiError::ServiceUnavailable( + "no execution client and no viable builder bid for the slot", + ))); + }; + let LocalBuild { + built, + execution_requests, + } = local; + let payload_value = decimal(&built.block_value); let assembled = { let built = built.clone(); tokio::task::spawn_blocking(move || { assemble( &config, - prepared, + &prepared, built, execution_requests, candidates, @@ -469,17 +547,51 @@ async fn produce( blobs = built.blobs_bundle.blobs.len(), "Produced gloas block" ); - Ok(Produced { + Ok(Production::Local(Produced { built, block, envelope, payload_value, - }) + })) +} + +/// The pooled bids this node could build on at the prepared slot: on the same +/// parent payload the local build extends, with its `prev_randao`, packable +/// into a block on the advanced state, and, when the proposer's preferences are +/// held, paying the fee recipient they name. Value descending. +/// +/// Bids on the head's parent (a proposer reorg) are not considered. +fn candidate_bids( + market: &SharedBuilderMarket, + prepared: &Prepared, +) -> Vec { + market + .bids_for( + prepared.state.slot(), + prepared.head_root, + prepared.inputs.head_block_hash, + ) + .into_iter() + .filter(|signed| { + let bid = &signed.message; + bid.prev_randao == prepared.inputs.prev_randao + && prepared + .preferences + .as_ref() + .is_none_or(|prefs| prefs.message.fee_recipient == bid.fee_recipient) + && bid_is_includable(&prepared.state, signed, &prepared.parent_requests) + }) + .collect() } /// The chain-side half of production: pick the parent payload branch, advance /// the head state to `slot` and derive the payload inputs from it. -fn prepare(store: &Store, slot: Slot, randao_reveal: BlsSignature) -> Result { +fn prepare( + store: &Store, + market: &SharedBuilderMarket, + slot: Slot, + randao_reveal: BlsSignature, +) -> Result { let config = store.config(); let (head_root, head_state) = head(store)?; let head_slot = head_state.slot(); @@ -527,8 +639,18 @@ fn prepare(store: &Store, slot: Slot, randao_reveal: BlsSignature) -> Result
 Result
 Result {
+    let built = build_payload(store, engine, fee_recipients, custody, prepared).await?;
+    let bundle = &built.blobs_bundle;
+    if bundle.commitments.len() != bundle.blobs.len()
+        || bundle.proofs.len()
+            != bundle.blobs.len() * ethlambda_types::beacon::preset::CELLS_PER_EXT_BLOB
+    {
+        warn!(%slot, "The execution client's blobs bundle is inconsistent");
+        return Err(ApiError::ServiceUnavailable(
+            "the execution client returned an inconsistent blobs bundle",
+        ));
+    }
+    let execution_requests = parse_gloas_execution_requests(&built.execution_requests)
+        .map_err(|_| ApiError::Internal("the execution client's request list is malformed"))?;
+    Ok(LocalBuild {
+        built,
+        execution_requests,
+    })
+}
+
+/// Where a self-built payload's fee recipient comes from: the proposer's signed
+/// preferences, else `prepare_beacon_proposer`'s, else the zero address.
+fn fee_recipient_for(fee_recipients: &FeeRecipients, prepared: &Prepared) -> ExecutionAddress {
+    if let Some(preferences) = &prepared.preferences {
+        debug!(
+            proposer = prepared.proposer,
+            "Using the signed proposer preferences' fee recipient"
+        );
+        return preferences.message.fee_recipient;
+    }
+    let prepared_recipient = fee_recipients
+        .lock()
+        .expect("fee recipient lock poisoned")
+        .get(&prepared.proposer)
+        .copied();
+    match prepared_recipient {
+        Some(recipient) => {
+            debug!(
+                proposer = prepared.proposer,
+                "Using the fee recipient from prepare_beacon_proposer"
+            );
+            recipient
+        }
+        None => {
+            warn!(
+                proposer = prepared.proposer,
+                "No fee recipient for the proposer; using the zero address"
+            );
+            ExecutionAddress::ZERO
+        }
+    }
+}
+
 /// Ask the execution client to build on the chosen parent payload for the
 /// prepared slot, then collect what it built.
 ///
@@ -575,18 +760,7 @@ async fn build_payload(
     prepared: &Prepared,
 ) -> Result {
     let inputs = &prepared.inputs;
-    let fee_recipient = fee_recipients
-        .lock()
-        .expect("fee recipient lock poisoned")
-        .get(&prepared.proposer)
-        .copied()
-        .unwrap_or_else(|| {
-            warn!(
-                proposer = prepared.proposer,
-                "No fee recipient prepared for the proposer; using the zero address"
-            );
-            ExecutionAddress::ZERO
-        });
+    let fee_recipient = fee_recipient_for(fee_recipients, prepared);
     let forkchoice = ForkchoiceStateV1 {
         head_block_hash: inputs.head_block_hash,
         safe_block_hash: checkpoint_hash(store, store.beacon_justified_checkpoint().root),
@@ -628,7 +802,7 @@ async fn build_payload(
 #[allow(clippy::too_many_arguments)]
 fn assemble(
     config: &Config,
-    prepared: Prepared,
+    prepared: &Prepared,
     built: BuiltGloasPayload,
     execution_requests: ExecutionRequests,
     candidates: Vec,
@@ -643,9 +817,9 @@ fn assemble(
         parent_requests,
         ..
     } = prepared;
-    let attestations = pack_gloas_attestations(&state, candidates);
+    let attestations = pack_gloas_attestations(state, candidates);
     let payload_attestations =
-        pack_payload_attestations(&state, head_root, head_slot, messages, config);
+        pack_payload_attestations(state, *head_root, *head_slot, messages, config);
     let commitments = built.blobs_bundle.commitments;
     let inputs = |attestations, payload_attestations| GloasBlockInputs {
         randao_reveal,
@@ -658,14 +832,55 @@ fn assemble(
         execution_requests: execution_requests.clone(),
     };
     let operations = attestations.len() + payload_attestations.len();
-    match assemble_gloas_block(&state, inputs(attestations, payload_attestations), config) {
+    match assemble_gloas_block(state, inputs(attestations, payload_attestations), config) {
         Ok(produced) => Ok(produced),
         // The packers check every operation's signature against this state, so
         // this should not happen; but a block without them still earns the
         // proposal, and one that fails to build earns nothing.
         Err(err) if operations > 0 => {
             warn!(slot = state.slot(), %err, "Block with operations failed to build; retrying without");
-            assemble_gloas_block(&state, inputs(Vec::new(), Vec::new()), config)
+            assemble_gloas_block(state, inputs(Vec::new(), Vec::new()), config)
+                .map_err(|_| ApiError::Internal("the block failed to build"))
+        }
+        Err(_) => Err(ApiError::Internal("the block failed to build")),
+    }
+}
+
+/// [`assemble`] for a block that commits to a builder's bid: no payload, no
+/// envelope. `process_block` on the block enforces the bid's own rules.
+fn assemble_on_bid(
+    config: &Config,
+    prepared: &Prepared,
+    signed_bid: SignedExecutionPayloadBid,
+    candidates: Vec,
+    messages: Vec,
+    randao_reveal: BlsSignature,
+    graffiti: Bytes32,
+) -> Result {
+    let Prepared {
+        state,
+        head_root,
+        head_slot,
+        parent_requests,
+        ..
+    } = prepared;
+    let attestations = pack_gloas_attestations(state, candidates);
+    let payload_attestations =
+        pack_payload_attestations(state, *head_root, *head_slot, messages, config);
+    let inputs = |attestations, payload_attestations| GloasBidBlockInputs {
+        randao_reveal,
+        graffiti,
+        attestations,
+        payload_attestations,
+        parent_execution_requests: parent_requests.clone(),
+        signed_bid: signed_bid.clone(),
+    };
+    let operations = attestations.len() + payload_attestations.len();
+    match assemble_gloas_block_on_bid(state, inputs(attestations, payload_attestations), config) {
+        Ok(block) => Ok(block),
+        Err(err) if operations > 0 => {
+            warn!(slot = state.slot(), %err, "Bid block with operations failed to build; retrying without");
+            assemble_gloas_block_on_bid(state, inputs(Vec::new(), Vec::new()), config)
                 .map_err(|_| ApiError::Internal("the block failed to build"))
         }
         Err(_) => Err(ApiError::Internal("the block failed to build")),
@@ -922,6 +1137,7 @@ mod tests {
             .layer(Extension(engine))
             .layer(Extension(SharedAttestationPool::default()))
             .layer(Extension(SharedPayloadAttestationPool::default()))
+            .layer(Extension(SharedBuilderMarket::default()))
             .layer(Extension(FeeRecipients::default()))
             .layer(Extension(NodeCustodyColumns::default()))
     }
@@ -1017,7 +1233,10 @@ mod tests {
     fn a_builder_config_decodes_as_json_or_ssz() {
         let mut headers = HeaderMap::new();
         assert_eq!(
-            decode_builder_config(&headers, EMPTY_CONFIG.as_bytes()).unwrap(),
+            decode_builder_config(&headers, EMPTY_CONFIG.as_bytes())
+                .unwrap()
+                .builders
+                .len(),
             0
         );
         assert!(decode_builder_config(&headers, b"{}").is_err());
@@ -1029,7 +1248,13 @@ mod tests {
         // min_bid, boost factor, then the offset of an empty builders list.
         let mut ssz = vec![0u8; 16];
         ssz.extend_from_slice(&20u32.to_le_bytes());
-        assert_eq!(decode_builder_config(&headers, &ssz).unwrap(), 0);
+        assert_eq!(
+            decode_builder_config(&headers, &ssz)
+                .unwrap()
+                .builders
+                .len(),
+            0
+        );
         assert!(decode_builder_config(&headers, &ssz[..19]).is_err());
     }
 
diff --git a/crates/net/rpc/src/beacon/proposer_preferences.rs b/crates/net/rpc/src/beacon/proposer_preferences.rs
index 6dd90008..bd217f0b 100644
--- a/crates/net/rpc/src/beacon/proposer_preferences.rs
+++ b/crates/net/rpc/src/beacon/proposer_preferences.rs
@@ -2,12 +2,143 @@
 //! from a validator client, validated with the gossip rules, cached in the
 //! shared `BuilderMarket` and gossiped on `proposer_preferences`.
 //!
-//! Stub: no routes until filled.
+//! Not gated on the sync status: the specification lists no 503, and a node
+//! that lacks the dependent block simply answers `ignore: unknown_block` per
+//! entry.
 
-use axum::Router;
+use axum::{
+    Extension, Router,
+    body::Bytes,
+    extract::State,
+    http::{HeaderMap, StatusCode},
+    response::{IntoResponse, Response},
+    routing::post,
+};
+use ethlambda_network_api::RpcToP2PRef;
+use ethlambda_state_transition::beacon::{
+    builder_market::SharedBuilderMarket,
+    gossip::{IgnoreReason, Outcome, proposer_preferences::validate},
+    preset,
+};
 use ethlambda_storage::Store;
+use ethlambda_types::beacon::{containers::gloas::SignedProposerPreferences, fork::ForkName};
+use serde::Serialize;
+use tracing::{debug, warn};
+
+use crate::beacon::{
+    ApiError,
+    bids::{describe, require_version, unix_ms},
+    decode_list,
+};
+
+/// The most entries one request may carry: the SSZ list's bound,
+/// `(MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH`.
+const MAX_ENTRIES: usize = preset::PROPOSER_LOOKAHEAD_LENGTH;
 
-#[allow(dead_code)] // filled by Agent C
 pub(crate) fn routes() -> Router {
-    Router::new()
+    Router::new().route(
+        "/eth/v1/validator/proposer_preferences",
+        post(post_proposer_preferences),
+    )
+}
+
+/// One refused entry, in the Beacon API's `IndexedErrorMessage` shape.
+#[derive(Debug, Serialize)]
+struct Failure {
+    index: usize,
+    message: String,
+}
+
+/// `POST /eth/v1/validator/proposer_preferences`.
+///
+/// Each entry goes through the topic's rules (current slot window, a known
+/// dependent block, the proposer the lookahead names, the signature), since a
+/// message that fails them is one every peer would score this node down for
+/// relaying. Valid ones are cached for block production and for the bids judged
+/// against them, then gossiped; the others are reported by position and the
+/// rest still go out. An entry already cached, identical, is a success that is
+/// not republished.
+async fn post_proposer_preferences(
+    State(store): State,
+    Extension(p2p): Extension,
+    Extension(market): Extension,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    // A validator client submits during the epoch before gloas, when the
+    // consensus version it names is still fulu.
+    if let Err(err) = require_version(&headers, &[ForkName::Fulu, ForkName::Gloas]) {
+        return err.into_response();
+    }
+    let preferences = match decode_list::(&headers, &body) {
+        Ok(preferences) => preferences,
+        Err(err) => return err.into_response(),
+    };
+    if preferences.len() > MAX_ENTRIES {
+        return ApiError::BadRequest("too many signed proposer preferences").into_response();
+    }
+
+    let validated =
+        tokio::task::spawn_blocking(move || submit(&store, &p2p, &market, preferences)).await;
+    let failures = match validated {
+        Ok(failures) => failures,
+        Err(_) => return ApiError::Internal("validating the preferences failed").into_response(),
+    };
+    if failures.is_empty() {
+        return StatusCode::OK.into_response();
+    }
+    let body = serde_json::json!({
+        "code": 400,
+        "message": "some signed proposer preferences failed validation and were not published",
+        "failures": failures,
+    });
+    let mut response = crate::json_response(body);
+    *response.status_mut() = StatusCode::BAD_REQUEST;
+    response
+}
+
+/// Validate, record and publish each entry, in order.
+fn submit(
+    store: &Store,
+    p2p: &RpcToP2PRef,
+    market: &SharedBuilderMarket,
+    preferences: Vec,
+) -> Vec {
+    let now_ms = unix_ms();
+    let wall_slot = crate::beacon::node::wall_slot(store);
+    let mut failures = Vec::new();
+    for (index, signed) in preferences.into_iter().enumerate() {
+        let slot = signed.message.proposal_slot;
+        let validator = signed.message.validator_index;
+        let held = market.preferences(slot, signed.message.dependent_root);
+        if held.as_ref() == Some(&signed) {
+            debug!(%slot, validator, "Proposer preferences already cached; not republishing");
+            continue;
+        }
+        let verdict = validate(market, store, &signed, now_ms);
+        if verdict != Outcome::Accept {
+            let (outcome, reason) = verdict.labels();
+            warn!(%slot, validator, outcome, reason, "Refused submitted proposer preferences");
+            failures.push(Failure {
+                index,
+                message: describe(&verdict),
+            });
+            continue;
+        }
+        if !market.record_preferences(signed.clone(), wall_slot) {
+            failures.push(Failure {
+                index,
+                message: describe(&Outcome::Ignore(IgnoreReason::AlreadySeen)),
+            });
+            continue;
+        }
+        match p2p.publish_proposer_preferences(signed) {
+            Ok(()) => debug!(%slot, validator, "Accepted proposer preferences for gossip"),
+            Err(_) => failures.push(Failure {
+                index,
+                message: "the network actor is not running".to_string(),
+            }),
+        }
+    }
+    failures
 }
diff --git a/crates/net/rpc/src/beacon/states.rs b/crates/net/rpc/src/beacon/states.rs
index 5d307019..66734684 100644
--- a/crates/net/rpc/src/beacon/states.rs
+++ b/crates/net/rpc/src/beacon/states.rs
@@ -67,7 +67,10 @@ fn resolve_state_id(store: &Store, state_id: &str) -> Result {
 }
 
 /// Load the state a `state_id` names, or the response explaining why not.
-fn load(store: &Store, state_id: &str) -> Result<(H256, std::sync::Arc), ApiError> {
+pub(crate) fn load(
+    store: &Store,
+    state_id: &str,
+) -> Result<(H256, std::sync::Arc), ApiError> {
     let root = resolve_state_id(store, state_id)?;
     let state = store
         .get_state(&root)
diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs
index 93196049..cb1de182 100644
--- a/crates/net/rpc/src/beacon/validator_client_tests.rs
+++ b/crates/net/rpc/src/beacon/validator_client_tests.rs
@@ -109,6 +109,9 @@ async fn spawn_server(
         .layer(Extension(p2p))
         .layer(Extension(SharedAttestationPool::default()))
         .layer(Extension(payload_pool.clone()))
+        .layer(Extension(
+            ethlambda_state_transition::beacon::builder_market::SharedBuilderMarket::default(),
+        ))
         .layer(Extension(crate::CustodyColumns(Vec::new())))
         .layer(Extension(crate::beacon::validator::FeeRecipients::default()))
         .layer(Extension(engine));
diff --git a/docs/rpc.md b/docs/rpc.md
index 17fd4b80..acbd4720 100644
--- a/docs/rpc.md
+++ b/docs/rpc.md
@@ -252,10 +252,13 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a
 | `GET` | `/eth/v2/validator/aggregate_attestation` | JSON | The pooled votes for a data root and committee, aggregated |
 | `POST` | `/eth/v2/validator/aggregate_and_proofs` | *(status only)* | Validate and gossip `SignedAggregateAndProof`s (JSON or SSZ body) |
 | `GET` | `/eth/v3/validator/blocks/{slot}` | SSZ or JSON | An unsigned fulu block built on the head (`produceBlockV3`) |
-| `POST` | `/eth/v4/validator/blocks/{slot}` | SSZ or JSON | An unsigned self-built gloas block, with its envelope and blobs when asked (`produceBlockV4`) |
+| `POST` | `/eth/v4/validator/blocks/{slot}` | SSZ or JSON | An unsigned gloas block: self-built, with its envelope and blobs when asked, or on a pooled builder bid (`produceBlockV4`) |
 | `GET` | `/eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` | SSZ or JSON | The unsigned envelope `produceBlockV4` built (gloas) |
 | `POST` | `/eth/v2/beacon/blocks` | *(status only)* | Gossip and import a signed fulu or gloas block (`publishBlockV2`; JSON or SSZ body) |
 | `POST` | `/eth/v1/beacon/execution_payload_envelopes` | *(status only)* | Gossip a signed envelope and its data columns (gloas; JSON or SSZ body) |
+| `POST` | `/eth/v1/beacon/execution_payload_bids` | *(status only)* | Validate, pool and gossip a builder's `SignedExecutionPayloadBid` (gloas; JSON or SSZ body) |
+| `POST` | `/eth/v1/validator/proposer_preferences` | *(status only)* | Validate, cache and gossip `SignedProposerPreferences` (gloas; JSON or SSZ body) |
+| `POST` | `/eth/v1/beacon/states/{state_id}/builders` | JSON | The gloas builder registry, filtered by id and status |
 | `POST` | `/eth/v1/validator/prepare_beacon_proposer` | *(status only)* | Acknowledged, not acted on (see below) |
 
 ### Validator endpoints
@@ -387,10 +390,12 @@ publication below builds and gossips the columns.
 ### Gloas block production
 
 A gloas proposer signs two things on their own, the block and the envelope that
-reveals its payload, and this node serves both halves. It only builds for
-itself: no builder bid is taken from gossip or a builder API, and no
-`SignedProposerPreferences` is read (see
-[Spec Deviations](./spec_deviations.md#self-build-only)).
+reveals its payload, and this node serves both halves for a block it builds
+itself. It may instead build on a builder's bid it holds in the shared builder
+market (see [Builder market](#builder-market)); then the builder, not this node,
+reveals the payload. The builder-API path (a bid requested from a builder's URL
+through the `BuilderConfig`'s `builders` entries) is not implemented: those
+entries are decoded and not consulted.
 
 **`POST /eth/v4/validator/blocks/{slot}`** (`produceBlockV4`):
 
@@ -398,18 +403,21 @@ itself: no builder bid is taken from gossip or a builder API, and no
 |---|---|
 | Query | `randao_reveal` (required), `include_payload` (required, `true` or `false`), `graffiti` (optional, 32-byte hex), `skip_randao_verification` (accepted, ignored) |
 | Request headers | `Eth-Consensus-Version` is optional but must be `gloas` when present. `Accept: application/octet-stream` for SSZ, JSON otherwise |
-| Body | A `BuilderConfig` (`min_bid`, `builder_boost_factor`, `builders`), JSON, or SSZ with `Content-Type: application/octet-stream`. It is decoded and otherwise ignored; a missing or undecodable one is a `400` |
-| `200` SSZ | With `include_payload=true`, `BlockContents` (`block`, `execution_payload_envelope`, `kzg_proofs`, `blobs`); with `false`, the bare `gloas::BeaconBlock` |
+| Body | A `BuilderConfig`, JSON or SSZ with `Content-Type: application/octet-stream`: `min_bid` and `builder_boost_factor` (govern the bids seen over p2p) and `builders` (up to 64 entries, decoded and ignored). Integers are quoted strings, as everywhere in the Beacon API. A missing or undecodable one is a `400` |
+| `200` SSZ | Self-built with `include_payload=true`: `BlockContents` (`block`, `execution_payload_envelope`, `kzg_proofs`, `blobs`). Otherwise, and for every bid-won block, the bare `gloas::BeaconBlock` |
 | `200` JSON | `{version: "gloas", consensus_block_value, execution_payload_value, execution_payload_included, data}`, where `data` is the same container as the SSZ body |
-| Response headers | `Eth-Consensus-Version: gloas`, `Eth-Execution-Payload-Included` (`true` or `false`), `Eth-Execution-Payload-Value` (wei, decimal), `Eth-Consensus-Block-Value` (always `0`: no builder comparison happens on this node) |
+| Response headers | `Eth-Consensus-Version: gloas`, `Eth-Execution-Payload-Included` (`true` or `false`; always `false` for a bid-won block), `Eth-Execution-Payload-Value` (wei, decimal; a bid's value times `10^9` when it won), `Eth-Consensus-Block-Value` (always `0`: the comparison is of execution payload values only). No `Eth-Builder-Url`: that is for builder-API bids |
 | `400` | A slot the schedule does not place at gloas, a missing or malformed query, a wrong `Eth-Consensus-Version`, a bad body, a slot not after the head block, or a `randao_reveal` that does not verify against the slot's proposer. The slot check precedes the execution-client check, so it is a `400` on any node |
-| `503` | No execution client configured, the execution client did not start or return a build, or the node is building on a FULL parent whose envelope it does not hold |
+| `503` | No execution client and no viable pooled bid for the slot, the execution client did not start or return a build (and no bid took its place), the node is building on a FULL parent whose envelope it does not hold, or the winning bid failed to build and there is no local payload to fall back on |
 
 The node advances the head state to the slot, and decides which parent payload
 to build on with `should_build_on_full` over the payload status fork choice
 recorded for the head. It then asks its execution client to build
-(`forkchoiceUpdatedV4` with `PayloadAttributesV4`, then `getPayloadV6`) with the
-proposer's `prepare_beacon_proposer` fee recipient. The body packs the
+(`forkchoiceUpdatedV4` with `PayloadAttributesV4`, then `getPayloadV6`). The
+fee recipient and gas target of that build come from the proposer's signed
+`ProposerPreferences` for the slot when the market holds them under the slot's
+dependent root, else from `prepare_beacon_proposer` (fee recipient, or the zero
+address with a warning) and the parent bid's gas limit. The body packs the
 attestation pool's best aggregates and the payload attestation pool's votes for
 the parent block (an aggregate that does not verify against the advanced state
 is dropped, since one bad operation fails the block), and the state root comes
@@ -417,6 +425,22 @@ from running the block through `process_block`. The bid is a zero-value
 self-build bid read off the built payload. What was built is cached by `(slot,
 block root)`, for the current and previous slot only.
 
+**Choosing between the local payload and a bid.** The bids considered are the
+market's for `(slot, head root, parent payload hash)` with this build's
+`prev_randao`, packable into a block on the advanced state, and paying the
+preferences' fee recipient when those are held. The best bid is the highest
+whose `value + execution_payment` is at least the config's `min_bid`. It wins iff
+`builder_boost_factor * bid_gwei > floor(local_value_wei / 10^7)` (the
+specification's weighting of the local value by 100, in integers), and the local
+payload wins a tie, so a factor of `0` prefers it and `2^64 - 1` prefers the bid.
+An engine that sets `shouldOverrideBuilder` keeps the local payload. Without a
+local payload (no engine, or the build failed) the best bid is taken whatever its
+weight. A bid-won block caches nothing, so the envelope `GET` below answers
+`404` for it, and `POST /eth/v1/beacon/execution_payload_envelopes` refuses a
+proposer-signed (self-build) envelope for it because the builder index differs
+from the bid's. The builder's own envelope goes through that endpoint as for any
+block.
+
 **`GET /eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}`**
 serves the cached unsigned envelope, for a client that asked for the block with
 `include_payload=false`: `{version: "gloas", data}` as JSON, or the SSZ
@@ -450,6 +474,47 @@ envelope and all `NUMBER_OF_COLUMNS` gloas data column sidecars are handed to
 P2P, which gossips them together. A node that does not subscribe to a column's
 subnet publishes through gossipsub fanout.
 
+### Builder market
+
+Three endpoints serve the gloas builder market. Bids and preferences go through
+the same rules as the `execution_payload_bid` and `proposer_preferences` gossip
+topics and are held in one shared builder market that p2p also fills, so a
+message accepted from either side is seen by both.
+
+- **`POST /eth/v1/beacon/execution_payload_bids`** takes one
+  `SignedExecutionPayloadBid`, as JSON (or no `Content-Type`) or SSZ
+  (`application/octet-stream`, at most 196,932 bytes; any other type is `415`).
+  `Eth-Consensus-Version` is optional and must be `gloas` when present. An
+  identical bid already pooled is `200` and is not republished. Otherwise the
+  gossip rules run (a known parent, the preferences for the slot and their fee
+  recipient and gas limit, a known parent payload, an active funded builder, the
+  signature), and a bid that is anything but accepted is a `400` whose message is
+  `"{outcome}: {reason}"` (for instance `reject: bad_signature` or `ignore:
+  preferences_unseen`), because there is no status for a valid bid this node
+  would not relay. An accepted bid is pooled for block production and gossiped.
+  `500` when the network actor is down.
+- **`POST /eth/v1/validator/proposer_preferences`** takes a list of
+  `SignedProposerPreferences` (at most `(MIN_SEED_LOOKAHEAD + 1) *
+  SLOTS_PER_EPOCH`, 64 on mainnet), as a JSON array or an SSZ list.
+  `Eth-Consensus-Version` is optional and may be `fulu` or `gloas`, since a
+  validator client submits during the epoch before the fork. Each entry runs the
+  gossip rules in order; an entry already cached, identical, succeeds without a
+  republish. Accepted entries are cached (the first per `(proposal slot,
+  dependent root)` wins) and gossiped. If any entry fails the answer is `400`
+  `{code, message, failures: [{index, message}]}` and the others were still
+  published. The endpoint is not gated on the sync status. A node whose
+  dependent block is unknown answers `ignore: unknown_block` for that entry.
+- **`POST /eth/v1/beacon/states/{state_id}/builders`** takes an optional JSON
+  body `{ids?: [index | 0x pubkey], statuses?: [pending | active | exited]}`
+  (empty or absent selects everything) and answers `{execution_optimistic,
+  finalized, data: [{index, status, builder}]}` in registry order, integers
+  quoted. An id naming no builder is omitted. `exited` is a set
+  `withdrawable_epoch`, else `active` per `is_active_builder`, else `pending`.
+  `400` for a malformed body, id or status, or a pre-gloas state; `404` for an
+  unknown state. The Beacon API defines no `GET` form.
+
+`POST /eth/v1/validator/builder_preferences` is not served (`404`).
+
 ### Payload timeliness committee
 
 - **`POST /eth/v1/validator/duties/ptc/{epoch}`** takes a JSON array of quoted

From 4d307bc4aa6f778bef155efc109825c085059d39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?=
 <47506558+MegaRedHand@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:59:09 -0300
Subject: [PATCH 07/12] test(rpc): drive the builder market endpoints and bid
 selection end to end

Cover the three endpoints, the verdicts they turn into 400s, idempotent
resubmission, SSZ bodies, produceBlockV4 choosing between a stand-in engine's
payload and a pooled bid, the preferences-driven fee recipient and gas target,
and the envelope endpoints' behaviour for a block built on a bid.
---
 .../rpc/src/beacon/builder_market_tests.rs    | 1225 +++++++++++++++++
 crates/net/rpc/src/beacon/gloas_proposal.rs   |    4 +
 2 files changed, 1229 insertions(+)
 create mode 100644 crates/net/rpc/src/beacon/builder_market_tests.rs

diff --git a/crates/net/rpc/src/beacon/builder_market_tests.rs b/crates/net/rpc/src/beacon/builder_market_tests.rs
new file mode 100644
index 00000000..b43efd66
--- /dev/null
+++ b/crates/net/rpc/src/beacon/builder_market_tests.rs
@@ -0,0 +1,1225 @@
+//! The builder market endpoints and `produceBlockV4`'s bid selection, driven
+//! through the real router with a stand-in execution client.
+//!
+//! Tests that need the gossip rules, `dependent_root_at`, `bid_is_includable`
+//! or `assemble_gloas_block_on_bid` exercise those functions as they are
+//! implemented in `ethlambda-state-transition`; the ones that only check shapes
+//! (headers, status codes, routing, idempotency against a primed market) do not
+//! depend on them.
+
+use std::sync::{Arc, Mutex};
+
+use axum::{
+    Extension, Router,
+    body::Body,
+    http::{HeaderMap, Request, StatusCode},
+};
+use ethlambda_engine::{EngineClient, JwtSecret};
+use ethlambda_network_api::RpcToP2PRef;
+use ethlambda_state_transition::beacon::{
+    attestation_pool::SharedAttestationPool,
+    block_production::advance_to_slot,
+    builder_market::SharedBuilderMarket,
+    gloas_block_production::test_support::{
+        config as chain_config, parent_state, post_state, randao_reveal,
+    },
+    gossip::proposer_preferences::dependent_root_at,
+    helpers::{
+        accessors::get_domain,
+        misc::compute_signing_root,
+        test_state::{secret_key_for, sign_for},
+    },
+    payload_attestation_pool::SharedPayloadAttestationPool,
+};
+use ethlambda_storage::Store;
+use ethlambda_types::{
+    beacon::{
+        constants::{
+            BUILDER_INDEX_SELF_BUILD, DOMAIN_BEACON_BUILDER, DOMAIN_PROPOSER_PREFERENCES,
+            FAR_FUTURE_EPOCH, PAYLOAD_BUILDER_VERSION,
+        },
+        containers::{
+            BeaconState, SignedBeaconBlock,
+            gloas::{
+                BeaconBlock, Builder, ExecutionPayloadBid, ExecutionRequests, ProposerPreferences,
+                SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope,
+                SignedProposerPreferences,
+            },
+        },
+        primitives::{BlsPubkey, BlsSignature, ExecutionAddress, HashTreeRoot as _},
+        signing::compute_epoch_at_slot,
+    },
+    primitives::H256,
+};
+use http_body_util::BodyExt as _;
+use libssz::{SszDecode as _, SszEncode as _};
+use tower::ServiceExt as _;
+
+use super::{Prepared, prepare, routes as produce_routes};
+use crate::{
+    CustodyColumns,
+    beacon::{bids, proposer_preferences, validator::FeeRecipients},
+    test_utils::{RecordingNetwork, beacon_store_with_head_block, gloas_beacon_block},
+};
+
+/// The slot the block is built for. The head block sits one slot earlier, and
+/// the clock is at the head's slot, so this one is the next slot: the one bids
+/// and preferences are accepted for.
+const SLOT: u64 = 33;
+const HEAD_SLOT: u64 = SLOT - 1;
+const GWEI: u64 = 1_000_000_000;
+
+// ---------------------------------------------------------------------------
+// Fake execution client
+// ---------------------------------------------------------------------------
+
+/// A stand-in execution client and what it was asked to build.
+struct FakeEngine {
+    client: EngineClient,
+    /// The payload attributes the last `forkchoiceUpdatedV4` carried.
+    attributes: Arc>>,
+}
+
+/// `forkchoiceUpdated` with attributes answers a payload id, and `getPayloadV6`
+/// a payload extending the requested head with exactly the requested
+/// attributes, worth `block_value_wei`.
+async fn fake_engine(block_value_wei: u64, should_override_builder: bool) -> FakeEngine {
+    use axum::{Json, routing::post};
+
+    let attributes: Arc>> = Arc::default();
+    let recorded = attributes.clone();
+    let handler = move |Json(request): Json| {
+        let recorded = recorded.clone();
+        async move {
+            let result = match request["method"].as_str() {
+                Some("engine_forkchoiceUpdatedV4") => {
+                    let mut attributes = request["params"][1].clone();
+                    attributes["parentHash"] = request["params"][0]["headBlockHash"].clone();
+                    *recorded.lock().unwrap() = Some(attributes);
+                    serde_json::json!({
+                        "payloadStatus": { "status": "VALID", "latestValidHash": null },
+                        "payloadId": "0x0000000000000001",
+                    })
+                }
+                Some("engine_getPayloadV6") => {
+                    let attributes = recorded.lock().unwrap().clone().unwrap();
+                    serde_json::json!({
+                        "executionPayload": {
+                            "parentHash": attributes["parentHash"],
+                            "feeRecipient": attributes["suggestedFeeRecipient"],
+                            "stateRoot": format!("0x{}", "00".repeat(32)),
+                            "receiptsRoot": format!("0x{}", "00".repeat(32)),
+                            "logsBloom": format!("0x{}", "00".repeat(256)),
+                            "prevRandao": attributes["prevRandao"],
+                            "blockNumber": "0x1",
+                            "gasLimit": attributes["targetGasLimit"],
+                            "gasUsed": "0x0",
+                            "timestamp": attributes["timestamp"],
+                            "extraData": "0x",
+                            "baseFeePerGas": "0x7",
+                            "blockHash": format!("0x{}", "ee".repeat(32)),
+                            "transactions": [],
+                            "withdrawals": attributes["withdrawals"],
+                            "blobGasUsed": "0x0",
+                            "excessBlobGas": "0x0",
+                            "blockAccessList": "0xc0",
+                            "slotNumber": attributes["slotNumber"],
+                        },
+                        "blockValue": format!("0x{block_value_wei:x}"),
+                        "blobsBundle": { "commitments": [], "proofs": [], "blobs": [] },
+                        "shouldOverrideBuilder": should_override_builder,
+                        "executionRequests": [],
+                    })
+                }
+                _ => serde_json::Value::Null,
+            };
+            Json(serde_json::json!({ "jsonrpc": "2.0", "id": request["id"], "result": result }))
+        }
+    };
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let address = listener.local_addr().unwrap();
+    let router = Router::new().route("/", post(handler));
+    tokio::spawn(async move { axum::serve(listener, router).await });
+    FakeEngine {
+        client: EngineClient::new(format!("http://{address}"), JwtSecret::new([0x0f; 32])).unwrap(),
+        attributes,
+    }
+}
+
+/// An execution client nothing listens for, so every build fails.
+fn dead_engine() -> FakeEngine {
+    FakeEngine {
+        client: EngineClient::new("http://127.0.0.1:1".to_string(), JwtSecret::new([0x0f; 32]))
+            .unwrap(),
+        attributes: Arc::default(),
+    }
+}
+
+impl FakeEngine {
+    fn requested(&self) -> serde_json::Value {
+        self.attributes
+            .lock()
+            .unwrap()
+            .clone()
+            .expect("the engine was asked to build")
+    }
+}
+
+// ---------------------------------------------------------------------------
+// The chain
+// ---------------------------------------------------------------------------
+
+fn builder_pubkey() -> BlsPubkey {
+    BlsPubkey(secret_key_for(1000).sk_to_pk().to_bytes())
+}
+
+fn sign_as_builder(root: H256) -> BlsSignature {
+    BlsSignature(
+        secret_key_for(1000)
+            .sign(
+                root.as_slice(),
+                ethlambda_state_transition::beacon::bls::DST,
+                &[],
+            )
+            .to_bytes(),
+    )
+}
+
+/// The block at slot 0 whose root the head state names as the dependent root of
+/// every slot of the first two epochs.
+fn genesis_block() -> SignedBeaconBlock {
+    gloas_beacon_block(0, H256::ZERO, H256::ZERO, H256::repeat_byte(0x0a))
+}
+
+/// A gloas head state at slot 32 whose registry holds one funded, active
+/// builder (index 0, key `secret_key_for(1000)`) and whose block roots name the
+/// genesis block.
+fn chain_state() -> BeaconState {
+    let mut state = parent_state();
+    let genesis_root = genesis_block().message_hash_tree_root();
+    let BeaconState::Gloas(inner) = &mut state else {
+        unreachable!("built as gloas")
+    };
+    // `is_active_builder` wants the deposit epoch below the finalized one.
+    inner.finalized_checkpoint.epoch = 1;
+    inner.builders.push(Builder {
+        pubkey: builder_pubkey(),
+        version: PAYLOAD_BUILDER_VERSION,
+        execution_address: ExecutionAddress::ZERO,
+        balance: 100_000_000_000,
+        deposit_epoch: 0,
+        withdrawable_epoch: FAR_FUTURE_EPOCH,
+    });
+    inner.block_roots[0] = genesis_root;
+    state
+}
+
+struct World {
+    store: Store,
+    state: BeaconState,
+    head_root: H256,
+    market: SharedBuilderMarket,
+    network: Arc,
+    fee_recipients: FeeRecipients,
+}
+
+impl World {
+    /// The head block at slot 32 on a clock where that slot is running.
+    fn new() -> Self {
+        let state = chain_state();
+        let genesis = genesis_block();
+        let genesis_root = genesis.message_hash_tree_root();
+        let block = gloas_beacon_block(
+            state.slot(),
+            genesis_root,
+            H256::repeat_byte(0x01),
+            H256::repeat_byte(0x02),
+        );
+        // The root a block built on this state names as its parent.
+        let mut header = state.latest_block_header().clone();
+        header.state_root = state.hash_tree_root();
+        let head_root = header.hash_tree_root();
+        let mut store = beacon_store_with_head_block(
+            state.clone(),
+            chain_config(),
+            block,
+            head_root,
+            HEAD_SLOT,
+        );
+        store.insert_signed_block(genesis_root, genesis).unwrap();
+        store.insert_state(genesis_root, state.clone()).unwrap();
+        Self {
+            store,
+            state,
+            head_root,
+            market: SharedBuilderMarket::default(),
+            network: Arc::default(),
+            fee_recipients: FeeRecipients::default(),
+        }
+    }
+
+    fn app(&self, engine: Option<&FakeEngine>) -> Router {
+        let p2p: RpcToP2PRef = self.network.clone();
+        produce_routes()
+            .merge(bids::routes())
+            .merge(proposer_preferences::routes())
+            .with_state(self.store.clone())
+            .layer(Extension(p2p))
+            .layer(Extension(engine.map(|engine| engine.client.clone())))
+            .layer(Extension(SharedAttestationPool::default()))
+            .layer(Extension(SharedPayloadAttestationPool::default()))
+            .layer(Extension(self.market.clone()))
+            .layer(Extension(self.fee_recipients.clone()))
+            .layer(Extension(CustodyColumns::default()))
+    }
+
+    fn advanced(&self) -> BeaconState {
+        advance_to_slot(&self.state, SLOT, &chain_config()).unwrap()
+    }
+
+    /// What `produceBlockV4` reads off the chain for [`SLOT`].
+    fn prepared(&self) -> Prepared {
+        prepare(
+            &self.store,
+            &self.market,
+            SLOT,
+            randao_reveal(&self.advanced()),
+        )
+        .unwrap()
+    }
+
+    /// A bid of builder 0 on the parent payload `produceBlockV4` builds on,
+    /// signed under the builder domain.
+    fn bid(&self, value: u64, fee_recipient: ExecutionAddress) -> SignedExecutionPayloadBid {
+        let prepared = self.prepared();
+        let message = ExecutionPayloadBid {
+            parent_block_hash: prepared.inputs.head_block_hash,
+            parent_block_root: prepared.head_root,
+            block_hash: H256::repeat_byte(0xb1),
+            prev_randao: prepared.inputs.prev_randao,
+            fee_recipient,
+            gas_limit: prepared.inputs.target_gas_limit,
+            builder_index: 0,
+            slot: SLOT,
+            value,
+            execution_payment: 0,
+            blob_kzg_commitments: Default::default(),
+            execution_requests_root: ExecutionRequests::default().hash_tree_root(),
+        };
+        let domain = get_domain(&prepared.state, DOMAIN_BEACON_BUILDER, None);
+        let signing_root = compute_signing_root(message.hash_tree_root(), domain);
+        SignedExecutionPayloadBid {
+            message,
+            signature: sign_as_builder(signing_root),
+        }
+    }
+
+    /// The proposer's preferences for [`SLOT`], signed with its key.
+    fn preferences(&self, fee_recipient: ExecutionAddress, gas: u64) -> SignedProposerPreferences {
+        self.preferences_by(self.prepared().proposer, fee_recipient, gas)
+    }
+
+    /// Preferences naming `validator`, signed with that validator's key.
+    fn preferences_by(
+        &self,
+        validator: u64,
+        fee_recipient: ExecutionAddress,
+        gas: u64,
+    ) -> SignedProposerPreferences {
+        let dependent_root = dependent_root_at(&self.state, self.head_root, SLOT)
+            .expect("the head's chain gives the slot a dependent root");
+        let message = ProposerPreferences {
+            dependent_root,
+            proposal_slot: SLOT,
+            validator_index: validator,
+            fee_recipient,
+            target_gas_limit: gas,
+        };
+        let domain = get_domain(
+            &self.state,
+            DOMAIN_PROPOSER_PREFERENCES,
+            Some(compute_epoch_at_slot(SLOT)),
+        );
+        let signing_root = compute_signing_root(message.hash_tree_root(), domain);
+        SignedProposerPreferences {
+            signature: sign_for(validator as usize, signing_root),
+            message,
+        }
+    }
+
+    /// Preferences recorded straight into the market, without the gossip rules.
+    fn prime_preferences(&self, fee_recipient: ExecutionAddress, gas: u64) {
+        let signed = self.preferences(fee_recipient, gas);
+        assert!(self.market.record_preferences(signed, HEAD_SLOT));
+    }
+
+    fn prime_bid(&self, bid: &SignedExecutionPayloadBid) {
+        assert!(self.market.record_bid(bid.clone()));
+    }
+
+    /// The parent payload bids are judged against becomes known to gossip, as
+    /// its envelope arriving would make it.
+    fn reveal_parent_payload(&self) {
+        let prepared = self.prepared();
+        let mut envelope = crate::test_utils::gloas_envelope(self.head_root, HEAD_SLOT);
+        envelope.message.payload.block_hash = prepared.inputs.head_block_hash;
+        envelope.message.payload.gas_limit = 30_000_000;
+        self.market.record_execution_payload(&envelope.message);
+    }
+}
+
+struct Reply {
+    status: StatusCode,
+    headers: HeaderMap,
+    body: Vec,
+}
+
+impl Reply {
+    fn json(&self) -> serde_json::Value {
+        serde_json::from_slice(&self.body).unwrap_or_default()
+    }
+}
+
+async fn send(app: &Router, request: Request) -> Reply {
+    let response = app.clone().oneshot(request).await.unwrap();
+    let status = response.status();
+    let headers = response.headers().clone();
+    let body = response
+        .into_body()
+        .collect()
+        .await
+        .unwrap()
+        .to_bytes()
+        .to_vec();
+    Reply {
+        status,
+        headers,
+        body,
+    }
+}
+
+fn address(byte: u8) -> ExecutionAddress {
+    ExecutionAddress::repeat_byte(byte)
+}
+
+// ---------------------------------------------------------------------------
+// produceBlockV4
+// ---------------------------------------------------------------------------
+
+fn builder_config(min_bid: u64, factor: u64) -> String {
+    format!(r#"{{"min_bid":"{min_bid}","builder_boost_factor":"{factor}","builders":[]}}"#)
+}
+
+fn produce_request(
+    world: &World,
+    include_payload: bool,
+    config: &str,
+    accept_ssz: bool,
+) -> Request {
+    let reveal = randao_reveal(&world.advanced());
+    let mut request = Request::post(format!(
+        "/eth/v4/validator/blocks/{SLOT}?randao_reveal=0x{}&include_payload={include_payload}",
+        hex::encode(reveal.0)
+    ))
+    .header("eth-consensus-version", "gloas")
+    .header("content-type", "application/json");
+    if accept_ssz {
+        request = request.header("accept", "application/octet-stream");
+    }
+    request.body(Body::from(config.to_string())).unwrap()
+}
+
+async fn produce(
+    world: &World,
+    engine: Option<&FakeEngine>,
+    include_payload: bool,
+    config: &str,
+) -> Reply {
+    send(
+        &world.app(engine),
+        produce_request(world, include_payload, config, false),
+    )
+    .await
+}
+
+/// The bid the produced block commits to, from a JSON response either way: a
+/// bare block, or contents carrying it.
+fn produced_block_bid(reply: &Reply) -> ExecutionPayloadBid {
+    let data = &reply.json()["data"];
+    let block = if data.get("block").is_some() {
+        &data["block"]
+    } else {
+        data
+    };
+    serde_json::from_value(block["body"]["signed_execution_payload_bid"]["message"].clone())
+        .expect("a gloas block carries its bid")
+}
+
+fn self_built(reply: &Reply) -> bool {
+    produced_block_bid(reply).builder_index == BUILDER_INDEX_SELF_BUILD
+}
+
+#[tokio::test]
+async fn a_bid_worth_more_than_the_local_payload_is_built_on_and_returned_bare() {
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    let bid = world.bid(5, address(0xcc));
+    world.prime_bid(&bid);
+
+    // Factor 100 weights both sides evenly: 5 gwei against 1 gwei.
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, 100)).await;
+
+    assert_eq!(
+        reply.status,
+        StatusCode::OK,
+        "{}",
+        String::from_utf8_lossy(&reply.body)
+    );
+    assert_eq!(produced_block_bid(&reply), bid.message);
+    // `include_payload=true` still comes back bare: the builder reveals.
+    let json = reply.json();
+    assert_eq!(json["execution_payload_included"], false);
+    assert_eq!(json["version"], "gloas");
+    assert!(json["data"].get("execution_payload_envelope").is_none());
+    assert_eq!(reply.headers["eth-execution-payload-included"], "false");
+    assert_eq!(
+        reply.headers["eth-execution-payload-value"],
+        (5 * GWEI).to_string().as_str()
+    );
+    assert_eq!(reply.headers["eth-consensus-block-value"], "0");
+    assert_eq!(reply.headers["eth-consensus-version"], "gloas");
+    assert!(reply.headers.get("eth-builder-url").is_none());
+}
+
+#[tokio::test]
+async fn a_bid_won_block_is_served_as_ssz_too() {
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    let bid = world.bid(5, address(0xcc));
+    world.prime_bid(&bid);
+
+    let reply = send(
+        &world.app(Some(&engine)),
+        produce_request(&world, false, &builder_config(0, 100), true),
+    )
+    .await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    assert_eq!(reply.headers["eth-execution-payload-included"], "false");
+    let block = BeaconBlock::from_ssz_bytes(&reply.body).unwrap();
+    assert_eq!(block.body.signed_execution_payload_bid, bid);
+}
+
+#[tokio::test]
+async fn nothing_is_cached_for_a_bid_block_and_a_self_build_envelope_for_it_is_refused() {
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    world.prime_bid(&world.bid(5, address(0xcc)));
+    let app = world.app(Some(&engine));
+    let reply = send(
+        &app,
+        produce_request(&world, false, &builder_config(0, 100), true),
+    )
+    .await;
+    assert_eq!(reply.status, StatusCode::OK);
+    let block = BeaconBlock::from_ssz_bytes(&reply.body).unwrap();
+    let root = block.hash_tree_root();
+
+    // No envelope was cached for the block.
+    let reply = send(
+        &app,
+        Request::get(format!(
+            "/eth/v1/validator/execution_payload_envelopes/{SLOT}/{root:?}"
+        ))
+        .body(Body::empty())
+        .unwrap(),
+    )
+    .await;
+    assert_eq!(reply.status, StatusCode::NOT_FOUND);
+
+    // The block is imported; the proposer's self-build envelope for it names
+    // a different builder than the bid, so it is refused.
+    let mut store = world.store.clone();
+    let post = post_state(&world.advanced(), &block);
+    store
+        .insert_signed_block(
+            root,
+            SignedBeaconBlock::Gloas(
+                ethlambda_types::beacon::containers::gloas::SignedBeaconBlock {
+                    message: block,
+                    signature: Default::default(),
+                },
+            ),
+        )
+        .unwrap();
+    store.insert_state(root, post).unwrap();
+    let mut envelope = crate::test_utils::gloas_envelope(root, SLOT);
+    envelope.message.builder_index = BUILDER_INDEX_SELF_BUILD;
+    let request = Request::post("/eth/v1/beacon/execution_payload_envelopes")
+        .header("eth-consensus-version", "gloas")
+        .header("eth-blob-data-included", "false")
+        .header("content-type", crate::SSZ_CONTENT_TYPE)
+        .body(Body::from(SignedExecutionPayloadEnvelope::to_ssz(
+            &envelope,
+        )))
+        .unwrap();
+    let reply = send(&app, request).await;
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    assert_eq!(
+        reply.json()["message"],
+        "the envelope does not fulfill the block's bid"
+    );
+    assert!(world.network.envelopes.lock().unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn the_local_payload_wins_ties_a_zero_factor_a_floor_and_an_override() {
+    // (local value in wei, should_override, min_bid in gwei, factor)
+    let cases = [
+        // 5 gwei each side at factor 100: a tie.
+        (5 * GWEI, false, 0, 100),
+        // Factor 0 prefers the local payload.
+        (1, false, 0, 0),
+        // The bid is below the config's floor.
+        (1, false, 6, u64::MAX),
+        // The engine insists on its own payload.
+        (1, true, 0, u64::MAX),
+    ];
+    for (local_wei, should_override, min_bid, factor) in cases {
+        let world = World::new();
+        let engine = fake_engine(local_wei, should_override).await;
+        world.prime_bid(&world.bid(5, address(0xcc)));
+
+        let reply = produce(
+            &world,
+            Some(&engine),
+            true,
+            &builder_config(min_bid, factor),
+        )
+        .await;
+
+        assert_eq!(
+            reply.status,
+            StatusCode::OK,
+            "{local_wei} {min_bid} {factor}"
+        );
+        assert!(
+            self_built(&reply),
+            "{local_wei} {should_override} {min_bid} {factor}"
+        );
+        assert_eq!(reply.json()["execution_payload_included"], true);
+        assert_eq!(reply.headers["eth-execution-payload-included"], "true");
+        assert!(
+            reply.json()["data"]
+                .get("execution_payload_envelope")
+                .is_some()
+        );
+    }
+}
+
+#[tokio::test]
+async fn a_bid_wins_when_the_local_build_is_below_it_by_one_unit_of_weight() {
+    // 1e9 wei is 1 gwei: a tie at factor 100. One wei less and the bid wins.
+    for (local_wei, bid_wins) in [(GWEI, false), (GWEI - 1, true)] {
+        let world = World::new();
+        let engine = fake_engine(local_wei, false).await;
+        world.prime_bid(&world.bid(1, address(0xcc)));
+
+        let reply = produce(&world, Some(&engine), false, &builder_config(0, 100)).await;
+
+        assert_eq!(reply.status, StatusCode::OK);
+        assert_eq!(self_built(&reply), !bid_wins, "{local_wei}");
+    }
+}
+
+#[tokio::test]
+async fn without_an_engine_a_viable_bid_is_built_on_and_none_is_a_503() {
+    let world = World::new();
+    let reply = produce(&world, None, true, &builder_config(0, 0)).await;
+    assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE);
+
+    let bid = world.bid(5, address(0xcc));
+    world.prime_bid(&bid);
+    // Even a factor of 0 takes the bid when there is nothing to prefer.
+    let reply = produce(&world, None, true, &builder_config(0, 0)).await;
+    assert_eq!(reply.status, StatusCode::OK);
+    assert_eq!(produced_block_bid(&reply), bid.message);
+
+    // A bid under the floor is not viable.
+    let reply = produce(&world, None, true, &builder_config(6, 100)).await;
+    assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE);
+}
+
+#[tokio::test]
+async fn a_failed_local_build_falls_back_to_a_viable_bid() {
+    let world = World::new();
+    let engine = dead_engine();
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, 0)).await;
+    assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE);
+
+    let bid = world.bid(5, address(0xcc));
+    world.prime_bid(&bid);
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, 0)).await;
+    assert_eq!(reply.status, StatusCode::OK);
+    assert_eq!(produced_block_bid(&reply), bid.message);
+}
+
+#[tokio::test]
+async fn bids_that_do_not_fit_the_slot_are_left_to_the_local_payload() {
+    // Each would win on value (factor MAX against a 1 wei local build).
+    let mismatches: [(&str, fn(&mut ExecutionPayloadBid)); 3] = [
+        ("randao", |bid| bid.prev_randao = H256::repeat_byte(0x99)),
+        ("parent hash", |bid| {
+            bid.parent_block_hash = H256::repeat_byte(0x98)
+        }),
+        ("parent root", |bid| {
+            bid.parent_block_root = H256::repeat_byte(0x97)
+        }),
+    ];
+    for (name, change) in mismatches {
+        let world = World::new();
+        let engine = fake_engine(1, false).await;
+        let mut bid = world.bid(5, address(0xcc));
+        change(&mut bid.message);
+        // Re-signing is beside the point: the pool is keyed on the fields that
+        // changed, and a bid that does not match never reaches a block.
+        world.market.record_bid(bid);
+
+        let reply = produce(&world, Some(&engine), true, &builder_config(0, u64::MAX)).await;
+
+        assert_eq!(reply.status, StatusCode::OK, "{name}");
+        assert!(self_built(&reply), "{name}");
+    }
+}
+
+#[tokio::test]
+async fn a_bid_paying_someone_other_than_the_preferred_recipient_is_skipped() {
+    let world = World::new();
+    let engine = fake_engine(1, false).await;
+    world.prime_preferences(address(0xaa), 30_000_000);
+    world.prime_bid(&world.bid(5, address(0xcc)));
+
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, u64::MAX)).await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    assert!(self_built(&reply));
+}
+
+#[tokio::test]
+async fn a_bid_paying_the_preferred_recipient_is_taken() {
+    let world = World::new();
+    let engine = fake_engine(1, false).await;
+    world.prime_preferences(address(0xaa), 30_000_000);
+    let bid = world.bid(5, address(0xaa));
+    world.prime_bid(&bid);
+
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, u64::MAX)).await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    assert_eq!(produced_block_bid(&reply), bid.message);
+}
+
+#[tokio::test]
+async fn the_self_build_takes_its_fee_recipient_and_gas_target_from_the_preferences() {
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    let proposer = world.prepared().proposer;
+    world
+        .fee_recipients
+        .lock()
+        .unwrap()
+        .insert(proposer, address(0xbb));
+    world.prime_preferences(address(0xaa), 31_000_000);
+
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, 0)).await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    let requested = engine.requested();
+    assert_eq!(
+        requested["suggestedFeeRecipient"],
+        format!("0x{}", "aa".repeat(20))
+    );
+    assert_eq!(
+        requested["targetGasLimit"],
+        format!("0x{:x}", 31_000_000u64)
+    );
+    // The payload the engine built carries them into the block's bid.
+    let bid = produced_block_bid(&reply);
+    assert_eq!(bid.fee_recipient, address(0xaa));
+    assert_eq!(bid.gas_limit, 31_000_000);
+}
+
+#[tokio::test]
+async fn without_preferences_the_self_build_falls_back_to_the_prepared_recipient_and_parent_gas() {
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    let proposer = world.prepared().proposer;
+    world
+        .fee_recipients
+        .lock()
+        .unwrap()
+        .insert(proposer, address(0xbb));
+
+    let reply = produce(&world, Some(&engine), true, &builder_config(0, 0)).await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    let requested = engine.requested();
+    assert_eq!(
+        requested["suggestedFeeRecipient"],
+        format!("0x{}", "bb".repeat(20))
+    );
+    // The parent bid's gas limit, which is what `chain_state` gives it.
+    assert_eq!(
+        requested["targetGasLimit"],
+        format!("0x{:x}", 30_000_000u64)
+    );
+
+    // Neither: the zero address.
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    produce(&world, Some(&engine), true, &builder_config(0, 0)).await;
+    assert_eq!(
+        engine.requested()["suggestedFeeRecipient"],
+        format!("0x{}", "00".repeat(20))
+    );
+}
+
+#[tokio::test]
+async fn preferences_of_another_validator_are_not_the_proposers() {
+    let world = World::new();
+    let engine = fake_engine(GWEI, false).await;
+    let proposer = world.prepared().proposer;
+    let other = (proposer + 1) % 64;
+    let stranger = world.preferences_by(other, address(0xaa), 31_000_000);
+    assert!(world.market.record_preferences(stranger, HEAD_SLOT));
+
+    produce(&world, Some(&engine), true, &builder_config(0, 0)).await;
+
+    let requested = engine.requested();
+    assert_eq!(
+        requested["suggestedFeeRecipient"],
+        format!("0x{}", "00".repeat(20))
+    );
+    assert_eq!(
+        requested["targetGasLimit"],
+        format!("0x{:x}", 30_000_000u64)
+    );
+}
+
+#[tokio::test]
+async fn the_existing_error_paths_are_unchanged() {
+    let world = World::new();
+    let app = world.app(None);
+    // A pre-existing client sends the empty local-preferred config.
+    let reply = send(
+        &app,
+        produce_request(&world, true, &builder_config(0, 0), false),
+    )
+    .await;
+    assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE);
+    let reply = send(&app, produce_request(&world, true, "not a config", false)).await;
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    assert_eq!(reply.json()["message"], "the body is not a BuilderConfig");
+}
+
+// ---------------------------------------------------------------------------
+// POST execution_payload_bids
+// ---------------------------------------------------------------------------
+
+fn bid_request(bid: &SignedExecutionPayloadBid) -> Request {
+    Request::post("/eth/v1/beacon/execution_payload_bids")
+        .header("eth-consensus-version", "gloas")
+        .header("content-type", "application/json")
+        .body(Body::from(serde_json::to_vec(bid).unwrap()))
+        .unwrap()
+}
+
+/// A world where a bid of builder 0 passes every rule: the preferences for the
+/// slot are cached and the parent payload is known.
+fn world_ready_for_bids() -> World {
+    let world = World::new();
+    world.prime_preferences(address(0xaa), 30_000_000);
+    world.reveal_parent_payload();
+    world
+}
+
+#[tokio::test]
+async fn a_valid_bid_is_pooled_and_published_once() {
+    let world = world_ready_for_bids();
+    let app = world.app(None);
+    let bid = world.bid(5, address(0xaa));
+
+    let reply = send(&app, bid_request(&bid)).await;
+
+    assert_eq!(
+        reply.status,
+        StatusCode::OK,
+        "{}",
+        String::from_utf8_lossy(&reply.body)
+    );
+    assert_eq!(*world.network.bids.lock().unwrap(), vec![bid.clone()]);
+    assert!(world.market.contains_bid(&bid));
+
+    // An identical resubmission is a success and is not gossiped again.
+    let reply = send(&app, bid_request(&bid)).await;
+    assert_eq!(reply.status, StatusCode::OK);
+    assert_eq!(world.network.bids.lock().unwrap().len(), 1);
+}
+
+#[tokio::test]
+async fn a_pooled_bid_resubmitted_is_a_200_without_a_publish() {
+    // Needs only the market: the identical bid short-circuits the rules.
+    let world = World::new();
+    let bid = world.bid(5, address(0xaa));
+    world.prime_bid(&bid);
+
+    let reply = send(&world.app(None), bid_request(&bid)).await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    assert!(world.network.bids.lock().unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn a_bid_is_accepted_as_ssz() {
+    let world = world_ready_for_bids();
+    let bid = world.bid(5, address(0xaa));
+    let request = Request::post("/eth/v1/beacon/execution_payload_bids")
+        .header("eth-consensus-version", "gloas")
+        .header("content-type", crate::SSZ_CONTENT_TYPE)
+        .body(Body::from(bid.to_ssz()))
+        .unwrap();
+
+    let reply = send(&world.app(None), request).await;
+
+    assert_eq!(
+        reply.status,
+        StatusCode::OK,
+        "{}",
+        String::from_utf8_lossy(&reply.body)
+    );
+    assert_eq!(*world.network.bids.lock().unwrap(), vec![bid]);
+}
+
+#[tokio::test]
+async fn a_bid_with_a_bad_signature_is_a_400_naming_the_verdict() {
+    let world = world_ready_for_bids();
+    let mut bid = world.bid(5, address(0xaa));
+    bid.signature = BlsSignature::default();
+
+    let reply = send(&world.app(None), bid_request(&bid)).await;
+
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    assert_eq!(reply.json()["code"], 400);
+    assert_eq!(reply.json()["message"], "reject: bad_signature");
+    assert!(world.network.bids.lock().unwrap().is_empty());
+    assert!(!world.market.contains_bid(&bid));
+}
+
+#[tokio::test]
+async fn a_bid_for_a_slot_without_preferences_is_a_400() {
+    let world = World::new();
+    world.reveal_parent_payload();
+    let bid = world.bid(5, address(0xaa));
+
+    let reply = send(&world.app(None), bid_request(&bid)).await;
+
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    assert_eq!(reply.json()["message"], "ignore: preferences_unseen");
+    assert!(world.network.bids.lock().unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn a_lower_bid_after_a_higher_one_is_refused_and_the_verdict_is_a_400() {
+    let world = world_ready_for_bids();
+    let app = world.app(None);
+    let high = world.bid(9, address(0xaa));
+    assert_eq!(send(&app, bid_request(&high)).await.status, StatusCode::OK);
+    let mut low = world.bid(5, address(0xaa));
+    low.message.builder_index = 0;
+
+    let reply = send(&app, bid_request(&low)).await;
+
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    assert_eq!(world.network.bids.lock().unwrap().len(), 1);
+}
+
+#[tokio::test]
+async fn a_bid_with_a_wrong_header_or_content_type_or_body_is_refused() {
+    let world = world_ready_for_bids();
+    let app = world.app(None);
+    let bid = world.bid(5, address(0xaa));
+    let json = serde_json::to_vec(&bid).unwrap();
+
+    let wrong_fork = Request::post("/eth/v1/beacon/execution_payload_bids")
+        .header("eth-consensus-version", "fulu")
+        .body(Body::from(json.clone()))
+        .unwrap();
+    assert_eq!(send(&app, wrong_fork).await.status, StatusCode::BAD_REQUEST);
+
+    let plain = Request::post("/eth/v1/beacon/execution_payload_bids")
+        .header("content-type", "text/plain")
+        .body(Body::from(json.clone()))
+        .unwrap();
+    assert_eq!(
+        send(&app, plain).await.status,
+        StatusCode::UNSUPPORTED_MEDIA_TYPE
+    );
+
+    let garbage = Request::post("/eth/v1/beacon/execution_payload_bids")
+        .body(Body::from("not a bid"))
+        .unwrap();
+    assert_eq!(send(&app, garbage).await.status, StatusCode::BAD_REQUEST);
+
+    let oversized = Request::post("/eth/v1/beacon/execution_payload_bids")
+        .header("content-type", crate::SSZ_CONTENT_TYPE)
+        .body(Body::from(vec![0u8; 196_933]))
+        .unwrap();
+    assert_eq!(send(&app, oversized).await.status, StatusCode::BAD_REQUEST);
+
+    // No header at all is read leniently.
+    let lenient = Request::post("/eth/v1/beacon/execution_payload_bids")
+        .body(Body::from(json))
+        .unwrap();
+    assert_eq!(send(&app, lenient).await.status, StatusCode::OK);
+}
+
+// ---------------------------------------------------------------------------
+// POST proposer_preferences
+// ---------------------------------------------------------------------------
+
+fn preferences_request(
+    preferences: &[SignedProposerPreferences],
+    version: Option<&str>,
+) -> Request {
+    let mut request = Request::post("/eth/v1/validator/proposer_preferences")
+        .header("content-type", "application/json");
+    if let Some(version) = version {
+        request = request.header("eth-consensus-version", version);
+    }
+    request
+        .body(Body::from(serde_json::to_vec(preferences).unwrap()))
+        .unwrap()
+}
+
+#[tokio::test]
+async fn valid_preferences_are_cached_and_published_once() {
+    let world = World::new();
+    let app = world.app(None);
+    let signed = world.preferences(address(0xaa), 30_000_000);
+
+    let reply = send(&app, preferences_request(&[signed.clone()], Some("gloas"))).await;
+
+    assert_eq!(
+        reply.status,
+        StatusCode::OK,
+        "{}",
+        String::from_utf8_lossy(&reply.body)
+    );
+    assert_eq!(
+        *world.network.proposer_preferences.lock().unwrap(),
+        vec![signed.clone()]
+    );
+    assert_eq!(
+        world
+            .market
+            .preferences(SLOT, signed.message.dependent_root),
+        Some(signed.clone())
+    );
+
+    // The same again is a success without a second publish.
+    let reply = send(&app, preferences_request(&[signed], Some("gloas"))).await;
+    assert_eq!(reply.status, StatusCode::OK);
+    assert_eq!(world.network.proposer_preferences.lock().unwrap().len(), 1);
+}
+
+#[tokio::test]
+async fn cached_preferences_resubmitted_are_a_200_without_a_publish() {
+    // Needs only the market: an identical entry short-circuits the rules.
+    let world = World::new();
+    let signed = world.preferences(address(0xaa), 30_000_000);
+    assert!(world.market.record_preferences(signed.clone(), HEAD_SLOT));
+
+    let reply = send(&world.app(None), preferences_request(&[signed], None)).await;
+
+    assert_eq!(reply.status, StatusCode::OK);
+    assert!(
+        world
+            .network
+            .proposer_preferences
+            .lock()
+            .unwrap()
+            .is_empty()
+    );
+}
+
+#[tokio::test]
+async fn different_preferences_for_a_held_key_fail_with_their_position() {
+    let world = World::new();
+    let first = world.preferences(address(0xaa), 30_000_000);
+    let second = world.preferences(address(0xbb), 30_000_000);
+
+    let reply = send(
+        &world.app(None),
+        preferences_request(&[first.clone(), second], Some("gloas")),
+    )
+    .await;
+
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    let json = reply.json();
+    assert_eq!(json["code"], 400);
+    assert_eq!(json["failures"].as_array().unwrap().len(), 1);
+    assert_eq!(json["failures"][0]["index"], 1);
+    assert_eq!(json["failures"][0]["message"], "ignore: already_seen");
+    // The first still went out.
+    assert_eq!(
+        *world.network.proposer_preferences.lock().unwrap(),
+        vec![first]
+    );
+}
+
+#[tokio::test]
+async fn preferences_by_the_wrong_proposer_or_signer_fail_and_the_rest_still_go_out() {
+    let world = World::new();
+    let proposer = world.prepared().proposer;
+    let wrong_proposer = world.preferences_by((proposer + 1) % 64, address(0xaa), 30_000_000);
+    let mut bad_signature = world.preferences(address(0xaa), 30_000_000);
+    bad_signature.signature = BlsSignature::default();
+    let good = world.preferences(address(0xcc), 30_000_000);
+
+    let reply = send(
+        &world.app(None),
+        preferences_request(
+            &[wrong_proposer, bad_signature, good.clone()],
+            Some("gloas"),
+        ),
+    )
+    .await;
+
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    let json = reply.json();
+    let failures: Vec<(u64, String)> = json["failures"]
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|failure| {
+            (
+                failure["index"].as_u64().unwrap(),
+                failure["message"].as_str().unwrap().to_string(),
+            )
+        })
+        .collect();
+    assert_eq!(
+        failures,
+        vec![
+            (0, "reject: wrong_proposer".to_string()),
+            (1, "reject: bad_signature".to_string()),
+        ]
+    );
+    assert_eq!(
+        *world.network.proposer_preferences.lock().unwrap(),
+        vec![good]
+    );
+}
+
+#[tokio::test]
+async fn preferences_are_accepted_as_an_ssz_list_and_the_header_may_be_fulu() {
+    let world = World::new();
+    let signed = world.preferences(address(0xaa), 30_000_000);
+    let request = Request::post("/eth/v1/validator/proposer_preferences")
+        .header("content-type", crate::SSZ_CONTENT_TYPE)
+        .header("eth-consensus-version", "fulu")
+        .body(Body::from(vec![signed.clone()].to_ssz()))
+        .unwrap();
+
+    let reply = send(&world.app(None), request).await;
+
+    assert_eq!(
+        reply.status,
+        StatusCode::OK,
+        "{}",
+        String::from_utf8_lossy(&reply.body)
+    );
+    assert_eq!(
+        *world.network.proposer_preferences.lock().unwrap(),
+        vec![signed]
+    );
+}
+
+#[tokio::test]
+async fn preferences_with_a_bad_header_type_body_or_count_are_refused() {
+    let world = World::new();
+    let app = world.app(None);
+    let signed = world.preferences(address(0xaa), 30_000_000);
+
+    for version in ["electra", "nonsense"] {
+        let reply = send(&app, preferences_request(&[signed.clone()], Some(version))).await;
+        assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{version}");
+    }
+    let plain = Request::post("/eth/v1/validator/proposer_preferences")
+        .header("content-type", "text/plain")
+        .body(Body::from("[]"))
+        .unwrap();
+    assert_eq!(
+        send(&app, plain).await.status,
+        StatusCode::UNSUPPORTED_MEDIA_TYPE
+    );
+    let garbage = Request::post("/eth/v1/validator/proposer_preferences")
+        .body(Body::from("not json"))
+        .unwrap();
+    assert_eq!(send(&app, garbage).await.status, StatusCode::BAD_REQUEST);
+
+    // One over the list bound is refused before any entry is looked at.
+    let many =
+        vec![signed; ethlambda_state_transition::beacon::preset::PROPOSER_LOOKAHEAD_LENGTH + 1];
+    let reply = send(&app, preferences_request(&many, Some("gloas"))).await;
+    assert_eq!(reply.status, StatusCode::BAD_REQUEST);
+    assert!(
+        world
+            .network
+            .proposer_preferences
+            .lock()
+            .unwrap()
+            .is_empty()
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Routing
+// ---------------------------------------------------------------------------
+
+/// Every new route answers something other than a 500 through the layer set
+/// the production server applies, and the phase-2 route does not exist.
+#[tokio::test]
+async fn the_new_routes_are_wired_with_the_production_layers() {
+    use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus};
+
+    let world = World::new();
+    let router =
+        crate::build_beacon_api_router(world.store.clone(), "ethlambda/test", "peer".into())
+            .layer(Extension(SyncStatusController::new(SyncStatus::Synced)))
+            .layer(Extension::(world.network.clone()))
+            .layer(Extension(SharedAttestationPool::default()))
+            .layer(Extension(SharedPayloadAttestationPool::default()))
+            .layer(Extension(world.market.clone()))
+            .layer(Extension(CustodyColumns::default()))
+            .layer(Extension(FeeRecipients::default()))
+            .layer(Extension(None::));
+
+    for (uri, body) in [
+        ("/eth/v1/beacon/execution_payload_bids", "{}"),
+        ("/eth/v1/validator/proposer_preferences", "[]"),
+        ("/eth/v1/beacon/states/head/builders", ""),
+    ] {
+        let reply = send(&router, Request::post(uri).body(Body::from(body)).unwrap()).await;
+        assert_ne!(reply.status, StatusCode::INTERNAL_SERVER_ERROR, "{uri}");
+        assert_ne!(reply.status, StatusCode::NOT_FOUND, "{uri}");
+    }
+    let reply = send(
+        &router,
+        Request::post("/eth/v1/validator/builder_preferences")
+            .body(Body::from("[]"))
+            .unwrap(),
+    )
+    .await;
+    assert_eq!(reply.status, StatusCode::NOT_FOUND);
+}
diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs
index d12245d0..3c9a3d1d 100644
--- a/crates/net/rpc/src/beacon/gloas_proposal.rs
+++ b/crates/net/rpc/src/beacon/gloas_proposal.rs
@@ -1839,3 +1839,7 @@ mod tests {
         assert_eq!(rejected.envelopes.lock().unwrap().len(), 1);
     }
 }
+
+#[cfg(test)]
+#[path = "builder_market_tests.rs"]
+mod builder_market_tests;

From 5c905dbad5a6ba2bf199982bdaacde11f181db8e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?=
 <47506558+MegaRedHand@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:00:55 -0300
Subject: [PATCH 08/12] style(rpc): satisfy clippy in the builder market code
 and tests

---
 crates/net/rpc/src/beacon/builder_market_tests.rs | 15 ++++++++++++---
 crates/net/rpc/src/beacon/gloas_proposal.rs       |  2 ++
 2 files changed, 14 insertions(+), 3 deletions(-)

diff --git a/crates/net/rpc/src/beacon/builder_market_tests.rs b/crates/net/rpc/src/beacon/builder_market_tests.rs
index b43efd66..26876e6e 100644
--- a/crates/net/rpc/src/beacon/builder_market_tests.rs
+++ b/crates/net/rpc/src/beacon/builder_market_tests.rs
@@ -667,7 +667,8 @@ async fn a_failed_local_build_falls_back_to_a_viable_bid() {
 #[tokio::test]
 async fn bids_that_do_not_fit_the_slot_are_left_to_the_local_payload() {
     // Each would win on value (factor MAX against a 1 wei local build).
-    let mismatches: [(&str, fn(&mut ExecutionPayloadBid)); 3] = [
+    type Change = fn(&mut ExecutionPayloadBid);
+    let mismatches: [(&str, Change); 3] = [
         ("randao", |bid| bid.prev_randao = H256::repeat_byte(0x99)),
         ("parent hash", |bid| {
             bid.parent_block_hash = H256::repeat_byte(0x98)
@@ -1007,7 +1008,11 @@ async fn valid_preferences_are_cached_and_published_once() {
     let app = world.app(None);
     let signed = world.preferences(address(0xaa), 30_000_000);
 
-    let reply = send(&app, preferences_request(&[signed.clone()], Some("gloas"))).await;
+    let reply = send(
+        &app,
+        preferences_request(std::slice::from_ref(&signed), Some("gloas")),
+    )
+    .await;
 
     assert_eq!(
         reply.status,
@@ -1152,7 +1157,11 @@ async fn preferences_with_a_bad_header_type_body_or_count_are_refused() {
     let signed = world.preferences(address(0xaa), 30_000_000);
 
     for version in ["electra", "nonsense"] {
-        let reply = send(&app, preferences_request(&[signed.clone()], Some(version))).await;
+        let reply = send(
+            &app,
+            preferences_request(std::slice::from_ref(&signed), Some(version)),
+        )
+        .await;
         assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{version}");
     }
     let plain = Request::post("/eth/v1/validator/proposer_preferences")
diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs
index 3c9a3d1d..59d5b760 100644
--- a/crates/net/rpc/src/beacon/gloas_proposal.rs
+++ b/crates/net/rpc/src/beacon/gloas_proposal.rs
@@ -373,6 +373,8 @@ fn blobs_list(blobs: Vec>) -> Result {
 }
 
 /// What `produceBlockV4` built for one block.
+// Short-lived (one per request), so boxing the larger variant buys nothing.
+#[allow(clippy::large_enum_variant)]
 enum Production {
     /// A block on this node's own payload, with the envelope that reveals it.
     Local(Produced),

From 7b6fa4cdb736b84761c18a2a8459e7952e2e1faa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?=
 <47506558+MegaRedHand@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:03:46 -0300
Subject: [PATCH 09/12] feat(state-transition): validate gloas bids and
 preferences, assemble blocks on a bid

Gives the builder market its rules: the spec's execution_payload_bid and
proposer_preferences gossip validation, split into cheap and stateful halves
like the other topics, plus block assembly on a builder's bid.

The rules read cached states only and never queue, so a verdict gossipsub
waits on is never stalled by a replay. Two shortcuts keep that honest: the
parent's own state stands in for the advanced one within its epoch (gloas's
process_slot touches none of the fields the later rules read), and a
proposer's lookahead comes from the head state when it shares the dependent
root, since a dependent block about an epoch old is usually out of the state
cache. Preference signatures verify under the fork versions on both sides of
the gloas boundary, because clients disagree there.

assemble_gloas_block_on_bid and bid_is_includable let produceBlockV4 commit a
block to another builder's bid; the pre-filter keeps a bid that would fail
process_block from being tried.

Both gossip vector handlers now run (44 and 26 mainnet cases pass). The
runner records a delivered envelope the way the spec generators do, without
re-verifying it, and applies the fixture's finalized-checkpoint override to
the stored states, which is how those generators activate builders.
docs/spec_deviations.md lists what departs from the spec.
---
 .../src/beacon/gloas_block_production.rs      |  284 ++++-
 .../beacon/gossip/execution_payload_bid.rs    | 1094 ++++++++++++++++-
 .../src/beacon/gossip/proposer_preferences.rs |  594 ++++++++-
 .../src/beacon/gossip/test_support.rs         |  159 +++
 .../tests/beacon_spec/gossip.rs               |  135 +-
 docs/spec_deviations.md                       |  173 ++-
 6 files changed, 2311 insertions(+), 128 deletions(-)

diff --git a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs
index 6b3af3aa..1a0fa8ed 100644
--- a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs
+++ b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs
@@ -29,8 +29,8 @@ use super::helpers::accessors::{
     CommitteeCache, get_beacon_proposer_index, get_current_epoch, get_randao_mix,
 };
 use super::helpers::gloas::{
-    get_indexed_payload_attestation, get_ptc, gloas_state_ref, is_attestation_same_slot,
-    is_valid_indexed_payload_attestation,
+    can_builder_cover_bid, get_indexed_payload_attestation, get_ptc, gloas_state_ref,
+    is_active_builder, is_attestation_same_slot, is_valid_indexed_payload_attestation,
 };
 use super::stf;
 use ethlambda_types::beacon::{
@@ -359,28 +359,88 @@ pub struct GloasBidBlockInputs {
     pub signed_bid: SignedExecutionPayloadBid,
 }
 
-/// The unsigned block for `state.slot()` committing to `inputs.signed_bid`.
-/// `process_block` on a copy fills `state_root` and enforces
-/// `process_execution_payload_bid` (active, cover, signature, slot, parent
-/// hash and root, randao). No envelope: the builder reveals it.
-#[allow(dead_code)] // filled by Agent A
+/// The unsigned block for `state.slot()` committing to `inputs.signed_bid`, a
+/// builder's bid rather than a payload this node built.
+///
+/// The body is [`assemble_gloas_block`]'s (empty sync aggregate, the state's own
+/// `eth1_data`), with the bid in place of the self-build one. `process_block` on
+/// a copy fills `state_root` and enforces `process_execution_payload_bid`:
+/// the builder is active, covers the bid and signed it, and the bid's slot,
+/// parent hash and root and randao are the state's. There is no envelope: the
+/// builder reveals the payload itself.
 pub fn assemble_gloas_block_on_bid(
-    _state: &BeaconState,
-    _inputs: GloasBidBlockInputs,
-    _config: &Config,
+    state: &BeaconState,
+    inputs: GloasBidBlockInputs,
+    config: &Config,
 ) -> Result {
-    Err(Error::SpecAssert("unimplemented"))
+    verify(
+        matches!(state, BeaconState::Gloas(_)),
+        "gloas block production runs on a gloas state",
+    )?;
+    let body = BeaconBlockBody {
+        randao_reveal: inputs.randao_reveal,
+        eth1_data: state.eth1_data().clone(),
+        graffiti: inputs.graffiti,
+        attestations: inputs.attestations.into(),
+        sync_aggregate: empty_sync_aggregate(),
+        signed_execution_payload_bid: inputs.signed_bid,
+        payload_attestations: inputs.payload_attestations.into(),
+        parent_execution_requests: inputs.parent_execution_requests,
+        ..BeaconBlockBody::empty()
+    };
+    let mut block = BeaconBlock {
+        slot: state.slot(),
+        proposer_index: get_beacon_proposer_index(state)?,
+        parent_root: state.latest_block_header().hash_tree_root(),
+        state_root: Root::ZERO,
+        body,
+    };
+    let mut post = state.clone();
+    stf::gloas::process_block(&mut post, &block, config, &CommitteeCache::default())?;
+    block.state_root = post.hash_tree_root();
+    Ok(block)
 }
 
 /// Cheap pre-filter on the state advanced to the slot: whether the bid could
-/// be packed into a block on `state`. The signature is not checked here.
-#[allow(dead_code)] // filled by Agent A
+/// be packed into a block on `state`, so the producer skips a bid whose block
+/// would fail rather than learning it from a full `process_block`.
+///
+/// Checks the bid is for `state`'s slot, parent root, previous randao and
+/// parent payload (full: the parent bid's block hash; empty: the state's
+/// `latest_block_hash`), that the builder exists, is a payload builder, is
+/// active and covers the value, that the commitment count is within the preset
+/// bound, and that the parent payload does not exit the builder when the bid
+/// builds on it (`process_parent_execution_payload` runs before the bid, so a
+/// builder that exits there is no longer active). The signature is not checked
+/// here; `process_block` does.
 pub fn bid_is_includable(
-    _state: &BeaconState,
-    _signed_bid: &SignedExecutionPayloadBid,
-    _parent_requests: &ExecutionRequests,
+    state: &BeaconState,
+    signed_bid: &SignedExecutionPayloadBid,
+    parent_requests: &ExecutionRequests,
 ) -> bool {
-    false
+    let BeaconState::Gloas(inner) = state else {
+        return false;
+    };
+    let bid = &signed_bid.message;
+    let parent_is_full = bid.parent_block_hash == inner.latest_execution_payload_bid.block_hash;
+    let builds_on_known_parent = parent_is_full || bid.parent_block_hash == inner.latest_block_hash;
+    let Some(builder) = inner.builders.get(bid.builder_index as usize) else {
+        return false;
+    };
+    let exited_by_parent = parent_is_full
+        && parent_requests.builder_exits.iter().any(|exit| {
+            exit.pubkey == builder.pubkey && exit.source_address == builder.execution_address
+        });
+    bid.slot == state.slot()
+        && bid.parent_block_root == state.latest_block_header().hash_tree_root()
+        && bid.prev_randao == get_randao_mix(state, get_current_epoch(state))
+        && builds_on_known_parent
+        && bid.block_hash != bid.parent_block_hash
+        && builder.version == constants::PAYLOAD_BUILDER_VERSION
+        && bid.blob_kzg_commitments.len() <= preset::MAX_BLOB_COMMITMENTS_PER_BLOCK
+        && !exited_by_parent
+        && is_active_builder(inner, bid.builder_index).unwrap_or(false)
+        && can_builder_cover_bid(inner, bid.builder_index, bid.value).unwrap_or(false)
 }
 
 /// A produced block and the unsigned envelope that reveals its payload.
@@ -1113,3 +1173,193 @@ mod gloas_block_production_tests {
         assert!(parse_gloas_execution_requests(&[vec![0x7f, 0]]).is_err());
     }
 }
+
+#[cfg(test)]
+mod bid_assembly_tests {
+    use super::test_support::*;
+    use super::*;
+    use crate::beacon::ForkName;
+    use crate::beacon::block_production::advance_to_slot;
+    use crate::beacon::builder_market::test_support::{
+        builder_secret, gloas_state_with_builder, sign_bid,
+    };
+    use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionAddress};
+
+    /// A state at slot 33 with a funded, active builder 0, and a bid on its
+    /// full parent that a block can carry.
+    fn scene() -> (BeaconState, SignedExecutionPayloadBid) {
+        let parent = gloas_state_with_builder(0, 100_000_000_000, 0);
+        let state = advance_to_slot(&parent, parent.slot() + 1, &config()).unwrap();
+        let bid = bid_for(&state, |_| {});
+        (state, bid)
+    }
+
+    /// A bid on `state`'s full parent payload, signed by builder 0 after `edit`.
+    fn bid_for(
+        state: &BeaconState,
+        edit: impl FnOnce(&mut ExecutionPayloadBid),
+    ) -> SignedExecutionPayloadBid {
+        let mut bid = ExecutionPayloadBid {
+            parent_block_hash: ExecutionBlockHash::repeat_byte(PARENT_BLOCK_HASH),
+            parent_block_root: state.latest_block_header().hash_tree_root(),
+            block_hash: ExecutionBlockHash::repeat_byte(0x33),
+            prev_randao: get_randao_mix(state, get_current_epoch(state)),
+            gas_limit: 30_000_000,
+            builder_index: 0,
+            slot: state.slot(),
+            value: 7,
+            ..Default::default()
+        };
+        edit(&mut bid);
+        sign_bid(state, bid, 0)
+    }
+
+    fn inputs(state: &BeaconState, signed_bid: SignedExecutionPayloadBid) -> GloasBidBlockInputs {
+        GloasBidBlockInputs {
+            randao_reveal: randao_reveal(state),
+            graffiti: Bytes32::repeat_byte(7),
+            attestations: Vec::new(),
+            payload_attestations: Vec::new(),
+            parent_execution_requests: ExecutionRequests::default(),
+            signed_bid,
+        }
+    }
+
+    #[test]
+    fn a_funded_signed_bid_becomes_a_block_that_carries_it() {
+        let (state, bid) = scene();
+        assert!(bid_is_includable(
+            &state,
+            &bid,
+            &ExecutionRequests::default()
+        ));
+        let block =
+            assemble_gloas_block_on_bid(&state, inputs(&state, bid.clone()), &config()).unwrap();
+        assert_eq!(block.body.signed_execution_payload_bid, bid);
+        assert_eq!(block.slot, state.slot());
+        assert_ne!(block.state_root, Root::ZERO);
+        // The block goes through the state transition as the network would run it.
+        let post = post_state(&state, &block);
+        let BeaconState::Gloas(inner) = &post else {
+            unreachable!("gloas")
+        };
+        assert_eq!(inner.latest_execution_payload_bid, bid.message);
+        assert_eq!(block.state_root, post.hash_tree_root());
+    }
+
+    #[test]
+    fn an_inactive_builder_cannot_have_a_block_built() {
+        // Deposited at the finalized epoch itself: not yet active.
+        let parent = gloas_state_with_builder(0, 100_000_000_000, 0);
+        let mut state = advance_to_slot(&parent, parent.slot() + 1, &config()).unwrap();
+        let BeaconState::Gloas(inner) = &mut state else {
+            unreachable!("gloas")
+        };
+        inner.builders[0].deposit_epoch = inner.finalized_checkpoint.epoch;
+        let bid = bid_for(&state, |_| {});
+        assert!(!bid_is_includable(
+            &state,
+            &bid,
+            &ExecutionRequests::default()
+        ));
+        assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err());
+    }
+
+    #[test]
+    fn a_bid_on_the_wrong_parent_hash_is_refused() {
+        let (state, _) = scene();
+        let bid = bid_for(&state, |bid| {
+            bid.parent_block_hash = ExecutionBlockHash::repeat_byte(0x77)
+        });
+        assert!(!bid_is_includable(
+            &state,
+            &bid,
+            &ExecutionRequests::default()
+        ));
+        assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err());
+    }
+
+    #[test]
+    fn a_bid_with_the_wrong_randao_is_refused() {
+        let (state, _) = scene();
+        let bid = bid_for(&state, |bid| bid.prev_randao = Bytes32::repeat_byte(9));
+        assert!(!bid_is_includable(
+            &state,
+            &bid,
+            &ExecutionRequests::default()
+        ));
+        assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err());
+    }
+
+    #[test]
+    fn a_bid_with_a_bad_signature_is_built_into_nothing() {
+        let (state, mut bid) = scene();
+        bid.signature.0[3] ^= 1;
+        // The cheap filter does not verify signatures, `process_block` does.
+        assert!(bid_is_includable(
+            &state,
+            &bid,
+            &ExecutionRequests::default()
+        ));
+        assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err());
+    }
+
+    #[test]
+    fn a_bid_for_another_slot_or_parent_root_is_not_includable() {
+        let (state, _) = scene();
+        let requests = ExecutionRequests::default();
+        let later = bid_for(&state, |bid| bid.slot += 1);
+        assert!(!bid_is_includable(&state, &later, &requests));
+        let other_root = bid_for(&state, |bid| bid.parent_block_root = Root::repeat_byte(5));
+        assert!(!bid_is_includable(&state, &other_root, &requests));
+        let same_hash = bid_for(&state, |bid| bid.block_hash = bid.parent_block_hash);
+        assert!(!bid_is_includable(&state, &same_hash, &requests));
+    }
+
+    #[test]
+    fn an_unknown_or_overdrawn_builder_is_not_includable() {
+        let (state, _) = scene();
+        let requests = ExecutionRequests::default();
+        let unknown = bid_for(&state, |bid| bid.builder_index = 4);
+        assert!(!bid_is_includable(&state, &unknown, &requests));
+        let overdrawn = bid_for(&state, |bid| bid.value = 200_000_000_000);
+        assert!(!bid_is_includable(&state, &overdrawn, &requests));
+    }
+
+    #[test]
+    fn a_builder_exited_by_the_parents_requests_is_not_includable() {
+        let (state, bid) = scene();
+        let pubkey = BlsPubkey(builder_secret(0).sk_to_pk().to_bytes());
+        let exit = |source_address| ExecutionRequests {
+            builder_exits: vec![gloas::BuilderExitRequest {
+                source_address,
+                pubkey,
+            }]
+            .into(),
+            ..Default::default()
+        };
+        // The builder's execution address is `index + 1` repeated.
+        assert!(!bid_is_includable(
+            &state,
+            &bid,
+            &exit(ExecutionAddress::repeat_byte(1))
+        ));
+        // A request from another address does not exit it.
+        assert!(bid_is_includable(
+            &state,
+            &bid,
+            &exit(ExecutionAddress::repeat_byte(9))
+        ));
+    }
+
+    #[test]
+    fn a_non_gloas_state_has_no_includable_bid() {
+        let (_, bid) = scene();
+        let fulu = crate::beacon::helpers::test_state::with_validators_at(ForkName::Fulu, 8);
+        assert!(!bid_is_includable(
+            &fulu,
+            &bid,
+            &ExecutionRequests::default()
+        ));
+    }
+}
diff --git a/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs
index 9cda084b..59ad8c92 100644
--- a/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs
+++ b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs
@@ -2,63 +2,454 @@
 //! `SignedExecutionPayloadBid`, the commitment a proposer may choose in place
 //! of building its own payload.
 //!
-//! Split like [`super::envelope`]: [`cheap_checks`] reads only the message, the
-//! market's seen state and the clock, so the p2p actor runs it inline;
-//! [`stateful_checks`] reads cached states and verifies the signature, so it
-//! runs on a blocking thread. Never queues: every "MAY be queued" is IGNORE.
+//! The rules are the specification's `validate_execution_payload_bid_gossip`
+//! (`specs/gloas/p2p-interface.md`), split like [`super::envelope`]:
+//! [`cheap_checks`] reads only the message, the market's seen state and the
+//! clock, so the p2p actor runs it inline; [`stateful_checks`] reads cached
+//! states and verifies the signature, so it runs on a blocking thread. The
+//! caller records the bid in the [`BuilderMarket`] on `Accept`.
 //!
-//! Stubs until filled: every rule answers `Ignore(NoConsumer)`.
+//! Deliberate departures from the specification (`docs/spec_deviations.md`):
+//!
+//! - Never queues. Every "MAY be queued" is IGNORE, and a state that is not
+//!   cached is IGNORE rather than rebuilt from disk, so a verdict gossipsub
+//!   waits on is never stalled by a replay.
+//! - `store.block_states[parent]` advanced with `process_slots` to `bid.slot`
+//!   becomes the parent's cached post-state when the bid is in the parent's
+//!   own epoch (gloas's `process_slot` touches none of the fields rules 17 to
+//!   22 read), and the cached checkpoint state of the bid's epoch otherwise.
+//! - `get_head(store)` is the head the chain actor recorded, with a fresh walk
+//!   only when none is recorded.
+//! - `seen.execution_payloads` is the market's known payloads: envelopes gossip
+//!   accepted or this node published, plus a pre-gloas parent's own payload.
 
 use std::sync::Arc;
 
-use super::{IgnoreReason, Outcome};
-use crate::beacon::builder_market::BuilderMarket;
+use ethlambda_storage::CacheKey;
+
+use super::{
+    IgnoreReason, Outcome, RejectReason, is_current_slot, is_gloas_slot,
+    proposer_preferences::dependent_root_at,
+};
+use crate::beacon::builder_market::{BuilderMarket, KnownPayload};
 use crate::beacon::config::Config;
-use crate::beacon::containers::{BeaconState, gloas};
-use crate::beacon::fork_choice::Store;
-use crate::beacon::primitives::{Epoch, Root, Slot};
+use crate::beacon::constants::PAYLOAD_BUILDER_VERSION;
+use crate::beacon::containers::{BeaconState, SignedBeaconBlock, gloas};
+use crate::beacon::fork_choice::{self, ForkChoiceNode, PayloadStatus, Store};
+use crate::beacon::helpers::accessors::{get_current_epoch, get_randao_mix};
+use crate::beacon::helpers::gloas::{can_builder_cover_bid, is_active_builder};
+use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_start_slot_at_epoch};
+use crate::beacon::lean_boundary::lean_state_unreachable;
+use crate::beacon::preset;
+use crate::beacon::primitives::{Epoch, ExecutionBlockHash, Root, Slot};
+use crate::beacon::stf;
+use crate::beacon::stf::gloas::verify_execution_payload_bid_signature;
 
 /// Gloas p2p preset: the largest decompressed `SignedExecutionPayloadBid`.
 pub const MAX_SIGNED_EXECUTION_PAYLOAD_BID_SIZE: usize = 196_932;
 
-/// The spec's `is_gas_limit_target_compatible`.
-/// `max_diff = (parent / 1024).saturating_sub(1)`, `min = parent - max_diff`,
-/// `max = parent.saturating_add(max_diff)`.
-#[allow(dead_code, unused_variables)] // filled by Agent A
+/// The spec's `is_gas_limit_target_compatible`: whether `gas_limit` is what
+/// the EIP-1559 transition rule from `parent_gas_limit` allows when steering
+/// towards `target_gas_limit`.
 pub fn is_gas_limit_target_compatible(
     parent_gas_limit: u64,
     gas_limit: u64,
     target_gas_limit: u64,
 ) -> bool {
-    false
+    let max_difference = (parent_gas_limit / 1024).saturating_sub(1);
+    let min_gas_limit = parent_gas_limit - max_difference;
+    let max_gas_limit = parent_gas_limit.saturating_add(max_difference);
+    if target_gas_limit < min_gas_limit {
+        return gas_limit == min_gas_limit;
+    }
+    if target_gas_limit > max_gas_limit {
+        return gas_limit == max_gas_limit;
+    }
+    gas_limit == target_gas_limit
 }
 
-/// `is_current_slot(slot) || slot.checked_sub(1).is_some_and(is_current_slot)`.
-#[allow(dead_code, unused_variables)] // filled by Agent A
+/// The spec's `is_current_or_next_slot`.
 pub(crate) fn is_current_or_next_slot(config: &Config, slot: Slot, now_ms: u64) -> bool {
-    false
+    is_current_slot(config, slot, now_ms)
+        || slot
+            .checked_sub(1)
+            .is_some_and(|previous| is_current_slot(config, previous, now_ms))
 }
 
-#[allow(unused_variables)] // filled by Agent A
+/// The rules that read only the message, the market's seen state and the
+/// clock.
 pub fn cheap_checks(
     market: &BuilderMarket,
     store: &Store,
     signed: &gloas::SignedExecutionPayloadBid,
     now_ms: u64,
 ) -> Result<(), Outcome> {
-    Err(Outcome::Ignore(IgnoreReason::NoConsumer))
+    let bid = &signed.message;
+    let config = store.config();
+    // [IGNORE] The first bid for this slot, parent and builder, and [IGNORE]
+    // the highest value seen for the slot and parent.
+    market.check_bid_seen(bid).map_err(Outcome::Ignore)?;
+    // [IGNORE] The bid's slot is the current slot or the next slot.
+    if !is_current_or_next_slot(&config, bid.slot, now_ms) {
+        return Err(Outcome::Ignore(IgnoreReason::NotCurrentOrNextSlot));
+    }
+    // [REJECT] The bid's execution payment is zero.
+    if bid.execution_payment != 0 {
+        return Err(Outcome::Reject(RejectReason::ExecutionPaymentNonZero));
+    }
+    // [REJECT] The bid's block hash is not its parent block hash.
+    if bid.block_hash == bid.parent_block_hash {
+        return Err(Outcome::Reject(RejectReason::BlockHashEqualsParent));
+    }
+    // [REJECT] The commitment count is within the epoch's blob limit.
+    let proposal_epoch = compute_epoch_at_slot(bid.slot);
+    if bid.blob_kzg_commitments.len() as u64 > config.max_blobs_per_block(proposal_epoch) {
+        return Err(Outcome::Reject(RejectReason::TooManyBlobs));
+    }
+    // Ours: a slot before the fork has no bids. Placed after the rejects so
+    // a malformed message is still penalized, whichever fork it names.
+    if !is_gloas_slot(&config, bid.slot) {
+        return Err(Outcome::Ignore(IgnoreReason::PreGloasSlot));
+    }
+    Ok(())
+}
+
+/// A pre-gloas state's execution payload header, as far as a bid needs it:
+/// `(block_hash, gas_limit)`. `None` for a state with no payload header.
+fn pre_gloas_payload(state: &BeaconState) -> Option<(ExecutionBlockHash, u64)> {
+    match state {
+        BeaconState::Bellatrix(s) => {
+            let header = &s.latest_execution_payload_header;
+            Some((header.block_hash, header.gas_limit))
+        }
+        BeaconState::Capella(s) => {
+            let header = &s.latest_execution_payload_header;
+            Some((header.block_hash, header.gas_limit))
+        }
+        BeaconState::Deneb(s) => {
+            let header = &s.latest_execution_payload_header;
+            Some((header.block_hash, header.gas_limit))
+        }
+        BeaconState::Electra(s) => {
+            let header = &s.latest_execution_payload_header;
+            Some((header.block_hash, header.gas_limit))
+        }
+        BeaconState::Fulu(s) => {
+            let header = &s.latest_execution_payload_header;
+            Some((header.block_hash, header.gas_limit))
+        }
+        BeaconState::Phase0(_) | BeaconState::Altair(_) | BeaconState::Gloas(_) => None,
+        BeaconState::Lean(_) => lean_state_unreachable("execution_payload_bid::pre_gloas_payload"),
+    }
+}
+
+/// The head's payload-relevant facts, off whichever source answers.
+enum HeadView {
+    Gloas {
+        node: ForkChoiceNode,
+        parent_root: Root,
+        bid_parent_hash: ExecutionBlockHash,
+        bid_block_hash: ExecutionBlockHash,
+    },
+    PreGloas {
+        root: Root,
+        block_hash: ExecutionBlockHash,
+    },
+}
+
+/// The head, from the chain actor's record (a fresh walk only when no status
+/// is recorded), and its payload hashes from the cached post-state (the
+/// decoded block when that is not cached).
+fn head_view(store: &Store) -> Result {
+    let config = store.config();
+    let internal = || Outcome::Ignore(IgnoreReason::Internal);
+    let node = match (store.head().ok(), store.head_payload_status()) {
+        (Some(root), Some(payload_status)) => ForkChoiceNode {
+            root,
+            payload_status,
+        },
+        _ => fork_choice::get_head_node(store, &config).map_err(|_| internal())?,
+    };
+    if let Some(state) = store.cached_state(CacheKey::BlockState(node.root)) {
+        return match &*state {
+            BeaconState::Gloas(inner) => Ok(HeadView::Gloas {
+                node,
+                parent_root: inner.latest_block_header.parent_root,
+                bid_parent_hash: inner.latest_execution_payload_bid.parent_block_hash,
+                bid_block_hash: inner.latest_execution_payload_bid.block_hash,
+            }),
+            other => match pre_gloas_payload(other) {
+                Some((block_hash, _)) => Ok(HeadView::PreGloas {
+                    root: node.root,
+                    block_hash,
+                }),
+                None => Err(Outcome::Ignore(IgnoreReason::NotOnHeadBranch)),
+            },
+        };
+    }
+    let block = store
+        .get_signed_block(&node.root)
+        .map_err(|_| internal())?
+        .ok_or(Outcome::Ignore(IgnoreReason::StateUnavailable))?;
+    match block {
+        SignedBeaconBlock::Gloas(block) => {
+            let bid = &block.message.body.signed_execution_payload_bid.message;
+            Ok(HeadView::Gloas {
+                node,
+                parent_root: block.message.parent_root,
+                bid_parent_hash: bid.parent_block_hash,
+                bid_block_hash: bid.block_hash,
+            })
+        }
+        other => match other.execution_block_hash() {
+            Some(block_hash) => Ok(HeadView::PreGloas {
+                root: node.root,
+                block_hash,
+            }),
+            None => Err(Outcome::Ignore(IgnoreReason::NotOnHeadBranch)),
+        },
+    }
+}
+
+/// The spec's `is_bid_compatible_with_head`, against the recorded head.
+///
+/// A pre-gloas head has no bid, so a bid is compatible when it builds on that
+/// head and its payload (the boundary rule: pre-gloas parent payloads count as
+/// full).
+pub fn is_bid_compatible_with_head(
+    store: &Store,
+    bid: &gloas::ExecutionPayloadBid,
+) -> Result {
+    match head_view(store)? {
+        HeadView::PreGloas { root, block_hash } => {
+            Ok(bid.parent_block_root == root && bid.parent_block_hash == block_hash)
+        }
+        HeadView::Gloas {
+            node,
+            parent_root,
+            bid_parent_hash,
+            bid_block_hash,
+        } => {
+            let builds_on_parent_block = bid.parent_block_root == parent_root;
+            let builds_on_parent_payload = bid.parent_block_hash == bid_parent_hash;
+            if builds_on_parent_block && builds_on_parent_payload {
+                return Ok(true);
+            }
+            if bid.parent_block_root != node.root {
+                return Ok(false);
+            }
+            let builds_on_head_payload = bid.parent_block_hash == bid_block_hash;
+            // The head's status can only be PENDING if a caller recorded a
+            // walk's intermediate node, which `get_head_node` never returns.
+            debug_assert_ne!(node.payload_status, PayloadStatus::Pending);
+            let build_on_full = fork_choice::should_build_on_full(store, node, bid.slot)
+                .map_err(|_| Outcome::Ignore(IgnoreReason::Internal))?;
+            Ok(if build_on_full {
+                builds_on_head_payload
+            } else {
+                builds_on_parent_payload
+            })
+        }
+    }
+}
+
+/// Cached-only: a `CheckpointState{epoch, root}` hit; else the cached
+/// `BlockState(root)` advanced to the epoch start and cached. `None` = miss.
+///
+/// The rebuild-from-disk `fork_choice::checkpoint_state` does on a miss would
+/// stall a gossip verdict, so a state that is not cached is a miss here.
+pub(crate) fn cached_checkpoint_state(
+    store: &Store,
+    epoch: Epoch,
+    root: Root,
+) -> Option> {
+    let key = CacheKey::CheckpointState { epoch, root };
+    if let Some(state) = store.cached_state(key) {
+        return Some(state);
+    }
+    let state = store.cached_state(CacheKey::BlockState(root))?;
+    let target_slot = compute_start_slot_at_epoch(epoch);
+    let state = if state.slot() < target_slot {
+        let mut advanced = (*state).clone();
+        stf::process_slots(&mut advanced, target_slot, &store.config()).ok()?;
+        Arc::new(advanced)
+    } else {
+        state
+    };
+    store.cache_state(key, state.clone());
+    Some(state)
 }
 
-#[allow(unused_variables)] // filled by Agent A
+/// The exits a parent payload carried, for rule 21: the market's known payload
+/// when it is the parent block's own, else the verified envelope in the store.
+/// `None` when neither is available.
+fn parent_payload_exits(
+    store: &Store,
+    market: &BuilderMarket,
+    bid: &gloas::ExecutionPayloadBid,
+) -> Option<
+    Vec<(
+        crate::beacon::primitives::BlsPubkey,
+        crate::beacon::primitives::ExecutionAddress,
+    )>,
+> {
+    if let Some(KnownPayload {
+        beacon_block_root,
+        builder_exits,
+        ..
+    }) = market.known_payload(bid.parent_block_hash)
+        && beacon_block_root == bid.parent_block_root
+    {
+        return Some(builder_exits);
+    }
+    let envelope = store
+        .get_execution_payload_envelope(&bid.parent_block_root)
+        .ok()??;
+    Some(
+        envelope
+            .message
+            .execution_requests
+            .builder_exits
+            .iter()
+            .map(|exit| (exit.pubkey, exit.source_address))
+            .collect(),
+    )
+}
+
+/// The rules that need states, then the signature. Runs on a blocking thread.
 pub fn stateful_checks(
     store: &Store,
     market: &BuilderMarket,
     signed: &gloas::SignedExecutionPayloadBid,
 ) -> Outcome {
-    Outcome::Ignore(IgnoreReason::NoConsumer)
+    match stateful_rules(store, market, signed) {
+        Ok(()) => Outcome::Accept,
+        Err(outcome) => outcome,
+    }
 }
 
-/// Both halves. The caller records the bid on `Accept`.
+fn stateful_rules(
+    store: &Store,
+    market: &BuilderMarket,
+    signed: &gloas::SignedExecutionPayloadBid,
+) -> Result<(), Outcome> {
+    let bid = &signed.message;
+    let ignore = |reason| Outcome::Ignore(reason);
+    let reject = |reason| Outcome::Reject(reason);
+    let proposal_epoch = compute_epoch_at_slot(bid.slot);
+
+    // [IGNORE] The parent block is known (never queued).
+    if !store.has_block(&bid.parent_block_root) {
+        return Err(ignore(IgnoreReason::UnknownBlock));
+    }
+    // [REJECT] The bid is for a higher slot than its parent.
+    let (parent_slot, _) = store
+        .block_entry(&bid.parent_block_root)
+        .ok_or(ignore(IgnoreReason::UnknownBlock))?;
+    if bid.slot <= parent_slot {
+        return Err(reject(RejectReason::NotAfterParent));
+    }
+    // [IGNORE] The parent has been imported (its post-state is cached).
+    let parent_state = store
+        .cached_state(CacheKey::BlockState(bid.parent_block_root))
+        .ok_or(ignore(IgnoreReason::StateUnavailable))?;
+    // [IGNORE] The bid's slot is within the parent's proposer lookahead.
+    if proposal_epoch > get_current_epoch(&parent_state) + preset::MIN_SEED_LOOKAHEAD {
+        return Err(ignore(IgnoreReason::BeyondLookahead));
+    }
+    // [IGNORE] The matching proposer preferences have been seen. Rule 10
+    // keeps the dependent slot inside the parent state's `block_roots`.
+    let dependent_root = dependent_root_at(&parent_state, bid.parent_block_root, bid.slot)
+        .ok_or(ignore(IgnoreReason::AncestryUnknown))?;
+    let preferences = market
+        .preferences(bid.slot, dependent_root)
+        .ok_or(ignore(IgnoreReason::PreferencesUnseen))?
+        .message;
+    // [IGNORE] The fee recipient matches the proposer's preference.
+    if bid.fee_recipient != preferences.fee_recipient {
+        return Err(ignore(IgnoreReason::FeeRecipientMismatch));
+    }
+    // [IGNORE] The parent block hash is a known execution payload. Across the
+    // fork boundary, a pre-gloas parent's own payload counts.
+    let parent_gas_limit = match market.known_payload(bid.parent_block_hash) {
+        Some(known) => known.gas_limit,
+        None => match pre_gloas_payload(&parent_state) {
+            Some((block_hash, gas_limit)) if block_hash == bid.parent_block_hash => gas_limit,
+            _ => return Err(ignore(IgnoreReason::ParentPayloadUnknown)),
+        },
+    };
+    // [IGNORE] The gas limit is compatible with the proposer's target.
+    if !is_gas_limit_target_compatible(
+        parent_gas_limit,
+        bid.gas_limit,
+        preferences.target_gas_limit,
+    ) {
+        return Err(ignore(IgnoreReason::GasLimitIncompatible));
+    }
+    // [IGNORE] The bid is compatible with the head branch.
+    if !is_bid_compatible_with_head(store, bid)? {
+        return Err(ignore(IgnoreReason::NotOnHeadBranch));
+    }
+    // [REJECT] The previous randao is the parent state's.
+    if bid.prev_randao != get_randao_mix(&parent_state, get_current_epoch(&parent_state)) {
+        return Err(reject(RejectReason::PrevRandao));
+    }
+
+    // The parent state advanced to the bid's slot. Within the parent's own
+    // epoch the parent state answers identically, see the module docs.
+    let state = if proposal_epoch == get_current_epoch(&parent_state) {
+        parent_state.clone()
+    } else {
+        cached_checkpoint_state(store, proposal_epoch, bid.parent_block_root)
+            .ok_or(ignore(IgnoreReason::StateUnavailable))?
+    };
+    let BeaconState::Gloas(inner) = &*state else {
+        // A bid in a gloas epoch is advanced into gloas by the epoch
+        // transition; anything else is a state this cannot judge.
+        return Err(ignore(IgnoreReason::StateUnavailable));
+    };
+
+    // [REJECT] The builder index is valid, [REJECT] it is a payload builder
+    // and [REJECT] active.
+    let builder = inner
+        .builders
+        .get(bid.builder_index as usize)
+        .ok_or(reject(RejectReason::UnknownBuilder))?;
+    if builder.version != PAYLOAD_BUILDER_VERSION {
+        return Err(reject(RejectReason::NotPayloadBuilder));
+    }
+    if !is_active_builder(inner, bid.builder_index).unwrap_or(false) {
+        return Err(reject(RejectReason::InactiveBuilder));
+    }
+    // [IGNORE] The builder can cover the bid.
+    if !can_builder_cover_bid(inner, bid.builder_index, bid.value).unwrap_or(false) {
+        return Err(ignore(IgnoreReason::BuilderCannotCover));
+    }
+    // [IGNORE] The parent's payload does not try to exit the builder. Only a
+    // gloas parent has an envelope to carry the request.
+    let parent_is_gloas = matches!(&*parent_state, BeaconState::Gloas(_));
+    if parent_is_gloas && bid.parent_block_hash == inner.latest_execution_payload_bid.block_hash {
+        let exits = parent_payload_exits(store, market, bid)
+            .ok_or(ignore(IgnoreReason::ParentPayloadUnverified))?;
+        if exits.iter().any(|(pubkey, source)| {
+            *pubkey == builder.pubkey && *source == builder.execution_address
+        }) {
+            return Err(ignore(IgnoreReason::BuilderMayExit));
+        }
+    }
+    // [REJECT] The signature is valid. An error (an index the state lacks)
+    // is a signature that cannot be.
+    if !matches!(
+        verify_execution_payload_bid_signature(&state, signed),
+        Ok(true)
+    ) {
+        return Err(reject(RejectReason::BadSignature));
+    }
+    Ok(())
+}
+
+/// Both halves. The caller records the bid on `Accept`: the specification's
+/// `validate_execution_payload_bid_gossip`.
 pub fn validate(
     market: &BuilderMarket,
     store: &Store,
@@ -71,22 +462,645 @@ pub fn validate(
     stateful_checks(store, market, signed)
 }
 
-/// The spec's `is_bid_compatible_with_head`, against the recorded head.
-#[allow(dead_code, unused_variables)] // filled by Agent A
-pub fn is_bid_compatible_with_head(
-    store: &Store,
-    bid: &gloas::ExecutionPayloadBid,
-) -> Result {
-    Err(Outcome::Ignore(IgnoreReason::NoConsumer))
-}
+#[cfg(test)]
+mod tests {
+    use ethlambda_types::beacon::primitives::{
+        BlsPubkey, Bytes32, ExecutionAddress, KzgCommitment,
+    };
 
-/// Cached-only: a `CheckpointState{epoch, root}` hit; else the cached
-/// `BlockState(root)` advanced to the epoch start and cached. `None` = miss.
-#[allow(dead_code, unused_variables)] // filled by Agent A
-pub(crate) fn cached_checkpoint_state(
-    store: &Store,
-    epoch: Epoch,
-    root: Root,
-) -> Option> {
-    None
+    use super::*;
+    use crate::beacon::builder_market::test_support::{
+        builder_secret, envelope_with_gas_limit, sign_preferences,
+    };
+    use crate::beacon::gossip::proposer_preferences::dependent_root_at;
+    use crate::beacon::gossip::test_support::builder_scene::*;
+    use crate::beacon::gossip::test_support::{slot_start_ms, store};
+    use crate::beacon::helpers::accessors::get_current_epoch;
+    use crate::beacon::precheck::fixed_proposer;
+
+    fn ignore(reason: IgnoreReason) -> Outcome {
+        Outcome::Ignore(reason)
+    }
+
+    fn reject(reason: RejectReason) -> Outcome {
+        Outcome::Reject(reason)
+    }
+
+    /// `stateful_checks` on the scene's market and store.
+    fn stateful(scene: &Scene, bid: &gloas::SignedExecutionPayloadBid) -> Outcome {
+        stateful_checks(&scene.store, &scene.market, bid)
+    }
+
+    fn builder_pubkey() -> BlsPubkey {
+        BlsPubkey(builder_secret(0).sk_to_pk().to_bytes())
+    }
+
+    #[test]
+    fn the_scenes_bid_is_accepted() {
+        let scene = scene();
+        let outcome = validate(&scene.market, &scene.store, &scene.bid, scene.now_ms());
+        assert_eq!(outcome, Outcome::Accept);
+    }
+
+    // ---- is_gas_limit_target_compatible ----
+
+    #[test]
+    fn a_gas_limit_may_step_towards_a_nearby_target() {
+        let parent = 60_000_000;
+        // max_difference = 60_000_000 / 1024 - 1 = 58_592.
+        assert!(is_gas_limit_target_compatible(
+            parent,
+            parent + 100,
+            parent + 100
+        ));
+        assert!(is_gas_limit_target_compatible(
+            parent,
+            parent - 10,
+            parent - 10
+        ));
+        assert!(is_gas_limit_target_compatible(parent, parent, parent));
+        assert!(!is_gas_limit_target_compatible(
+            parent,
+            parent + 99,
+            parent + 100
+        ));
+    }
+
+    #[test]
+    fn a_gas_limit_is_pinned_to_the_step_limit_beyond_it() {
+        let parent = 60_000_000;
+        assert!(is_gas_limit_target_compatible(
+            parent,
+            parent + 58_592,
+            100_000_000
+        ));
+        assert!(!is_gas_limit_target_compatible(
+            parent,
+            parent + 58_593,
+            100_000_000
+        ));
+        assert!(!is_gas_limit_target_compatible(
+            parent,
+            parent + 58_591,
+            100_000_000
+        ));
+        assert!(is_gas_limit_target_compatible(
+            parent,
+            parent - 58_592,
+            30_000_000
+        ));
+        assert!(!is_gas_limit_target_compatible(
+            parent,
+            parent - 58_593,
+            30_000_000
+        ));
+        // The edge itself: a target exactly at the limit is no longer beyond it.
+        assert!(is_gas_limit_target_compatible(
+            parent,
+            parent + 58_592,
+            parent + 58_592
+        ));
+    }
+
+    #[test]
+    fn a_small_parent_cannot_move() {
+        // 1023 / 1024 = 0, so the allowed difference saturates to zero.
+        assert!(is_gas_limit_target_compatible(1023, 1023, 5_000));
+        assert!(!is_gas_limit_target_compatible(1023, 1024, 5_000));
+        assert!(is_gas_limit_target_compatible(0, 0, 100));
+    }
+
+    #[test]
+    fn the_step_limit_saturates_near_the_top_of_the_range() {
+        let parent = u64::MAX - 5;
+        assert!(is_gas_limit_target_compatible(parent, u64::MAX, u64::MAX));
+        assert!(is_gas_limit_target_compatible(parent, parent, parent));
+    }
+
+    // ---- the clock ----
+
+    #[test]
+    fn a_bid_is_timely_from_just_before_the_previous_slot_until_just_after_its_own() {
+        let store = store(0);
+        let config = store.config();
+        let slot = 34;
+        let earliest = slot_start_ms(&store, slot - 1) - 500;
+        let latest = slot_start_ms(&store, slot + 1) + 500;
+        assert!(is_current_or_next_slot(&config, slot, earliest));
+        assert!(!is_current_or_next_slot(&config, slot, earliest - 1));
+        assert!(is_current_or_next_slot(&config, slot, latest));
+        assert!(!is_current_or_next_slot(&config, slot, latest + 1));
+        // Slot zero has no previous slot.
+        assert!(is_current_or_next_slot(
+            &config,
+            0,
+            slot_start_ms(&store, 0)
+        ));
+    }
+
+    // ---- cheap rules ----
+
+    fn cheap(scene: &Scene, bid: &gloas::SignedExecutionPayloadBid) -> Result<(), Outcome> {
+        cheap_checks(&scene.market, &scene.store, bid, scene.now_ms())
+    }
+
+    #[test]
+    fn a_builders_second_bid_for_a_parent_is_already_seen() {
+        let scene = scene();
+        assert!(scene.market.record_bid(scene.bid.clone()));
+        assert_eq!(
+            cheap(&scene, &scene.signed(|bid| bid.value = 9)),
+            Err(ignore(IgnoreReason::AlreadySeen))
+        );
+    }
+
+    #[test]
+    fn a_bid_that_does_not_beat_the_best_is_not_highest() {
+        let scene = scene();
+        let mut other = scene.bid.clone();
+        other.message.builder_index = 1;
+        other.message.value = 5;
+        assert!(scene.market.record_bid(other));
+        assert_eq!(
+            cheap(&scene, &scene.bid),
+            Err(ignore(IgnoreReason::NotHighestBid))
+        );
+        assert_eq!(
+            cheap(&scene, &scene.signed(|bid| bid.value = 5)),
+            Err(ignore(IgnoreReason::NotHighestBid))
+        );
+    }
+
+    #[test]
+    fn a_bid_for_a_distant_slot_is_not_current_or_next() {
+        let scene = scene();
+        let far = scene.signed(|bid| bid.slot = 40);
+        assert_eq!(
+            cheap(&scene, &far),
+            Err(ignore(IgnoreReason::NotCurrentOrNextSlot))
+        );
+    }
+
+    #[test]
+    fn a_bid_with_an_execution_payment_is_rejected() {
+        let scene = scene();
+        let paid = scene.signed(|bid| bid.execution_payment = 1);
+        assert_eq!(
+            cheap(&scene, &paid),
+            Err(reject(RejectReason::ExecutionPaymentNonZero))
+        );
+    }
+
+    #[test]
+    fn a_bid_whose_block_hash_is_its_parents_is_rejected() {
+        let scene = scene();
+        let same = scene.signed(|bid| bid.block_hash = bid.parent_block_hash);
+        assert_eq!(
+            cheap(&scene, &same),
+            Err(reject(RejectReason::BlockHashEqualsParent))
+        );
+    }
+
+    #[test]
+    fn a_bid_with_too_many_commitments_is_rejected() {
+        let scene = scene();
+        let limit = scene.store.config().max_blobs_per_block(1) as usize;
+        let at_limit = scene.signed(|bid| {
+            bid.blob_kzg_commitments = vec![KzgCommitment([0; 48]); limit].into();
+        });
+        assert_eq!(cheap(&scene, &at_limit), Ok(()));
+        let over = scene.signed(|bid| {
+            bid.blob_kzg_commitments = vec![KzgCommitment([0; 48]); limit + 1].into();
+        });
+        assert_eq!(
+            cheap(&scene, &over),
+            Err(reject(RejectReason::TooManyBlobs))
+        );
+    }
+
+    #[test]
+    fn a_bid_before_the_fork_is_ignored() {
+        // A fulu-only store has no gloas slot at all.
+        let fulu = store(0);
+        let scene = scene();
+        let now_ms = slot_start_ms(&fulu, BID_SLOT) + 100;
+        assert_eq!(
+            cheap_checks(&scene.market, &fulu, &scene.signed(|_| {}), now_ms),
+            Err(ignore(IgnoreReason::PreGloasSlot))
+        );
+    }
+
+    // ---- stateful rules ----
+
+    #[test]
+    fn a_bid_on_an_unknown_parent_is_ignored() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.parent_block_root = Root::repeat_byte(9));
+        assert_eq!(stateful(&scene, &bid), ignore(IgnoreReason::UnknownBlock));
+    }
+
+    #[test]
+    fn a_bid_not_after_its_parent_is_rejected() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.slot = 32);
+        assert_eq!(stateful(&scene, &bid), reject(RejectReason::NotAfterParent));
+    }
+
+    #[test]
+    fn a_parent_without_a_cached_state_is_ignored() {
+        let mut scene = scene();
+        let stateless = Root::repeat_byte(0x60);
+        scene
+            .store
+            .insert_pending_block(stateless, block_at(10))
+            .expect("insert the block");
+        let bid = scene.signed(|bid| bid.parent_block_root = stateless);
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::StateUnavailable)
+        );
+    }
+
+    #[test]
+    fn a_bid_beyond_the_parents_lookahead_is_ignored() {
+        let scene = scene();
+        // The parent is in epoch 1, so epoch 3 is past its lookahead.
+        let bid = scene.signed(|bid| bid.slot = 96);
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::BeyondLookahead)
+        );
+    }
+
+    #[test]
+    fn a_bid_without_preferences_is_ignored() {
+        let scene = scene();
+        let empty = BuilderMarket::default();
+        assert_eq!(
+            stateful_checks(&scene.store, &empty, &scene.bid),
+            ignore(IgnoreReason::PreferencesUnseen)
+        );
+    }
+
+    #[test]
+    fn a_bid_for_another_fee_recipient_is_ignored() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.fee_recipient = ExecutionAddress::repeat_byte(0x99));
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::FeeRecipientMismatch)
+        );
+    }
+
+    #[test]
+    fn a_bid_on_an_unknown_payload_is_ignored() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.parent_block_hash = ExecutionBlockHash::repeat_byte(0x77));
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::ParentPayloadUnknown)
+        );
+    }
+
+    #[test]
+    fn a_gas_limit_the_target_cannot_reach_is_ignored() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.gas_limit = PARENT_GAS_LIMIT + 1_000);
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::GasLimitIncompatible)
+        );
+    }
+
+    #[test]
+    fn a_bid_off_the_head_branch_is_ignored() {
+        let scene = scene();
+        // A known payload, but neither the head's nor its parent's.
+        let other = ExecutionBlockHash::repeat_byte(0x44);
+        scene
+            .market
+            .record_execution_payload(&envelope_with_gas_limit(
+                other,
+                PARENT_GAS_LIMIT,
+                PARENT,
+                vec![],
+            ));
+        let bid = scene.signed(|bid| bid.parent_block_hash = other);
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::NotOnHeadBranch)
+        );
+    }
+
+    #[test]
+    fn a_wrong_previous_randao_is_rejected() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.prev_randao = Bytes32::repeat_byte(9));
+        assert_eq!(stateful(&scene, &bid), reject(RejectReason::PrevRandao));
+    }
+
+    #[test]
+    fn an_unregistered_builder_is_rejected() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.builder_index = 5);
+        assert_eq!(stateful(&scene, &bid), reject(RejectReason::UnknownBuilder));
+    }
+
+    #[test]
+    fn a_builder_of_another_version_is_rejected() {
+        let scene = scene_with(|state| state.builders[0].version = 7);
+        assert_eq!(
+            stateful(&scene, &scene.bid),
+            reject(RejectReason::NotPayloadBuilder)
+        );
+    }
+
+    #[test]
+    fn an_inactive_builder_is_rejected() {
+        let exiting = scene_with(|state| state.builders[0].withdrawable_epoch = 5);
+        assert_eq!(
+            stateful(&exiting, &exiting.bid),
+            reject(RejectReason::InactiveBuilder)
+        );
+        // Deposited at or after the finalized epoch.
+        let young = scene_with(|state| state.builders[0].deposit_epoch = 1);
+        assert_eq!(
+            stateful(&young, &young.bid),
+            reject(RejectReason::InactiveBuilder)
+        );
+    }
+
+    #[test]
+    fn a_builder_that_cannot_cover_the_bid_is_ignored() {
+        let scene = scene();
+        let bid = scene.signed(|bid| bid.value = 200_000_000_000);
+        assert_eq!(
+            stateful(&scene, &bid),
+            ignore(IgnoreReason::BuilderCannotCover)
+        );
+    }
+
+    #[test]
+    fn a_builder_the_parent_payload_exits_is_ignored() {
+        let scene = scene();
+        let exits = vec![(builder_pubkey(), ExecutionAddress::repeat_byte(1))];
+        scene
+            .market
+            .record_execution_payload(&envelope_with_gas_limit(
+                parent_block_hash(),
+                PARENT_GAS_LIMIT,
+                PARENT,
+                exits,
+            ));
+        assert_eq!(
+            stateful(&scene, &scene.bid),
+            ignore(IgnoreReason::BuilderMayExit)
+        );
+    }
+
+    #[test]
+    fn an_exit_for_another_builder_does_not_matter() {
+        let scene = scene();
+        let wrong_key = (BlsPubkey([3; 48]), ExecutionAddress::repeat_byte(1));
+        let wrong_source = (builder_pubkey(), ExecutionAddress::repeat_byte(9));
+        scene
+            .market
+            .record_execution_payload(&envelope_with_gas_limit(
+                parent_block_hash(),
+                PARENT_GAS_LIMIT,
+                PARENT,
+                vec![wrong_key, wrong_source],
+            ));
+        assert_eq!(stateful(&scene, &scene.bid), Outcome::Accept);
+    }
+
+    #[test]
+    fn exits_are_read_from_the_stored_envelope_when_the_known_payload_is_another_blocks() {
+        let mut scene = scene();
+        // The market knows the hash from some other block, so it cannot answer
+        // for the parent's own envelope.
+        scene
+            .market
+            .record_execution_payload(&envelope_with_gas_limit(
+                parent_block_hash(),
+                PARENT_GAS_LIMIT,
+                Root::repeat_byte(0x61),
+                vec![],
+            ));
+        assert_eq!(
+            stateful(&scene, &scene.bid),
+            ignore(IgnoreReason::ParentPayloadUnverified)
+        );
+
+        let exits = vec![(builder_pubkey(), ExecutionAddress::repeat_byte(1))];
+        let envelope = gloas::SignedExecutionPayloadEnvelope {
+            message: envelope_with_gas_limit(parent_block_hash(), PARENT_GAS_LIMIT, PARENT, exits),
+            signature: Default::default(),
+        };
+        scene.store.insert_verified_payload(32, &envelope);
+        assert_eq!(
+            stateful(&scene, &scene.bid),
+            ignore(IgnoreReason::BuilderMayExit)
+        );
+    }
+
+    #[test]
+    fn a_bad_signature_is_rejected() {
+        let scene = scene();
+        let mut bid = scene.bid.clone();
+        bid.signature.0[5] ^= 1;
+        assert_eq!(stateful(&scene, &bid), reject(RejectReason::BadSignature));
+        // Signed by another builder's key.
+        let forged = test_support_sign_by(&scene, 1);
+        assert_eq!(
+            stateful(&scene, &forged),
+            reject(RejectReason::BadSignature)
+        );
+    }
+
+    fn test_support_sign_by(scene: &Scene, secret: u64) -> gloas::SignedExecutionPayloadBid {
+        crate::beacon::builder_market::test_support::sign_bid(
+            &scene.state,
+            scene.bid.message.clone(),
+            secret,
+        )
+    }
+
+    // ---- the parent's state stands in for the advanced one ----
+
+    #[test]
+    fn a_state_advanced_within_its_epoch_keeps_what_the_rules_read() {
+        let scene = scene();
+        let config = scene.store.config();
+        let mut advanced = scene.state.clone();
+        stf::process_slots(&mut advanced, BID_SLOT, &config).expect("advance");
+        assert_eq!(advanced.slot(), BID_SLOT);
+        let (BeaconState::Gloas(before), BeaconState::Gloas(after)) = (&scene.state, &advanced)
+        else {
+            unreachable!("gloas states")
+        };
+        assert_eq!(before.builders, after.builders);
+        assert_eq!(before.finalized_checkpoint, after.finalized_checkpoint);
+        assert_eq!(before.fork, after.fork);
+        assert_eq!(
+            before.builder_pending_payments,
+            after.builder_pending_payments
+        );
+        assert_eq!(
+            before.builder_pending_withdrawals,
+            after.builder_pending_withdrawals
+        );
+        assert_eq!(
+            before.latest_execution_payload_bid,
+            after.latest_execution_payload_bid
+        );
+        assert_eq!(
+            get_randao_mix(&scene.state, get_current_epoch(&scene.state)),
+            get_randao_mix(&advanced, get_current_epoch(&advanced))
+        );
+    }
+
+    #[test]
+    fn a_bid_across_an_epoch_uses_and_caches_the_checkpoint_state() {
+        let scene = scene();
+        let slot = 64;
+        // Preferences for the later epoch's proposer.
+        let dependent = dependent_root_at(&scene.state, PARENT, slot).expect("in the window");
+        let proposer = fixed_proposer(&scene.state, slot).expect("in the lookahead window");
+        let preferences = sign_preferences(
+            &scene.state,
+            gloas::ProposerPreferences {
+                dependent_root: dependent,
+                proposal_slot: slot,
+                validator_index: proposer,
+                fee_recipient: fee_recipient(),
+                target_gas_limit: PARENT_GAS_LIMIT,
+            },
+        );
+        assert!(scene.market.record_preferences(preferences, slot - 1));
+        let bid = scene.signed(|bid| bid.slot = slot);
+        let key = CacheKey::CheckpointState {
+            epoch: 2,
+            root: PARENT,
+        };
+        assert!(scene.store.cached_state(key).is_none());
+        assert_eq!(stateful(&scene, &bid), Outcome::Accept);
+        let cached = scene.store.cached_state(key).expect("the advanced state");
+        assert_eq!(get_current_epoch(&cached), 2);
+        // A second bid finds it.
+        assert_eq!(stateful(&scene, &bid), Outcome::Accept);
+    }
+
+    /// A fulu parent one slot before gloas's first epoch: the rules reach the
+    /// advanced state (which the upgrade has made gloas, with no builders yet)
+    /// only if the parent's own payload counted as a known one.
+    fn fulu_parent_scene(
+        edit: impl FnOnce(&mut crate::beacon::containers::BeaconState),
+    ) -> (Store, BuilderMarket, gloas::SignedExecutionPayloadBid) {
+        use ethlambda_storage::ForkCheckpoints;
+
+        use crate::beacon::fork::ForkName;
+        use crate::beacon::gossip::test_support::{GENESIS_TIME, builder_scene::block_at};
+        use crate::beacon::helpers::test_state::with_signing_validators_at;
+
+        let config = Config::mainnet()
+            .with_fork_epoch(ForkName::Fulu, 0)
+            .with_fork_epoch(ForkName::Gloas, 2);
+        let parent = Root::repeat_byte(0x70);
+        let header_hash = ExecutionBlockHash::repeat_byte(0x41);
+        let mut state = with_signing_validators_at(ForkName::Fulu, 64);
+        *state.slot_mut() = 62;
+        if let BeaconState::Fulu(fulu) = &mut state {
+            fulu.latest_execution_payload_header.block_hash = header_hash;
+            fulu.latest_execution_payload_header.gas_limit = 30_000_000;
+        }
+        edit(&mut state);
+        state.apply_pending_mutations();
+
+        let mut store = Store::init_beacon(
+            Arc::new(ethlambda_storage::backend::InMemoryBackend::new()),
+            GENESIS_TIME,
+            config,
+            parent,
+            ethlambda_types::checkpoint::Checkpoint {
+                root: parent,
+                slot: 62,
+            },
+            62,
+        );
+        store
+            .insert_pending_block(parent, block_at(62))
+            .expect("insert the parent");
+        store
+            .insert_state(parent, state.clone())
+            .expect("insert the parent state");
+        store
+            .update_checkpoints(ForkCheckpoints::head_only(parent))
+            .expect("move the head");
+        // The status the chain actor records; a pre-gloas head has one node.
+        store.set_head_payload_status(parent, PayloadStatus::Empty);
+
+        let slot = 64;
+        let market = BuilderMarket::default();
+        let proposer = fixed_proposer(&state, slot).expect("in the window");
+        let dependent_root = dependent_root_at(&state, parent, slot).expect("in the window");
+        let preferences = sign_preferences(
+            &state,
+            gloas::ProposerPreferences {
+                dependent_root,
+                proposal_slot: slot,
+                validator_index: proposer,
+                fee_recipient: fee_recipient(),
+                target_gas_limit: 30_000_000,
+            },
+        );
+        assert!(market.record_preferences(preferences, slot - 1));
+        let bid = gloas::SignedExecutionPayloadBid {
+            message: gloas::ExecutionPayloadBid {
+                parent_block_hash: header_hash,
+                parent_block_root: parent,
+                block_hash: ExecutionBlockHash::repeat_byte(0x33),
+                prev_randao: get_randao_mix(&state, get_current_epoch(&state)),
+                fee_recipient: fee_recipient(),
+                gas_limit: 30_000_000,
+                builder_index: 0,
+                slot,
+                value: 1,
+                ..Default::default()
+            },
+            signature: Default::default(),
+        };
+        (store, market, bid)
+    }
+
+    #[test]
+    fn a_pre_gloas_parents_payload_counts_as_known_with_its_header_gas_limit() {
+        let (store, market, bid) = fulu_parent_scene(|_| {});
+        // Past the payload, gas, head and randao rules, the advanced state is
+        // gloas's first and has no builders yet.
+        assert_eq!(
+            stateful_checks(&store, &market, &bid),
+            reject(RejectReason::UnknownBuilder)
+        );
+    }
+
+    #[test]
+    fn a_pre_gloas_parents_header_gas_limit_bounds_the_bid() {
+        let (store, market, mut bid) = fulu_parent_scene(|_| {});
+        bid.message.gas_limit += 1_000;
+        assert_eq!(
+            stateful_checks(&store, &market, &bid),
+            ignore(IgnoreReason::GasLimitIncompatible)
+        );
+    }
+
+    #[test]
+    fn only_the_pre_gloas_parents_own_payload_hash_counts() {
+        let (store, market, mut bid) = fulu_parent_scene(|_| {});
+        bid.message.parent_block_hash = ExecutionBlockHash::repeat_byte(0x42);
+        assert_eq!(
+            stateful_checks(&store, &market, &bid),
+            ignore(IgnoreReason::ParentPayloadUnknown)
+        );
+    }
 }
diff --git a/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs
index 50c73650..a3bb007a 100644
--- a/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs
+++ b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs
@@ -2,34 +2,175 @@
 //! `SignedProposerPreferences` (fee recipient and gas target) for a slot, which
 //! bids on that slot are judged against.
 //!
-//! Split like [`super::envelope`]: [`cheap_checks`] inline in the p2p actor,
-//! [`stateful_checks`] on a blocking thread, reading cached states only.
+//! The rules are the specification's `validate_proposer_preferences_gossip`
+//! (`specs/gloas/p2p-interface.md`), split like [`super::envelope`]:
+//! [`cheap_checks`] inline in the p2p actor, [`stateful_checks`] on a blocking
+//! thread, reading cached states only. The caller records the preferences in
+//! the [`BuilderMarket`] on `Accept`.
 //!
-//! Stubs until filled: every rule answers `Ignore(NoConsumer)`.
+//! Deliberate departures from the specification (`docs/spec_deviations.md`):
+//!
+//! - Never queues: an unseen dependent block is IGNORE, and a state that is
+//!   not cached is IGNORE, not rebuilt from disk.
+//! - The lookahead comes from the cached head state when the head shares the
+//!   dependent root (the whole canonical case, and a dependent block about an
+//!   epoch old is usually evicted from the state cache), else from the cached
+//!   checkpoint state of the epoch before the proposal's.
+//! - The signature verifies under any of three domains, since clients disagree
+//!   at the fork boundary: see [`proposer_preferences_domains`].
+
+use ethlambda_storage::CacheKey;
 
-use super::{IgnoreReason, Outcome};
+use super::execution_payload_bid::cached_checkpoint_state;
+use super::{
+    IgnoreReason, Outcome, RejectReason, ancestor_at, is_future_slot, is_gloas_slot, slot_start_ms,
+};
+use crate::beacon::bls;
 use crate::beacon::builder_market::BuilderMarket;
 use crate::beacon::config::Config;
+use crate::beacon::constants::{DOMAIN_PROPOSER_PREFERENCES, MAXIMUM_GOSSIP_CLOCK_DISPARITY};
 use crate::beacon::containers::{BeaconState, gloas};
-use crate::beacon::fork_choice::Store;
-use crate::beacon::primitives::{Domain, Epoch, Root, Slot};
+use crate::beacon::fork_choice::{
+    Store, compute_shuffling_dependent_slot, compute_shuffling_lookahead_start_slot,
+};
+use crate::beacon::helpers::accessors::get_domain;
+use crate::beacon::helpers::misc::{compute_domain, compute_epoch_at_slot, compute_signing_root};
+use crate::beacon::precheck::fixed_proposer;
+use crate::beacon::preset;
+use crate::beacon::primitives::{Domain, Epoch, HashTreeRoot as _, Root, Slot};
 
-#[allow(unused_variables)] // filled by Agent A
+/// The rules that read only the message, the market's seen state and the
+/// clock.
 pub fn cheap_checks(
     market: &BuilderMarket,
     store: &Store,
     signed: &gloas::SignedProposerPreferences,
     now_ms: u64,
 ) -> Result<(), Outcome> {
-    Err(Outcome::Ignore(IgnoreReason::NoConsumer))
+    let preferences = &signed.message;
+    let config = store.config();
+    // [IGNORE] The first valid preferences for this dependent root and slot.
+    if market
+        .preferences(preferences.proposal_slot, preferences.dependent_root)
+        .is_some()
+    {
+        return Err(Outcome::Ignore(IgnoreReason::AlreadySeen));
+    }
+    // [IGNORE] The proposal epoch is after the gloas upgrade.
+    if !is_gloas_slot(&config, preferences.proposal_slot) {
+        return Err(Outcome::Ignore(IgnoreReason::PreGloasSlot));
+    }
+    // [IGNORE] The proposal slot has not started yet.
+    if is_past_slot(&config, preferences.proposal_slot, now_ms) {
+        return Err(Outcome::Ignore(IgnoreReason::SlotStarted));
+    }
+    // [IGNORE] The proposer for the proposal slot is known.
+    let proposal_epoch = compute_epoch_at_slot(preferences.proposal_slot);
+    let lookahead_start_slot = compute_shuffling_lookahead_start_slot(proposal_epoch);
+    if is_future_slot(&config, lookahead_start_slot, now_ms) {
+        return Err(Outcome::Ignore(IgnoreReason::BeyondLookahead));
+    }
+    Ok(())
 }
 
-#[allow(unused_variables)] // filled by Agent A
+/// The rules that need the dependent block and a state, then the signature.
+/// Runs on a blocking thread.
 pub fn stateful_checks(store: &Store, signed: &gloas::SignedProposerPreferences) -> Outcome {
-    Outcome::Ignore(IgnoreReason::NoConsumer)
+    match stateful_rules(store, signed) {
+        Ok(()) => Outcome::Accept,
+        Err(outcome) => outcome,
+    }
 }
 
-/// Both halves. The caller records the preferences on `Accept`.
+fn stateful_rules(store: &Store, signed: &gloas::SignedProposerPreferences) -> Result<(), Outcome> {
+    let preferences = &signed.message;
+    let config = store.config();
+    let proposal_epoch = compute_epoch_at_slot(preferences.proposal_slot);
+    let dependent_slot = compute_shuffling_dependent_slot(proposal_epoch);
+
+    // [IGNORE] The dependent block has been seen (never queued).
+    if !store.has_block(&preferences.dependent_root) {
+        return Err(Outcome::Ignore(IgnoreReason::UnknownBlock));
+    }
+    // [IGNORE] The dependent block passes validation, i.e. has a post-state.
+    if !store
+        .has_state(&preferences.dependent_root)
+        .unwrap_or(false)
+    {
+        return Err(Outcome::Ignore(IgnoreReason::StateUnavailable));
+    }
+    // [REJECT] The dependent block is not after the shuffling dependent slot.
+    let (block_slot, _) = store
+        .block_entry(&preferences.dependent_root)
+        .ok_or(Outcome::Ignore(IgnoreReason::UnknownBlock))?;
+    if block_slot > dependent_slot {
+        return Err(Outcome::Reject(RejectReason::DependentRootTooLate));
+    }
+    // [IGNORE] The dependent block is a possible dependent block.
+    if !is_valid_dependent_root(store, preferences.dependent_root, dependent_slot) {
+        return Err(Outcome::Ignore(IgnoreReason::ImpossibleDependentRoot));
+    }
+
+    let state = lookahead_state(store, preferences, proposal_epoch)
+        .ok_or(Outcome::Ignore(IgnoreReason::StateUnavailable))?;
+
+    // [REJECT] The validator is the proposer for the slot in the lookahead.
+    if fixed_proposer(&state, preferences.proposal_slot) != Some(preferences.validator_index) {
+        return Err(Outcome::Reject(RejectReason::WrongProposer));
+    }
+    // [REJECT] The signature is valid, under any candidate domain.
+    let pubkey = state
+        .validator(preferences.validator_index)
+        .map_err(|_| Outcome::Reject(RejectReason::WrongProposer))?
+        .pubkey;
+    let message_root = preferences.hash_tree_root();
+    let valid = proposer_preferences_domains(&state, &config, proposal_epoch)
+        .into_iter()
+        .any(|domain| {
+            bls::verify(
+                &pubkey,
+                compute_signing_root(message_root, domain),
+                &signed.signature,
+            )
+        });
+    if !valid {
+        return Err(Outcome::Reject(RejectReason::BadSignature));
+    }
+    Ok(())
+}
+
+/// A cached state whose proposer lookahead answers for `preferences`: the
+/// head's when the head shares the dependent root, else the dependent block's
+/// state advanced to the epoch before the proposal's.
+fn lookahead_state(
+    store: &Store,
+    preferences: &gloas::ProposerPreferences,
+    proposal_epoch: Epoch,
+) -> Option> {
+    if let Ok(head_root) = store.head()
+        && let Some(head_state) = store.cached_state(CacheKey::BlockState(head_root))
+    {
+        // A state in the epoch before the proposal's, or in the proposal's
+        // own, holds the proposal slot in its two-epoch window.
+        let state_epoch = compute_epoch_at_slot(head_state.slot());
+        let covers = state_epoch == proposal_epoch
+            || state_epoch + preset::MIN_SEED_LOOKAHEAD == proposal_epoch;
+        if covers
+            && dependent_root_at(&head_state, head_root, preferences.proposal_slot)
+                == Some(preferences.dependent_root)
+        {
+            return Some(head_state);
+        }
+    }
+    cached_checkpoint_state(
+        store,
+        proposal_epoch.saturating_sub(preset::MIN_SEED_LOOKAHEAD),
+        preferences.dependent_root,
+    )
+}
+
+/// Both halves. The caller records the preferences on `Accept`: the
+/// specification's `validate_proposer_preferences_gossip`.
 pub fn validate(
     market: &BuilderMarket,
     store: &Store,
@@ -44,36 +185,443 @@ pub fn validate(
 
 /// The spec's `is_valid_dependent_root`: `root == store.head()`, or some block
 /// in the index has `parent_root == root` and `slot > dependent_slot`.
-#[allow(dead_code, unused_variables)] // filled by Agent A
 pub fn is_valid_dependent_root(store: &Store, root: Root, dependent_slot: Slot) -> bool {
-    false
+    if store.head().is_ok_and(|head| head == root) {
+        return true;
+    }
+    store
+        .block_index()
+        .values()
+        .any(|&(slot, parent_root)| parent_root == root && slot > dependent_slot)
 }
 
 /// `ancestor_at(state, state_block_root, compute_shuffling_dependent_slot(
-/// epoch(proposal_slot)))`. Used by `produceBlockV4` and the validator client.
-#[allow(dead_code, unused_variables)] // filled by Agent A
+/// epoch(proposal_slot)))`: the block that fixed the proposal slot's proposer
+/// shuffling on the chain `state` is the post-state of. Used by `produceBlockV4`
+/// and the validator client.
 pub fn dependent_root_at(
     state: &BeaconState,
     state_block_root: Root,
     proposal_slot: Slot,
 ) -> Option {
-    None
+    let dependent_slot = compute_shuffling_dependent_slot(compute_epoch_at_slot(proposal_slot));
+    ancestor_at(state, state_block_root, dependent_slot)
 }
 
 /// The distinct candidate signing domains, tried in order: `get_domain(
-/// lookahead_state, DOMAIN_PROPOSER_PREFERENCES, Some(P))`; the schedule's fork
-/// version at `P - MIN_SEED_LOOKAHEAD` (saturating); the schedule's at `P`.
-#[allow(dead_code, unused_variables)] // filled by Agent A
+/// lookahead_state, DOMAIN_PROPOSER_PREFERENCES, Some(P))` (the specification's);
+/// the schedule's fork version at `P - MIN_SEED_LOOKAHEAD` (saturating); and the
+/// schedule's at `P`.
+///
+/// Outside the first epoch of a fork the three coincide. Across a boundary
+/// the specification's gives the version of the epoch before the proposal's,
+/// while lighthouse signs with the proposal epoch's, so accepting both avoids
+/// rejecting an honest client's messages around the gloas upgrade.
 pub fn proposer_preferences_domains(
     lookahead_state: &BeaconState,
     config: &Config,
     proposal_epoch: Epoch,
 ) -> Vec {
-    Vec::new()
+    let genesis_validators_root = lookahead_state.genesis_validators_root();
+    let at = |epoch: Epoch| {
+        compute_domain(
+            DOMAIN_PROPOSER_PREFERENCES,
+            config.fork_version(config.fork_at_epoch(epoch)),
+            genesis_validators_root,
+        )
+    };
+    let domains = [
+        get_domain(
+            lookahead_state,
+            DOMAIN_PROPOSER_PREFERENCES,
+            Some(proposal_epoch),
+        ),
+        at(proposal_epoch.saturating_sub(preset::MIN_SEED_LOOKAHEAD)),
+        at(proposal_epoch),
+    ];
+    let mut distinct: Vec = Vec::with_capacity(domains.len());
+    for domain in domains {
+        if !distinct.contains(&domain) {
+            distinct.push(domain);
+        }
+    }
+    distinct
 }
 
-/// `now > slot_start + 500 ms`.
-#[allow(dead_code, unused_variables)] // filled by Agent A
+/// `now > slot_start + MAXIMUM_GOSSIP_CLOCK_DISPARITY`: the specification's
+/// `is_past_slot`.
 pub(crate) fn is_past_slot(config: &Config, slot: Slot, now_ms: u64) -> bool {
-    false
+    now_ms > slot_start_ms(config, slot).saturating_add(MAXIMUM_GOSSIP_CLOCK_DISPARITY)
+}
+
+#[cfg(test)]
+mod tests {
+    use ethlambda_storage::ForkCheckpoints;
+    use ethlambda_types::beacon::containers::Fork;
+
+    use super::*;
+    use crate::beacon::builder_market::test_support::sign_preferences;
+    use crate::beacon::fork::ForkName;
+    use crate::beacon::gossip::test_support::builder_scene::*;
+    use crate::beacon::gossip::test_support::{slot_start_ms, store};
+    use crate::beacon::helpers::test_state::{secret_key_for, with_signing_validators_at};
+    use crate::beacon::primitives::ValidatorIndex;
+    use crate::beacon::stf;
+
+    /// The epoch-2 slot the scene's proposer preferences name, and the block
+    /// that fixed its proposer: the genesis-slot block, an ancestor of the
+    /// head (slot 32) whose `block_roots` entry covers slot 31.
+    const PROPOSAL_SLOT: Slot = 64;
+    const DEPENDENT: Root = Root::repeat_byte(0x31);
+
+    fn ignore(reason: IgnoreReason) -> Outcome {
+        Outcome::Ignore(reason)
+    }
+
+    fn reject(reason: RejectReason) -> Outcome {
+        Outcome::Reject(reason)
+    }
+
+    /// The builder scene, with the parent's `block_roots` naming `DEPENDENT` at
+    /// slot 31, and `DEPENDENT` stored (block and state) as the head's parent.
+    fn dependent_scene() -> Scene {
+        let mut scene = scene_with(|state| state.block_roots[31] = DEPENDENT);
+        scene
+            .store
+            .insert_pending_block(DEPENDENT, block_at(0))
+            .expect("insert the dependent block");
+        // The head, as a live-chain child of the dependent block: the rule
+        // that a dependent block is possible reads the live chain.
+        scene
+            .store
+            .insert_signed_block(PARENT, block_with_parent(32, DEPENDENT))
+            .expect("link the head to the dependent block");
+        let mut dependent_state = scene.state.clone();
+        *dependent_state.slot_mut() = 0;
+        scene
+            .store
+            .insert_state(DEPENDENT, dependent_state)
+            .expect("insert the dependent state");
+        scene
+    }
+
+    fn preferences_for(
+        scene: &Scene,
+        dependent_root: Root,
+        proposer: ValidatorIndex,
+    ) -> gloas::SignedProposerPreferences {
+        sign_preferences(
+            &scene.state,
+            gloas::ProposerPreferences {
+                dependent_root,
+                proposal_slot: PROPOSAL_SLOT,
+                validator_index: proposer,
+                fee_recipient: fee_recipient(),
+                target_gas_limit: 30_000_000,
+            },
+        )
+    }
+
+    /// Valid preferences from the head's lookahead.
+    fn valid_preferences(scene: &Scene) -> gloas::SignedProposerPreferences {
+        let proposer =
+            crate::beacon::precheck::fixed_proposer(&scene.state, PROPOSAL_SLOT).expect("window");
+        preferences_for(scene, DEPENDENT, proposer)
+    }
+
+    fn now_ms(scene: &Scene) -> u64 {
+        scene.now_ms()
+    }
+
+    #[test]
+    fn valid_preferences_are_accepted_from_the_head_state() {
+        let scene = dependent_scene();
+        let preferences = valid_preferences(&scene);
+        let outcome = validate(&scene.market, &scene.store, &preferences, now_ms(&scene));
+        assert_eq!(outcome, Outcome::Accept);
+        // The head's own lookahead answered: nothing was advanced and cached.
+        let key = CacheKey::CheckpointState {
+            epoch: 1,
+            root: DEPENDENT,
+        };
+        assert!(scene.store.cached_state(key).is_none());
+    }
+
+    // ---- cheap rules ----
+
+    #[test]
+    fn a_second_message_for_a_key_is_already_seen() {
+        let scene = dependent_scene();
+        let preferences = valid_preferences(&scene);
+        assert!(scene.market.record_preferences(preferences.clone(), 33));
+        assert_eq!(
+            cheap_checks(&scene.market, &scene.store, &preferences, now_ms(&scene)),
+            Err(ignore(IgnoreReason::AlreadySeen))
+        );
+    }
+
+    #[test]
+    fn preferences_before_the_fork_are_ignored() {
+        let scene = dependent_scene();
+        let fulu = store(0);
+        let preferences = valid_preferences(&scene);
+        assert_eq!(
+            cheap_checks(&scene.market, &fulu, &preferences, now_ms(&scene)),
+            Err(ignore(IgnoreReason::PreGloasSlot))
+        );
+    }
+
+    #[test]
+    fn preferences_are_late_once_the_slot_began_by_more_than_the_disparity() {
+        let scene = dependent_scene();
+        let preferences = valid_preferences(&scene);
+        let started = slot_start_ms(&scene.store, PROPOSAL_SLOT);
+        let check = |now| cheap_checks(&scene.market, &scene.store, &preferences, now);
+        assert_eq!(check(started + 500), Ok(()));
+        assert_eq!(check(started + 501), Err(ignore(IgnoreReason::SlotStarted)));
+    }
+
+    #[test]
+    fn preferences_are_early_before_the_lookahead_opens() {
+        let scene = dependent_scene();
+        let mut preferences = valid_preferences(&scene);
+        // Epoch 3's lookahead opens with epoch 2, at slot 64.
+        preferences.message.proposal_slot = 96;
+        let opens = slot_start_ms(&scene.store, 64);
+        let check = |now| cheap_checks(&scene.market, &scene.store, &preferences, now);
+        assert_eq!(check(opens - 500), Ok(()));
+        assert_eq!(
+            check(opens - 501),
+            Err(ignore(IgnoreReason::BeyondLookahead))
+        );
+    }
+
+    // ---- stateful rules ----
+
+    #[test]
+    fn preferences_naming_an_unseen_dependent_block_are_ignored() {
+        let scene = dependent_scene();
+        let preferences = preferences_for(&scene, Root::repeat_byte(0x99), 0);
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            ignore(IgnoreReason::UnknownBlock)
+        );
+    }
+
+    #[test]
+    fn a_dependent_block_without_a_state_is_ignored() {
+        let mut scene = dependent_scene();
+        let stateless = Root::repeat_byte(0x62);
+        scene
+            .store
+            .insert_pending_block(stateless, block_at(20))
+            .expect("insert the block");
+        let preferences = preferences_for(&scene, stateless, 0);
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            ignore(IgnoreReason::StateUnavailable)
+        );
+    }
+
+    #[test]
+    fn a_dependent_block_after_the_dependent_slot_is_rejected() {
+        let scene = dependent_scene();
+        // The head itself is at slot 32, after slot 31.
+        let preferences = preferences_for(&scene, PARENT, 0);
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            reject(RejectReason::DependentRootTooLate)
+        );
+    }
+
+    #[test]
+    fn a_dependent_block_no_chain_can_use_is_ignored() {
+        let mut scene = dependent_scene();
+        let stray = Root::repeat_byte(0x63);
+        scene
+            .store
+            .insert_pending_block(stray, block_at(10))
+            .expect("insert the block");
+        scene
+            .store
+            .insert_state(stray, scene.state.clone())
+            .expect("insert the state");
+        let preferences = preferences_for(&scene, stray, 0);
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            ignore(IgnoreReason::ImpossibleDependentRoot)
+        );
+        assert!(!is_valid_dependent_root(&scene.store, stray, 31));
+        assert!(is_valid_dependent_root(&scene.store, PARENT, 31));
+    }
+
+    #[test]
+    fn a_validator_that_is_not_the_proposer_is_rejected() {
+        let scene = dependent_scene();
+        let proposer =
+            crate::beacon::precheck::fixed_proposer(&scene.state, PROPOSAL_SLOT).expect("window");
+        let other = (proposer + 1) % 8;
+        let preferences = preferences_for(&scene, DEPENDENT, other);
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            reject(RejectReason::WrongProposer)
+        );
+    }
+
+    #[test]
+    fn a_bad_signature_is_rejected() {
+        let scene = dependent_scene();
+        let mut preferences = valid_preferences(&scene);
+        preferences.signature.0[5] ^= 1;
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            reject(RejectReason::BadSignature)
+        );
+    }
+
+    #[test]
+    fn a_dependent_block_off_the_head_uses_the_cached_checkpoint_state() {
+        let mut scene = dependent_scene();
+        // The dependent block is the head, whose own state is epoch 0, so the
+        // lookahead has to come from its state advanced to epoch 1.
+        scene
+            .store
+            .update_checkpoints(ForkCheckpoints::head_only(DEPENDENT))
+            .expect("move the head");
+        let mut advanced = scene
+            .store
+            .cached_state(CacheKey::BlockState(DEPENDENT))
+            .expect("stored");
+        let mut owned = (*advanced).clone();
+        stf::process_slots(&mut owned, 32, &scene.store.config()).expect("advance");
+        advanced = std::sync::Arc::new(owned);
+        let proposer =
+            crate::beacon::precheck::fixed_proposer(&advanced, PROPOSAL_SLOT).expect("window");
+        let preferences = preferences_for(&scene, DEPENDENT, proposer);
+        let key = CacheKey::CheckpointState {
+            epoch: 1,
+            root: DEPENDENT,
+        };
+        assert!(scene.store.cached_state(key).is_none());
+        assert_eq!(stateful_checks(&scene.store, &preferences), Outcome::Accept);
+        assert!(scene.store.cached_state(key).is_some());
+    }
+
+    #[test]
+    fn a_dependent_state_evicted_from_the_cache_is_ignored_not_rebuilt() {
+        let mut scene = dependent_scene();
+        scene
+            .store
+            .update_checkpoints(ForkCheckpoints::head_only(DEPENDENT))
+            .expect("move the head");
+        let preferences = valid_preferences(&scene);
+        // Fill the bounded state cache with other blocks' states until the
+        // dependent block's is pushed out. The state still exists (`has_state`),
+        // but reading it would mean a rebuild from disk.
+        for byte in 0..64u8 {
+            let mut root = Root::repeat_byte(0xA0);
+            root.0[1] = byte;
+            scene
+                .store
+                .insert_state(root, scene.state.clone())
+                .expect("insert a filler state");
+        }
+        assert!(
+            scene
+                .store
+                .cached_state(CacheKey::BlockState(DEPENDENT))
+                .is_none()
+        );
+        assert!(scene.store.has_state(&DEPENDENT).expect("has_state"));
+        assert_eq!(
+            stateful_checks(&scene.store, &preferences),
+            ignore(IgnoreReason::StateUnavailable)
+        );
+    }
+
+    // ---- dependent roots and domains ----
+
+    #[test]
+    fn the_dependent_root_is_the_ancestor_at_the_shuffling_dependent_slot() {
+        let scene = dependent_scene();
+        assert_eq!(
+            dependent_root_at(&scene.state, PARENT, PROPOSAL_SLOT),
+            Some(DEPENDENT)
+        );
+        // At genesis the slot saturates to the state's own block.
+        let mut genesis = scene.state.clone();
+        *genesis.slot_mut() = 0;
+        assert_eq!(dependent_root_at(&genesis, PARENT, 5), Some(PARENT));
+    }
+
+    fn boundary_config() -> Config {
+        Config::mainnet()
+            .with_fork_epoch(ForkName::Fulu, 0)
+            .with_fork_epoch(ForkName::Gloas, 2)
+    }
+
+    fn fulu_state_with_fork(config: &Config) -> BeaconState {
+        let mut state = with_signing_validators_at(ForkName::Fulu, 8);
+        *state.fork_mut() = Fork {
+            previous_version: config.fork_version(ForkName::Electra),
+            current_version: config.fork_version(ForkName::Fulu),
+            epoch: 0,
+        };
+        state
+    }
+
+    #[test]
+    fn across_the_fork_both_neighbouring_versions_are_candidates() {
+        let config = boundary_config();
+        let state = fulu_state_with_fork(&config);
+        let root = state.genesis_validators_root();
+        let at =
+            |fork| compute_domain(DOMAIN_PROPOSER_PREFERENCES, config.fork_version(fork), root);
+        // Proposal epoch 2 is gloas's first: the lookahead state's fork (fulu)
+        // and the epoch before the proposal's agree, the proposal epoch's is gloas.
+        let domains = proposer_preferences_domains(&state, &config, 2);
+        assert_eq!(domains, vec![at(ForkName::Fulu), at(ForkName::Gloas)]);
+
+        // A signature under either verifies; one under neither does not.
+        let preferences = gloas::ProposerPreferences {
+            proposal_slot: 64,
+            ..Default::default()
+        };
+        let message_root = preferences.hash_tree_root();
+        let sign = |domain| {
+            let signature = secret_key_for(0).sign(
+                compute_signing_root(message_root, domain).as_slice(),
+                crate::beacon::bls::DST,
+                &[],
+            );
+            crate::beacon::primitives::BlsSignature(signature.to_bytes())
+        };
+        let pubkey = state.validator(0).expect("validator 0").pubkey;
+        let accepted = |signature| {
+            domains.iter().any(|domain| {
+                bls::verify(
+                    &pubkey,
+                    compute_signing_root(message_root, *domain),
+                    &signature,
+                )
+            })
+        };
+        assert!(accepted(sign(at(ForkName::Fulu))));
+        assert!(accepted(sign(at(ForkName::Gloas))));
+        assert!(!accepted(sign(at(ForkName::Electra))));
+    }
+
+    #[test]
+    fn away_from_a_fork_there_is_one_candidate_domain() {
+        let config = boundary_config();
+        let mut state = fulu_state_with_fork(&config);
+        *state.fork_mut() = Fork {
+            previous_version: config.fork_version(ForkName::Fulu),
+            current_version: config.fork_version(ForkName::Gloas),
+            epoch: 2,
+        };
+        assert_eq!(proposer_preferences_domains(&state, &config, 5).len(), 1);
+        // And at epoch 0 the saturating subtraction does not wrap.
+        assert!(!proposer_preferences_domains(&state, &config, 0).is_empty());
+    }
 }
diff --git a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs
index 78527bc2..451320a6 100644
--- a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs
+++ b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs
@@ -97,3 +97,162 @@ pub(crate) fn fulu_parent(proposer: ValidatorIndex) -> BeaconState {
     state.apply_pending_mutations();
     state
 }
+
+/// The builder-market rules' shared scene: a gloas chain whose head `PARENT`
+/// (slot 32, epoch 1) is a FULL block with a funded active builder 0, the
+/// proposer preferences and parent payload gossip would have delivered, and a
+/// signed bid for slot 34 that passes every rule.
+pub(crate) mod builder_scene {
+    use ethlambda_storage::ForkCheckpoints;
+    use ethlambda_types::beacon::containers::{SignedBeaconBlock, electra, gloas};
+
+    use super::*;
+    use crate::beacon::builder_market::{BuilderMarket, test_support};
+    use crate::beacon::fork_choice::PayloadStatus;
+    use crate::beacon::gloas_block_production::test_support as gloas_support;
+    use crate::beacon::gossip::proposer_preferences::dependent_root_at;
+    use crate::beacon::helpers::accessors::{get_current_epoch, get_randao_mix};
+    use crate::beacon::primitives::{ExecutionAddress, ExecutionBlockHash};
+
+    pub(crate) const PARENT: Root = Root::repeat_byte(0x50);
+    pub(crate) const BID_SLOT: Slot = 34;
+    pub(crate) const PARENT_GAS_LIMIT: u64 = 30_000_000;
+
+    pub(crate) fn fee_recipient() -> ExecutionAddress {
+        ExecutionAddress::repeat_byte(0x11)
+    }
+
+    /// The hash of the FULL parent's payload, which the bid builds on.
+    pub(crate) fn parent_block_hash() -> ExecutionBlockHash {
+        ExecutionBlockHash::repeat_byte(gloas_support::PARENT_BLOCK_HASH)
+    }
+
+    /// A fulu-shaped block at `slot`: the store only reads its slot and parent.
+    pub(crate) fn block_at(slot: Slot) -> SignedBeaconBlock {
+        block_with_parent(slot, Root::ZERO)
+    }
+
+    pub(crate) fn block_with_parent(slot: Slot, parent_root: Root) -> SignedBeaconBlock {
+        SignedBeaconBlock::Fulu(electra::SignedBeaconBlock {
+            message: electra::BeaconBlock {
+                slot,
+                proposer_index: 0,
+                parent_root,
+                state_root: Root::ZERO,
+                body: electra::BeaconBlockBody::empty(),
+            },
+            signature: Default::default(),
+        })
+    }
+
+    /// An empty store, gloas from genesis, anchored (and headed) at `PARENT`,
+    /// whose block the caller must store.
+    pub(crate) fn gloas_store() -> Store {
+        Store::init_beacon(
+            Arc::new(InMemoryBackend::new()),
+            GENESIS_TIME,
+            gloas_support::config(),
+            PARENT,
+            Checkpoint {
+                root: PARENT,
+                slot: 32,
+            },
+            32,
+        )
+    }
+
+    pub(crate) struct Scene {
+        pub store: Store,
+        pub market: BuilderMarket,
+        /// `PARENT`'s post-state.
+        pub state: BeaconState,
+        /// A signed bid that passes every rule at [`Scene::now_ms`].
+        pub bid: gloas::SignedExecutionPayloadBid,
+    }
+
+    impl Scene {
+        /// 100 ms into the bid's slot.
+        pub(crate) fn now_ms(&self) -> u64 {
+            slot_start_ms(&self.store, BID_SLOT) + 100
+        }
+
+        /// The scene's bid after `edit`, re-signed by its builder.
+        pub(crate) fn signed(
+            &self,
+            edit: impl FnOnce(&mut gloas::ExecutionPayloadBid),
+        ) -> gloas::SignedExecutionPayloadBid {
+            let mut bid = self.bid.message.clone();
+            edit(&mut bid);
+            test_support::sign_bid(&self.state, bid, self.bid.message.builder_index)
+        }
+    }
+
+    /// The scene with `edit` applied to the parent's state before it is stored,
+    /// and the prerequisites of a passing bid recorded in the market.
+    pub(crate) fn scene_with(edit: impl FnOnce(&mut gloas::BeaconState)) -> Scene {
+        let mut state = test_support::gloas_state_with_builder(0, 100_000_000_000, 0);
+        let BeaconState::Gloas(inner) = &mut state else {
+            unreachable!("built as gloas")
+        };
+        edit(inner);
+        state.apply_pending_mutations();
+        let mut store = gloas_store();
+        store
+            .insert_pending_block(PARENT, block_at(state.slot()))
+            .expect("insert the parent");
+        store
+            .insert_state(PARENT, state.clone())
+            .expect("insert the parent state");
+        store
+            .update_checkpoints(ForkCheckpoints::head_only(PARENT))
+            .expect("move the head");
+        store.set_head_payload_status(PARENT, PayloadStatus::Full);
+
+        let dependent_root = dependent_root_at(&state, PARENT, BID_SLOT).expect("in the window");
+        let proposer = crate::beacon::precheck::fixed_proposer(&state, BID_SLOT).expect("window");
+        let market = BuilderMarket::default();
+        let preferences = test_support::sign_preferences(
+            &state,
+            gloas::ProposerPreferences {
+                dependent_root,
+                proposal_slot: BID_SLOT,
+                validator_index: proposer,
+                fee_recipient: fee_recipient(),
+                target_gas_limit: PARENT_GAS_LIMIT,
+            },
+        );
+        assert!(market.record_preferences(preferences, BID_SLOT - 1));
+        market.record_execution_payload(&test_support::envelope_with_gas_limit(
+            parent_block_hash(),
+            PARENT_GAS_LIMIT,
+            PARENT,
+            vec![],
+        ));
+        let bid = test_support::sign_bid(
+            &state,
+            gloas::ExecutionPayloadBid {
+                parent_block_hash: parent_block_hash(),
+                parent_block_root: PARENT,
+                block_hash: ExecutionBlockHash::repeat_byte(0x33),
+                prev_randao: get_randao_mix(&state, get_current_epoch(&state)),
+                fee_recipient: fee_recipient(),
+                gas_limit: PARENT_GAS_LIMIT,
+                builder_index: 0,
+                slot: BID_SLOT,
+                value: 1,
+                ..Default::default()
+            },
+            0,
+        );
+        Scene {
+            store,
+            market,
+            state,
+            bid,
+        }
+    }
+
+    pub(crate) fn scene() -> Scene {
+        scene_with(|_| {})
+    }
+}
diff --git a/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs b/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs
index e98ec5ed..1d3e32ca 100644
--- a/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs
+++ b/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs
@@ -14,6 +14,7 @@ use std::num::NonZeroUsize;
 use std::sync::Arc;
 
 use ethlambda_state_transition::beacon::ForkName;
+use ethlambda_state_transition::beacon::builder_market::BuilderMarket;
 use ethlambda_state_transition::beacon::config::Config;
 use ethlambda_state_transition::beacon::containers::{
     BeaconState, Checkpoint, DataColumnSidecar, SignedAggregateAndProof, SignedBeaconBlock,
@@ -43,6 +44,8 @@ const HANDLERS: &[&str] = &[
     "gossip_beacon_attestation",
     "gossip_execution_payload_envelope",
     "gossip_payload_attestation_message",
+    "gossip_execution_payload_bid",
+    "gossip_proposer_preferences",
 ];
 
 /// The forks each of [`HANDLERS`] validates. A case from any other fork is
@@ -61,9 +64,10 @@ fn validated_forks(handler: &str) -> &'static [ForkName] {
         "gossip_beacon_aggregate_and_proof" | "gossip_beacon_attestation" => {
             &[ForkName::Fulu, ForkName::Gloas]
         }
-        "gossip_execution_payload_envelope" | "gossip_payload_attestation_message" => {
-            &[ForkName::Gloas]
-        }
+        "gossip_execution_payload_envelope"
+        | "gossip_payload_attestation_message"
+        | "gossip_execution_payload_bid"
+        | "gossip_proposer_preferences" => &[ForkName::Gloas],
         other => panic!("{other} is not in HANDLERS, so it has no validated forks"),
     }
 }
@@ -77,20 +81,14 @@ fn validated_forks(handler: &str) -> &'static [ForkName] {
 /// list, the same way [`super::UNMODELED_FORKS`] forces a decision on a new
 /// fork directory.
 ///
-/// `gossip_execution_payload_bid` and `gossip_proposer_preferences` are
-/// gloas's own topics (EIP-7732 ePBS): the builder's bid and a builder's
-/// advertised preferences, respectively. (Its envelope and payload
-/// attestation topics are in [`HANDLERS`].) None of them existed
-/// until gloas's fixture directory started parsing (`ForkName::Gloas`), so
-/// they land here rather than silently in `unknown` the first time this
-/// runner sees them. Alphabetized with the rest rather than kept together.
+/// Gloas's own topics (EIP-7732 ePBS) are all in [`HANDLERS`] now: the
+/// envelope, the payload attestation, the builder's bid and the proposer's
+/// preferences.
 const IGNORED_HANDLERS: &[&str] = &[
     "gossip_attester_slashing",
     "gossip_blob_sidecar",
     "gossip_bls_to_execution_change",
-    "gossip_execution_payload_bid",
     "gossip_partial_data_column_sidecar",
-    "gossip_proposer_preferences",
     "gossip_proposer_slashing",
     "gossip_sync_committee_contribution_and_proof",
     "gossip_sync_committee_message",
@@ -259,17 +257,28 @@ fn case_config(case: &Case, state: &BeaconState) -> Config {
 
 /// Delivers `entry`'s execution payload envelope, if it lists one, so
 /// `is_payload_verified` answers true for its block.
+///
+/// `trusted` records the envelope the way the specification's own bid and
+/// preferences generators do (`store.payloads[root] = envelope.message`, no
+/// verification): their gas-limit cases deliberately give the head an envelope
+/// whose payload gas limit differs from the block's bid, which
+/// `verify_execution_payload_envelope` would refuse.
 fn deliver_payload(
     store: &mut Store,
     case: &Case,
     entry: &StoreBlock,
     config: &Config,
+    trusted: bool,
 ) -> Result<(), String> {
     let Some(name) = &entry.payload else {
         return Ok(());
     };
     let envelope = gloas::SignedExecutionPayloadEnvelope::from_ssz_bytes(&case.ssz_bytes(name))
         .map_err(|err| format!("decoding {name}: {err:?}"))?;
+    if trusted {
+        fork_choice::accept_execution_payload_envelope(store, &envelope);
+        return Ok(());
+    }
     // No sampled columns are named, so the empty retrieval reads as available,
     // as in the fork-choice runner's envelope step.
     fork_choice::on_execution_payload_envelope(
@@ -282,6 +291,11 @@ fn deliver_payload(
     .map_err(|err| format!("delivering {name}: {err:?}"))
 }
 
+/// The two topics whose cases share one store setup and one market.
+fn is_builder_market_topic(topic: &str) -> bool {
+    matches!(topic, "execution_payload_bid" | "proposer_preferences")
+}
+
 /// The store the case describes: its anchor, then each listed block.
 fn build_store(
     case: &Case,
@@ -297,6 +311,9 @@ fn build_store(
     let backend = Arc::new(ethlambda_storage::backend::InMemoryBackend::new());
     let mut store = fork_choice::get_forkchoice_store(backend, state, anchor_block, config)
         .map_err(|err| format!("get_forkchoice_store: {err:?}"))?;
+    let builder_market = is_builder_market_topic(&meta.topic);
+    let trusted = builder_market;
+    let mut imported = Vec::new();
 
     // The store's clock at the case's base time, so `on_block` accepts every
     // listed block; a block from a slot past that time advances it to that
@@ -304,7 +321,7 @@ fn build_store(
     // slot a clock-disparity case sends its sidecar for.
     let mut clock_s = (config.genesis_time_ms() + meta.current_time_ms) / 1000;
     fork_choice::on_tick(&mut store, clock_s, config);
-    deliver_payload(&mut store, case, anchor, config)?;
+    deliver_payload(&mut store, case, anchor, config, trusted)?;
 
     for entry in rest {
         let block = decode_block(case, &entry.block)?;
@@ -348,7 +365,8 @@ fn build_store(
             &CommitteeCache::default(),
         )
         .map_err(|err| format!("importing {}: {err:?}", entry.block))?;
-        deliver_payload(&mut store, case, entry, config)?;
+        deliver_payload(&mut store, case, entry, config, trusted)?;
+        imported.push(root);
         if gloas && let Some(status) = entry.payload_status.as_deref() {
             let status = match status {
                 "VALID" => PayloadStatusEnum::Valid,
@@ -376,6 +394,27 @@ fn build_store(
         store
             .update_checkpoints(ForkCheckpoints::new(head, None, Some(checkpoint)))
             .map_err(|err| format!("overriding the finalized checkpoint: {err}"))?;
+        if builder_market {
+            // The bid generators activate their builders by finalizing epoch 1
+            // in the store *and* in the head's post-state (a replayed chain of
+            // empty blocks never finalizes), and say so through this override.
+            for block_root in &imported {
+                let Some(state) = store
+                    .get_state(block_root)
+                    .map_err(|err| format!("reading a state: {err}"))?
+                else {
+                    continue;
+                };
+                let mut state = (*state).clone();
+                *state.finalized_checkpoint_mut() = Checkpoint {
+                    epoch: finalized.epoch,
+                    root,
+                };
+                store
+                    .insert_state(*block_root, state)
+                    .map_err(|err| format!("overriding a state's finalized checkpoint: {err}"))?;
+            }
+        }
     }
 
     Ok(store)
@@ -406,7 +445,14 @@ fn run_case(case: &Case) -> Result<(), String> {
     let state = BeaconState::from_ssz(case.fork, &case.ssz_bytes("state"))
         .map_err(|err| format!("decoding state: {err:?}"))?;
     let config = case_config(case, &state);
-    let store = build_store(case, &meta, state, &config)?;
+    let mut store = build_store(case, &meta, state, &config)?;
+    if is_builder_market_topic(&meta.topic) {
+        // `on_block` records no head, and the bid and preference rules read
+        // the recorded one.
+        fork_choice::get_head(&mut store, &config)
+            .map_err(|err| format!("computing the head: {err:?}"))?;
+    }
+    let market = BuilderMarket::default();
     let capacity = NonZeroUsize::new(SEEN_CAPACITY).expect("non-zero");
     let mut seen_blocks = SeenBlocks::new(capacity);
     let mut seen_columns = SeenColumns::new(capacity);
@@ -534,6 +580,19 @@ fn run_case(case: &Case) -> Result<(), String> {
                 }
                 outcome
             }
+            // The bid cases mix three message types under one topic (the
+            // preferences and envelope a bid depends on arrive in order, and
+            // share the case's seen state), so the message's own name says
+            // which rule judges it.
+            "execution_payload_bid" | "proposer_preferences" => run_builder_market_message(
+                case,
+                &store,
+                &market,
+                &mut seen_envelopes,
+                message,
+                now_ms,
+                &config,
+            )?,
             other => return Err(format!("topic {other} has no runner")),
         };
         check(message, outcome).map_err(|err| format!("message {index}: {err}"))?;
@@ -541,6 +600,52 @@ fn run_case(case: &Case) -> Result<(), String> {
     Ok(())
 }
 
+/// One message of a `execution_payload_bid` or `proposer_preferences` case.
+fn run_builder_market_message(
+    case: &Case,
+    store: &Store,
+    market: &BuilderMarket,
+    seen_envelopes: &mut SeenEnvelopes,
+    message: &GossipMessage,
+    now_ms: u64,
+    config: &Config,
+) -> Result {
+    let bytes = case.ssz_bytes(&message.message);
+    let decode_err = |err| format!("decoding {}: {err:?}", message.message);
+    if message.message.starts_with("proposer_preferences_") {
+        let preferences =
+            gloas::SignedProposerPreferences::from_ssz_bytes(&bytes).map_err(decode_err)?;
+        let outcome = rules::proposer_preferences::validate(market, store, &preferences, now_ms);
+        if outcome == Outcome::Accept {
+            let wall_slot =
+                now_ms.saturating_sub(config.genesis_time_ms()) / config.slot_duration_ms;
+            market.record_preferences(preferences, wall_slot);
+        }
+        Ok(outcome)
+    } else if message.message.starts_with("execution_payload_envelope_") {
+        let envelope =
+            gloas::SignedExecutionPayloadEnvelope::from_ssz_bytes(&bytes).map_err(decode_err)?;
+        let outcome = rules::envelope::validate(seen_envelopes, store, &envelope);
+        if outcome == Outcome::Accept {
+            seen_envelopes.record(
+                envelope.message.beacon_block_root,
+                envelope.message.builder_index,
+            );
+            market.record_execution_payload(&envelope.message);
+        }
+        Ok(outcome)
+    } else if message.message.starts_with("execution_payload_bid_") {
+        let bid = gloas::SignedExecutionPayloadBid::from_ssz_bytes(&bytes).map_err(decode_err)?;
+        let outcome = rules::execution_payload_bid::validate(market, store, &bid, now_ms);
+        if outcome == Outcome::Accept {
+            market.record_bid(bid);
+        }
+        Ok(outcome)
+    } else {
+        Err(format!("{} is of no known message type", message.message))
+    }
+}
+
 pub fn trials() -> Vec {
     let mut trials = Vec::new();
     for handler in HANDLERS {
diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md
index f3e00b67..b5fb5f41 100644
--- a/docs/spec_deviations.md
+++ b/docs/spec_deviations.md
@@ -93,47 +93,154 @@ same keys, already signed.
   runs, and treat a restart as an event that needs the same care a manual key
   move would. The client warns about this at startup on every run.
 
-## Self-build only
+## Builder bids: gossip and API only (no builder API)
 
-The gloas validator duties are served for a proposer that builds its own
-payload, and for no one else.
+A gloas proposer served by this node can take a builder's bid from gossip or
+from the Beacon API, and no other way.
 
 - **The specification:** a gloas proposer may take a builder's signed bid
   (`SignedExecutionPayloadBid`, from gossip or a builder API) instead of building
-  its own payload, and publishes a `SignedProposerPreferences` so builders know
-  its fee recipient and gas limit target. `produceBlockV4` takes a
-  `BuilderConfig` (`min_bid`, `builder_boost_factor`, `builders`) to steer that
-  choice.
-- **ethlambda:** the beacon node never takes a bid, from gossip or otherwise, and
-  never builds or reads a `SignedProposerPreferences`. `produceBlockV4` decodes
-  the `BuilderConfig` body (a missing or undecodable one is a `400`, as the
-  specification requires) and ignores it, logging at debug when it names
-  builders. Every block commits to a zero-value self-build bid
-  (`BUILDER_INDEX_SELF_BUILD`, the G2 point at infinity as signature), and
-  `Eth-Consensus-Block-Value` is always `0` since there is nothing to compare.
-  The validator client signs the envelope with the proposer's own key under
-  `DOMAIN_BEACON_BUILDER`, and leaves a block that commits to anyone else's bid
-  alone.
-- **Why:** a scope decision for the first gloas duties: taking bids needs a bid
-  pool, builder payment handling and a builder-facing API, none of which this
-  node has.
-- **Consequence:** a validator run through this node never earns a builder's
-  payment, and an execution client's own block value is what
-  `Eth-Execution-Payload-Value` reports.
-
-## `target_gas_limit` is the parent bid's gas limit
+  its own payload. `produceBlockV4` takes a `BuilderConfig` (`min_bid`,
+  `builder_boost_factor`, `builders`) to steer that choice.
+- **ethlambda:** bids seen on `execution_payload_bid` or posted to
+  `POST /eth/v1/beacon/execution_payload_bids` are pooled, and `produceBlockV4`
+  compares the best one with the local build under the top-level `min_bid` and
+  `builder_boost_factor`: the bid must reach `min_bid`, and wins when
+  `builder_boost_factor * bid_value > local_value / 10^7` (in Gwei against Wei,
+  so a factor of 100 is parity). The local build wins a tie, and
+  `shouldOverrideBuilder` from the execution client is honored. With no
+  execution client, or a failed local build, the best viable bid is taken.
+- **What it does not do:** the `builders` entries are decoded and ignored, so
+  no builder is asked for a bid over HTTP and no `Eth-Builder-Url` is returned.
+  Gossip bids are not filtered by `builder_pubkeys` either, since the Beacon API
+  puts that list on each entry and it governs only that entry's own bid.
+  `Eth-Consensus-Block-Value` stays `0`, because the consensus reward is not
+  computed.
+- **A bid win returns no envelope.** The block commits to the builder's bid, the
+  builder reveals the payload, and nothing is cached for
+  `GET .../execution_payload_envelopes`, so that endpoint answers `404` and
+  `Eth-Execution-Payload-Included` is `false`. The node never signs or
+  publishes an envelope for such a block, and the proposer-signed
+  self-build envelope is refused for it, since its `builder_index` differs from
+  the bid's.
+- **Why:** gossip and API bids need only a pool and the gossip rules. A builder
+  API client is a separate crate with its own failure modes and timeouts, and
+  is left for a later phase.
+
+## Fee recipient and gas target come from proposer preferences, with fallbacks
 
 - **The specification:** the payload is built toward the `target_gas_limit` of
-  the proposer's `SignedProposerPreferences`, and `bid.gas_limit` must be
-  compatible with it (`is_gas_limit_target_compatible`).
-- **ethlambda:** with no preferences to read, `PayloadAttributesV4.targetGasLimit`
-  is the `gas_limit` of `latest_execution_payload_bid` of the state being built
-  on (`gloas_payload_inputs`), so the execution client holds the gas limit where
-  it is.
+  the proposer's `SignedProposerPreferences`, which also names the fee
+  recipient builders must pay, and `bid.gas_limit` must be compatible with the
+  target (`is_gas_limit_target_compatible`).
+- **ethlambda:** the self-build reads the signed preferences for
+  `(slot, dependent_root)` that name the proposer, from gossip or the Beacon API.
+  The fee recipient is theirs, else `prepare_beacon_proposer`'s, else zero with
+  a warning. The target gas limit is theirs, else the `gas_limit` of
+  `latest_execution_payload_bid` of the state being built on, so the execution
+  client holds the gas limit where it is.
 - **Equivalence:** the bid is built from the payload the execution client
   returns, so `bid.gas_limit` is whatever that payload carries and is always
-  consistent with the block. A validator that wants the limit to move cannot say
-  so through this node: it follows the previous block's.
+  consistent with the block. A proposer that wants the limit to move without
+  preferences cannot say so through this node: it follows the previous block's.
+
+## Bid and preference gossip never queues
+
+- **The specification:** several rules say the message "MAY be queued": an
+  unknown parent block, an unimported parent, an unseen dependent block.
+- **ethlambda:** every one of them is IGNORE. A bid or preferences message that
+  names a block or state this node lacks is dropped, never parked, so a burst
+  of them holds no memory and nothing is replayed later.
+- **Why:** both topics are only useful for the next slot or two, and a bid that
+  arrives after its parent was imported would be stale by the time a replay ran.
+  A proposer that missed a message builds locally.
+
+## Bid gossip judges against the recorded head and cached states
+
+- **The specification:** `validate_execution_payload_bid_gossip` reads
+  `get_head(store)`, `store.block_states[parent]` and the parent state advanced
+  with `process_slots` to the bid's slot.
+- **ethlambda:** the head node is the one the chain actor recorded
+  (`Store::head` and `head_payload_status`), with a fresh `get_head_node` walk
+  only when no status is recorded. The dependent root is read from the parent
+  state's `block_roots`, which the lookahead rule keeps in range. The parent
+  state stands in for the advanced one when the bid is in the parent's own
+  epoch, since gloas's `process_slot` touches none of `builders`,
+  `finalized_checkpoint`, `builder_pending_*`, `fork` or
+  `latest_execution_payload_bid`, which are all the later rules read. Across an
+  epoch the cached `CheckpointState` of the bid's epoch is used and filled, the
+  same entry attestation target states use. A state that is not cached is
+  IGNORE and is never rebuilt from disk.
+- **Why:** gossip verdicts are waited on by gossipsub, and the state cache holds
+  32 states, so a spec-literal read of a parent about an epoch old would miss
+  often. The head record and the equivalence above give the same verdict
+  without a replay.
+
+## Known execution payloads are gossip-accepted or self-published envelopes only
+
+- **The specification:** `seen.execution_payloads` holds a payload for every
+  envelope accepted from gossip.
+- **ethlambda:** the known payloads are the market's, a 256-entry LRU filled by
+  envelopes that passed gossip validation and by envelopes this node publishes
+  (gossip never echoes a node's own messages). They are not persisted, and an
+  envelope that was queued and verified later, or fetched by request and
+  response, does not count. After a restart bids on a pre-restart payload are
+  IGNORE until new envelopes arrive.
+- **Exception:** a pre-gloas parent's own payload counts as known, with its
+  execution payload header's gas limit, see the fork boundary entry below.
+
+## Proposer preferences are judged off cached states only
+
+- **The specification:** the lookahead is read from
+  `store.block_states[dependent_root]` advanced to the epoch before the
+  proposal's.
+- **ethlambda:** the cached head state is used when it shares the dependent root
+  and is in the epoch before the proposal's or the proposal's own (the whole
+  canonical case), else the cached `CheckpointState` of the epoch before the
+  proposal's, else IGNORE. Nothing is rebuilt from disk.
+- **Why:** a dependent block about an epoch old is usually out of the 32-state
+  cache, so reading it would IGNORE most honest preferences.
+
+## The fulu-to-gloas boundary for bids and preferences
+
+- **The specification:** says nothing about a parent that is not a gloas block.
+- **ethlambda:** a pre-gloas parent's payload counts as known, with its header's
+  `gas_limit`. A pre-gloas head's payload hashes come from its payload header,
+  and a bid is compatible with it when it builds on that head and its payload.
+  The builder-exit check is skipped for a pre-gloas parent, which carries no
+  envelope.
+- **Preference signatures** are accepted under the lookahead state's own
+  `DOMAIN_PROPOSER_PREFERENCES` domain (the specification's), the fork version of
+  the epoch before the proposal's, or the proposal epoch's. They coincide outside
+  the first epoch of a fork. Lighthouse signs with the proposal epoch's version
+  and the specification gives the earlier one, so accepting both avoids
+  rejecting an honest client's messages around the gloas upgrade.
+- **Consequence:** builders onboarded at the fork are inactive until their
+  deposit epoch is finalized, so gossip bids are rejected for the first epochs of
+  gloas and `produceBlockV4` self-builds.
+
+## Beacon API answers for bids and preferences
+
+- **An IGNORE verdict is a `400`,** the same as a REJECT, because the API has no
+  separate status for it. The message names the verdict and the reason, such as
+  `ignore: preferences_unseen`.
+- **An identical resubmission is a `200`** and is not published again.
+- **The prose and the rules disagree** on a mismatched fee recipient or gas
+  limit: the Beacon API text says the bid is rejected, while consensus-specs
+  IGNOREs it. ethlambda follows consensus-specs.
+- **`Eth-Consensus-Version` may be absent** on the bid and preferences posts. If
+  present it must name a gloas-compatible fork.
+
+## No minimum bid increment or rate limit
+
+- **The specification:** a note says implementations SHOULD guard against
+  builders spamming bids with minimal increments, for example with a minimum
+  threshold or by forwarding only the best bid at intervals.
+- **ethlambda:** neither is implemented. Spam is bounded by one bid per builder
+  per `(slot, parent_hash, parent_root)`, a strictly higher value than the best
+  seen, a funded active registered builder with a valid signature, a cap on keys
+  per slot and on bids pooled per parent, and a separate permit pool for
+  validating them. A configurable minimum increment is a follow-up.
 
 ## `skip_randao_verification` is ignored
 

From 17dea23d7a31304324cd0c9a4341a6fa654acff4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?=
 <47506558+MegaRedHand@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:17:12 -0300
Subject: [PATCH 10/12] docs(claude): describe the gloas builder market and its
 two extra topics

CLAUDE.md still said gloas added two topics and that duties were self-build only.
---
 CLAUDE.md | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0a8186d0..771ed2d2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -334,9 +334,11 @@ actual_slot = finalized_slot + 1 + relative_index
   - Beacon wire: `validate_messages()` is on, so every beacon message waits for a verdict (~4.2s before gossipsub's cache evicts it). Rules in `state_transition::beacon::gossip` (cheap half inline, stateful half on a bounded `spawn_blocking` task); plumbing in `p2p/src/beacon/verdict.rs`. Lean gossip still auto-forwards
   - Data columns: every check runs in p2p. A column gossip did not accept (`Queue`/`Overloaded`), every fetched column, and parked columns replayed after their parent imports go through `column::chain_checks` in `p2p/src/beacon/column_checks.rs`. The chain actor stores what it gets unchecked; only debug builds re-run `chain_checks` there
   - Beacon subscribes seven global topics plus two node-id-derived subnet families: custody
-    columns and backbone attestation subnets. Gloas digests add two more topics,
-    `execution_payload` and `payload_attestation_message` (`BeaconTopics::for_fork`; earlier
-    digests never carry them). Gloas has its own rules for `beacon_block`,
+    columns and backbone attestation subnets. Gloas digests add four more topics,
+    `execution_payload`, `payload_attestation_message`, `execution_payload_bid` and
+    `proposer_preferences` (`BeaconTopics::for_fork`; earlier digests never carry them).
+    Bids and preferences are validated in p2p against one shared `BuilderMarket`
+    (`state_transition::beacon::builder_market`) and never reach the chain actor. Gloas has its own rules for `beacon_block`,
     `data_column_sidecar` (fork enum `DataColumnSidecar`, fork from the topic's digest),
     aggregates (`SignedAggregateAndProof::Gloas`, aggregation-bits length bounded before
     expansion) and attestations (`verify_attestation_payload_status`). Deliberate
@@ -767,8 +769,13 @@ transitions are in `ethlambda-types`, per the section above. Nothing above
   the parking of gloas column sidecars (they carry no signature). Fork-choice
   events are timed at arrival, not at the slot tick, and the head's payload
   status is kept with its root (`Store::head_payload_status`, recomputed after
-  a restart). The node also serves gloas validator duties, self-build only (no
-  bids, no proposer preferences; see `docs/spec_deviations.md`).
+  a restart). The node also serves gloas validator duties and the builder market:
+  it validates, pools and relays gossip bids and proposer preferences, serves
+  `POST /eth/v1/beacon/execution_payload_bids` and `/proposer_preferences` plus the
+  builder endpoints, and `produceBlockV4` weighs the best pooled p2p bid against
+  the local build per `BuilderConfig` (`min_bid`, `builder_boost_factor`; the local
+  build wins a tie). A winning bid returns the block only. The builder API (relay
+  or `builder_pubkeys` bids) is phase 2; see `docs/spec_deviations.md`.
   `state_transition/src/beacon/gloas_block_production.rs` assembles the block and
   its envelope (`gloas_payload_inputs`, `parse_gloas_execution_requests`,
   `pack_gloas_attestations`, `pack_payload_attestations`, `assemble_gloas_block`,

From 3cf6f110a5d3bbf5a6013a192588fb282c795d5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?=
 <47506558+MegaRedHand@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:21:30 -0300
Subject: [PATCH 11/12] test(p2p): make the second bid in the settle test
 outbid the first

The spec ignores a bid whose value does not exceed the best recorded for the
same (slot, parent hash, parent root), so a value-10 bid after a value-10 one
answered NotHighestBid, not Accept. The fixture was wrong; the rule is spec.
---
 crates/net/p2p/src/beacon/verdict.rs | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/crates/net/p2p/src/beacon/verdict.rs b/crates/net/p2p/src/beacon/verdict.rs
index ef263492..4810155d 100644
--- a/crates/net/p2p/src/beacon/verdict.rs
+++ b/crates/net/p2p/src/beacon/verdict.rs
@@ -1224,8 +1224,10 @@ mod tests {
             settle(&mut server, Outcome::Accept, &object),
             Outcome::Ignore(IgnoreReason::AlreadySeen)
         );
-        // Anything but an accept records nothing.
-        let other = bid_object(&server, bid(5, 4, 10));
+        // Anything but an accept records nothing. The value must beat the
+        // best one recorded for the same (slot, parent), or the spec's
+        // highest-bid rule would ignore it on its own account.
+        let other = bid_object(&server, bid(5, 4, 11));
         assert_eq!(
             settle(
                 &mut server,

From 6d77c4df201e8f92a528c4f2134eba77225e5ff6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?=
 <47506558+MegaRedHand@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:56:31 -0300
Subject: [PATCH 12/12] test(state-transition): start the builder scene at a
 reachable finalized epoch

The scene put a funded, active builder in an epoch-1 state: it is active only
once the finalized epoch passes its deposit epoch, but a chain never finalizes
past the previous epoch, so no real chain holds that state. Advancing it
through the end of epoch 1 underflows get_previous_epoch - finalized epoch,
which the spec treats as invalid and a stricter get_finality_delay now errors on.

scene_at builds the parent in a later epoch by advancing with nothing
finalized and finalizing epoch 1 afterwards. The epoch-crossing bid test uses
it with its slots and epochs shifted by one epoch; its assertions are unchanged.
---
 .../beacon/gossip/execution_payload_bid.rs    | 10 +--
 .../src/beacon/gossip/test_support.rs         | 61 +++++++++++++++----
 2 files changed, 56 insertions(+), 15 deletions(-)

diff --git a/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs
index 59ad8c92..5e534094 100644
--- a/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs
+++ b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs
@@ -962,8 +962,10 @@ mod tests {
 
     #[test]
     fn a_bid_across_an_epoch_uses_and_caches_the_checkpoint_state() {
-        let scene = scene();
-        let slot = 64;
+        // The parent is in epoch 2: a funded builder is active only after
+        // finality passes its deposit epoch, which an epoch-1 chain cannot have.
+        let scene = scene_at(64, |_| {});
+        let slot = 96;
         // Preferences for the later epoch's proposer.
         let dependent = dependent_root_at(&scene.state, PARENT, slot).expect("in the window");
         let proposer = fixed_proposer(&scene.state, slot).expect("in the lookahead window");
@@ -980,13 +982,13 @@ mod tests {
         assert!(scene.market.record_preferences(preferences, slot - 1));
         let bid = scene.signed(|bid| bid.slot = slot);
         let key = CacheKey::CheckpointState {
-            epoch: 2,
+            epoch: 3,
             root: PARENT,
         };
         assert!(scene.store.cached_state(key).is_none());
         assert_eq!(stateful(&scene, &bid), Outcome::Accept);
         let cached = scene.store.cached_state(key).expect("the advanced state");
-        assert_eq!(get_current_epoch(&cached), 2);
+        assert_eq!(get_current_epoch(&cached), 3);
         // A second bid finds it.
         assert_eq!(stateful(&scene, &bid), Outcome::Accept);
     }
diff --git a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs
index 451320a6..7d9bf637 100644
--- a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs
+++ b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs
@@ -116,6 +116,13 @@ pub(crate) mod builder_scene {
 
     pub(crate) const PARENT: Root = Root::repeat_byte(0x50);
     pub(crate) const BID_SLOT: Slot = 34;
+    /// The slot the scene's parent sits at: epoch 1, whose state can hold a
+    /// funded builder only with a finalized epoch that a real chain cannot have
+    /// there (a builder is active once the finalized epoch passes its deposit
+    /// epoch, and the finalized epoch never exceeds the previous one). Fine for
+    /// a rule that reads the state as it is; a test that advances it through
+    /// an epoch's end needs [`scene_at`] with a parent in epoch 2 or later.
+    pub(crate) const PARENT_SLOT: Slot = 32;
     pub(crate) const PARENT_GAS_LIMIT: u64 = 30_000_000;
 
     pub(crate) fn fee_recipient() -> ExecutionAddress {
@@ -147,7 +154,7 @@ pub(crate) mod builder_scene {
 
     /// An empty store, gloas from genesis, anchored (and headed) at `PARENT`,
     /// whose block the caller must store.
-    pub(crate) fn gloas_store() -> Store {
+    fn gloas_store_at(parent_slot: Slot) -> Store {
         Store::init_beacon(
             Arc::new(InMemoryBackend::new()),
             GENESIS_TIME,
@@ -155,9 +162,9 @@ pub(crate) mod builder_scene {
             PARENT,
             Checkpoint {
                 root: PARENT,
-                slot: 32,
+                slot: parent_slot,
             },
-            32,
+            parent_slot,
         )
     }
 
@@ -170,10 +177,35 @@ pub(crate) mod builder_scene {
         pub bid: gloas::SignedExecutionPayloadBid,
     }
 
+    /// `PARENT`'s post-state at `parent_slot`, with an active funded builder 0.
+    /// Reached by advancing the epoch-1 state while nothing is finalized, so
+    /// every epoch transition on the way is one a real chain takes, then
+    /// finalizing epoch 1: reachable once `parent_slot` is in epoch 2 or later.
+    fn parent_state_at(parent_slot: Slot) -> BeaconState {
+        let mut state = test_support::gloas_state_with_builder(0, 100_000_000_000, 0);
+        if parent_slot == PARENT_SLOT {
+            return state;
+        }
+        let BeaconState::Gloas(inner) = &mut state else {
+            unreachable!("built as gloas")
+        };
+        inner.finalized_checkpoint.epoch = 0;
+        let config = gloas_support::config();
+        let mut state =
+            crate::beacon::block_production::advance_to_slot(&state, parent_slot, &config)
+                .expect("advance to the parent's slot");
+        let BeaconState::Gloas(inner) = &mut state else {
+            unreachable!("built as gloas")
+        };
+        inner.finalized_checkpoint.epoch = 1;
+        inner.latest_block_header.slot = inner.slot;
+        state
+    }
+
     impl Scene {
         /// 100 ms into the bid's slot.
         pub(crate) fn now_ms(&self) -> u64 {
-            slot_start_ms(&self.store, BID_SLOT) + 100
+            slot_start_ms(&self.store, self.bid.message.slot) + 100
         }
 
         /// The scene's bid after `edit`, re-signed by its builder.
@@ -190,13 +222,20 @@ pub(crate) mod builder_scene {
     /// The scene with `edit` applied to the parent's state before it is stored,
     /// and the prerequisites of a passing bid recorded in the market.
     pub(crate) fn scene_with(edit: impl FnOnce(&mut gloas::BeaconState)) -> Scene {
-        let mut state = test_support::gloas_state_with_builder(0, 100_000_000_000, 0);
+        scene_at(PARENT_SLOT, edit)
+    }
+
+    /// [`scene_with`] for a parent at `parent_slot`, whose bid is for the slot
+    /// two after it.
+    pub(crate) fn scene_at(parent_slot: Slot, edit: impl FnOnce(&mut gloas::BeaconState)) -> Scene {
+        let bid_slot = parent_slot + (BID_SLOT - PARENT_SLOT);
+        let mut state = parent_state_at(parent_slot);
         let BeaconState::Gloas(inner) = &mut state else {
             unreachable!("built as gloas")
         };
         edit(inner);
         state.apply_pending_mutations();
-        let mut store = gloas_store();
+        let mut store = gloas_store_at(parent_slot);
         store
             .insert_pending_block(PARENT, block_at(state.slot()))
             .expect("insert the parent");
@@ -208,20 +247,20 @@ pub(crate) mod builder_scene {
             .expect("move the head");
         store.set_head_payload_status(PARENT, PayloadStatus::Full);
 
-        let dependent_root = dependent_root_at(&state, PARENT, BID_SLOT).expect("in the window");
-        let proposer = crate::beacon::precheck::fixed_proposer(&state, BID_SLOT).expect("window");
+        let dependent_root = dependent_root_at(&state, PARENT, bid_slot).expect("in the window");
+        let proposer = crate::beacon::precheck::fixed_proposer(&state, bid_slot).expect("window");
         let market = BuilderMarket::default();
         let preferences = test_support::sign_preferences(
             &state,
             gloas::ProposerPreferences {
                 dependent_root,
-                proposal_slot: BID_SLOT,
+                proposal_slot: bid_slot,
                 validator_index: proposer,
                 fee_recipient: fee_recipient(),
                 target_gas_limit: PARENT_GAS_LIMIT,
             },
         );
-        assert!(market.record_preferences(preferences, BID_SLOT - 1));
+        assert!(market.record_preferences(preferences, bid_slot - 1));
         market.record_execution_payload(&test_support::envelope_with_gas_limit(
             parent_block_hash(),
             PARENT_GAS_LIMIT,
@@ -238,7 +277,7 @@ pub(crate) mod builder_scene {
                 fee_recipient: fee_recipient(),
                 gas_limit: PARENT_GAS_LIMIT,
                 builder_index: 0,
-                slot: BID_SLOT,
+                slot: bid_slot,
                 value: 1,
                 ..Default::default()
             },