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`, 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..6eda031b --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/builder_market.rs @@ -0,0 +1,623 @@ +//! 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. + +use std::{ + collections::{BTreeMap, BTreeSet}, + num::NonZeroUsize, + sync::{Arc, Mutex, MutexGuard}, +}; + +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)>, +} + +/// 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 { + 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)), + } + } +} + +/// 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. + + /// 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> { + 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 { + 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 { + 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. + pub fn bids_for( + &self, + slot: Slot, + parent_block_root: Root, + parent_block_hash: ExecutionBlockHash, + ) -> Vec { + 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 { + 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) { + let mut pool = lock(&self.bids); + pool.slots = pool.slots.split_off(&slot); + } + + // Proposer preferences: seen.proposer_preferences. + + pub fn preferences( + &self, + proposal_slot: Slot, + dependent_root: Root, + ) -> Option { + lock(&self.preferences) + .get(&(proposal_slot, dependent_root)) + .cloned() + } + + /// 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 { + 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) { + 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) { + 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 { + 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()); + } +} 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..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::{ @@ -347,6 +347,102 @@ 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`, 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, +) -> Result { + 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`, 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, +) -> bool { + 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. #[derive(Debug, Clone)] pub struct GloasProduced { @@ -1077,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 new file mode 100644 index 00000000..5e534094 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/execution_payload_bid.rs @@ -0,0 +1,1108 @@ +//! 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. +//! +//! 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`. +//! +//! 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 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::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`: 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 { + 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 +} + +/// The spec's `is_current_or_next_slot`. +pub(crate) fn is_current_or_next_slot(config: &Config, slot: Slot, now_ms: u64) -> bool { + is_current_slot(config, slot, now_ms) + || slot + .checked_sub(1) + .is_some_and(|previous| is_current_slot(config, previous, now_ms)) +} + +/// 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> { + 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) +} + +/// 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 { + match stateful_rules(store, market, signed) { + Ok(()) => Outcome::Accept, + Err(outcome) => outcome, + } +} + +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, + 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) +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::primitives::{ + BlsPubkey, Bytes32, ExecutionAddress, KzgCommitment, + }; + + 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() { + // 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"); + 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: 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), 3); + // 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/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..a3bb007a --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs @@ -0,0 +1,627 @@ +//! 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. +//! +//! 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`. +//! +//! 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::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, 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}; + +/// 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> { + 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(()) +} + +/// 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 { + match stateful_rules(store, signed) { + Ok(()) => Outcome::Accept, + Err(outcome) => outcome, + } +} + +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, + 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`. +pub fn is_valid_dependent_root(store: &Store, root: Root, dependent_slot: Slot) -> bool { + 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)))`: 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 { + 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 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 { + 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 + MAXIMUM_GOSSIP_CLOCK_DISPARITY`: the specification's +/// `is_past_slot`. +pub(crate) fn is_past_slot(config: &Config, slot: Slot, now_ms: u64) -> bool { + 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..7d9bf637 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,201 @@ 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; + /// 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 { + 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. + fn gloas_store_at(parent_slot: Slot) -> Store { + Store::init_beacon( + Arc::new(InMemoryBackend::new()), + GENESIS_TIME, + gloas_support::config(), + PARENT, + Checkpoint { + root: PARENT, + slot: parent_slot, + }, + parent_slot, + ) + } + + 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, + } + + /// `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, self.bid.message.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 { + 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_at(parent_slot); + 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/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/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/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); +} 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/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] 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..f44ce4bb --- /dev/null +++ b/crates/net/p2p/src/beacon/builder_market.rs @@ -0,0 +1,414 @@ +//! Gloas builder market gossip: the `execution_payload_bid` and +//! `proposer_preferences` topics. +//! +//! 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::{ + 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::beacon::verdict::{Dispatch, Validated}; +use crate::beacon::{decode as beacon_decode, topics as beacon_topics}; +use crate::gossipsub::compress_message; +use crate::{P2PServer, metrics}; + +/// 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 { + 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`. The +/// container is fixed-size, so decode is its own size check. +pub(crate) fn triage_proposer_preferences(server: &P2PServer, payload: &[u8]) -> Dispatch { + 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, 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. +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. +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) +} + +#[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/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..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. //! @@ -51,10 +53,19 @@ 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. -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. @@ -344,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 @@ -354,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 5d820eed..4810155d 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,14 @@ 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. + ExecutionPayloadBid { + bid: Box, + market: SharedBuilderMarket, + }, + /// A gloas `proposer_preferences`. + ProposerPreferences(Box), /// A gloas `payload_attestation_message`. PayloadAttestation(PayloadAttestationMessage), } @@ -115,6 +125,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) } @@ -143,10 +159,26 @@ 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()) + } + 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 +299,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 +481,9 @@ fn permits_for<'a>( Validated::Aggregate { .. } | Validated::Attestation { .. } | Validated::PayloadAttestation(_) => &server.attestation_validation_permits, + Validated::ExecutionPayloadBid { .. } | Validated::ProposerPreferences(_) => { + &server.builder_validation_permits + } } } @@ -1149,6 +1188,203 @@ 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. 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, + 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 1b664a47..49915f73 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) }; @@ -814,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 1701c5f3..1e2b402f 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -2,6 +2,7 @@ mod encoding; mod handler; mod messages; +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..82b890c5 --- /dev/null +++ b/crates/net/rpc/src/beacon/bid_selection.rs @@ -0,0 +1,230 @@ +//! Choosing between this node's own build and a pooled builder bid for +//! `produceBlockV4`. Pure: no store, no clock. +//! +//! 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. +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(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. +pub(crate) fn bid_total_gwei(bid: &ExecutionPayloadBid) -> u64 { + bid.value.saturating_add(bid.execution_payment) +} + +/// 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 { + 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")) +} + +/// 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 { + 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 new file mode 100644 index 00000000..e7da89e7 --- /dev/null +++ b/crates/net/rpc/src/beacon/bids.rs @@ -0,0 +1,154 @@ +//! `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`. +//! +//! 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::{ + 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}; + +pub(crate) fn routes() -> Router { + 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 new file mode 100644 index 00000000..ebea79e1 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_config.rs @@ -0,0 +1,275 @@ +//! The `BuilderConfig` a validator client sends with `produceBlockV4`. +//! +//! 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, 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; + +pub(crate) const MAX_BUILDER_ENTRIES: usize = 64; +pub(crate) const MAX_BUILDER_URL_SIZE: usize = 2048; +pub(crate) const MAX_BUILDER_PUBKEYS: usize = 64; +pub(crate) const MAX_BUILDER_AUTH_DATA_SIZE: usize = 4096; + +/// 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 { + #[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, +} + +/// 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, + #[serde(with = "ethlambda_types::beacon::serde_helpers::seq")] + pub(crate) builders: SszList, +} + +impl BuilderEntry { + /// A non-empty url, non-empty `auth.data` and `auth.message.slot == slot`. + /// 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() + } +} + +/// 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], +) -> Result { + 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/builder_market_tests.rs b/crates/net/rpc/src/beacon/builder_market_tests.rs new file mode 100644 index 00000000..26876e6e --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_market_tests.rs @@ -0,0 +1,1234 @@ +//! 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). + 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) + }), + ("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(std::slice::from_ref(&signed), 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(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") + .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/builders.rs b/crates/net/rpc/src/beacon/builders.rs new file mode 100644 index 00000000..36a5d854 --- /dev/null +++ b/crates/net/rpc/src/beacon/builders.rs @@ -0,0 +1,336 @@ +//! `POST /eth/v1/beacon/states/{state_id}/builders`: the builder registry of a +//! gloas state, filtered by id and status. +//! +//! 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, + 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}; + +pub(crate) fn routes() -> Router { + 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..59d5b760 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,21 @@ fn blobs_list(blobs: Vec>) -> Result { Blobs::try_from(blobs).map_err(|_| ()) } -/// Everything `produceBlockV4` built. +/// 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), + /// 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 +394,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 +410,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 +428,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 +471,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 +549,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 +641,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 +762,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 +804,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 +819,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 +834,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 +1139,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 +1235,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 +1250,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());
     }
 
@@ -1614,3 +1841,7 @@ mod tests {
         assert_eq!(rejected.envelopes.lock().unwrap().len(), 1);
     }
 }
+
+#[cfg(test)]
+#[path = "builder_market_tests.rs"]
+mod builder_market_tests;
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..bd217f0b
--- /dev/null
+++ b/crates/net/rpc/src/beacon/proposer_preferences.rs
@@ -0,0 +1,144 @@
+//! `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`.
+//!
+//! 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::{
+    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;
+
+pub(crate) fn routes() -> Router {
+    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/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,
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
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
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