diff --git a/Cargo.lock b/Cargo.lock index dffc1c91..7961014c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4355,6 +4355,7 @@ dependencies = [ "libssz-types", "lru 0.16.4", "once_cell", + "rayon", "rkyv", "rustc-hash", "secp256k1 0.30.0", @@ -4453,6 +4454,7 @@ dependencies = [ "ethrex-rlp", "libssz", "malachite", + "rayon", "rustc-hash", "serde", "strum 0.27.2", @@ -4624,6 +4626,7 @@ dependencies = [ "ethrex-crypto", "ethrex-levm", "ethrex-rlp", + "rayon", "rustc-hash", "serde", "thiserror 2.0.20", @@ -5790,6 +5793,7 @@ dependencies = [ "tokio-util", "tower 0.5.3", "tracing", + "tracing-appender", "tracing-subscriber 0.3.23", "uuid", "zstd", diff --git a/Cargo.toml b/Cargo.toml index ac7e1b53..69dada9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,9 +54,10 @@ ethereum_ssz_derive = "0.10" # NOTE: the `ethrex` cmd-lib is deliberately NOT a dependency: it force-enables # ethrex-crypto's `aws-lc-rs` feature, whose cc >=1.2.26 requirement conflicts with # reth-mdbx-sys's exact `cc = 1.2.15` pin (via helix-simulator). The node is -# assembled from the library crates instead. -ethrex-blockchain = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "metrics"] } -ethrex-common = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg"] } +# assembled from the library crates instead, so `rayon` must be listed by hand: +# the parallel BAL execution path is cfg-gated on it. +ethrex-blockchain = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "metrics", "rayon"] } +ethrex-common = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "rayon"] } ethrex-config = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" } # default features minus `aws-lc-rs` (see note above); P-256 verify uses the portable fallback ethrex-crypto = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["std", "kzg-rs", "secp256k1", "blst", "c-kzg"] } @@ -66,7 +67,7 @@ ethrex-p2p = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec61 ethrex-rlp = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" } ethrex-rpc = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" } ethrex-storage = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", features = ["rocksdb"] } -ethrex-vm = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg"] } +ethrex-vm = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "rayon"] } eyre = "0.6.12" clickhouse = "0.14.2" diff --git a/README.md b/README.md index a50560c7..b80c58f5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Helix is a MEV-Boost Relay designed with three key foundational principles: fast, simple, contained. +## Documentation + +- [Architecture](docs/architecture.md) +- [Running on a Gloas testnet](docs/gloas-testnet.md) +- [Local development with Kurtosis](scripts/devnet/README.md) + ## Audits Audit conducted by Spearbit, with [Alex Stokes](https://github.com/ralexstokes) (EF) and [Matthias Seitz](https://github.com/mattsse) (Reth, Foundry, ethers-rs) leading as security researchers. See the report [here](audits/spearbit-audit.pdf). diff --git a/config.example.yml b/config.example.yml index 4b90282d..b121556a 100644 --- a/config.example.yml +++ b/config.example.yml @@ -61,7 +61,8 @@ discord_webhook_url: null blacklist_provider: null is_submission_instance: true is_registration_instance: true -logging: !File +logging: + type: File dir_path: /app/logs file_name: titan_relay.log otlp_server: http://localhost:4317 diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 3d482a8c..b0ae635a 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -59,6 +59,7 @@ tikv-jemallocator.workspace = true tokio.workspace = true tokio-util.workspace = true tracing.workspace = true +tracing-appender.workspace = true tracing-subscriber.workspace = true uuid = { workspace = true, features = ["serde"] } zstd.workspace = true diff --git a/crates/builder/README.md b/crates/builder/README.md index 86f0172f..4d27bbcd 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -50,7 +50,10 @@ the relay must reach it through the simulator's `ssz_url`. Differences from - The disallow list rejects interaction by effect -- a state change, or a transaction addressed to a listed account. `crates/simulator` also rejects a block that merely *reads* one, so it rejects strictly more blocks. -- Only Fulu (V5) and the relay-internal merged method are served. +- `/validate` serves Fulu and Gloas; `/validate_merged` serves Fulu only, + because merging protocol v1 cannot carry an Amsterdam block. A fork with no + shape here is refused with `501`, not `400`, so a helix limitation cannot + demote a builder. ## The building role @@ -68,9 +71,14 @@ The block itself is ethrex's own payload machinery, with the builder as the coinbase, so tips accrue to the builder and the bid is funded from them: ``` -payout = tips + subsidy_wei - payout_gas_reserve * base_fee +payout = tips + subsidy_wei - payout_gas * base_fee ``` +`payout_gas` is `payout_gas_reserve` (21000 by default), plus EIP-8037's state +gas for creating the recipient when it has no account yet -- which is the normal +case the first time a fee recipient is paid. See +[the Gloas testnet runbook](../../docs/gloas-testnet.md). + The block ends with a plain transfer of `payout` to the proposer's registered fee recipient. `subsidy_wei` exists because the relay rejects a zero-value block: without it an idle testnet would produce no bids at all, which is when @@ -80,10 +88,17 @@ Gas for that transfer is held back from the fill by lowering ethrex's `remaining_gas` before `fill_transactions` and restoring it afterwards. The builder signs under the domain read from the beacon node's own spec and -genesis, never a compiled-in fork version. It builds at each -`submit_offsets_ms` point in the slot and submits only when the value beats -what it already sent for that slot and parent -- a new parent, after a re-org, -starts a fresh auction. +genesis, never a compiled-in fork version. It starts building `build_lead_ms` +before the slot begins, because the proposer asks for its bid at the slot +start, and then rebuilds and resubmits without pause, picking up whatever the +mempool has gained. It submits only when the value beats what it already sent +for that slot and parent -- a new parent, after a re-org, starts a fresh +auction. It stops when the relay reports that it has moved to the next bid +slot, or when the slot's own time runs out. + +From Amsterdam the block also carries the EIP-7928 block access list ethrex +records and the EIP-7843 slot number, and the submission goes out in the Gloas +shape so the list travels with it. What it does not do: no bundles or `eth_sendBundle`, no ordering of its own (ethrex's tip-sorted fill), no cancellations, no bidding strategy (it always @@ -167,12 +182,18 @@ On the relay side, add a merging builder to with `ssz_url` set to this role's `ssz_addr` (see the repo-root `config.example.yml`). +The embedded node is ethrex, pinned by rev in the workspace `Cargo.toml`. +Currently v26.0.0, which supports glamsterdam-devnet-8 +(`--network plataberget`). + ## Limitations - Merging protocol v1 carries `ExecutionPayloadV3`; post-Amsterdam blocks - (EIP-7928 block access lists) are rejected as merge bases. The simulation - role has the same gap: the payload carries no block-access-list hash, so - Amsterdam needs a newer payload version. + (EIP-7928 block access lists) are rejected as merge bases, and the merged + validation route refuses Gloas for the same reason. The simulation and + building roles do handle Amsterdam: the block access list travels beside the + payload on a Gloas submission, and the header's list hash and slot number are + derived from it. - The simulation role serves no JSON-RPC, so a relay without `ssz_url` cannot use it. - A blob is 128 KiB and crosses the stack several times in a debug build, which @@ -180,7 +201,9 @@ with `ssz_url` set to this role's `ssz_addr` (see the repo-root the simulation and building roles in release. - The building role's payout uses a fixed `payout_gas_reserve`, 21000 by default. A contract fee recipient needing more makes the payout fail and the - slot is skipped. + slot is skipped. From Amsterdam the builder adds EIP-8037's state gas for a + recipient that does not exist yet (183600 at the pinned ethrex revision), + because a fee recipient's first payment creates its account. - The building role includes a blob transaction only when its sidecar reached the node's mempool over devp2p; it does not rebuild sidecars. - The base block's declared `block_hash` is trusted as the pool key; the wire diff --git a/crates/builder/build-config.example.yml b/crates/builder/build-config.example.yml index 2df42ef4..0fd9ec22 100644 --- a/crates/builder/build-config.example.yml +++ b/crates/builder/build-config.example.yml @@ -20,10 +20,13 @@ beacon_url: "http://localhost:3500" subsidy_wei: 1000000000000000 # Gas held back from the fill for the trailing payout transaction. A contract -# fee recipient that needs more than this makes the payout fail. +# fee recipient that needs more than this makes the payout fail. From Amsterdam +# the builder adds EIP-8037's state gas on top when the recipient does not exist +# yet, so this covers the transfer alone. payout_gas_reserve: 21000 extra_data: "helix-builder" -# Points into the slot, in milliseconds, at which to build and submit. -submit_offsets_ms: [500, 2000] +# How long before the slot starts to begin building, in milliseconds. From then +# the builder rebuilds and resubmits without pause until the relay moves on. +build_lead_ms: 2000 diff --git a/crates/builder/src/building/assemble.rs b/crates/builder/src/building/assemble.rs index ccaa12de..581d483d 100644 --- a/crates/builder/src/building/assemble.rs +++ b/crates/builder/src/building/assemble.rs @@ -15,6 +15,7 @@ use ethrex_common::{ }, }; use ethrex_crypto::native::NativeCrypto; +use ethrex_rlp::encode::RLPEncode; use ethrex_storage::Store; use thiserror::Error; @@ -37,6 +38,8 @@ pub enum BuildError { PayoutReverted, #[error("the payout recipient is the builder itself")] PayoutToSelf, + #[error("an Amsterdam block was built without a block access list")] + MissingBlockAccessList, #[error("build failed: {0}")] Internal(String), } @@ -53,6 +56,9 @@ pub struct BuiltBlock { /// The changed accounts, for checking the payment the way the relay does. #[allow(dead_code)] pub account_updates: Vec, + /// The encoded EIP-7928 list, from Amsterdam onwards. The header commits to + /// these exact bytes, so the submission has to carry them unchanged. + pub block_access_list: Option>, /// Paid to the proposer by the trailing transaction, and the value the /// `BidTrace` claims. pub value: U256, @@ -83,6 +89,10 @@ pub fn build( return Err(BuildError::MissingParent); } + // EIP-7843 puts the proposal slot in the header, and only from Amsterdam: + // setting it earlier would change every pre-Amsterdam block hash. + let is_amsterdam = store.get_chain_config().is_amsterdam_activated(slot.timestamp); + let args = BuildPayloadArgs { parent: h256(slot.parent_hash), timestamp: slot.timestamp, @@ -90,7 +100,7 @@ pub fn build( random: h256(slot.prev_randao), withdrawals: Some(slot.withdrawals.iter().map(ewithdrawal_lh).collect()), beacon_root: Some(h256(slot.parent_beacon_block_root)), - slot_number: None, + slot_number: is_amsterdam.then_some(slot.slot), version: 3, elasticity_multiplier: ELASTICITY_MULTIPLIER, // `create_payload` runs this through `calc_gas_limit`, which applies @@ -107,16 +117,20 @@ pub fn build( .map_err(|e| BuildError::Internal(format!("system operations: {e}")))?; // `fill_transactions` spends every last drop of `remaining_gas`, so hold - // the payout's share back and restore it once the fill is done. - let reserve = config.payout_gas_reserve.min(ctx.remaining_gas); + // the payout's share back and restore it once the fill is done. The reserve + // is sized before the fill and the transaction after it, so a recipient the + // fill creates is not charged for twice. + let reserve = payout_gas_limit(config, is_amsterdam, recipient_exists(&mut ctx, slot)?) + .min(ctx.remaining_gas); ctx.remaining_gas -= reserve; blockchain .fill_transactions(&mut ctx) .map_err(|e| BuildError::Internal(format!("fill transactions: {e}")))?; ctx.remaining_gas += reserve; + let payout_gas = payout_gas_limit(config, is_amsterdam, recipient_exists(&mut ctx, slot)?); let base_fee = ctx.payload.header.base_fee_per_gas.unwrap_or_default(); - let payout = payout_value(&ctx, config, base_fee)?; + let payout = payout_value(&ctx, config, payout_gas, base_fee)?; let nonce = ctx .vm @@ -132,7 +146,7 @@ pub fn build( .map_err(|e| BuildError::Internal(e.to_string()))? .info .balance; - let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + let gas_cost = EU256::from(payout_gas) * EU256::from(base_fee); if balance < eu256(payout) + gas_cost { return Err(BuildError::PayoutUnaffordable); } @@ -143,7 +157,7 @@ pub fn build( nonce, slot.proposer_fee_recipient, payout, - config.payout_gas_reserve, + payout_gas, base_fee as u128, )?; let sender = payout_tx @@ -159,6 +173,19 @@ pub fn build( return Err(BuildError::PayoutReverted); } + // EIP-7928: the requests and withdrawals phase records under index n+1, and every + // withdrawal recipient counts as touched. Mirrors ethrex's `build_payload`. + if is_amsterdam { + let post_tx_index = + u32::try_from(ctx.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); + ctx.vm.set_bal_index(post_tx_index); + if let Some(recorder) = ctx.vm.db.bal_recorder_mut() && + let Some(withdrawals) = &ctx.payload.body.withdrawals + { + recorder.extend_touched_addresses(withdrawals.iter().map(|w| w.address)); + } + } + blockchain .extract_requests(&mut ctx) .map_err(|e| BuildError::Internal(format!("extract requests: {e}")))?; @@ -169,22 +196,64 @@ pub fn build( .finalize_payload(&mut ctx) .map_err(|e| BuildError::Internal(format!("finalize: {e}")))?; + // `finalize_payload` hashed this into the header, so the submission has to + // carry the same encoding rather than re-deriving one. + let block_access_list = ctx.block_access_list.as_ref().map(|bal| bal.encode_to_vec()); + if is_amsterdam && block_access_list.is_none() { + return Err(BuildError::MissingBlockAccessList); + } + Ok(BuiltBlock { block: ctx.payload, blobs_bundle: ctx.blobs_bundle, requests: ctx.requests.unwrap_or_default(), account_updates: ctx.account_updates, + block_access_list, value: payout, }) } +/// Whether the payout recipient already has an account, read from in-block +/// state so a payment earlier in the same block counts. +fn recipient_exists(ctx: &mut PayloadBuildContext, slot: &SlotContext) -> Result { + Ok(ctx + .vm + .db + .get_account(eaddr(slot.proposer_fee_recipient)) + .map_err(|e| BuildError::Internal(e.to_string()))? + .info != + Default::default()) +} + +/// The gas the payout transaction needs. `payout_gas_reserve` covers the +/// transfer; paying an address for the first time also creates it, and from +/// Amsterdam that costs state gas (EIP-8037). A fee recipient's first payment +/// is exactly that case, and it is the normal case on a fresh testnet, so the +/// reserve has to cover it or every block is lost. +fn payout_gas_limit(config: &BuildingConfig, is_amsterdam: bool, recipient_exists: bool) -> u64 { + if is_amsterdam && !recipient_exists { + config.payout_gas_reserve + new_account_state_gas() + } else { + config.payout_gas_reserve + } +} + +/// EIP-8037's charge for the state a new account occupies. Read from ethrex so +/// it cannot drift from the rules the simulator enforces. +fn new_account_state_gas() -> u64 { + use ethrex_levm::gas_cost::{STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte}; + // `cost_per_state_byte` ignores its argument at this ethrex revision. + STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte(0) +} + /// Tips earned plus the subsidy, less the gas the payout itself will burn. fn payout_value( ctx: &PayloadBuildContext, config: &BuildingConfig, + payout_gas: u64, base_fee: u64, ) -> Result { - let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + let gas_cost = EU256::from(payout_gas) * EU256::from(base_fee); let funded = ctx.block_value + EU256::from(config.subsidy_wei); let payout = funded.checked_sub(gas_cost).ok_or(BuildError::NoPayout)?; if payout.is_zero() { diff --git a/crates/builder/src/building/assemble/tests.rs b/crates/builder/src/building/assemble/tests.rs index 6abb8178..25f7a593 100644 --- a/crates/builder/src/building/assemble/tests.rs +++ b/crates/builder/src/building/assemble/tests.rs @@ -8,7 +8,9 @@ use ethrex_vm::VmDatabase; use helix_types::{BlsPublicKeyBytes, Withdrawal, Withdrawals}; use super::*; -use crate::testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}; +use crate::testing::{ + ETH, GWEI, deploy_amsterdam_predeploys, dev_genesis_store_with, funded_signers, signed_transfer, +}; const PROPOSER: Address = Address::repeat_byte(0x77); /// Must clear the payout's own gas cost, ~21000 * base fee. @@ -22,11 +24,26 @@ struct Fixture { parent_timestamp: u64, parent_gas_limit: u64, chain_id: u64, + is_amsterdam: bool, } impl Fixture { async fn new() -> Self { - let (store, genesis) = dev_genesis_store().await; + Self::with_genesis(|_| {}).await + } + + /// Amsterdam from genesis, so every block carries a block access list and a + /// slot number. The EIP-8282 predeploys are required, not optional. + async fn amsterdam() -> Self { + Self::with_genesis(|genesis| { + genesis.config.amsterdam_time = Some(0); + deploy_amsterdam_predeploys(genesis); + }) + .await + } + + async fn with_genesis(edit: impl FnOnce(&mut ethrex_common::types::Genesis)) -> Self { + let (store, genesis) = dev_genesis_store_with(edit).await; let genesis_block = genesis.get_block(); let blockchain = Arc::new(Blockchain::new(store.clone(), BlockchainOptions { r#type: BlockchainType::L1, @@ -38,6 +55,7 @@ impl Fixture { parent_timestamp: genesis_block.header.timestamp, parent_gas_limit: genesis_block.header.gas_limit, chain_id: genesis.config.chain_id, + is_amsterdam: genesis.config.is_amsterdam_activated(genesis_block.header.timestamp), store, blockchain, } @@ -49,18 +67,14 @@ impl Fixture { } fn config(&self) -> BuildingConfig { - serde_yaml::from_str(&format!( - "relay_url: \"http://localhost:4040\"\napi_key: \"key\"\n\ - beacon_url: \"http://localhost:3500\"\nsubsidy_wei: {SUBSIDY}\n" - )) - .unwrap() + test_config() } fn slot(&self) -> SlotContext { SlotContext { slot: 1, parent_hash: self.parent_hash, - parent_block_number: 0, + parent_block_number: Some(0), timestamp: self.parent_timestamp + 12, prev_randao: B256::repeat_byte(0xcc), withdrawals: Withdrawals::default(), @@ -86,6 +100,30 @@ impl Fixture { self.blockchain.add_transaction_to_pool(tx).await.unwrap(); } + /// A transfer with enough gas to create its recipient under Amsterdam, + /// which the flat 21000 of `pool_transfer` cannot do. + async fn pool_transfer_creating(&self, index: usize, to: Address) { + use alloy_consensus::SignableTransaction; + use alloy_signer::SignerSync; + let tx = alloy_consensus::TxEip1559 { + chain_id: self.chain_id, + nonce: 0, + gas_limit: 300_000, + max_fee_per_gas: 100 * GWEI, + max_priority_fee_per_gas: GWEI, + to: to.into(), + value: U256::from(GWEI), + access_list: Default::default(), + input: Default::default(), + }; + let signature = self.signers[index].sign_hash_sync(&tx.signature_hash()).unwrap(); + let encoded = alloy_eips::eip2718::Encodable2718::encoded_2718( + &alloy_consensus::TxEnvelope::from(tx.into_signed(signature)), + ); + let tx = Transaction::decode_canonical(&encoded).unwrap(); + self.blockchain.add_transaction_to_pool(tx).await.unwrap(); + } + fn build(&self, slot: &SlotContext, config: &BuildingConfig) -> Result { build(&self.store, &self.blockchain, slot, config, self.builder(), self.chain_id) } @@ -94,6 +132,44 @@ impl Fixture { self.build(&self.slot(), &self.config()) } + /// A signing context whose spec puts the fixture's fork at genesis, so the + /// submission shape matches the block shape. + fn signing(&self) -> helix_common::signing::RelaySigningContext { + let mut spec = helix_types::ChainSpec::mainnet(); + match self.fork() { + helix_types::ForkName::Gloas => { + spec.gloas_fork_epoch = Some(helix_types::Epoch::new(0)) + } + _ => spec.fulu_fork_epoch = Some(helix_types::Epoch::new(0)), + } + helix_common::signing::RelaySigningContext::new( + helix_types::BlsKeypair::random(), + Arc::new(helix_common::chain_info::ChainInfo::new(spec, B256::ZERO, 0)), + ) + } + + fn fork(&self) -> helix_types::ForkName { + if self.is_amsterdam { helix_types::ForkName::Gloas } else { helix_types::ForkName::Fulu } + } + + /// The simulation role, over the same store the block was built on. + fn validator(&self) -> crate::validation::BlockValidator { + let parent = self.parent_header(); + let (head, _) = tokio::sync::watch::channel(crate::node::HeadInfo { + number: parent.number, + hash: parent.hash(), + timestamp: parent.timestamp, + is_synced: true, + }); + crate::validation::BlockValidator::new( + self.store.clone(), + head.subscribe(), + 8, + Arc::new(dashmap::DashSet::new()), + 1, + ) + } + fn parent_header(&self) -> ethrex_common::types::BlockHeader { self.store .get_block_header_by_hash(crate::engine::convert::h256(self.parent_hash)) @@ -102,6 +178,14 @@ impl Fixture { } } +fn test_config() -> BuildingConfig { + serde_yaml::from_str(&format!( + "relay_url: \"http://localhost:4040\"\napi_key: \"key\"\n\ + beacon_url: \"http://localhost:3500\"\nsubsidy_wei: {SUBSIDY}\n" + )) + .unwrap() +} + /// The trailing transaction, decoded. fn payout_tx(built: &BuiltBlock) -> &Transaction { built.block.body.transactions.last().expect("a block always ends with the payout") @@ -336,3 +420,208 @@ async fn blob_transactions_carry_their_sidecar() { Some(u64::from(ethrex_common::constants::GAS_PER_BLOB)) ); } + +// --- Amsterdam --- + +#[tokio::test] +async fn an_amsterdam_block_carries_a_block_access_list_and_a_slot_number() { + let fixture = Fixture::amsterdam().await; + + let built = fixture.build_default().unwrap(); + + let bal = built.block_access_list.as_deref().expect("Amsterdam records a list"); + assert!(!bal.is_empty(), "even an idle block touches accounts"); + assert_eq!( + built.block.header.block_access_list_hash, + Some(ethrex_common::utils::keccak(bal)), + "the submission has to carry the bytes the header committed to", + ); + assert_eq!(built.block.header.slot_number, Some(fixture.slot().slot)); +} + +/// Setting either field before Amsterdam would change every block hash. +#[tokio::test] +async fn a_pre_amsterdam_block_carries_neither() { + let fixture = Fixture::new().await; + + let built = fixture.build_default().unwrap(); + + assert!(built.block_access_list.is_none()); + assert!(built.block.header.block_access_list_hash.is_none()); + assert!(built.block.header.slot_number.is_none()); +} + +/// EIP-7843 wants the proposal slot, which is neither the block number nor zero. +#[tokio::test] +async fn the_header_slot_number_is_the_proposal_slot() { + let fixture = Fixture::amsterdam().await; + let mut slot = fixture.slot(); + slot.slot = 4_242; + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + assert_eq!(built.block.header.slot_number, Some(4_242)); + assert_ne!(built.block.header.number, 4_242, "not the block number"); +} + +/// The proposer keeps being paid in-block under Gloas, so the trailing payout +/// is unchanged. +#[tokio::test] +async fn an_amsterdam_block_still_pays_the_proposer_in_block() { + let fixture = Fixture::amsterdam().await; + fixture.pool_transfer(1, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + let payout = payout_tx(&built); + assert_eq!(payout.to(), ethrex_common::types::TxKind::Call(eaddr(PROPOSER))); + assert_eq!(payout.value(), eu256(built.value)); + assert!(!built.value.is_zero(), "the relay rejects a zero-value block"); +} + +/// Amsterdam charges gas for the list's items out of the same block budget the +/// payout reserve is taken from, so the fill can still starve the payout. +#[tokio::test] +async fn an_amsterdam_block_includes_mempool_transactions() { + let fixture = Fixture::amsterdam().await; + fixture.pool_transfer(1, 0, GWEI).await; + fixture.pool_transfer(2, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + assert_eq!(built.block.body.transactions.len(), 3, "two mempool txs plus the payout"); +} + +/// A fee recipient's first payment creates its account, and EIP-8037 charges +/// state gas for that. It is the normal case on a fresh testnet: with only the +/// transfer's own 21000 reserved, the payout runs out of gas and every block is +/// lost. Pre-Amsterdam the same payment costs nothing extra. +#[tokio::test] +async fn paying_a_new_account_reserves_the_amsterdam_state_gas() { + let config = BuildingConfig { payout_gas_reserve: 21_000, ..test_config() }; + + assert_eq!(payout_gas_limit(&config, false, false), 21_000, "no such charge before Amsterdam"); + assert_eq!(payout_gas_limit(&config, true, true), 21_000, "an existing account is a transfer"); + assert_eq!( + payout_gas_limit(&config, true, false), + 204_600, + "measured against ethrex: 21000 transfer + 120 state bytes at 1530 each", + ); +} + +/// The reserve is only useful if the payment it sizes actually succeeds. +#[tokio::test] +async fn a_first_payment_to_a_new_account_succeeds_under_amsterdam() { + let fixture = Fixture::amsterdam().await; + let slot = fixture.slot(); + assert!( + fixture + .store + .get_account_info_by_hash( + crate::engine::convert::h256(fixture.parent_hash), + eaddr(slot.proposer_fee_recipient), + ) + .unwrap() + .is_none(), + "the fixture's proposer must start absent, or this proves nothing", + ); + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + let payout = payout_tx(&built); + assert_eq!(payout.gas_limit(), 204_600); + assert_eq!(payout.value(), eu256(built.value)); +} + +/// The recipient's account is read from in-block state, so a payment earlier in +/// the same block already counts as creating it. +#[tokio::test] +async fn a_recipient_created_earlier_in_the_block_needs_no_extra_reserve() { + let fixture = Fixture::amsterdam().await; + let mut slot = fixture.slot(); + slot.proposer_fee_recipient = Address::repeat_byte(0x55); + fixture.pool_transfer_creating(1, slot.proposer_fee_recipient).await; + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + assert_eq!(built.block.body.transactions.len(), 2, "the transfer plus the payout"); + let balance = built + .account_updates + .iter() + .find(|update| update.address == eaddr(slot.proposer_fee_recipient)) + .and_then(|update| update.info.as_ref().map(|info| info.balance)) + .expect("both payments must touch the recipient"); + assert_eq!( + balance, + eu256(built.value) + ethrex_common::U256::from(GWEI), + "the earlier transfer has to land, or it created nothing", + ); + assert_eq!(payout_tx(&built).gas_limit(), 21_000, "already created, so no state charge"); +} + +/// The strongest check available without a relay: build a block, submit it the +/// way the relay would receive it, and validate it with our own simulation +/// role. A disagreement between steps 3 and 4 shows up here and nowhere else. +async fn our_simulator_accepts_our_own_block(fixture: &Fixture, slot: &SlotContext) { + use axum::http::StatusCode; + use tower::ServiceExt; + + let built = fixture.build(slot, &fixture.config()).unwrap(); + let bid = crate::building::submit::Submitter::new( + "http://localhost:1", + "key".to_string(), + fixture.signing(), + ) + .sign(&built, slot) + .expect("a block we built must be submittable"); + + let request = helix_common::simulator::SszValidationRequest { + apply_blacklist: false, + registered_gas_limit: slot.registered_gas_limit, + parent_beacon_block_root: slot.parent_beacon_block_root, + inclusion_list: Default::default(), + decoder_params: Some(helix_common::decoder::SubmissionDecoderParams::plain(fixture.fork())), + signed_bid_submission: bid.as_ssz_bytes(), + }; + + let response = crate::validation::server::router(fixture.validator(), 1) + .oneshot( + axum::http::Request::post("/validate") + .body(axum::body::Body::from(ssz::Encode::as_ssz_bytes(&request))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); +} + +#[tokio::test] +async fn our_simulation_role_accepts_our_gloas_block() { + let fixture = Fixture::amsterdam().await; + let slot = fixture.slot(); + our_simulator_accepts_our_own_block(&fixture, &slot).await; +} + +#[tokio::test] +async fn our_simulation_role_accepts_our_fulu_block() { + let fixture = Fixture::new().await; + let slot = fixture.slot(); + our_simulator_accepts_our_own_block(&fixture, &slot).await; +} + +#[tokio::test] +async fn our_simulation_role_accepts_our_gloas_block_with_withdrawals() { + let fixture = Fixture::amsterdam().await; + let mut slot = fixture.slot(); + slot.withdrawals = Withdrawals::new(vec![Withdrawal { + index: 1, + validator_index: 2, + address: Address::repeat_byte(0x66), + amount: 32_000_000_000, + }]) + .unwrap(); + our_simulator_accepts_our_own_block(&fixture, &slot).await; +} diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 16c578c4..b2c882fd 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -8,7 +8,7 @@ mod slot; mod submit; mod watcher; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use alloy_signer_local::PrivateKeySigner; use ethrex_blockchain::Blockchain; @@ -20,7 +20,10 @@ use tracing::{debug, error, info, warn}; pub use watcher::run as watch_slots; use crate::{ - building::{schedule::BestBid, slot::SlotContext}, + building::{ + schedule::{Attempt, BestBid}, + slot::SlotContext, + }, config::BuildingConfig, }; @@ -46,30 +49,30 @@ pub async fn build_blocks( payout_signer: PrivateKeySigner, signing: RelaySigningContext, chain_id: u64, - mut contexts: mpsc::Receiver, + contexts: mpsc::Receiver, ) { - let submitter = submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing); - let mut best = BestBid::default(); + let lead_ms = config.build_lead_ms; + // A slot cannot outlive itself: without a closing answer from the relay the + // loop still has to end. + let budget = Duration::from_secs(signing.chain_info.seconds_per_slot()); + let submitter = + Arc::new(submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing)); + let best = Arc::new(std::sync::Mutex::new(BestBid::default())); - while let Some(slot) = contexts.recv().await { - best.prune(slot.slot); - - // Each delay is measured from the same instant, so they must not be - // slept end to end. - let base = tokio::time::Instant::now(); - for delay in schedule::delays(slot.timestamp, &config.submit_offsets_ms, now_ms()) { - tokio::time::sleep_until(base + delay).await; - - let (build_config, store, blockchain, signer, build_slot) = ( - config.clone(), - store.clone(), - blockchain.clone(), - payout_signer.clone(), - slot.clone(), - ); + let attempt = move |slot: SlotContext| { + let (config, store, blockchain, signer, submitter, best) = ( + config.clone(), + store.clone(), + blockchain.clone(), + payout_signer.clone(), + submitter.clone(), + best.clone(), + ); + async move { + let build_slot = slot.clone(); // Building is CPU-bound and must not stall the runtime. let built = tokio::task::spawn_blocking(move || { - assemble::build(&store, &blockchain, &build_slot, &build_config, &signer, chain_id) + assemble::build(&store, &blockchain, &build_slot, &config, &signer, chain_id) }) .await; @@ -77,40 +80,56 @@ pub async fn build_blocks( Ok(Ok(built)) => built, Ok(Err(e)) => { warn!(slot = slot.slot, err = %e, "skipping slot"); - continue; + return Attempt::Continue; } Err(e) => { error!(slot = slot.slot, err = %e, "build task panicked"); - continue; + return Attempt::Continue; } }; - if !best.improves(slot.slot, slot.parent_hash, built.value) { - debug!(slot = slot.slot, value = %built.value, "not an improvement"); - continue; + { + let mut best = best.lock().expect("bid tracker mutex"); + best.prune(slot.slot); + if !best.improves(slot.slot, slot.parent_hash, built.value) { + debug!(slot = slot.slot, value = %built.value, "not an improvement"); + return Attempt::Continue; + } } - let submission = match submitter.sign(&built, &slot) { - Ok(submission) => submission, + let bid = match submitter.sign(&built, &slot) { + Ok(bid) => bid, Err(e) => { warn!(slot = slot.slot, err = %e, "cannot sign the block"); - continue; + return Attempt::Continue; } }; - match submitter.submit(&submission).await { - Ok(()) => info!( - slot = slot.slot, - block_hash = %submission.message.block_hash, - txs = built.block.body.transactions.len(), - value = %built.value, - "submitted a block", - ), + match submitter.submit(&bid).await { + Ok(()) => { + info!( + slot = slot.slot, + block_hash = %bid.message().block_hash, + txs = built.block.body.transactions.len(), + value = %built.value, + "submitted a block", + ); + Attempt::Continue + } + Err(e) if e.is_slot_closed() => { + info!(slot = slot.slot, "the relay has moved to the next slot"); + Attempt::SlotClosed + } // The relay's reason is how an operator learns the blocks are bad. - Err(e) => warn!(slot = slot.slot, err = %e, "the relay refused the block"), + Err(e) => { + warn!(slot = slot.slot, err = %e, "the relay refused the block"); + Attempt::Continue + } } } - } + }; + + schedule::drive(contexts, lead_ms, budget, now_ms, attempt).await; } fn now_ms() -> u64 { diff --git a/crates/builder/src/building/schedule.rs b/crates/builder/src/building/schedule.rs index 904313a0..710a84ae 100644 --- a/crates/builder/src/building/schedule.rs +++ b/crates/builder/src/building/schedule.rs @@ -1,27 +1,79 @@ -use std::time::Duration; +use std::{future::Future, time::Duration}; use alloy_primitives::{B256, U256}; use rustc_hash::FxHashMap; +use tokio::sync::mpsc; -/// How long to wait before each build attempt, measured from `now_ms`. -/// -/// Every entry is relative to the same instant, so a caller must sleep to an -/// absolute deadline rather than sleeping each in turn. -/// -/// A slot learned about late still gets one immediate attempt: dropping it -/// would mean no bid at all for that slot. -pub fn delays(slot_timestamp: u64, offsets: &[u64], now_ms: u64) -> Vec { - let start_ms = slot_timestamp * 1_000; - let mut sorted: Vec = offsets.to_vec(); - sorted.sort_unstable(); - - let upcoming: Vec = sorted - .iter() - .filter_map(|offset| start_ms.checked_add(*offset)?.checked_sub(now_ms)) - .map(Duration::from_millis) - .collect(); - - if upcoming.is_empty() { vec![Duration::ZERO] } else { upcoming } +use crate::building::slot::SlotContext; + +/// A floor between attempts, so a build that fails immediately cannot spin. +const MIN_ATTEMPT_INTERVAL: Duration = Duration::from_millis(25); + +/// What the relay's answer says about whether this slot is still worth bidding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Attempt { + /// Keep going: a better block may still win the slot. + Continue, + /// The relay has moved on to the next bid slot, so nothing further is + /// accepted and building again would only waste the CPU. + SlotClosed, +} + +/// When to start building for a slot: `lead_ms` before it begins, or now if that +/// moment has passed. +fn start_delay(slot_timestamp: u64, lead_ms: u64, now_ms: u64) -> Duration { + let start_ms = (slot_timestamp * 1_000).saturating_sub(lead_ms); + Duration::from_millis(start_ms.saturating_sub(now_ms)) +} + +/// Builds and submits without pause from `lead_ms` before the slot starts, each +/// attempt picking up whatever the mempool has gained, until the relay says the +/// slot has closed or the slot itself has run out. +async fn run_schedule(slot: SlotContext, start: Duration, budget: Duration, attempt: F) +where + F: Fn(SlotContext) -> Fut, + Fut: Future, +{ + let base = tokio::time::Instant::now(); + tokio::time::sleep_until(base + start).await; + + let deadline = base + start + budget; + while tokio::time::Instant::now() < deadline { + let attempted_at = tokio::time::Instant::now(); + if attempt(slot.clone()).await == Attempt::SlotClosed { + return; + } + tokio::time::sleep_until(attempted_at + MIN_ATTEMPT_INTERVAL).await; + } +} + +/// Drives one slot at a time. A new context supersedes the one in flight: the +/// head moved, so every further attempt would bid on a parent the relay has +/// already replaced. +pub async fn drive( + mut contexts: mpsc::Receiver, + lead_ms: u64, + budget: Duration, + now_ms: N, + attempt: F, +) where + F: Fn(SlotContext) -> Fut + Clone + Send + 'static, + Fut: Future + Send + 'static, + N: Fn() -> u64, +{ + let mut current: Option> = None; + + while let Some(slot) = contexts.recv().await { + if let Some(handle) = current.take() { + handle.abort(); + } + let start = start_delay(slot.timestamp, lead_ms, now_ms()); + current = Some(tokio::spawn(run_schedule(slot, start, budget, attempt.clone()))); + } + + if let Some(handle) = current.take() { + let _ = handle.await; + } } /// The best bid already sent, per slot and parent. @@ -59,47 +111,137 @@ impl BestBid { #[cfg(test)] mod tests { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; + + use helix_types::{BlsPublicKeyBytes, Withdrawals}; + use super::*; const SLOT_TIMESTAMP: u64 = 1_700_000_000; const START_MS: u64 = SLOT_TIMESTAMP * 1_000; + const LEAD_MS: u64 = 2_000; + const BUDGET: Duration = Duration::from_millis(400); - #[test] - fn offsets_become_delays_from_the_slot_start() { - let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS); + fn context(slot: u64, parent: u8, timestamp: u64) -> SlotContext { + SlotContext { + slot, + parent_hash: B256::repeat_byte(parent), + parent_block_number: Some(slot.saturating_sub(1)), + timestamp, + prev_randao: B256::ZERO, + withdrawals: Withdrawals::default(), + parent_beacon_block_root: B256::ZERO, + proposer_pubkey: BlsPublicKeyBytes::default(), + proposer_fee_recipient: alloy_primitives::Address::ZERO, + registered_gas_limit: 30_000_000, + } + } + + /// Drives `contexts`, answering `outcome` for each attempt, and reports the + /// parent of every attempt in order. + async fn attempts_for(contexts: Vec, now_ms: u64, outcome: F) -> Vec + where + F: Fn(usize) -> Attempt + Send + Sync + 'static, + { + let (tx, rx) = mpsc::channel(16); + for context in contexts { + tx.send(context).await.unwrap(); + } + drop(tx); - assert_eq!(delays, vec![Duration::from_millis(500), Duration::from_millis(2000)]); + let seen = Arc::new(Mutex::new(Vec::new())); + let calls = Arc::new(AtomicUsize::new(0)); + let recorder = seen.clone(); + let outcome = Arc::new(outcome); + drive( + rx, + LEAD_MS, + BUDGET, + move || now_ms, + move |slot: SlotContext| { + let (recorder, calls, outcome) = (recorder.clone(), calls.clone(), outcome.clone()); + async move { + recorder.lock().unwrap().push(slot.parent_hash); + outcome(calls.fetch_add(1, Ordering::SeqCst)) + } + }, + ) + .await; + + let attempts = seen.lock().unwrap().clone(); + attempts } #[test] - fn unsorted_offsets_are_ordered() { - let delays = delays(SLOT_TIMESTAMP, &[2000, 500], START_MS); + fn building_starts_before_the_slot_does() { + let delay = start_delay(SLOT_TIMESTAMP, LEAD_MS, START_MS - 10_000); - assert_eq!( - delays, - vec![Duration::from_millis(500), Duration::from_millis(2000)], - "the config is a plain list and nothing else sorts it", - ); + assert_eq!(delay, Duration::from_millis(8_000), "2s before a slot 10s away"); } #[test] - fn an_offset_already_past_is_skipped() { - let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 1_000); + fn a_slot_learned_about_late_starts_at_once() { + let delay = start_delay(SLOT_TIMESTAMP, LEAD_MS, START_MS + 500); - assert_eq!(delays, vec![Duration::from_millis(1000)], "only the 2000ms offset is ahead"); + assert_eq!(delay, Duration::ZERO, "no waiting for a moment already gone"); } - #[test] - fn a_late_event_still_gets_one_attempt() { - let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 5_000); + /// The point of the loop: keep building better blocks for as long as the + /// relay will take them, rather than bidding once and stopping. + #[tokio::test] + async fn it_keeps_building_until_the_relay_closes_the_slot() { + let attempts = attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], START_MS, |n| { + if n >= 3 { Attempt::SlotClosed } else { Attempt::Continue } + }) + .await; - assert_eq!( - delays, - vec![Duration::ZERO], - "a late payload_attributes must not mean no bid for the slot", + assert_eq!(attempts.len(), 4, "three accepted attempts, then the closing one"); + assert!(attempts.iter().all(|p| *p == B256::repeat_byte(0xa1))); + } + + /// Once the relay is bidding for the next slot nothing more can be accepted, + /// so further building is wasted. + #[tokio::test] + async fn it_stops_as_soon_as_the_slot_closes() { + let attempts = + attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], START_MS, |_| Attempt::SlotClosed) + .await; + + assert_eq!(attempts.len(), 1); + } + + /// The head moved, so everything still to come would bid on a parent the + /// relay has already replaced. + #[tokio::test] + async fn a_superseded_context_stops_attempting() { + let attempts = attempts_for( + vec![context(1, 0xa1, SLOT_TIMESTAMP), context(1, 0xb2, SLOT_TIMESTAMP)], + START_MS, + |n| if n >= 2 { Attempt::SlotClosed } else { Attempt::Continue }, + ) + .await; + + assert!( + attempts.iter().all(|p| *p == B256::repeat_byte(0xb2)), + "only the newest parent may be bid on, got: {attempts:?}", ); } + /// Without a closing answer the loop still has to end, or a dead relay would + /// leave it building for ever. + #[tokio::test] + async fn the_budget_ends_a_slot_the_relay_never_closes() { + let attempts = + attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], START_MS, |_| Attempt::Continue) + .await; + + assert!(!attempts.is_empty(), "it must try"); + assert!(attempts.len() < 100, "the budget must stop it, got {} attempts", attempts.len(),); + } + #[test] fn a_higher_value_is_submitted() { let mut best = BestBid::default(); diff --git a/crates/builder/src/building/slot.rs b/crates/builder/src/building/slot.rs index 30064f5d..fb3f23db 100644 --- a/crates/builder/src/building/slot.rs +++ b/crates/builder/src/building/slot.rs @@ -22,7 +22,7 @@ pub struct ProposerDuty { pub struct SlotContext { pub slot: u64, pub parent_hash: B256, - pub parent_block_number: u64, + pub parent_block_number: Option, pub timestamp: u64, pub prev_randao: B256, pub withdrawals: Withdrawals, @@ -143,7 +143,7 @@ mod tests { data: PayloadAttributesEventData { proposer_index: 1, proposal_slot: slot.into(), - parent_block_number: slot - 1, + parent_block_number: Some(slot - 1), parent_block_root: String::new(), parent_block_hash: parent, payload_attributes: PayloadAttributes { @@ -173,7 +173,7 @@ mod tests { assert_eq!(context.slot, 10); assert_eq!(context.parent_hash, B256::repeat_byte(0x11)); - assert_eq!(context.parent_block_number, 9); + assert_eq!(context.parent_block_number, Some(9)); assert_eq!(context.timestamp, 1_700_000_000 + 120); assert_eq!(context.prev_randao, B256::repeat_byte(0xcc)); assert_eq!(context.parent_beacon_block_root, B256::repeat_byte(0xdd)); @@ -336,11 +336,40 @@ mod tests { let context = tracker.on_payload_attributes(event).expect("a complete event must build"); assert_eq!(context.slot, 11111); - assert_eq!(context.parent_block_number, 999); + assert_eq!(context.parent_block_number, Some(999)); assert_eq!(context.timestamp, 1_700_000_000); assert_eq!(context.parent_hash, B256::repeat_byte(0x22)); assert_eq!(context.parent_beacon_block_root, B256::repeat_byte(0x44)); assert_eq!(context.withdrawals.len(), 1); assert_eq!(context.withdrawals[0].amount, 32_000_000_000); } + + #[test] + fn parses_a_gloas_payload_attributes_event_without_a_parent_block_number() { + let json = r#"{ + "version": "gloas", + "data": { + "proposer_index": "123", + "proposal_slot": "11111", + "parent_block_root": "0x1111111111111111111111111111111111111111111111111111111111111111", + "parent_block_hash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "payload_attributes": { + "timestamp": "1700000000", + "prev_randao": "0x3333333333333333333333333333333333333333333333333333333333333333", + "suggested_fee_recipient": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "withdrawals": [], + "parent_beacon_block_root": "0x4444444444444444444444444444444444444444444444444444444444444444" + } + } + }"#; + + let event: PayloadAttributesEvent = serde_json::from_str(json).unwrap(); + let mut tracker = SlotTracker::default(); + tracker.on_duties(vec![duty_response(11111)]); + + let context = tracker.on_payload_attributes(event).expect("a Gloas event must build"); + + assert_eq!(context.slot, 11111); + assert_eq!(context.parent_block_number, None); + } } diff --git a/crates/builder/src/building/submit.rs b/crates/builder/src/building/submit.rs index 578b0ce3..1cdfbbc5 100644 --- a/crates/builder/src/building/submit.rs +++ b/crates/builder/src/building/submit.rs @@ -6,20 +6,44 @@ use helix_common::{ signing::RelaySigningContext, }; use helix_types::{ - BidTrace, BlobsBundle, KzgCommitments, SignedBidSubmission, payload_from_v3, requests_from_v4, + BidTrace, BlobsBundle, BlockAccessListBytes, ForkName, GloasSubmissionData, KzgCommitments, + SignedBidSubmission, SignedBidSubmissionGloas, Slot, payload_from_v3, }; use ssz::Encode; use thiserror::Error; use crate::{ building::{assemble::BuiltBlock, slot::SlotContext}, - engine::convert::{block_to_payload_v3, requests_to_v4}, + engine::convert::{block_to_payload_v3, decode_execution_requests}, }; +impl SubmitError { + /// Whether the relay has moved on to the next bid slot. Nothing further will + /// be accepted for this one, so there is no point building again. + /// + /// The relay answers in plain text, so this matches on the message. Both + /// come from `BlockValidationError::SubmissionForWrongSlot` and the + /// auctioneer's late-simulation path. + pub fn is_slot_closed(&self) -> bool { + match self { + Self::Rejected { body, .. } => { + body.contains("submission for wrong slot") || body.contains("already on next slot") + } + _ => false, + } + } +} + #[derive(Debug, Error)] pub enum SubmitError { #[error("blobs bundle: {0}")] Blobs(String), + #[error("a Gloas submission needs a block access list, and the block has none")] + MissingBlockAccessList, + #[error("the {0} submission shape cannot carry a block access list")] + UnsubmittableBlockAccessList(ForkName), + #[error("the {0} submission shape cannot carry EIP-8282 builder requests")] + UnsubmittableBuilderRequests(ForkName), #[error("payload exceeds the consensus limits")] OversizedPayload, #[error("requests: {0}")] @@ -30,6 +54,30 @@ pub enum SubmitError { Transport(String), } +/// The submission in the shape its fork uses. Only Gloas has room for the +/// block access list, so the choice of shape is the choice of fork. +#[derive(Debug)] +pub enum Bid { + Fulu(SignedBidSubmission), + Gloas(SignedBidSubmissionGloas), +} + +impl Bid { + pub fn message(&self) -> &BidTrace { + match self { + Bid::Fulu(bid) => &bid.message, + Bid::Gloas(bid) => &bid.message, + } + } + + pub fn as_ssz_bytes(&self) -> Vec { + match self { + Bid::Fulu(bid) => bid.as_ssz_bytes(), + Bid::Gloas(bid) => bid.as_ssz_bytes(), + } + } +} + pub struct Submitter { http: reqwest::Client, url: String, @@ -51,17 +99,12 @@ impl Submitter { } /// Builds the `BidTrace` and signs it under the builder domain. - pub fn sign( - &self, - built: &BuiltBlock, - slot: &SlotContext, - ) -> Result { + pub fn sign(&self, built: &BuiltBlock, slot: &SlotContext) -> Result { let payload_v3 = block_to_payload_v3(&built.block); let payload = payload_from_v3(payload_v3).ok_or(SubmitError::OversizedPayload)?; - let requests_v4 = requests_to_v4(&built.requests).map_err(SubmitError::Requests)?; - let requests = requests_from_v4(requests_v4) - .ok_or_else(|| SubmitError::Requests("exceeds the consensus limits".into()))?; + let decoded = decode_execution_requests(&built.requests).map_err(SubmitError::Requests)?; + let requests = decoded.requests; let blobs = wire_blobs(&built.blobs_bundle)?; @@ -80,16 +123,43 @@ impl Submitter { }; let signature = self.signing.sign_builder_message(&message); - Ok(SignedBidSubmission { + let submission = SignedBidSubmission { message, execution_payload: Arc::new(payload), blobs_bundle: Arc::new(blobs), execution_requests: Arc::new(requests), signature: signature.serialize().into(), - }) + }; + + // The relay decodes by the fork its own clock reports, so the shape has + // to be chosen from the same spec rather than from the block. + let fork = self.signing.chain_info.fork_at_slot(Slot::new(slot.slot)); + match (fork, &built.block_access_list) { + (ForkName::Gloas, Some(bal)) => { + Ok(Bid::Gloas(SignedBidSubmissionGloas::join(submission, GloasSubmissionData { + block_access_list: BlockAccessListBytes(bal.clone().into()), + builder_deposits: decoded.builder_deposits, + builder_exits: decoded.builder_exits, + }))) + } + (ForkName::Gloas, None) => Err(SubmitError::MissingBlockAccessList), + // No pre-Gloas submission shape carries EIP-8282 requests, so sending + // one would drop them and the simulator would rebuild a different + // requests hash. + (fork, _) + if !decoded.builder_deposits.is_empty() || !decoded.builder_exits.is_empty() => + { + Err(SubmitError::UnsubmittableBuilderRequests(fork)) + } + // Sending it anyway drops the list, and the simulator then rebuilds + // a header whose hash is not the one signed here. Every bid would + // die as a hash mismatch, with nothing to point at. + (fork, Some(_)) => Err(SubmitError::UnsubmittableBlockAccessList(fork)), + (_, None) => Ok(Bid::Fulu(submission)), + } } - pub async fn submit(&self, submission: &SignedBidSubmission) -> Result<(), SubmitError> { + pub async fn submit(&self, submission: &Bid) -> Result<(), SubmitError> { let response = self .http .post(&self.url) diff --git a/crates/builder/src/building/submit/tests.rs b/crates/builder/src/building/submit/tests.rs index aa6bfdb8..ddce4f80 100644 --- a/crates/builder/src/building/submit/tests.rs +++ b/crates/builder/src/building/submit/tests.rs @@ -23,7 +23,7 @@ fn slot_context() -> SlotContext { SlotContext { slot: 42, parent_hash: B256::repeat_byte(0x11), - parent_block_number: 41, + parent_block_number: Some(41), timestamp: 1_700_000_000, prev_randao: B256::repeat_byte(0xcc), withdrawals: Withdrawals::default(), @@ -46,10 +46,19 @@ fn built_block(bundle: EthrexBlobsBundle) -> BuiltBlock { blobs_bundle: bundle, requests: Vec::new(), account_updates: Vec::new(), + block_access_list: None, value: U256::from(1_234_567_u64), } } +/// The pre-Gloas shape, which is what a default `ChainInfo` selects. +fn fulu(bid: Bid) -> SignedBidSubmission { + match bid { + Bid::Fulu(bid) => bid, + Bid::Gloas(_) => panic!("a mainnet spec is not on Gloas"), + } +} + // --- blobs conversion --- #[test] @@ -102,7 +111,7 @@ fn the_bid_trace_mirrors_the_payload() { let submitter = submitter("http://localhost:1"); let built = built_block(EthrexBlobsBundle::default()); - let submission = submitter.sign(&built, &slot_context()).unwrap(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); // `payload.validate()` on the relay rejects any disagreement here. let payload = &submission.execution_payload; @@ -118,7 +127,7 @@ fn the_bid_trace_carries_the_slot_and_proposer() { let built = built_block(EthrexBlobsBundle::default()); let slot = slot_context(); - let submission = submitter.sign(&built, &slot).unwrap(); + let submission = fulu(submitter.sign(&built, &slot).unwrap()); assert_eq!(submission.message.slot, 42); assert_eq!(submission.message.proposer_pubkey, slot.proposer_pubkey); @@ -134,7 +143,7 @@ fn the_signature_verifies_under_the_builder_domain() { let submitter = Submitter::new("http://localhost:1", "key".to_string(), signing); let built = built_block(EthrexBlobsBundle::default()); - let submission = submitter.sign(&built, &slot_context()).unwrap(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); // The exact check the relay's decoder tile makes. submission.verify_signature(domain).expect("the relay must accept our signature"); @@ -144,9 +153,9 @@ fn the_signature_verifies_under_the_builder_domain() { fn the_submission_round_trips_through_ssz() { let submitter = submitter("http://localhost:1"); let built = built_block(EthrexBlobsBundle::default()); - let submission = submitter.sign(&built, &slot_context()).unwrap(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); - let encoded = submission.as_ssz_bytes(); + let encoded = ssz::Encode::as_ssz_bytes(&submission); let decoded = SignedBidSubmission::from_ssz_bytes(&encoded).expect("the wire format"); assert_eq!(decoded.message.block_hash, submission.message.block_hash); @@ -159,8 +168,8 @@ fn a_submission_with_blobs_round_trips() { let submitter = submitter("http://localhost:1"); let built = built_block(blob_bundle(1)); - let submission = submitter.sign(&built, &slot_context()).unwrap(); - let encoded = submission.as_ssz_bytes(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); + let encoded = ssz::Encode::as_ssz_bytes(&submission); let decoded = SignedBidSubmission::from_ssz_bytes(&encoded) .expect("a bundle decodes only with 128 proofs per blob"); @@ -240,3 +249,117 @@ async fn a_relay_rejection_is_reported_with_its_body() { assert!(err.to_string().contains("invalid state root"), "got: {err}"); assert!(matches!(err, SubmitError::Rejected { status: 400, .. }), "got: {err}"); } + +// --- the Gloas shape --- + +/// Gloas from genesis, so `fork_at_slot` reports it for every slot. +fn gloas_signing() -> RelaySigningContext { + let mut spec = helix_types::ChainSpec::mainnet(); + spec.gloas_fork_epoch = Some(helix_types::Epoch::new(0)); + RelaySigningContext::new(BlsKeypair::random(), Arc::new(ChainInfo::new(spec, B256::ZERO, 0))) +} + +fn built_with_bal(bal: Vec) -> BuiltBlock { + BuiltBlock { block_access_list: Some(bal), ..built_block(EthrexBlobsBundle::default()) } +} + +#[test] +fn a_gloas_submission_carries_the_block_access_list() { + let submitter = Submitter::new("http://localhost:1", "key".to_string(), gloas_signing()); + let bal = vec![9u8; 64]; + + let bid = submitter.sign(&built_with_bal(bal.clone()), &slot_context()).unwrap(); + + // Decoded the way the relay's own decoder will, rather than by field access. + let params = helix_common::decoder::SubmissionDecoderParams::plain(ForkName::Gloas); + let mut buf = Vec::new(); + let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) + .decode(&bid.as_ssz_bytes(), &mut buf) + .expect("the relay must be able to decode what we send"); + assert_eq!(decoded.expect("Gloas carries a list").block_access_list.to_vec(), bal); +} + +#[test] +fn a_fulu_submission_keeps_its_shape() { + let submitter = submitter("http://localhost:1"); + let built = built_block(EthrexBlobsBundle::default()); + + let bid = submitter.sign(&built, &slot_context()).unwrap(); + + assert!(matches!(bid, Bid::Fulu(_))); + let params = helix_common::decoder::SubmissionDecoderParams::plain(ForkName::Fulu); + let mut buf = Vec::new(); + let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) + .decode(&bid.as_ssz_bytes(), &mut buf) + .expect("the Fulu shape must be unchanged"); + assert!(decoded.is_none(), "only Gloas carries one"); +} + +/// Refused rather than sent with an empty list, which the simulator would +/// reject as a block hash mismatch with nothing to point at. +#[test] +fn a_gloas_submission_without_a_list_is_refused() { + let submitter = Submitter::new("http://localhost:1", "key".to_string(), gloas_signing()); + + let err = submitter + .sign(&built_block(EthrexBlobsBundle::default()), &slot_context()) + .expect_err("Gloas needs a list"); + + assert!(matches!(err, SubmitError::MissingBlockAccessList), "got: {err}"); +} + +/// The other direction: an Amsterdam block cannot go out in a pre-Gloas +/// submission, because the shape has nowhere to put its list. +#[test] +fn an_amsterdam_block_in_a_fulu_submission_is_refused() { + let mut spec = helix_types::ChainSpec::mainnet(); + spec.fulu_fork_epoch = Some(helix_types::Epoch::new(0)); + let signing = RelaySigningContext::new( + BlsKeypair::random(), + Arc::new(ChainInfo::new(spec, B256::ZERO, 0)), + ); + let submitter = Submitter::new("http://localhost:1", "key".to_string(), signing); + + let err = submitter + .sign(&built_with_bal(vec![1u8; 32]), &slot_context()) + .expect_err("the list would be dropped"); + + assert!(matches!(err, SubmitError::UnsubmittableBlockAccessList(ForkName::Fulu)), "got: {err}"); +} + +/// Step 3 derives the header's slot number from the bid trace, so these two +/// must name the same slot or the simulator rebuilds a different block. +#[test] +fn the_bid_trace_slot_matches_the_slot_the_block_was_built_for() { + let submitter = Submitter::new("http://localhost:1", "key".to_string(), gloas_signing()); + let slot = slot_context(); + + let bid = submitter.sign(&built_with_bal(vec![2u8; 16]), &slot).unwrap(); + + assert_eq!(bid.message().slot, slot.slot); +} + +/// The loop only stops when the relay says the slot has moved on, so the two +/// messages that mean exactly that have to be recognised. +#[test] +fn a_wrong_slot_rejection_closes_the_slot() { + let rejected = |body: &str| SubmitError::Rejected { status: 400, body: body.to_string() }; + + assert!( + rejected("block validation: submission for wrong slot. expected: 11, got: 10") + .is_slot_closed() + ); + assert!(rejected("late sim, already on next slot").is_slot_closed()); +} + +/// Anything else is a bad block, not a closed slot: keep building. +#[test] +fn other_rejections_do_not_close_the_slot() { + let rejected = |body: &str| SubmitError::Rejected { status: 400, body: body.to_string() }; + + assert!(!rejected("block validation: unknown parent hash").is_slot_closed()); + assert!( + !rejected("block simulation: BlockValidationFailed(\"bad state root\")").is_slot_closed() + ); + assert!(!SubmitError::Transport("connection refused".into()).is_slot_closed()); +} diff --git a/crates/builder/src/cli.rs b/crates/builder/src/cli.rs index ffd3dac5..7e1ab89c 100644 --- a/crates/builder/src/cli.rs +++ b/crates/builder/src/cli.rs @@ -74,6 +74,14 @@ pub struct NodeOptions { pub skip_genesis_validation: bool, #[arg(long = "log.level", default_value_t = Level::INFO, value_name = "LOG_LEVEL", help = "info, debug, trace, warn or error", env = "ETHREX_LOG_LEVEL", help_heading = "Node options")] pub log_level: Level, + #[arg( + long = "log.dir", + value_name = "PATH", + help = "Write daily-rotated logs here instead of stdout, so a redeploy keeps the record.", + env = "HELIX_LOG_DIR", + help_heading = "Node options" + )] + pub log_dir: Option, #[arg( long = "http.addr", diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 8d601a21..2650c244 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -225,14 +225,18 @@ pub struct BuildingConfig { #[allow(dead_code)] #[serde(default = "default_subsidy_wei")] pub subsidy_wei: u128, - /// Gas held back from the fill for the trailing payout transaction. + /// Gas held back from the fill for the trailing payout transaction. Covers + /// the transfer only: from Amsterdam, creating a recipient that does not + /// exist yet is charged on top. See `assemble::payout_gas_limit`. #[serde(default = "default_payout_gas_reserve")] pub payout_gas_reserve: u64, #[serde(default = "default_extra_data")] pub extra_data: String, - /// Points into the slot, in milliseconds, at which to build and submit. - #[serde(default = "default_submit_offsets_ms")] - pub submit_offsets_ms: Vec, + /// How long before the slot starts to begin building, in milliseconds. + /// From then the builder rebuilds and resubmits without pause, picking up + /// whatever the mempool has gained, until the relay moves to the next slot. + #[serde(default = "default_build_lead_ms")] + pub build_lead_ms: u64, } impl BuildingConfig { @@ -255,8 +259,8 @@ impl BuildingConfig { if self.payout_gas_reserve < TX_GAS_COST { eyre::bail!("building config: payout_gas_reserve must be at least {TX_GAS_COST}"); } - if self.submit_offsets_ms.is_empty() { - eyre::bail!("building config: submit_offsets_ms must not be empty"); + if self.build_lead_ms == 0 { + eyre::bail!("building config: build_lead_ms must not be zero"); } if self.extra_data.len() > MAX_EXTRA_DATA_BYTES { eyre::bail!("building config: extra_data must be at most {MAX_EXTRA_DATA_BYTES} bytes"); @@ -320,8 +324,8 @@ fn default_payout_gas_reserve() -> u64 { fn default_extra_data() -> String { "helix-builder".to_string() } -fn default_submit_offsets_ms() -> Vec { - vec![500, 2000] +fn default_build_lead_ms() -> u64 { + 2_000 } fn default_blacklist_endpoint() -> String { "http://localhost:3520/blacklist".to_string() @@ -517,7 +521,7 @@ mod building_config_tests { assert_eq!(config.subsidy_wei, 1_000_000_000_000_000); assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); - assert_eq!(config.submit_offsets_ms, vec![500, 2000]); + assert_eq!(config.build_lead_ms, 2_000); } #[test] @@ -531,7 +535,10 @@ mod building_config_tests { ); assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); - assert_eq!(config.submit_offsets_ms, vec![500, 2000]); + assert_eq!( + config.build_lead_ms, 2_000, + "bidding has to start before the proposer asks, which it does at the slot start", + ); } #[test] @@ -554,12 +561,12 @@ mod building_config_tests { } #[test] - fn rejects_empty_submit_offsets() { - let err = with_line("submit_offsets_ms: []") + fn rejects_a_zero_build_lead() { + let err = with_line("build_lead_ms: 0") .validate() - .expect_err("without an offset the role would start and never build"); + .expect_err("starting at the slot start is already too late for the proposer"); - assert!(err.to_string().contains("submit_offsets_ms"), "got: {err}"); + assert!(err.to_string().contains("build_lead_ms"), "got: {err}"); } #[test] diff --git a/crates/builder/src/engine/convert.rs b/crates/builder/src/engine/convert.rs index e5f90724..5092751e 100644 --- a/crates/builder/src/engine/convert.rs +++ b/crates/builder/src/engine/convert.rs @@ -17,6 +17,8 @@ use ethrex_common::{ }, }; use ethrex_crypto::NativeCrypto; +use helix_types::{BuilderDepositRequests, BuilderExitRequests, ExecutionRequests, RequestType}; +use ssz::Encode; use crate::validation::error::ValidationError; @@ -95,6 +97,20 @@ pub fn block_to_payload_v3(block: &Block) -> ExecutionPayloadV3 { } } +/// The Amsterdam header inputs no `ExecutionPayloadV3` carries: the EIP-7928 +/// block access list, the EIP-7843 slot number, and the EIP-8282 builder +/// request lists that the `requests_hash` commits to. `None` for an earlier +/// fork. +#[derive(Clone, Copy, Default)] +pub struct Amsterdam<'a> { + /// The list as the builder encoded it. It is hashed as received and never + /// re-encoded, because the block hash commits to these exact bytes. + pub block_access_list: &'a [u8], + pub slot: u64, + pub builder_deposits: Option<&'a BuilderDepositRequests>, + pub builder_exits: Option<&'a BuilderExitRequests>, +} + /// Inverse of [`block_to_payload_v3`]. The roots the payload omits are /// recomputed, so the hash this yields is the one the submission is bound to. #[allow(dead_code)] @@ -102,7 +118,13 @@ pub fn payload_v3_to_block( payload: &ExecutionPayloadV3, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + amsterdam: Option>, ) -> Result { + // An empty list hashes to something no builder committed to, which would + // surface as a block hash mismatch. Name the real fault instead. + if amsterdam.is_some_and(|a| a.block_access_list.is_empty()) { + return Err(ValidationError::EmptyBlockAccessList); + } let inner = &payload.payload_inner.payload_inner; let transactions = inner @@ -138,7 +160,10 @@ pub fn payload_v3_to_block( blob_gas_used: Some(payload.blob_gas_used), excess_blob_gas: Some(payload.excess_blob_gas), parent_beacon_block_root: Some(h256(parent_beacon_block_root)), - requests_hash: Some(compute_requests_hash(&encoded_requests(requests))), + requests_hash: Some(compute_requests_hash(&encoded_requests_all(requests, amsterdam))), + block_access_list_hash: amsterdam + .map(|a| ethrex_common::utils::keccak(a.block_access_list)), + slot_number: amsterdam.map(|a| a.slot), ..Default::default() }; @@ -177,6 +202,32 @@ fn encoded_requests(requests: &ExecutionRequestsV4) -> Vec { requests.to_requests().iter().map(|request| EncodedRequests(request.clone().0)).collect() } +/// The flat EIP-7685 list the header's `requests_hash` commits to. EIP-8282's +/// builder lists carry types 3 and 4, so they follow the three alloy encodes, +/// and an empty list is omitted exactly as the EIP requires. +fn encoded_requests_all( + requests: &ExecutionRequestsV4, + amsterdam: Option>, +) -> Vec { + let mut list = encoded_requests(requests); + let mut push = |request_type: RequestType, body: Vec, is_empty: bool| { + if is_empty { + return; + } + let mut bytes = Vec::with_capacity(1 + body.len()); + bytes.push(request_type.to_u8()); + bytes.extend_from_slice(&body); + list.push(EncodedRequests(bytes.into())); + }; + if let Some(deposits) = amsterdam.and_then(|a| a.builder_deposits) { + push(RequestType::BuilderDeposit, deposits.as_ssz_bytes(), deposits.is_empty()); + } + if let Some(exits) = amsterdam.and_then(|a| a.builder_exits) { + push(RequestType::BuilderExit, exits.as_ssz_bytes(), exits.is_empty()); + } + list +} + /// Converts ethrex's encoded EIP-7685 requests into the wire /// `ExecutionRequestsV4`, dropping empty requests per the EIP. pub fn requests_to_v4(encoded: &[EncodedRequests]) -> Result { @@ -186,6 +237,56 @@ pub fn requests_to_v4(encoded: &[EncodedRequests]) -> Result Result { + let mut decoded = DecodedRequests::default(); + + for entry in encoded.iter().filter(|r| !r.is_empty()) { + let (prefix, body) = entry.0.split_first().ok_or("empty execution request")?; + let request_type = RequestType::from_u8(*prefix) + .ok_or_else(|| format!("unknown request_type prefix: {prefix}"))?; + + match request_type { + RequestType::Deposit => { + decoded.requests.deposits = ssz_decode(body, "deposits")?; + } + RequestType::Withdrawal => { + decoded.requests.withdrawals = ssz_decode(body, "withdrawals")?; + } + RequestType::Consolidation => { + decoded.requests.consolidations = ssz_decode(body, "consolidations")?; + } + RequestType::BuilderDeposit => { + decoded.builder_deposits = ssz_decode(body, "builder deposits")?; + } + RequestType::BuilderExit => { + decoded.builder_exits = ssz_decode(body, "builder exits")?; + } + } + } + + Ok(decoded) +} + +fn ssz_decode(body: &[u8], what: &str) -> Result { + T::from_ssz_bytes(body).map_err(|e| format!("{what}: {e:?}")) +} + #[cfg(test)] mod tests { use super::*; @@ -198,6 +299,48 @@ mod tests { assert_eq!(au256(EU256::max_value()), AU256::MAX); } + /// Production shape: the node emits EIP-8282 builder deposits and exits, and + /// the block was unbiddable while the decoder rejected prefix 3. + #[test] + fn the_flat_list_decodes_every_eip_7685_type() { + use ssz::Encode; + let deposits: BuilderDepositRequests = vec![helix_types::BuilderDepositRequest { + pubkey: helix_types::BlsPublicKeyBytesLh::empty(), + withdrawal_credentials: B256::repeat_byte(0x11), + amount: 32_000_000_000, + signature: helix_types::BlsSignatureBytesLh::empty(), + }] + .into(); + let exits: BuilderExitRequests = vec![helix_types::BuilderExitRequest { + source_address: AAddress::repeat_byte(0x22), + pubkey: helix_types::BlsPublicKeyBytesLh::empty(), + }] + .into(); + let encoded = vec![ + prefixed(RequestType::BuilderDeposit, deposits.as_ssz_bytes()), + prefixed(RequestType::BuilderExit, exits.as_ssz_bytes()), + ]; + + let decoded = decode_execution_requests(&encoded).expect("prefixes 3 and 4 are valid"); + + assert_eq!(decoded.builder_deposits, deposits); + assert_eq!(decoded.builder_exits, exits); + } + + #[test] + fn an_unknown_request_type_is_named() { + let err = decode_execution_requests(&[EncodedRequests(vec![9u8, 0u8].into())]) + .expect_err("prefix 9 is not an EIP-7685 type"); + + assert!(err.contains("unknown request_type prefix: 9"), "got: {err}"); + } + + fn prefixed(request_type: RequestType, body: Vec) -> EncodedRequests { + let mut bytes = vec![request_type.to_u8()]; + bytes.extend_from_slice(&body); + EncodedRequests(bytes.into()) + } + #[test] fn hash_and_address_roundtrip() { let b = B256::repeat_byte(0xab); diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index f1e2b171..be511423 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -3,6 +3,7 @@ use flux::{ spine::FluxSpine, tile::{TileConfig, attach_tile}, }; +use helix_common::utils::install_default_crypto_provider; use tracing::info; use tracing_subscriber::EnvFilter; @@ -31,8 +32,10 @@ use validation::{BlockValidator, server as validation_server}; static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; fn main() -> eyre::Result<()> { + install_default_crypto_provider(); + let cli = BuilderCli::parse(); - init_tracing(&cli); + let _log_guard = init_tracing(&cli); let merging_config = cli.merging_config.as_deref().map(MergingConfig::load).transpose()?; let simulation_config = cli.sim_config.as_deref().map(SimulationConfig::load).transpose()?; @@ -186,8 +189,23 @@ fn main() -> eyre::Result<()> { Ok(()) } -fn init_tracing(cli: &BuilderCli) { +/// Returns the appender guard, which has to outlive the process's logging. +fn init_tracing(cli: &BuilderCli) -> Option { let filter = EnvFilter::builder().with_default_directive(cli.node.log_level.into()).from_env_lossy(); - tracing_subscriber::fmt().with_env_filter(filter).init(); + + let Some(dir) = cli.node.log_dir.as_ref() else { + tracing_subscriber::fmt().with_env_filter(filter).init(); + return None; + }; + + let appender = tracing_appender::rolling::Builder::new() + .filename_prefix("helix_builder.log") + .max_log_files(14) + .rotation(tracing_appender::rolling::Rotation::DAILY) + .build(dir) + .expect("failed to create the log appender"); + let (writer, guard) = tracing_appender::non_blocking(appender); + tracing_subscriber::fmt().with_env_filter(filter).with_writer(writer).with_ansi(false).init(); + Some(guard) } diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index 52aec8f7..7c573a17 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -64,10 +64,6 @@ pub fn signed_transfer( alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718() } -pub async fn dev_genesis_store() -> (Store, Genesis) { - dev_genesis_store_with(|_| {}).await -} - /// `edit` may add allocations before the genesis state root is computed. pub async fn dev_genesis_store_with(edit: impl FnOnce(&mut Genesis)) -> (Store, Genesis) { let mut genesis = Network::LocalDevnet.get_genesis().unwrap(); @@ -123,6 +119,35 @@ pub fn deploy_payment_forwarder(genesis: &mut Genesis) { }); } +/// The EIP-8282 builder deposit and exit predeploys. Empty code at either +/// address invalidates every Amsterdam block, so an Amsterdam fixture cannot +/// build without them. +/// +/// The addresses come from ethrex rather than a literal: they are Nick's-method +/// addresses and they moved between devnet-7 and devnet-8, so a hardcoded pair +/// silently breaks on an ethrex bump. The runtime bytecode is unchanged across +/// that move and is copied from `fixtures/genesis/l1-bal.json`. +pub fn deploy_amsterdam_predeploys(genesis: &mut Genesis) { + use ethrex_vm::system_contracts::{ + BUILDER_DEPOSIT_CONTRACT_ADDRESS, BUILDER_EXIT_CONTRACT_ADDRESS, + }; + for (contract, code) in [ + (BUILDER_DEPOSIT_CONTRACT_ADDRESS, BUILDER_DEPOSIT_CODE), + (BUILDER_EXIT_CONTRACT_ADDRESS, BUILDER_EXIT_CODE), + ] { + genesis.alloc.insert(contract.address, GenesisAccount { + code: hex::decode(code).unwrap().into(), + storage: Default::default(), + balance: ethrex_common::U256::zero(), + nonce: 0, + }); + } +} + +const BUILDER_DEPOSIT_CODE: &str = "3373fffffffffffffffffffffffffffffffffffffffe146101065760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461023457600182026001905f5b5f82111560695781019083028483029004916001019190604e565b90939004925050503660b814608957366102345734610234575f5260205ff35b8034106102345760383567ffffffffffffffff1680633b9aca001161023457633b9aca00029034031061023457600154600101600155600354806006026004015f358155600101602035815560010160403581556001016060358155600101608035815560010160a035905560b85f5f3760b85fa0600101600355005b600354600254808203806101001161011d57506101005b5f5b8181146101c3578281016006026004018160b8028154815260200181600101548152602001816002015480825260401c67ffffffffffffffff16816010018160381c81600701538160301c81600601538160281c81600501538160201c81600401538160181c81600301538160101c81600201538160081c81600101535360200181600301548152602001816004015481526020019060050154905260010161011f565b91018092146101d557906002556101e0565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561020d57505f5b6001546020828201116102225750505f610228565b01602090035b5f555f60015560b8025ff35b5f5ffd"; + +const BUILDER_EXIT_CODE: &str = "3373fffffffffffffffffffffffffffffffffffffffe1460cb5760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461018857600182026001905f5b5f82111560685781019083028483029004916001019190604d565b909390049250505036603014608857366101885734610188575f5260205ff35b341061018857600154600101600155600354806003026004013381556001015f35815560010160203590553360601b5f5260305f60143760445fa0600101600355005b6003546002548082038060101160df575060105b5f5b8181146101175782810160030260040181604402815460601b8152601401816001015481526020019060020154905260010160e1565b91018092146101295790600255610134565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561016157505f5b6001546002828201116101765750505f61017c565b01600290035b5f555f6001556044025ff35b5f5ffd"; + /// Runtime that reads the balance of the address in its calldata and stops: /// PUSH0 CALLDATALOAD BALANCE POP STOP. Reads an account without touching it. pub fn deploy_balance_probe(genesis: &mut Genesis, address: Address) { diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs index a5b7b088..9d09c7f2 100644 --- a/crates/builder/src/validation/error.rs +++ b/crates/builder/src/validation/error.rs @@ -39,6 +39,8 @@ pub enum ValidationError { Blacklist(Address), #[error("invalid blobs bundle")] InvalidBlobsBundle, + #[error("submission carries an empty block access list")] + EmptyBlockAccessList, } impl ValidationError { @@ -61,6 +63,7 @@ impl ValidationError { ValidationError::ProposerPayment => "proposer_payment", ValidationError::Blacklist(_) => "blacklist", ValidationError::InvalidBlobsBundle => "invalid_blobs_bundle", + ValidationError::EmptyBlockAccessList => "empty_block_access_list", } } } diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index 52d3f4c9..6fb1cb04 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -27,8 +27,8 @@ use ethrex_common::{ Transaction, TxKind, }, validation::{ - validate_block_pre_execution, validate_gas_used, validate_receipts_root_and_logs_bloom, - validate_requests_hash, + validate_block_access_list_hash, validate_block_pre_execution, validate_gas_used, + validate_receipts_root_and_logs_bloom, validate_requests_hash, }, }; use ethrex_crypto::NativeCrypto; @@ -42,7 +42,7 @@ use tokio::sync::watch; use crate::{ engine::{ - convert::{aaddr, au256, b256, eaddr, eu256, h256, payload_v3_to_block}, + convert::{Amsterdam, aaddr, au256, b256, eaddr, eu256, h256, payload_v3_to_block}, simulate::balance_of, }, metrics, @@ -107,9 +107,10 @@ impl BlockValidator { message: &BidTrace, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + amsterdam: Option>, ) -> Result { let t = Instant::now(); - let block = self.to_block(payload, parent_beacon_block_root, requests)?; + let block = self.to_block(payload, parent_beacon_block_root, requests, amsterdam)?; let t = metrics::sim_lap("to_block", t); self.validate_message_against_header(&block, message)?; let t = metrics::sim_lap("check_trace", t); @@ -118,6 +119,9 @@ impl BlockValidator { Ok(PreparedBlock { block, parent_header }) } + // A request struct would read better at nine fields. That refactor touches + // every caller, so it is not this change's job. + #[allow(clippy::too_many_arguments)] pub fn validate( &self, payload: &ExecutionPayloadV3, @@ -126,8 +130,10 @@ impl BlockValidator { requests: &ExecutionRequestsV4, blobs: &BlobsBundle, apply_blacklist: bool, + amsterdam: Option>, ) -> Result { - let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + let prepared = + self.prepare(payload, message, parent_beacon_block_root, requests, amsterdam)?; let t = Instant::now(); self.validate_blobs_bundle(&prepared.block, blobs)?; metrics::sim_lap("blobs", t); @@ -154,8 +160,10 @@ impl BlockValidator { blobs: &BlobsBundle, apply_blacklist: bool, base_payment_tx_index: u64, + amsterdam: Option>, ) -> Result { - let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + let prepared = + self.prepare(payload, message, parent_beacon_block_root, requests, amsterdam)?; let t = Instant::now(); self.validate_blobs_bundle(&prepared.block, blobs)?; metrics::sim_lap("blobs", t); @@ -276,7 +284,8 @@ impl BlockValidator { vm.db.store = parent_reads; metrics::sim_lap("vm_setup", t); - let merkle_pool = self.merkle_pools.checkout(); + let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp); + let merkle_pool = if is_amsterdam { None } else { self.merkle_pools.checkout() }; let queue_length = AtomicUsize::new(0); let (receipts, tx_details, account_updates) = std::thread::scope(|scope| { let (mut stream, merkleizer) = merkle_pool @@ -291,17 +300,26 @@ impl BlockValidator { }) .unzip(); - let (receipts, tx_details, gas_used) = - Self::execute_transactions(&mut vm, &block, stream.as_mut())?; - let t = Instant::now(); - let requests = vm - .extract_requests(&receipts, &block.header) - .map_err(|e| ValidationError::Execution(e.to_string()))?; - if let Some(withdrawals) = &block.body.withdrawals { - vm.process_withdrawals(withdrawals) + let (receipts, requests, gas_used, tx_details, bal) = if is_amsterdam { + let (result, bal) = vm + .execute_block(&block) .map_err(|e| ValidationError::Execution(e.to_string()))?; - } - let t = metrics::sim_lap("requests_withdrawals", t); + (result.receipts, result.requests, result.block_gas_used, Vec::new(), bal) + } else { + let (receipts, tx_details, gas_used) = + Self::execute_transactions(&mut vm, &block, stream.as_mut())?; + let t = Instant::now(); + let requests = vm + .extract_requests(&receipts, &block.header) + .map_err(|e| ValidationError::Execution(e.to_string()))?; + if let Some(withdrawals) = &block.body.withdrawals { + vm.process_withdrawals(withdrawals) + .map_err(|e| ValidationError::Execution(e.to_string()))?; + } + metrics::sim_lap("requests_withdrawals", t); + (receipts, requests, gas_used, tx_details, None) + }; + let t = Instant::now(); let serial_updates = match stream.take() { Some(mut stream) => { @@ -321,6 +339,25 @@ impl BlockValidator { .map_err(|e| ValidationError::PostExecution(e.to_string()))?; validate_requests_hash(&block.header, &chain_config, &requests) .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + + // The header commits to the list the builder submitted, so this compares + // that list against what execution produced. ethrex skips the check when + // the VM returns no list; a relay simulator must fail closed instead. + if is_amsterdam { + let bal = bal.ok_or_else(|| { + ValidationError::PostExecution( + "no block access list from execution".to_string(), + ) + })?; + validate_block_access_list_hash( + &block.header, + &chain_config, + &bal, + block.body.transactions.len(), + &NativeCrypto, + ) + .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + } let t = metrics::sim_lap("post_execution", t); let (account_updates, state_root) = match (merkleizer, serial_updates) { @@ -362,7 +399,7 @@ impl BlockValidator { /// The transaction loop of ethrex's `execute_block`, reading the coinbase /// balance after each transaction. Mirrors the pre-Amsterdam gas accounting - /// only: the V3 payloads this server decodes predate that fork. + /// only; Amsterdam blocks go through `execute_block` itself. fn execute_transactions( vm: &mut Evm, block: &Block, @@ -582,8 +619,9 @@ impl BlockValidator { payload: &ExecutionPayloadV3, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + amsterdam: Option>, ) -> Result { - payload_v3_to_block(payload, parent_beacon_block_root, requests) + payload_v3_to_block(payload, parent_beacon_block_root, requests, amsterdam) } /// The relay serves the trace's fields, so a trace that misdescribes a valid diff --git a/crates/builder/src/validation/server.rs b/crates/builder/src/validation/server.rs index b7addda6..1afe40a2 100644 --- a/crates/builder/src/validation/server.rs +++ b/crates/builder/src/validation/server.rs @@ -16,13 +16,13 @@ use helix_common::{ decoder::{DecoderError, SubmissionDecoder, SubmissionDecoderParams}, simulator::{SszMergedValidationRequest, SszValidationRequest, SszValidationResponse}, }; -use helix_types::Submission; +use helix_types::{ForkName, GloasSubmissionData, Submission}; use ssz::{Decode, Encode}; use tokio::{net::TcpListener, sync::Semaphore, time}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::{ - engine::convert::eblobs, + engine::convert::{Amsterdam, eblobs}, metrics, validation::{BlockValidator, error::ValidationError}, }; @@ -58,22 +58,48 @@ pub async fn run(validator: BlockValidator, addr: SocketAddr, max_concurrent: us } } +/// Forks this validator understands. A submission from any other fork is +/// refused rather than validated under the wrong rules. +fn supported_fork(params: &Option) -> bool { + // No params means raw Fulu-shaped SSZ bytes. + params + .as_ref() + .is_none_or(|params| matches!(params.fork_name, ForkName::Fulu | ForkName::Gloas)) +} + +/// The merged route has no Gloas shape yet, so its fork list is shorter. +fn supported_merged_fork(params: &Option) -> bool { + params.as_ref().is_none_or(|params| matches!(params.fork_name, ForkName::Fulu)) +} + +/// 501, not 400: the relay maps a 400 body to `BlockValidationFailed`, which +/// demotes the builder. This is helix's own limitation, not the builder's. +fn unsupported_fork(params: &Option) -> Response { + let fork = params.as_ref().map(|params| params.fork_name); + warn!(?fork, "refusing a submission from an unsupported fork"); + (StatusCode::NOT_IMPLEMENTED, format!("unsupported fork: {fork:?}")).into_response() +} + +/// A decoded submission, with the Gloas-only data a Gloas one carries. +type Decoded = (SignedBidSubmissionV5, Option); + /// A dehydrated submission needs transactions this simulator does not cache. /// The relay answers a 424 by retrying with full SSZ bytes. fn decode_submission( params: Option, bytes: &[u8], -) -> Result, DecoderError> { +) -> Result, DecoderError> { match params { Some(params) => { let mut buf = Vec::new(); - let (submission, _, _) = SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; + let (submission, _, _, gloas_data) = + SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; match submission { - Submission::Full(submission) => Ok(Some(submission.into())), + Submission::Full(submission) => Ok(Some((submission.into(), gloas_data))), Submission::Dehydrated(_) => Ok(None), } } - None => Ok(Some(SignedBidSubmissionV5::from_ssz_bytes(bytes)?)), + None => Ok(Some((SignedBidSubmissionV5::from_ssz_bytes(bytes)?, None))), } } @@ -85,22 +111,30 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> return finish("validate", "bad_request", start, bad_request(format!("{err:?}"))) } }; + if !supported_fork(&request.decoder_params) { + return finish( + "validate", + "unsupported_fork", + start, + unsupported_fork(&request.decoder_params), + ); + } let t = metrics::sim_lap("decode_request", start); - let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) - { - Ok(Some(submission)) => submission, - Ok(None) => { - return finish( - "validate", - "dehydrated", - start, - StatusCode::FAILED_DEPENDENCY.into_response(), - ); - } - Err(err) => { - return finish("validate", "bad_submission", start, bad_request(err.to_string())) - } - }; + let (submission, gloas_data) = + match decode_submission(request.decoder_params, &request.signed_bid_submission) { + Ok(Some(decoded)) => decoded, + Ok(None) => { + return finish( + "validate", + "dehydrated", + start, + StatusCode::FAILED_DEPENDENCY.into_response(), + ); + } + Err(err) => { + return finish("validate", "bad_submission", start, bad_request(err.to_string())) + } + }; metrics::sim_lap("decode_submission", t); run_validation(state, "validate", start, move |validator| { @@ -111,11 +145,24 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> &submission.execution_requests, &eblobs(&submission.blobs_bundle), request.apply_blacklist, + amsterdam(gloas_data.as_ref(), submission.message.slot), ) }) .await } +/// Gloas data marks the submission as Amsterdam, and the slot number the header +/// needs is the one the trace already carries. The builder request lists go in +/// too: the `requests_hash` the block hash commits to covers them. +fn amsterdam(gloas_data: Option<&GloasSubmissionData>, slot: u64) -> Option> { + gloas_data.map(|data| Amsterdam { + block_access_list: &data.block_access_list.0, + slot, + builder_deposits: Some(&data.builder_deposits), + builder_exits: Some(&data.builder_exits), + }) +} + async fn validate_merged(State(state): State, body: axum::body::Bytes) -> Response { let start = Instant::now(); let request = match SszMergedValidationRequest::from_ssz_bytes(&body) { @@ -124,22 +171,35 @@ async fn validate_merged(State(state): State, body: axum::body::Byt return finish("validate_merged", "bad_request", start, bad_request(format!("{err:?}"))) } }; + if !supported_merged_fork(&request.decoder_params) { + return finish( + "validate_merged", + "unsupported_fork", + start, + unsupported_fork(&request.decoder_params), + ); + } let t = metrics::sim_lap("decode_request", start); - let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) - { - Ok(Some(submission)) => submission, - Ok(None) => { - return finish( - "validate_merged", - "dehydrated", - start, - StatusCode::FAILED_DEPENDENCY.into_response(), - ); - } - Err(err) => { - return finish("validate_merged", "bad_submission", start, bad_request(err.to_string())) - } - }; + let (submission, _) = + match decode_submission(request.decoder_params, &request.signed_bid_submission) { + Ok(Some(decoded)) => decoded, + Ok(None) => { + return finish( + "validate_merged", + "dehydrated", + start, + StatusCode::FAILED_DEPENDENCY.into_response(), + ); + } + Err(err) => { + return finish( + "validate_merged", + "bad_submission", + start, + bad_request(err.to_string()), + ) + } + }; metrics::sim_lap("decode_submission", t); run_validation(state, "validate_merged", start, move |validator| { @@ -151,6 +211,7 @@ async fn validate_merged(State(state): State, body: axum::body::Byt &eblobs(&submission.blobs_bundle), request.apply_blacklist, request.base_payment_tx_index, + None, ) }) .await diff --git a/crates/builder/src/validation/server_tests.rs b/crates/builder/src/validation/server_tests.rs index 648fab59..fd1b0710 100644 --- a/crates/builder/src/validation/server_tests.rs +++ b/crates/builder/src/validation/server_tests.rs @@ -318,3 +318,90 @@ fn an_unchanged_list_reports_no_new_digest() { "the wrapped shape yields the same list" ); } + +/// Decoder params naming a fork, with everything else at its default. +fn params_for(fork: helix_types::ForkName) -> helix_common::decoder::SubmissionDecoderParams { + helix_common::decoder::SubmissionDecoderParams { + compression: Default::default(), + encoding: helix_common::decoder::Encoding::Ssz, + merge_type: Default::default(), + is_dehydrated: false, + with_mergeable_data: false, + with_adjustments: false, + mark_all_txs_mergeable: false, + dehydrated_v2: false, + merging_v2: false, + fork_name: fork, + } +} + +#[tokio::test] +async fn a_gloas_validation_request_is_accepted() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let request = fixture.gloas_ssz_request(&built); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +/// The fork gate stays shut for a fork with no wire shape here, so a later fork +/// cannot be validated under Gloas rules. +#[tokio::test] +async fn an_unscheduled_fork_is_still_refused() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut request = fixture.ssz_request(&built, true); + request.decoder_params = Some(params_for(helix_types::ForkName::Heze)); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{body}"); +} + +#[tokio::test] +async fn a_merged_gloas_request_is_refused() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let base = fixture.ssz_request(&built, true); + let request = SszMergedValidationRequest { + apply_blacklist: base.apply_blacklist, + registered_gas_limit: base.registered_gas_limit, + parent_beacon_block_root: base.parent_beacon_block_root, + inclusion_list: base.inclusion_list, + decoder_params: Some(params_for(helix_types::ForkName::Gloas)), + signed_bid_submission: base.signed_bid_submission, + base_payment_tx_index: 0, + }; + + let (status, body) = post(&fixture, "/validate_merged", request.as_ssz_bytes()).await; + + assert_eq!( + status, + StatusCode::NOT_IMPLEMENTED, + "the merged route has no Gloas shape until step 5: {body}", + ); +} + +#[tokio::test] +async fn a_fulu_request_is_still_validated() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut request = fixture.ssz_request(&built, true); + request.decoder_params = Some(params_for(helix_types::ForkName::Fulu)); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +#[test] +fn refusing_an_unsupported_fork_cannot_demote_a_builder() { + // `SimulatorClient::ssz_request` maps 400 to `BlockValidationFailed`, which + // demotes, and everything else to `RpcError`, which does not. Refusing a + // fork is helix's own limitation, so it must not cost a builder its + // optimistic status. + assert_ne!(StatusCode::NOT_IMPLEMENTED, StatusCode::BAD_REQUEST); + assert!(!helix_common::simulator::BlockSimError::RpcError.is_demotable()); +} diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index e32ee6bb..98c77596 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -12,25 +12,31 @@ use ethrex_blockchain::{ payload::{BuildPayloadArgs, create_payload}, }; use ethrex_common::{H256, types::ELASTICITY_MULTIPLIER}; +use ethrex_rlp::encode::RLPEncode; use ethrex_storage::Store; use helix_common::simulator::BlockSimError; use tokio::sync::watch; use crate::{ engine::convert::{ - b256, block_to_payload_v3, eaddr, eblobs, h256, payload_v3_to_block, requests_to_v4, + Amsterdam, b256, block_to_payload_v3, eaddr, eblobs, h256, payload_v3_to_block, + requests_to_v4, }, node::HeadInfo, testing::{ - ETH, GWEI, blob_bundle, deploy_balance_probe, deploy_payment_forwarder, - dev_genesis_store_with, funded_signers, signed_blob_transfer, signed_transfer, - signed_unprotected_transfer, + ETH, GWEI, blob_bundle, deploy_amsterdam_predeploys, deploy_balance_probe, + deploy_payment_forwarder, dev_genesis_store_with, funded_signers, signed_blob_transfer, + signed_transfer, signed_unprotected_transfer, }, validation::{BlockValidator, error::ValidationError}, }; const WINDOW: u64 = 3; +/// The proposal slot every fixture block is built for. Amsterdam puts it in the +/// header, so the bid trace and the header have to agree on it. +const SLOT: u64 = 1; + fn empty_bundle() -> ethrex_common::types::BlobsBundle { ethrex_common::types::BlobsBundle::default() } @@ -38,12 +44,24 @@ fn empty_bundle() -> ethrex_common::types::BlobsBundle { pub(crate) struct Built { payload: ExecutionPayloadV3, requests: ExecutionRequestsV4, + /// The encoded EIP-7928 list, as a Gloas submission would carry it. `None` + /// before Amsterdam, which is what every pre-Gloas test wants. + pub(crate) block_access_list: Option>, + slot: u64, } impl Built { fn block_hash(&self) -> B256 { self.payload.payload_inner.payload_inner.block_hash } + + pub(crate) fn amsterdam(&self) -> Option> { + self.block_access_list.as_deref().map(|block_access_list| Amsterdam { + block_access_list, + slot: self.slot, + ..Default::default() + }) + } } pub(crate) struct Fixture { @@ -57,6 +75,7 @@ pub(crate) struct Fixture { pub(crate) proposer: Address, head: watch::Sender, disallow: Arc>, + is_amsterdam: bool, } impl Fixture { @@ -64,6 +83,16 @@ impl Fixture { Self::with_genesis(|_| {}).await } + /// Amsterdam from genesis, so every block this fixture builds carries a + /// block access list and a slot number. + pub(crate) async fn amsterdam() -> Self { + Self::with_genesis(|genesis| { + genesis.config.amsterdam_time = Some(0); + deploy_amsterdam_predeploys(genesis); + }) + .await + } + pub(crate) async fn with_forwarder() -> Self { Self::with_genesis(deploy_payment_forwarder).await } @@ -118,6 +147,7 @@ impl Fixture { proposer: signers[3].address(), signers, disallow: Arc::new(DashSet::new()), + is_amsterdam: genesis.config.is_amsterdam_activated(genesis_block.header.timestamp), } } @@ -175,7 +205,7 @@ impl Fixture { random: H256::zero(), withdrawals: Some(withdrawals), beacon_root: Some(H256::zero()), - slot_number: None, + slot_number: self.is_amsterdam.then_some(SLOT), version: 3, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: self.gas_limit, @@ -190,6 +220,8 @@ impl Fixture { Built { payload: block_to_payload_v3(&built.payload), requests: requests_to_v4(&built.requests).unwrap(), + block_access_list: built.block_access_list.as_ref().map(|bal| bal.encode_to_vec()), + slot: SLOT, } } @@ -202,7 +234,7 @@ impl Fixture { let built = self.build_on(parent, timestamp, i as u64); let block = self .validator() - .to_block(&built.payload, B256::ZERO, &built.requests) + .to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) .expect("the fixture builds a convertible block"); parent = block.hash(); let number = block.header.number; @@ -268,10 +300,11 @@ impl Fixture { pub(crate) fn bid_trace(&self, built: &Built) -> BidTrace { let header = &built.payload.payload_inner.payload_inner; - let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests) - .expect("the fixture builds a convertible payload"); + let block = + payload_v3_to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("the fixture builds a convertible payload"); BidTrace { - slot: 1, + slot: built.slot, parent_hash: header.parent_hash, block_hash: b256(block.hash()), builder_pubkey: Default::default(), @@ -292,7 +325,7 @@ async fn a_valid_submission_prepares_its_block() { let prepared = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect("a block the fixture built must prepare"); assert_eq!(b256(prepared.block.hash()), built.block_hash()); @@ -308,7 +341,7 @@ async fn the_payload_round_trips_to_the_same_block_hash() { let block = fixture .validator() - .to_block(&built.payload, B256::ZERO, &built.requests) + .to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) .expect("a block the fixture built must convert"); assert_eq!(b256(block.hash()), built.block_hash()); @@ -323,7 +356,7 @@ async fn a_bid_trace_with_the_wrong_block_hash_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched block hash must be rejected"); assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); @@ -338,7 +371,7 @@ async fn a_bid_trace_with_the_wrong_parent_hash_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched parent hash must be rejected"); assert!(matches!(error, ValidationError::ParentHashMismatch { .. }), "{error}"); @@ -353,7 +386,7 @@ async fn a_bid_trace_with_the_wrong_gas_limit_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched gas limit must be rejected"); assert!(matches!(error, ValidationError::GasLimitMismatch { .. }), "{error}"); @@ -368,7 +401,7 @@ async fn a_bid_trace_with_the_wrong_gas_used_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched gas used must be rejected"); assert!(matches!(error, ValidationError::GasUsedMismatch { .. }), "{error}"); @@ -385,7 +418,7 @@ async fn a_tampered_payload_fails_the_block_hash_check() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("an edited payload must be rejected"); assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); @@ -400,7 +433,7 @@ async fn an_undecodable_transaction_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("an undecodable transaction must be rejected"); assert!(matches!(error, ValidationError::DecodeTransaction(_)), "{error}"); @@ -414,12 +447,17 @@ async fn an_unknown_parent_is_rejected() { let mut payload = built.payload.clone(); payload.payload_inner.payload_inner.parent_hash = B256::repeat_byte(0xcc); message.parent_hash = B256::repeat_byte(0xcc); - message.block_hash = - b256(fixture.validator().to_block(&payload, B256::ZERO, &built.requests).unwrap().hash()); + message.block_hash = b256( + fixture + .validator() + .to_block(&payload, B256::ZERO, &built.requests, built.amsterdam()) + .unwrap() + .hash(), + ); let error = fixture .validator() - .prepare(&payload, &message, B256::ZERO, &built.requests) + .prepare(&payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("an unknown parent must be rejected"); assert!(matches!(error, ValidationError::MissingParentBlock), "{error}"); @@ -435,7 +473,7 @@ async fn a_parent_inside_the_validation_window_is_accepted() { fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect("a block built on the head must prepare"); } @@ -450,7 +488,7 @@ async fn a_parent_outside_the_validation_window_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a parent outside the window must be rejected"); assert!(matches!(error, ValidationError::BlockTooOld), "{error}"); @@ -489,7 +527,15 @@ async fn a_valid_block_passes_execution() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a block the fixture built must validate"); assert_eq!(executed.receipts.len(), 1); @@ -506,7 +552,15 @@ async fn validating_a_block_does_not_store_it() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a block the fixture built must validate"); assert_eq!(fixture.store.get_latest_block_number().unwrap(), 0); @@ -525,7 +579,15 @@ async fn a_tampered_state_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong state root must be rejected"); assert!(matches!(error, ValidationError::StateRootMismatch { .. }), "{error}"); @@ -540,7 +602,15 @@ async fn a_tampered_gas_used_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong gas used must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -555,7 +625,15 @@ async fn a_tampered_receipts_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong receipts root must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -568,7 +646,7 @@ async fn execution_requests_that_the_block_did_not_produce_are_rejected() { let fixture = Fixture::new().await; let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); let requests = withdrawal_request(); - let tampered = Built { payload: built.payload.clone(), requests }; + let tampered = Built { payload: built.payload.clone(), requests, ..built }; let message = fixture.bid_trace(&tampered); let error = fixture @@ -580,6 +658,7 @@ async fn execution_requests_that_the_block_did_not_produce_are_rejected() { &tampered.requests, &empty_bundle(), false, + tampered.amsterdam(), ) .expect_err("unproduced requests must be rejected"); @@ -597,7 +676,15 @@ async fn a_tampered_base_fee_is_rejected_before_execution() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong base fee must be rejected"); assert!(matches!(error, ValidationError::PreExecution(_)), "{error}"); @@ -621,7 +708,15 @@ async fn a_block_with_an_unexecutable_transaction_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("an unexecutable transaction must be rejected"); assert!(matches!(error, ValidationError::Execution(_)), "{error}"); @@ -660,7 +755,15 @@ async fn a_payment_by_balance_delta_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a balance delta covering the bid must be accepted"); } @@ -687,7 +790,15 @@ async fn a_trailing_direct_transfer_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a trailing direct transfer must be accepted"); } @@ -714,7 +825,15 @@ async fn an_underpaid_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("paying less than the bid must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -745,7 +864,15 @@ async fn a_payment_tx_with_a_priority_fee_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a tipping payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -768,7 +895,15 @@ async fn an_unprotected_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("an unprotected payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -797,7 +932,15 @@ async fn a_withdrawal_does_not_pay_the_bid() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a withdrawal must not count as the bid payment"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -830,7 +973,15 @@ async fn a_payment_through_the_forwarder_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a forwarder payment must be accepted where the forwarder is deployed"); } @@ -857,7 +1008,15 @@ async fn a_forwarder_payment_is_rejected_where_the_forwarder_is_absent() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("an undeployed forwarder must not be trusted"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -886,7 +1045,15 @@ async fn a_reverted_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a reverted payment must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -945,6 +1112,7 @@ async fn a_merged_payment_split_across_two_txs_is_accepted() { &empty_bundle(), false, 1, + built.amsterdam(), ) .expect("both payment positions must count"); } @@ -1001,6 +1169,7 @@ async fn a_merged_payment_with_a_wrong_base_index_is_rejected() { &empty_bundle(), false, 2, + built.amsterdam(), ) .expect_err("a wrong base payment index must fail closed"); @@ -1042,7 +1211,15 @@ async fn a_split_payment_is_not_accepted_on_the_regular_path() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("the regular path must not sum two positions"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -1071,7 +1248,15 @@ async fn a_blacklisted_sender_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed sender must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1095,7 +1280,15 @@ async fn a_blacklisted_recipient_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed recipient must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1121,7 +1314,15 @@ async fn a_blacklisted_coinbase_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed coinbase must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1136,7 +1337,15 @@ async fn a_blacklisted_proposer_fee_recipient_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed fee recipient must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1160,7 +1369,15 @@ async fn a_blacklisted_internal_value_target_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed internal value target must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1178,7 +1395,15 @@ async fn a_blacklisted_created_account_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed created account must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1200,7 +1425,15 @@ async fn an_account_that_is_only_read_is_not_blacklisted() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect("a read alone must not reject the block"); assert!(executed.receipts[0].succeeded, "the probe must have run for this to prove anything"); @@ -1214,7 +1447,15 @@ async fn a_block_touching_no_listed_account_passes() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect("a block touching nothing listed must pass"); } @@ -1239,7 +1480,15 @@ async fn a_non_filtering_proposer_bypasses_the_blacklist() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("apply_blacklist = false must skip the check"); } @@ -1264,7 +1513,15 @@ async fn a_block_with_a_valid_blobs_bundle_passes() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect("a valid blobs bundle must pass"); assert!( @@ -1299,7 +1556,15 @@ async fn a_bundle_whose_commitments_do_not_match_the_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("commitments not matching the block's hashes must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1313,7 +1578,15 @@ async fn a_bundle_with_a_wrong_proof_count_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("a short proof list must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1327,7 +1600,15 @@ async fn a_bundle_with_an_invalid_cell_proof_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("a corrupt cell proof must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1341,7 +1622,15 @@ async fn a_bundle_carrying_more_blobs_than_the_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("a bundle with a spare blob must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1354,7 +1643,15 @@ async fn a_blob_tx_with_an_empty_bundle_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a blob tx without its blobs must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1369,7 +1666,15 @@ async fn a_bundle_for_a_block_with_no_blob_txs_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &blob_bundle(1), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &blob_bundle(1), + false, + built.amsterdam(), + ) .expect_err("a bundle for a blobless block must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1426,6 +1731,34 @@ impl Fixture { ssz::Encode::as_ssz_bytes(&submission) } + /// The request shape the relay sends for a Gloas submission: the Gloas + /// wire shape, named by its decoder params, with the list inside. + pub(crate) fn gloas_ssz_request( + &self, + built: &Built, + ) -> helix_common::simulator::SszValidationRequest { + let submission: helix_types::SignedBidSubmission = + self.submission(built).try_into().expect("the fixture builds a convertible submission"); + let bal = built.block_access_list.clone().expect("a Gloas request carries a list"); + let gloas = helix_types::SignedBidSubmissionGloas::join( + submission, + helix_types::GloasSubmissionData { + block_access_list: helix_types::BlockAccessListBytes(bal.into()), + ..Default::default() + }, + ); + helix_common::simulator::SszValidationRequest { + apply_blacklist: false, + registered_gas_limit: 0, + parent_beacon_block_root: B256::ZERO, + inclusion_list: Default::default(), + decoder_params: Some(helix_common::decoder::SubmissionDecoderParams::plain( + helix_types::ForkName::Gloas, + )), + signed_bid_submission: ssz::Encode::as_ssz_bytes(&gloas), + } + } + pub(crate) fn ssz_request( &self, built: &Built, @@ -1445,3 +1778,191 @@ impl Fixture { } } } + +/// Proves the fixture really is on Amsterdam: without the fork the tests below +/// would silently pass under Fulu rules. +#[tokio::test] +async fn an_amsterdam_fixture_builds_a_block_with_a_block_access_list() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + let bal = built.block_access_list.as_deref().expect("Amsterdam builds a block access list"); + assert!(!bal.is_empty(), "even an idle block touches accounts"); + + let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("the fixture builds a convertible payload"); + assert_eq!(block.header.slot_number, Some(SLOT)); + assert_eq!(block.header.block_access_list_hash, Some(ethrex_common::utils::keccak(bal))); +} + +#[tokio::test] +async fn an_amsterdam_payload_round_trips_to_the_same_block_hash() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + let block = fixture + .validator() + .to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("a block the fixture built must convert"); + + assert_eq!(b256(block.hash()), built.block_hash()); +} + +#[tokio::test] +async fn a_valid_amsterdam_block_is_accepted() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect("an Amsterdam block the fixture built must validate"); +} + +/// The block hash covers the list's hash, so changing a byte breaks the +/// commitment the builder signed. +#[tokio::test] +async fn a_tampered_block_access_list_is_rejected() { + let fixture = Fixture::amsterdam().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + built.block_access_list.as_mut().expect("Amsterdam builds one")[0] ^= 0xff; + + let error = fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect_err("a changed block access list must be rejected"); + + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); +} + +/// The sharper case: a well-formed list, committed to consistently, that is not +/// the list execution produces. Only re-running the block catches this. +#[tokio::test] +async fn a_block_access_list_that_contradicts_execution_is_rejected() { + let fixture = Fixture::amsterdam().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + // Another block's list: valid bytes, wrong block. + let other = fixture.build_block( + fixture.genesis_hash, + fixture.genesis_timestamp + 12, + vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x77), + U256::from(GWEI), + 100 * GWEI, + 0, + )], + Vec::new(), + ); + built.block_access_list = other.block_access_list; + assert!(built.block_access_list.is_some(), "the substitute list must exist"); + + // Commit to the substituted list, so the hash checks all pass and only + // execution can tell the difference. + let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("the substituted list still converts"); + let mut message = fixture.bid_trace(&built); + message.block_hash = b256(block.hash()); + + let error = fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect_err("a list execution did not produce must be rejected"); + + let ValidationError::PostExecution(reason) = &error else { + panic!("{error}"); + }; + assert!(reason.to_lowercase().contains("access list"), "rejected for another reason: {reason}"); +} + +#[tokio::test] +async fn an_empty_block_access_list_is_rejected() { + let fixture = Fixture::amsterdam().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + built.block_access_list = Some(Vec::new()); + + let error = fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect_err("an empty block access list must be rejected"); + + assert!(matches!(error, ValidationError::EmptyBlockAccessList), "{error}"); +} + +/// A Gloas submission whose payload predates Amsterdam, and the reverse. The +/// header fields are fork-gated, so ethrex's pre-execution checks catch both +/// without a fork check of our own. +#[tokio::test] +async fn a_fork_and_payload_that_disagree_are_rejected() { + let amsterdam = Fixture::amsterdam().await; + let built = amsterdam.build_on(amsterdam.genesis_hash, amsterdam.genesis_timestamp + 12, 0); + let message = amsterdam.bid_trace(&built); + let error = amsterdam + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + None, + ) + .expect_err("an Amsterdam block with no list must be rejected"); + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); + + let fulu = Fixture::new().await; + let built = fulu.build_on(fulu.genesis_hash, fulu.genesis_timestamp + 12, 0); + let message = fulu.bid_trace(&built); + let error = fulu + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + Some(Amsterdam { block_access_list: &[0xc0], slot: SLOT, ..Default::default() }), + ) + .expect_err("a Fulu block carrying Amsterdam fields must be rejected"); + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); +} diff --git a/crates/common/src/beacon/beacon_client.rs b/crates/common/src/beacon/beacon_client.rs index cd014580..17dce6f9 100644 --- a/crates/common/src/beacon/beacon_client.rs +++ b/crates/common/src/beacon/beacon_client.rs @@ -3,8 +3,8 @@ use std::{sync::Arc, task::Poll, time::Duration}; use ::ssz::Encode; use alloy_primitives::B256; use helix_types::{ - ForkName, LhConfig, SignedExecutionPayloadEnvelopeContents, VersionedSignedProposal, - spec_from_config, + ForkName, LhConfig, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelopeContents, + VersionedSignedProposal, spec_from_config, }; use http::{Request, header::CONTENT_TYPE}; use http_body_util::Full; @@ -117,6 +117,41 @@ impl BeaconClient { } } + /// Publishes a Gloas `SignedBeaconBlock` SSZ-encoded. Post-Gloas the block carries no + /// blob sidecars, so the body is the bare block rather than `SignedBlockContents`. + /// Sending it here gives our own node the block root before the reveal needs it. + pub async fn publish_gloas_block( + &self, + block: Arc, + ) -> Result { + let target = self.config.url.join("eth/v2/beacon/blocks")?; + let body_bytes = Bytes::from(block.as_ssz_bytes()); + let req = Request::builder() + .method("POST") + .uri(target.as_str()) + .header(CONSENSUS_VERSION_HEADER, ForkName::Gloas.to_string()) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Full::new(body_bytes))?; + let mut pending = self.http.send(&target, req)?.with_timeout(PUBLISH_BLOCK_TIMEOUT); + + let (status, body) = loop { + match pending.poll_bytes() { + Poll::Pending => {} + Poll::Ready(Ok(r)) => break r, + Poll::Ready(Err(e)) => return Err(e.into()), + } + tokio::task::yield_now().await; + }; + + match status { + 200 | 202 => Ok(status), + _ => { + let api_err: ApiError = serde_json::from_slice(&body)?; + Err(BeaconClientError::Api(api_err)) + } + } + } + /// Publishes a signed execution payload envelope SSZ-encoded, so a connected beacon node /// broadcasts it to the `execution_payload` gossip topic on helix's behalf. /// @@ -125,7 +160,13 @@ impl BeaconClient { envelope: Arc, fork: ForkName, ) -> Result { - let target = self.config.url.join("eth/v1/beacon/execution_payload_envelopes")?; + let mut target = self.config.url.join("eth/v1/beacon/execution_payload_envelopes")?; + // Fail closed on an equivocating proposer: the node refuses to broadcast a + // payload for a block it has seen a competing version of. + target.query_pairs_mut().append_pair( + "broadcast_validation", + &BroadcastValidation::ConsensusAndEquivocation.to_string(), + ); let body_bytes = Bytes::from(envelope.as_ssz_bytes()); let req = Request::builder() .method("POST") @@ -222,6 +263,22 @@ mod tests { assert_eq!(result.unwrap(), 200); } + #[tokio::test] + async fn publish_execution_payload_envelope_asks_for_equivocation_validation() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/eth/v1/beacon/execution_payload_envelopes") + .query_param("broadcast_validation", "consensus_and_equivocation"); + then.status(200); + }); + + let client = test_client(Url::parse(&server.url("/")).unwrap()); + client.publish_execution_payload_envelope(empty_envelope(), ForkName::Gloas).await.unwrap(); + + mock.assert(); + } + #[tokio::test] async fn publish_execution_payload_envelope_202_is_ok() { let server = MockServer::start(); diff --git a/crates/common/src/beacon/multi_beacon_client.rs b/crates/common/src/beacon/multi_beacon_client.rs index aaf66017..2cccc6b4 100644 --- a/crates/common/src/beacon/multi_beacon_client.rs +++ b/crates/common/src/beacon/multi_beacon_client.rs @@ -4,7 +4,10 @@ use std::sync::{ }; use futures::future::join_all; -use helix_types::{ForkName, SignedExecutionPayloadEnvelopeContents, VersionedSignedProposal}; +use helix_types::{ + ForkName, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelopeContents, + VersionedSignedProposal, +}; use crate::{ beacon::{beacon_client::BeaconClient, error::BeaconClientError, types::BroadcastValidation}, @@ -84,6 +87,27 @@ impl MultiBeaconClient { Err(last_error.unwrap_or(BeaconClientError::BeaconNodeUnavailable)) } + /// Publishes a Gloas beacon block to all beacon clients; returns on first success. + /// The proposer already gossips its own block, so a failure here is not fatal: it only + /// costs us the head start on the block root that the reveal needs. + pub async fn publish_gloas_block( + &self, + block: Arc, + ) -> Result<(), BeaconClientError> { + let futures = + self.beacon_clients.iter().map(|client| client.publish_gloas_block(block.clone())); + + let mut last_error: Option = None; + for res in join_all(futures).await { + match res { + Ok(_) => return Ok(()), + Err(err) => last_error = Some(err), + } + } + + Err(last_error.unwrap_or(BeaconClientError::BeaconNodeUnavailable)) + } + /// Publishes the signed execution payload envelope to all beacon clients; returns on first /// success. Unlike `publish_block`, fans out via plain concurrent futures, not /// `spawn_tracked!`. diff --git a/crates/common/src/beacon/types/chain.rs b/crates/common/src/beacon/types/chain.rs index b68fbe24..3b4e8b11 100644 --- a/crates/common/src/beacon/types/chain.rs +++ b/crates/common/src/beacon/types/chain.rs @@ -1,6 +1,12 @@ -use alloy_primitives::{B256, hex}; -use helix_types::{Slot, Withdrawals}; +use alloy_primitives::{Address, B256, hex}; +use helix_types::{ + BlsPublicKey, BlsPublicKeyBytes, BlsSignature, BlsSignatureBytes, Domain, EthSpec, + MainnetEthSpec, SignedRoot, Slot, Withdrawals, +}; use serde::{Deserialize, Serialize}; +use tree_hash_derive::TreeHash; + +use crate::chain_info::ChainInfo; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum StateId { @@ -70,6 +76,55 @@ pub struct HeadEventData { pub state: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProposerPreferencesEvent { + pub version: String, + pub data: SignedProposerPreferences, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SignedProposerPreferences { + pub message: ProposerPreferences, + pub signature: BlsSignatureBytes, +} + +impl SignedRoot for ProposerPreferences {} + +impl SignedProposerPreferences { + /// Whether `pubkey` signed these preferences. The fee recipient a builder pays + /// comes from here, so an unsigned message would let anyone redirect it. + pub fn verify(&self, pubkey: &BlsPublicKeyBytes, chain_info: &ChainInfo) -> bool { + let epoch = self.message.proposal_slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::ProposerPreferences, + &fork, + chain_info.genesis_validators_root, + ); + let Ok(pubkey) = BlsPublicKey::deserialize(pubkey.as_ref()) else { + return false; + }; + let Ok(signature) = BlsSignature::deserialize(self.signature.as_ref()) else { + return false; + }; + signature.verify(&pubkey, self.message.signing_root(domain)) + } +} + +/// Gossiped once per proposal slot, per +/// . +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, TreeHash)] +pub struct ProposerPreferences { + pub dependent_root: B256, + pub proposal_slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] + pub validator_index: u64, + pub fee_recipient: Address, + #[serde(with = "serde_utils::quoted_u64")] + pub target_gas_limit: u64, +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct PayloadAttributesEvent { pub version: String, @@ -81,13 +136,29 @@ pub struct PayloadAttributesEventData { #[serde(with = "serde_utils::quoted_u64")] pub proposer_index: u64, pub proposal_slot: Slot, - #[serde(with = "serde_utils::quoted_u64")] - pub parent_block_number: u64, + /// Absent from Gloas, which no longer carries it. + #[serde(default, with = "quoted_u64_opt")] + pub parent_block_number: Option, pub parent_block_root: String, pub parent_block_hash: B256, pub payload_attributes: PayloadAttributes, } +mod quoted_u64_opt { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use serde_utils::quoted_u64::Quoted; + + pub fn serialize(value: &Option, serializer: S) -> Result { + value.map(|value| Quoted { value }).serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Ok(Option::>::deserialize(deserializer)?.map(|quoted| quoted.value)) + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct PayloadAttributes { #[serde(with = "serde_utils::quoted_u64")] @@ -97,3 +168,79 @@ pub struct PayloadAttributes { pub withdrawals: Withdrawals, pub parent_beacon_block_root: Option, } + +#[cfg(test)] +mod proposer_preferences_signature_tests { + use helix_types::BlsKeypair; + + use super::*; + + fn prefs(slot: u64) -> ProposerPreferences { + ProposerPreferences { + dependent_root: B256::repeat_byte(0x11), + proposal_slot: Slot::new(slot), + validator_index: 42, + fee_recipient: Address::repeat_byte(0x22), + target_gas_limit: 45_000_000, + } + } + + fn sign( + message: &ProposerPreferences, + keypair: &BlsKeypair, + chain_info: &ChainInfo, + ) -> BlsSignatureBytes { + let epoch = message.proposal_slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::ProposerPreferences, + &fork, + chain_info.genesis_validators_root, + ); + keypair.sk.sign(message.signing_root(domain)).serialize().into() + } + + #[test] + fn the_proposers_own_signature_verifies() { + crate::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let keypair = BlsKeypair::random(); + let message = prefs(100); + let signed = + SignedProposerPreferences { signature: sign(&message, &keypair, &chain_info), message }; + + assert!(signed.verify(&keypair.pk.serialize().into(), &chain_info)); + } + + /// The whole point: preferences signed by someone else must not be accepted for + /// this proposer, or the fee recipient the builder pays can be redirected. + #[test] + fn another_keys_signature_is_refused() { + crate::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let proposer = BlsKeypair::random(); + let attacker = BlsKeypair::random(); + let message = prefs(100); + let signed = SignedProposerPreferences { + signature: sign(&message, &attacker, &chain_info), + message, + }; + + assert!(!signed.verify(&proposer.pk.serialize().into(), &chain_info)); + } + + #[test] + fn a_tampered_fee_recipient_is_refused() { + crate::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let keypair = BlsKeypair::random(); + let message = prefs(100); + let signature = sign(&message, &keypair, &chain_info); + let mut tampered = message; + tampered.fee_recipient = Address::repeat_byte(0xff); + let signed = SignedProposerPreferences { message: tampered, signature }; + + assert!(!signed.verify(&keypair.pk.serialize().into(), &chain_info)); + } +} diff --git a/crates/common/src/chain_info.rs b/crates/common/src/chain_info.rs index e3234a5a..a2165267 100644 --- a/crates/common/src/chain_info.rs +++ b/crates/common/src/chain_info.rs @@ -11,6 +11,10 @@ pub(crate) const MAINNET_GENESIS_VALIDATOR_ROOT: [u8; 32] = [ 243, 63, 246, 207, 90, 210, 127, 81, 27, 254, 149, ]; +/// `ATTESTATION_DUE_BPS_GLOAS` from the Gloas preset: attestations are due a quarter +/// of the way into the slot. +const ATTESTATION_DUE_BPS_GLOAS: u32 = 2500; + /// Runtime config with all chain specific information #[derive(Clone)] pub struct ChainInfo { @@ -75,6 +79,24 @@ impl ChainInfo { self.clock.now().unwrap_or(Slot::new(0)) } + /// Unix time at which `slot` starts. + pub fn slot_start(&self, slot: Slot) -> Duration { + Duration::from_secs(self.genesis_time_in_secs + slot.as_u64() * self.seconds_per_slot()) + } + + /// Gloas divides the slot in quarters: attestations are due at 25%, the payload + /// reveal at 50%. + pub fn gloas_attestation_deadline(&self) -> Duration { + self.spec.get_slot_duration() * ATTESTATION_DUE_BPS_GLOAS / 10_000 + } + + /// How long to hold a Gloas payload before revealing it. Revealing before the + /// attestation deadline lets an equivocating proposer build a competing block on + /// the payload while the honest block still has no attestations behind it. + pub fn gloas_reveal_delay(&self, slot: Slot, now: Duration) -> Duration { + (self.slot_start(slot) + self.gloas_attestation_deadline()).saturating_sub(now) + } + pub fn max_blobs_per_block(&self) -> usize { let epoch = self.current_slot().epoch(self.slots_per_epoch()); self.spec.max_blobs_per_block(epoch) as usize @@ -87,3 +109,42 @@ impl Default for ChainInfo { Self::new(spec, MAINNET_GENESIS_VALIDATOR_ROOT.into(), MAINNET_GENESIS_TIME) } } + +#[cfg(test)] +mod gloas_reveal_tests { + use super::*; + + fn chain_info() -> ChainInfo { + ChainInfo::default() + } + + #[test] + fn the_deadline_is_a_quarter_of_the_slot() { + let info = chain_info(); + assert_eq!(info.gloas_attestation_deadline(), Duration::from_secs(3)); + } + + #[test] + fn a_reveal_asked_for_at_the_slot_start_waits_for_the_deadline() { + let info = chain_info(); + let slot = Slot::new(1_000); + let delay = info.gloas_reveal_delay(slot, info.slot_start(slot)); + assert_eq!(delay, Duration::from_secs(3)); + } + + #[test] + fn a_reveal_asked_for_after_the_deadline_does_not_wait() { + let info = chain_info(); + let slot = Slot::new(1_000); + let now = info.slot_start(slot) + Duration::from_secs(5); + assert_eq!(info.gloas_reveal_delay(slot, now), Duration::ZERO); + } + + #[test] + fn the_wait_never_runs_past_the_payload_deadline() { + let info = chain_info(); + let slot = Slot::new(1_000); + let delay = info.gloas_reveal_delay(slot, info.slot_start(slot)); + assert!(delay < info.spec.get_slot_duration() / 2); + } +} diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 054b31c1..6d8854f7 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -1023,3 +1023,40 @@ mod tests { assert!(!config.treat_as_append_only(other)); } } + +#[cfg(test)] +mod logging_config_tests { + use super::*; + + /// A config the relay cannot parse crash-loops it on start, so the exact YAML + /// shape the deployed file uses has to be pinned here. + #[test] + fn the_file_logging_shape_parses() { + let yaml = " +type: File +dir_path: /app/logs +file_name: titan_relay.log +otlp_server: null +"; + let parsed: LoggingConfig = serde_yaml::from_str(yaml).expect("deployed shape must parse"); + + match parsed { + LoggingConfig::File { dir_path, file_name, otlp_server } => { + assert_eq!(dir_path, PathBuf::from("/app/logs")); + assert_eq!(file_name, "titan_relay.log"); + assert!(otlp_server.is_none()); + } + LoggingConfig::Console => panic!("parsed as Console"), + } + } + + /// `config.example.yml` carries the externally tagged `!File` form, which this + /// internally tagged enum cannot read. Pinned so the example gets fixed rather + /// than copied into a deployment. + #[test] + fn the_externally_tagged_shape_does_not_parse() { + let yaml = "!File\ndir_path: /app/logs\nfile_name: titan_relay.log\notlp_server: null\n"; + + assert!(serde_yaml::from_str::(yaml).is_err()); + } +} diff --git a/crates/common/src/decoder.rs b/crates/common/src/decoder.rs index 45e17dd0..8824f037 100644 --- a/crates/common/src/decoder.rs +++ b/crates/common/src/decoder.rs @@ -12,8 +12,8 @@ use helix_types::{ DehydratedBidSubmissionFulu, DehydratedBidSubmissionFuluV1, DehydratedBidSubmissionFuluWithAdjustments, DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, - DehydratedBidSubmissionFuluWithMergingData, ForkName, ForkVersionDecode, MergeType, - SignedBidSubmission, SignedBidSubmissionWithAdjustments, + DehydratedBidSubmissionFuluWithMergingData, ForkName, ForkVersionDecode, GloasSubmissionData, + MergeType, SignedBidSubmission, SignedBidSubmissionGloas, SignedBidSubmissionWithAdjustments, SignedBidSubmissionWithAdjustmentsAndMergingData, SignedBidSubmissionWithMergingData, Submission, WithAdjustments, WithAdjustmentsAndMergingData, WithMergingData, }; @@ -41,6 +41,15 @@ use crate::{ }, }; +/// What one submission decodes into: the submission itself plus the sidecars +/// only some forks and headers carry. +pub type DecodedParts = ( + Submission, + Option, + Option, + Option, +); + #[derive(Debug, thiserror::Error)] pub enum DecoderError { #[error("json decode error: {0}")] @@ -57,6 +66,9 @@ pub enum DecoderError { #[error("v2 shapes are SSZ over TCP; merging v2 requires Mergeable")] V2Unsupported, + + #[error("unsupported combination: {0}")] + UnsupportedCombination(&'static str), } impl IntoResponse for DecoderError { @@ -84,7 +96,8 @@ impl DecoderError { DecoderError::SszDecode(_) | DecoderError::IOError(_) | DecoderError::PayloadDecode | - DecoderError::V2Unsupported => StatusCode::BAD_REQUEST, + DecoderError::V2Unsupported | + DecoderError::UnsupportedCombination(_) => StatusCode::BAD_REQUEST, } } } @@ -185,6 +198,25 @@ pub struct SubmissionDecoderParams { pub fork_name: ForkName, } +impl SubmissionDecoderParams { + /// Uncompressed SSZ bytes of the fork's plain submission shape, with no + /// sidecars. This is what the relay re-encodes for an SSZ simulator. + pub fn plain(fork_name: ForkName) -> Self { + Self { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::None, + is_dehydrated: false, + with_mergeable_data: false, + with_adjustments: false, + mark_all_txs_mergeable: false, + dehydrated_v2: false, + merging_v2: false, + fork_name, + } + } +} + #[derive(Debug)] pub struct SubmissionDecoder { compression: Compression, @@ -275,8 +307,7 @@ impl SubmissionDecoder { &mut self, payload: &[u8], buf: &mut Vec, - ) -> Result<(Submission, Option, Option), DecoderError> - { + ) -> Result { let body: &[u8] = match self.decompress(payload, buf) { None => payload, Some(Ok(())) => buf, @@ -298,11 +329,7 @@ impl SubmissionDecoder { /// header flags. `dehydrated_v2` selects the v2 submission shape, /// `merging_v2` the v2 merging shape; each pairs with the other's v1. #[timed] - fn decode_v2( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { + fn decode_v2(&mut self, body: &[u8]) -> Result { let mergeable = self.merge_type == MergeType::Mergeable; if !matches!(self.encoding, Encoding::Ssz) || self.fork_name != ForkName::Fulu || @@ -340,7 +367,7 @@ impl SubmissionDecoder { } MergeType::None | MergeType::Pause => None, }; - Ok((submission, merging_data, adjustments)) + Ok((submission, merging_data, adjustments, None)) } fn v2_parts( @@ -379,11 +406,13 @@ impl SubmissionDecoder { } #[timed] - fn decode_dehydrated( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { + fn decode_dehydrated(&mut self, body: &[u8]) -> Result { + // Every shape below is Fulu's, which has no block access list. Decoding + // a Gloas submission into one would drop it and simulate a block the + // builder never committed to. + if self.fork_name == ForkName::Gloas { + return Err(DecoderError::UnsupportedCombination("dehydrated Gloas")); + } if self.merge_type == MergeType::Mergeable { if self.with_adjustments { let sub: DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData = @@ -394,6 +423,7 @@ impl SubmissionDecoder { Submission::Dehydrated(submission), Some(merging_data.into()), Some(adjustment_data), + None, )); } @@ -401,7 +431,7 @@ impl SubmissionDecoder { self.decode_by_fork(body, self.fork_name)?; let (submission, merging_data) = sub_with_merging.split(); - return Ok((Submission::Dehydrated(submission), Some(merging_data.into()), None)); + return Ok((Submission::Dehydrated(submission), Some(merging_data.into()), None, None)); } let (submission, bid_adjustment) = if self.with_adjustments { @@ -434,15 +464,16 @@ impl SubmissionDecoder { MergeType::Pause => None, }; - Ok((Submission::Dehydrated(submission), merging_data, bid_adjustment)) + Ok((Submission::Dehydrated(submission), merging_data, bid_adjustment, None)) } #[timed] - fn decode_merge( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { + fn decode_merge(&mut self, body: &[u8]) -> Result { + // Gloas merging data comes with the merge builder's own step; until then + // these Fulu shapes would drop the block access list. + if self.fork_name == ForkName::Gloas { + return Err(DecoderError::UnsupportedCombination("Gloas with merging data")); + } let decoded = if self.with_adjustments { self._decode::(body).map(|sub| { let (submission, adjustment_data, merging_data) = sub.split(); @@ -474,24 +505,33 @@ impl SubmissionDecoder { MergeType::None => Some(merging_data), MergeType::Pause => None, }; - Ok((Submission::Full(submission), merging_data.map(Into::into), bid_adjustment)) + Ok((Submission::Full(submission), merging_data.map(Into::into), bid_adjustment, None)) } #[timed] - fn decode_default( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { - let (submission, bid_adjustment) = if self.with_adjustments { + fn decode_default(&mut self, body: &[u8]) -> Result { + let is_gloas = self.fork_name == ForkName::Gloas; + let (submission, bid_adjustment, gloas_data) = if self.with_adjustments { + if is_gloas { + // Refused rather than decoded into the wrong shape. Adjustments + // are a BuilderNet feature and Gloas does not need them yet. + return Err(DecoderError::UnsupportedCombination("Gloas with bid adjustments")); + } let sub_with_adjustment: SignedBidSubmissionWithAdjustments = self._decode(body)?; let (sub, adjustment_data) = sub_with_adjustment.split(); - (sub, Some(adjustment_data)) + (sub, Some(adjustment_data), None) + } else if is_gloas { + // Gloas carries the builder's EIP-7928 block access list and its + // EIP-8282 builder deposit and exit requests. + let gloas: SignedBidSubmissionGloas = self._decode(body)?; + let (submission, gloas_data) = gloas.split(); + + (submission, None, Some(gloas_data)) } else { let submission: SignedBidSubmission = self._decode(body)?; - (submission, None) + (submission, None, None) }; let merging_data = match self.merge_type { @@ -515,7 +555,7 @@ impl SubmissionDecoder { } MergeType::Pause => None, }; - Ok((Submission::Full(submission), merging_data, bid_adjustment)) + Ok((Submission::Full(submission), merging_data, bid_adjustment, gloas_data)) } // TODO: pass a buffer pool to avoid allocations @@ -621,7 +661,7 @@ mod tests { BidAdjData, BidAdjustmentDataV1, BlobsBundle, BundleOrder, DehydratedBidSubmissionFuluV1, DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, DehydratedBidSubmissionFuluWithMergingData, MergeType, Order, - SignedBidSubmissionWithAdjustmentsAndMergingData, TestRandom, + SignedBidSubmissionWithAdjustmentsAndMergingData, TestRandom, TestRandomSeed, }; use ssz::Encode; @@ -656,6 +696,88 @@ mod tests { assert_eq!(MergeType::AppendOnly.as_ref(), "append_only"); } + /// Plain SSZ params for `fork`, nothing else enabled. + #[test] + fn the_decoder_selects_the_gloas_shape_by_fork() { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + submission.block_access_list = helix_types::BlockAccessListBytes(vec![3u8; 32].into()); + let body = submission.as_ssz_bytes(); + + let params = SubmissionDecoderParams::plain(ForkName::Gloas); + let mut buf = Vec::new(); + let (_, _, _, block_access_list) = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect("a Gloas submission must decode"); + + assert_eq!( + block_access_list + .expect("Gloas carries a block access list") + .block_access_list + .to_vec(), + vec![3u8; 32], + ); + } + + #[test] + fn the_decoder_keeps_the_fulu_shape_for_fulu() { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + let body = submission.as_ssz_bytes(); + + let params = SubmissionDecoderParams::plain(ForkName::Fulu); + let mut buf = Vec::new(); + let (_, _, _, block_access_list) = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect("the Fulu shape must be unchanged"); + + assert!(block_access_list.is_none(), "only Gloas carries one"); + } + + #[test] + fn gloas_with_adjustments_is_refused() { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + let body = submission.as_ssz_bytes(); + + let mut params = SubmissionDecoderParams::plain(ForkName::Gloas); + params.with_adjustments = true; + let mut buf = Vec::new(); + let err = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect_err("the combination has no wire shape"); + + assert!( + matches!(err, DecoderError::UnsupportedCombination(_)), + "refused explicitly, not decoded into the wrong shape: {err}", + ); + } + + /// Both wire shapes below are Fulu's, with no room for a block access list. + #[test] + fn gloas_with_the_other_submission_types_is_refused() { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + let body = submission.as_ssz_bytes(); + + for adjust in [ + |params: &mut SubmissionDecoderParams| params.is_dehydrated = true, + |params: &mut SubmissionDecoderParams| params.with_mergeable_data = true, + ] { + let mut params = SubmissionDecoderParams::plain(ForkName::Gloas); + adjust(&mut params); + let mut buf = Vec::new(); + let err = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect_err("the combination has no wire shape"); + + assert!( + matches!(err, DecoderError::UnsupportedCombination(_)), + "refused explicitly, not decoded into a shape that drops the list: {err}", + ); + } + } + #[test] fn test_merge_type_deserialization() { assert_eq!("mergeable".parse::().unwrap(), MergeType::Mergeable); @@ -702,7 +824,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -736,7 +858,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -763,7 +885,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -808,7 +930,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -839,7 +961,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, decoded_merging_data, bid_adjustment) = + let (decoded_submission, decoded_merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -887,7 +1009,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, decoded_merging_data, _) = + let (decoded_submission, decoded_merging_data, _, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -918,7 +1040,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, _) = + let (decoded_submission, merging_data, _, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -958,7 +1080,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, _) = + let (decoded_submission, merging_data, _, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -1002,7 +1124,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -1030,7 +1152,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -1057,7 +1179,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -1087,7 +1209,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -1123,7 +1245,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); match decoded_submission { @@ -1179,7 +1301,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 7f2bc6c1..b4b3000e 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -30,7 +30,7 @@ pub use adjustments::*; pub use builder_info::*; pub use config::*; pub use proposer::*; -pub use slot_info::{CurrentSlotInfo, PayloadAttributesUpdate, SlotDuties}; +pub use slot_info::{CurrentSlotInfo, ForkKey, PayloadAttributesUpdate, SlotDuties}; pub use traces::*; pub use validator::*; pub use validator_preferences::*; diff --git a/crates/common/src/slot_info.rs b/crates/common/src/slot_info.rs index b02024bd..de023e8d 100644 --- a/crates/common/src/slot_info.rs +++ b/crates/common/src/slot_info.rs @@ -21,6 +21,22 @@ pub struct PayloadAttributesUpdate { pub payload_attributes: PayloadAttributes, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct ForkKey { + pub parent_hash: B256, + pub parent_root: B256, +} + +impl PayloadAttributesUpdate { + pub fn parent_root(&self) -> B256 { + self.payload_attributes.parent_beacon_block_root.unwrap_or_default() + } + + pub fn fork(&self) -> ForkKey { + ForkKey { parent_hash: self.parent_hash, parent_root: self.parent_root() } + } +} + impl Deref for PayloadAttributesUpdate { type Target = PayloadAttributes; diff --git a/crates/database/src/postgres/postgres_db_service.rs b/crates/database/src/postgres/postgres_db_service.rs index 10a6af43..148e9de8 100644 --- a/crates/database/src/postgres/postgres_db_service.rs +++ b/crates/database/src/postgres/postgres_db_service.rs @@ -1258,6 +1258,11 @@ impl PostgresDatabaseService { ) -> Result<(), DatabaseError> { let mut record = DbMetricRecord::new("set_proposer_duties"); + if proposer_duties.is_empty() { + record.record_success(); + return Ok(()); + } + let mut client = self.high_priority_pool.get().await?; let transaction = client.transaction().await?; diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index 155d863e..a83aff7e 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -5,7 +5,7 @@ use axum::{ }; use helix_common::{beacon::BeaconClientError, local_cache::AuctioneerError}; use helix_database::error::DatabaseError; -use helix_types::{SigError, Slot, SszError}; +use helix_types::{BlsPublicKeyBytes, SigError, Slot, SszError}; use hyper::StatusCode; use ssz::DecodeError; use thiserror::Error; @@ -27,6 +27,12 @@ pub enum ProposerApiError { #[error("not the expected proposer index. expected {expected}, got {actual}")] UnexpectedProposerIndex { expected: u64, actual: u64 }, + #[error("bid requested for slot {actual}, the relay is bidding for {expected}")] + BidRequestSlotMismatch { expected: u64, actual: u64 }, + + #[error("bid requested by {actual}, the proposer for the slot is {expected}")] + UnexpectedProposerPubkey { expected: BlsPublicKeyBytes, actual: BlsPublicKeyBytes }, + #[error("no validators could be registered")] NoValidatorsCouldBeRegistered, @@ -150,6 +156,15 @@ pub enum ProposerApiError { "bid builder_index {bid} does not match this relay's configured builder_index {configured}" )] BuilderIndexMismatch { bid: u64, configured: u64 }, + + #[error("proposer equivocated at slot {slot}: already committed to block {first:?}")] + ProposerEquivocated { slot: u64, first: B256 }, + + #[error("the block is not signed by the proposer this bid was served to")] + InvalidProposerSignature, + + #[error("the payload held for block {0:?} has no known proposer")] + UnknownBidProposer(B256), } impl ProposerApiError { @@ -185,6 +200,8 @@ impl IntoResponse for ProposerApiError { ProposerApiError::AxumError(_) | ProposerApiError::ToStrError(_) | ProposerApiError::UnexpectedProposerIndex { .. } | + ProposerApiError::BidRequestSlotMismatch { .. } | + ProposerApiError::UnexpectedProposerPubkey { .. } | ProposerApiError::NoValidatorsCouldBeRegistered | ProposerApiError::InvalidFork | ProposerApiError::SerdeDecodeError(_) | @@ -214,7 +231,10 @@ impl IntoResponse for ProposerApiError { ProposerApiError::MissingTimingHeaders | ProposerApiError::NoHeldPayloadForBlock(_) | ProposerApiError::HeldPayloadBlockHashMismatch { .. } | - ProposerApiError::BuilderIndexMismatch { .. } => StatusCode::BAD_REQUEST, + ProposerApiError::BuilderIndexMismatch { .. } | + ProposerApiError::ProposerEquivocated { .. } | + ProposerApiError::InvalidProposerSignature | + ProposerApiError::UnknownBidProposer(_) => StatusCode::BAD_REQUEST, // All authentication failures, kept indistinguishable by status ProposerApiError::InvalidApiKey | @@ -229,6 +249,13 @@ impl IntoResponse for ProposerApiError { ProposerApiError::InvalidGetHeader(_) => StatusCode::UNAUTHORIZED, }; + // A 204 carries no body, and no content-type either. Attaching the message + // leaves the response malformed, and clients then report it as whatever + // their stack makes of it. + if code == StatusCode::NO_CONTENT { + return code.into_response(); + } + (code, self.to_string()).into_response() } } @@ -251,3 +278,37 @@ mod tests { assert!(ProposerApiError::InternalServerError.should_report_gossiped()); } } + +#[cfg(test)] +mod response_shape_tests { + use axum::body::to_bytes; + + use super::*; + + /// RFC 9110: a 204 carries no body, and therefore no content-type. Sending one + /// leaves the response malformed, and what a client reports it as is then + /// anyone's guess. + #[tokio::test] + async fn no_bid_is_an_empty_204() { + let response = ProposerApiError::NoBidPrepared.into_response(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response.headers().get(http::header::CONTENT_TYPE), + None, + "a 204 must not declare a content type", + ); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(body.is_empty(), "a 204 must not carry a body, got: {body:?}"); + } + + /// Errors that do carry an explanation keep it. + #[tokio::test] + async fn an_error_status_keeps_its_message() { + let response = ProposerApiError::InvalidFork.into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/crates/relay/src/api/proposer/get_execution_payload_bid.rs b/crates/relay/src/api/proposer/get_execution_payload_bid.rs index 3f6eac5d..db3ff479 100644 --- a/crates/relay/src/api/proposer/get_execution_payload_bid.rs +++ b/crates/relay/src/api/proposer/get_execution_payload_bid.rs @@ -9,7 +9,7 @@ use helix_common::{ decoder::{Encoding, HEADER_SSZ}, utils::extract_request_id, }; -use helix_types::{ForkName, SignedBuilderRequestAuth}; +use helix_types::{ForkName, GetExecutionPayloadBidResponse, SignedBuilderRequestAuth}; use http::{HeaderValue, header::CONTENT_TYPE}; use ssz::{Decode, Encode}; use tracing::{info, warn}; @@ -59,8 +59,12 @@ impl ProposerApi { .verify_signature(¶ms.proposer_pubkey, proposer_api.chain_info.request_auth_domain) .map_err(|_| ProposerApiError::InvalidRequestAuthSignature)?; + let (auth_slot, auth_data_len, auth_data) = super::auth_summary(&signed_request_auth); info!( slot = params.slot, + auth_slot, + auth_data_len, + %auth_data, parent_hash = ?params.parent_hash, parent_root = ?params.parent_root, proposer_pubkey = ?params.proposer_pubkey, @@ -80,7 +84,14 @@ impl ProposerApi { }; match Encoding::from_accept(&headers) { - Encoding::Json => Ok(axum::Json(serde_json::to_value(&signed_bid)?).into_response()), + Encoding::Json => { + let versioned = GetExecutionPayloadBidResponse { + version: ForkName::Gloas, + metadata: Default::default(), + data: signed_bid, + }; + Ok(axum::Json(serde_json::to_value(&versioned)?).into_response()) + } Encoding::Ssz => { let mut response = signed_bid.as_ssz_bytes().into_response(); let headers = response.headers_mut(); @@ -94,3 +105,27 @@ impl ProposerApi { } } } + +#[cfg(test)] +mod tests { + use helix_types::{BlsSignature, ExecutionPayloadBid, SignedExecutionPayloadBid}; + + use super::*; + + #[test] + fn json_bid_carries_the_version_key() { + let versioned = GetExecutionPayloadBidResponse { + version: ForkName::Gloas, + metadata: Default::default(), + data: SignedExecutionPayloadBid { + message: ExecutionPayloadBid::default(), + signature: BlsSignature::empty(), + }, + }; + + let json = serde_json::to_value(&versioned).unwrap(); + + assert_eq!(json["version"], "gloas"); + assert!(json["data"]["message"].is_object()); + } +} diff --git a/crates/relay/src/api/proposer/mod.rs b/crates/relay/src/api/proposer/mod.rs index 19efc8d8..ba6a2899 100644 --- a/crates/relay/src/api/proposer/mod.rs +++ b/crates/relay/src/api/proposer/mod.rs @@ -5,10 +5,11 @@ pub(crate) mod get_payload; mod header_stream; mod ip_tracker; mod register; +mod reveal_guard; mod submit_builder_preferences; mod submit_signed_beacon_block; -use std::sync::{Arc, atomic::Ordering}; +use std::sync::{Arc, Mutex, atomic::Ordering}; use axum::{Extension, response::IntoResponse}; pub use error::*; @@ -20,10 +21,14 @@ use helix_common::{ use helix_database::handle::DbHandle; use helix_operator::OperatorPubSub; use hyper::StatusCode; -pub use submit_signed_beacon_block::{GloasBuilderIdentity, GloasPayloadStore}; +pub use submit_signed_beacon_block::{GloasBuilderIdentity, HeldGloasPayload}; use crate::{ - api::{Api, proposer::ip_tracker::IpTracker, router::Terminating}, + api::{ + Api, + proposer::{ip_tracker::IpTracker, reveal_guard::RevealGuard}, + router::Terminating, + }, auctioneer::AuctioneerHandle, gossip::GrpcGossiperClientManager, registration::RegWorkerHandle, @@ -48,7 +53,8 @@ pub struct ProposerApi { pub operator_api: Option>, pub ip_tracker: IpTracker, pub gloas_builder_identity: Arc, - pub gloas_payload_store: Arc, + /// Blocks helix has already committed to redeem, per slot. + pub reveal_guard: Arc>, } impl ProposerApi { @@ -67,7 +73,6 @@ impl ProposerApi { reg_handle: RegWorkerHandle, alert_manager: Arc, operator_api: Option>, - gloas_payload_store: Arc, ) -> Self { let gloas_builder_identity = Arc::new(GloasBuilderIdentity { builder_index: relay_config.gloas_builder_index, @@ -90,7 +95,7 @@ impl ProposerApi { operator_api, ip_tracker: IpTracker::default(), gloas_builder_identity, - gloas_payload_store, + reveal_guard: Default::default(), } } } @@ -106,4 +111,59 @@ pub async fn status( } } +/// How much of a request auth's opaque `data` to log. Nothing reads the field yet, +/// so this is here to find out what clients actually put in it. +const LOGGED_AUTH_DATA_BYTES: usize = 64; + +/// Renders a request auth for logging: its slot, the length of `data`, and as much of +/// `data` as [`LOGGED_AUTH_DATA_BYTES`] allows. +pub(crate) fn auth_summary(auth: &helix_types::SignedBuilderRequestAuth) -> (u64, usize, String) { + let data = &auth.message.data; + let shown = data.len().min(LOGGED_AUTH_DATA_BYTES); + let mut rendered = format!("{}", alloy_primitives::Bytes::copy_from_slice(&data[..shown])); + if shown < data.len() { + rendered.push('\u{2026}'); + } + (auth.message.slot, data.len(), rendered) +} + const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; + +#[cfg(test)] +mod auth_summary_tests { + use helix_types::{ + BlsSignatureBytes, BuilderRequestAuth, RequestAuthData, SignedBuilderRequestAuth, + }; + + use super::*; + + fn auth(data: Vec) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: RequestAuthData(data.into()), slot: 42 }, + signature: BlsSignatureBytes::default(), + } + } + + #[test] + fn empty_data_is_reported_as_empty() { + let (slot, len, rendered) = auth_summary(&auth(vec![])); + assert_eq!((slot, len), (42, 0)); + assert_eq!(rendered, "0x"); + } + + #[test] + fn short_data_is_rendered_whole() { + let (_, len, rendered) = auth_summary(&auth(vec![0xde, 0xad, 0xbe, 0xef])); + assert_eq!(len, 4); + assert_eq!(rendered, "0xdeadbeef"); + } + + /// `data` runs to 4096 bytes; a log line must not carry all of it. + #[test] + fn long_data_is_truncated_but_its_length_is_kept() { + let (_, len, rendered) = auth_summary(&auth(vec![0xab; 1000])); + assert_eq!(len, 1000, "the real length is still reported"); + assert!(rendered.ends_with('\u{2026}'), "and the rendering says it was cut"); + assert_eq!(rendered.chars().count(), 2 + LOGGED_AUTH_DATA_BYTES * 2 + 1); + } +} diff --git a/crates/relay/src/api/proposer/reveal_guard.rs b/crates/relay/src/api/proposer/reveal_guard.rs new file mode 100644 index 00000000..c0fe9e82 --- /dev/null +++ b/crates/relay/src/api/proposer/reveal_guard.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeMap; + +use alloy_primitives::B256; +use helix_types::Slot; +use tokio::task::AbortHandle; + +/// Slots kept after the current one, so a late duplicate for the previous slot is +/// still recognised. +const RETAINED_SLOTS: u64 = 2; + +/// What [`RevealGuard::register`] decided about a block. +#[derive(Debug)] +pub enum Registered { + /// The first block for this slot, or a repeat of the same one. + Proceed, + /// A second, different block for a slot we already hold one for. + Equivocation { first: B256 }, +} + +/// Remembers the block root helix committed to for each recent slot, so a proposer +/// cannot redeem two different blocks against the same bid. A pending reveal is +/// abortable: the payload is held until the attestation deadline, so an equivocation +/// seen before then withholds it rather than merely recording the fact. +#[derive(Default)] +pub struct RevealGuard { + seen: BTreeMap)>, +} + +impl RevealGuard { + pub fn register(&mut self, slot: Slot, block_root: B256) -> Registered { + self.prune(slot); + match self.seen.get(&slot) { + Some((first, _)) if *first != block_root => Registered::Equivocation { first: *first }, + Some(_) => Registered::Proceed, + None => { + self.seen.insert(slot, (block_root, None)); + Registered::Proceed + } + } + } + + /// Attaches the task that will reveal `slot`'s payload, so it can be withheld. + pub fn attach(&mut self, slot: Slot, reveal: AbortHandle) { + if let Some(entry) = self.seen.get_mut(&slot) { + entry.1 = Some(reveal); + } + } + + /// Withholds `slot`'s payload if its reveal has not run yet. Returns whether a + /// pending reveal was stopped. + pub fn withhold(&mut self, slot: Slot) -> bool { + match self.seen.get_mut(&slot).and_then(|entry| entry.1.take()) { + Some(reveal) if !reveal.is_finished() => { + reveal.abort(); + true + } + _ => false, + } + } + + fn prune(&mut self, slot: Slot) { + let cutoff = slot.as_u64().saturating_sub(RETAINED_SLOTS); + self.seen.retain(|kept, _| kept.as_u64() >= cutoff); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, + }; + + use super::*; + + fn guard() -> RevealGuard { + RevealGuard::default() + } + + #[test] + fn the_first_block_for_a_slot_proceeds() { + let mut guard = guard(); + assert!(matches!(guard.register(Slot::new(10), B256::repeat_byte(1)), Registered::Proceed)); + } + + #[test] + fn the_same_block_sent_twice_is_not_an_equivocation() { + let mut guard = guard(); + let root = B256::repeat_byte(1); + guard.register(Slot::new(10), root); + assert!(matches!(guard.register(Slot::new(10), root), Registered::Proceed)); + } + + #[test] + fn a_different_block_for_the_same_slot_is_an_equivocation() { + let mut guard = guard(); + let first = B256::repeat_byte(1); + guard.register(Slot::new(10), first); + + match guard.register(Slot::new(10), B256::repeat_byte(2)) { + Registered::Equivocation { first: reported } => assert_eq!(reported, first), + other => panic!("expected an equivocation, got {other:?}"), + } + } + + #[test] + fn different_slots_do_not_collide() { + let mut guard = guard(); + guard.register(Slot::new(10), B256::repeat_byte(1)); + assert!(matches!(guard.register(Slot::new(11), B256::repeat_byte(2)), Registered::Proceed)); + } + + /// Without pruning the map grows without bound; a slot far enough back is + /// forgotten, so its root can no longer be compared. + #[test] + fn slots_well_behind_the_head_are_forgotten() { + let mut guard = guard(); + guard.register(Slot::new(10), B256::repeat_byte(1)); + guard.register(Slot::new(20), B256::repeat_byte(2)); + assert!(matches!(guard.register(Slot::new(10), B256::repeat_byte(3)), Registered::Proceed)); + } + + #[tokio::test] + async fn an_equivocation_withholds_a_pending_reveal() { + let mut guard = guard(); + let slot = Slot::new(10); + guard.register(slot, B256::repeat_byte(1)); + + let revealed = Arc::new(AtomicBool::new(false)); + let flag = revealed.clone(); + let task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + flag.store(true, Ordering::SeqCst); + }); + guard.attach(slot, task.abort_handle()); + + assert!(matches!( + guard.register(slot, B256::repeat_byte(2)), + Registered::Equivocation { .. } + )); + assert!(guard.withhold(slot), "the pending reveal must be stopped"); + + tokio::time::sleep(Duration::from_millis(400)).await; + assert!(!revealed.load(Ordering::SeqCst), "the payload must never be revealed"); + } + + #[tokio::test] + async fn a_reveal_that_already_ran_cannot_be_withheld() { + let mut guard = guard(); + let slot = Slot::new(10); + guard.register(slot, B256::repeat_byte(1)); + + let task = tokio::spawn(async {}); + guard.attach(slot, task.abort_handle()); + let _ = task.await; + + assert!(!guard.withhold(slot)); + } +} diff --git a/crates/relay/src/api/proposer/submit_builder_preferences.rs b/crates/relay/src/api/proposer/submit_builder_preferences.rs index 6ae951fd..fcfb38d0 100644 --- a/crates/relay/src/api/proposer/submit_builder_preferences.rs +++ b/crates/relay/src/api/proposer/submit_builder_preferences.rs @@ -37,9 +37,12 @@ impl ProposerApi { .verify_signature(¶ms.proposer_pubkey, proposer_api.chain_info.request_auth_domain) .map_err(|_| ProposerApiError::InvalidRequestAuthSignature)?; + let (auth_slot, auth_data_len, auth_data) = super::auth_summary(&request.auth); info!( proposer_pubkey = ?params.proposer_pubkey, - slot = request.auth.message.slot, + slot = auth_slot, + auth_data_len, + %auth_data, max_execution_payment = request.preferences.max_execution_payment, "validated submitBuilderPreferences request" ); diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index 7ff785ec..374419f1 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -1,21 +1,22 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; use axum::{Extension, http::HeaderMap}; -use dashmap::DashMap; -use helix_common::{chain_info::ChainInfo, decoder::Encoding, utils::extract_request_id}; +use helix_common::{ + chain_info::ChainInfo, decoder::Encoding, spawn_tracked, utils::extract_request_id, +}; use helix_types::{ - BeaconBlockRef, BlobsBundle, BlsKeypair, BlsPublicKey, BlsPublicKeyBytes, Domain, EthSpec, + BlobsBundle, BlsKeypair, BlsPublicKey, BlsPublicKeyBytes, Domain, EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, ForkName, - MainnetEthSpec, SigError, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelope, + MainnetEthSpec, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelopeContents, SignedRoot, }; use hyper::StatusCode; use ssz::Decode; -use tracing::info; +use tracing::{error, info, warn}; use tree_hash::TreeHash; -use super::{ProposerApi, get_payload::fork_name_from_header}; +use super::{ProposerApi, get_payload::fork_name_from_header, reveal_guard::Registered}; use crate::api::{Api, proposer::error::ProposerApiError}; /// A payload a builder has already handed helix for a proposer's committed bid. @@ -24,21 +25,9 @@ pub struct HeldGloasPayload { pub payload: ExecutionPayloadGloas, pub execution_requests: ExecutionRequestsGloas, pub blobs_bundle: Arc, -} - -/// Payloads held by a bid's committed block hash, removed once the envelope is broadcast. -// TODO(gloas): populate from the auctioneer; see gattaca-com/helix#489 step 3. -#[derive(Default)] -pub struct GloasPayloadStore(DashMap); - -impl GloasPayloadStore { - pub fn held_payload(&self, block_hash: B256) -> Option { - self.0.get(&block_hash).map(|payload| payload.clone()) - } - - pub fn remove(&self, block_hash: B256) { - self.0.remove(&block_hash); - } + /// The proposer this bid was served to, whose signature the redeeming block + /// must carry. + pub proposer_pubkey: Option, } /// Helix's own on-chain Gloas builder identity: `builder_index` plus signing key. @@ -67,12 +56,56 @@ impl GloasBuilderIdentity { let signature = self.keypair.sk.sign(message.signing_root(domain)); SignedExecutionPayloadEnvelope { message, signature } } + + /// Signs a `SignedExecutionPayloadBid` under the same domain as `sign_envelope`. + pub fn sign_bid( + &self, + message: helix_types::ExecutionPayloadBid, + chain_info: &ChainInfo, + ) -> helix_types::SignedExecutionPayloadBid { + let epoch = message.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &fork, + chain_info.genesis_validators_root, + ); + let signature = self.keypair.sk.sign(message.signing_root(domain)); + helix_types::SignedExecutionPayloadBid { message, signature } + } +} + +/// Whether the proposer helix bid to signed this block. The redeeming block is not +/// otherwise authenticated, and the bid's block hash is public once the block is +/// gossiped, so without this anyone could redeem a bid on the proposer's behalf. +fn proposer_signed_block( + block: &SignedBeaconBlockGloas, + pubkey: &BlsPublicKeyBytes, + chain_info: &ChainInfo, +) -> bool { + let epoch = block.message.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconProposer, + &fork, + chain_info.genesis_validators_root, + ); + let Ok(pubkey) = BlsPublicKey::deserialize(pubkey.as_ref()) else { + return false; + }; + let signing_root = + helix_types::SigningData { object_root: block.message.tree_hash_root(), domain } + .tree_hash_root(); + block.signature.verify(&pubkey, signing_root) } /// Constructs and signs the `SignedExecutionPayloadEnvelope` fulfilling `block`'s committed bid. +/// `held` is the payload the auctioneer has stored for the bid's committed block hash, if any. pub(super) fn construct_signed_envelope( block: &SignedBeaconBlockGloas, - store: &GloasPayloadStore, + held: Option, identity: &GloasBuilderIdentity, chain_info: &ChainInfo, ) -> Result { @@ -86,9 +119,14 @@ pub(super) fn construct_signed_envelope( }); } - let held = store - .held_payload(bid_block_hash) - .ok_or(ProposerApiError::NoHeldPayloadForBlock(bid_block_hash))?; + let held = held.ok_or(ProposerApiError::NoHeldPayloadForBlock(bid_block_hash))?; + + // Fail closed: a payload whose proposer we cannot name cannot be redeemed. + let proposer_pubkey = + held.proposer_pubkey.ok_or(ProposerApiError::UnknownBidProposer(bid_block_hash))?; + if !proposer_signed_block(block, &proposer_pubkey, chain_info) { + return Err(ProposerApiError::InvalidProposerSignature); + } let held_block_hash: B256 = held.payload.block_hash.0; if held_block_hash != bid_block_hash { @@ -113,26 +151,40 @@ pub(super) fn construct_signed_envelope( }) } -fn verify_proposer_signature( - block: &SignedBeaconBlockGloas, - proposer_pubkey: &BlsPublicKeyBytes, - chain_info: &ChainInfo, -) -> Result<(), SigError> { - let pubkey = BlsPublicKey::deserialize(proposer_pubkey.as_slice()) - .map_err(|_| SigError::InvalidBlsPubkeyBytes)?; - let epoch = block.message.slot.epoch(MainnetEthSpec::slots_per_epoch()); - let fork = chain_info.spec.fork_at_epoch(epoch); - let domain = chain_info.spec.get_domain( - epoch, - Domain::BeaconProposer, - &fork, - chain_info.genesis_validators_root, - ); - if !block.signature.verify(&pubkey, BeaconBlockRef::Gloas(&block.message).signing_root(domain)) - { - return Err(SigError::InvalidBlsSignature); +const PUBLISH_ATTEMPTS: u32 = 12; +const PUBLISH_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +fn unix_now() -> Duration { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the clock is after the unix epoch") +} + +/// Offers the envelope to the beacon nodes until one takes it. The block reaches a +/// node over gossip a few hundred milliseconds after the proposer hands it to us. +async fn reveal_envelope( + proposer_api: &ProposerApi, + signed_envelope: Arc, + slot: helix_types::Slot, +) { + for attempt in 0..PUBLISH_ATTEMPTS { + match proposer_api + .multi_beacon_client + .publish_execution_payload_envelope(signed_envelope.clone(), ForkName::Gloas) + .await + { + Ok(()) => { + info!(slot = slot.as_u64(), attempt, "revealed the payload"); + return; + } + Err(err) => { + warn!(%err, attempt, "could not reveal the payload yet"); + tokio::time::sleep(PUBLISH_RETRY_INTERVAL).await; + } + } } - Ok(()) + + error!(slot = slot.as_u64(), "gave up revealing the payload after {PUBLISH_ATTEMPTS} attempts"); } impl ProposerApi { @@ -157,36 +209,73 @@ impl ProposerApi { info!(slot = block.message.slot.as_u64(), "accepted submitSignedBeaconBlock request"); - let (_, slot_duty) = proposer_api.curr_slot_info.slot_info(); - let Some(slot_duty) = slot_duty else { - return Err(ProposerApiError::ProposerNotRegistered); + let bid_block_hash: B256 = + block.message.body.signed_execution_payload_bid.message.block_hash.0; + let Ok(rx) = proposer_api + .auctioneer_handle + .take_held_gloas_payload(bid_block_hash, block.message.slot) + else { + return Err(ProposerApiError::InternalServerError); + }; + let held = match rx.await { + Ok(held) => held, + Err(err) => { + warn!(%err, "failed to fetch held Gloas payload from auctioneer"); + return Err(ProposerApiError::InternalServerError); + } }; - if slot_duty.slot != block.message.slot { - return Err(ProposerApiError::InvalidBlindedBlockSlot { - internal_slot: slot_duty.slot, - blinded_block_slot: block.message.slot, - }); - } - verify_proposer_signature( - &block, - &slot_duty.entry.registration.message.pubkey, - &proposer_api.chain_info, - )?; - let signed_envelope = construct_signed_envelope( + let signed_envelope = Arc::new(construct_signed_envelope( &block, - &proposer_api.gloas_payload_store, + held, &proposer_api.gloas_builder_identity, &proposer_api.chain_info, - )?; + )?); + + // Only now is the block known to redeem our bid: this endpoint does not verify + // the proposer's signature, so an unvalidated block must never be able to + // withhold a legitimate reveal. A second, different block that still redeems + // the same bid is the proposer equivocating. + let slot = block.message.slot; + let block_root = block.message.tree_hash_root(); + let registered = proposer_api + .reveal_guard + .lock() + .expect("reveal guard mutex") + .register(slot, block_root); + if let Registered::Equivocation { first } = registered { + let withheld = + proposer_api.reveal_guard.lock().expect("reveal guard mutex").withhold(slot); + error!( + slot = slot.as_u64(), + ?first, + second = ?block_root, + withheld, + "the proposer equivocated", + ); + return Err(ProposerApiError::ProposerEquivocated { slot: slot.as_u64(), first }); + } - proposer_api - .multi_beacon_client - .publish_execution_payload_envelope(Arc::new(signed_envelope), ForkName::Gloas) - .await?; - proposer_api - .gloas_payload_store - .remove(block.message.body.signed_execution_payload_bid.message.block_hash.0); + let block = Arc::new(block); + + // The proposer gossips its own block, but handing it to our node directly is + // what makes the block root known by the time we reveal. + if let Err(err) = proposer_api.multi_beacon_client.publish_gloas_block(block.clone()).await + { + warn!(%err, "could not publish the proposer's block"); + } + + // Revealing before the attestation deadline hands an equivocating proposer a + // payload it can build a competing block on. Wait it out, then reveal, and let + // the proposer have its acknowledgement now rather than seconds from now. + let delay = proposer_api.chain_info.gloas_reveal_delay(slot, unix_now()); + info!(slot = slot.as_u64(), delay_ms = delay.as_millis() as u64, "holding the payload"); + let guard = proposer_api.reveal_guard.clone(); + let reveal = spawn_tracked!(async move { + tokio::time::sleep(delay).await; + reveal_envelope(&proposer_api, signed_envelope, slot).await; + }); + guard.lock().expect("reveal guard mutex").attach(slot, reveal.abort_handle()); Ok(StatusCode::ACCEPTED) } @@ -195,38 +284,63 @@ impl ProposerApi { #[cfg(test)] mod construct_signed_envelope_tests { use helix_common::utils::install_default_crypto_provider; - use helix_types::{BeaconBlockGloas, BlsSignature, EmptyBlock, ExecutionBlockHash}; + use helix_types::{BeaconBlockGloas, EmptyBlock, ExecutionBlockHash, SigningData}; use super::*; - fn store_holding(block_hash: B256, payload: HeldGloasPayload) -> GloasPayloadStore { - let store = GloasPayloadStore::default(); - store.0.insert(block_hash, payload); - store - } - - fn held_payload(block_hash: B256) -> HeldGloasPayload { + fn held_payload(block_hash: B256, proposer: &BlsKeypair) -> HeldGloasPayload { let mut payload = ExecutionPayloadGloas::default(); payload.block_hash = ExecutionBlockHash(block_hash); HeldGloasPayload { payload, execution_requests: ExecutionRequestsGloas::default(), blobs_bundle: Default::default(), + proposer_pubkey: Some(proposer.pk.compress().serialize().into()), } } - fn test_block( - block_hash: B256, - builder_index: u64, - parent_root: B256, + pub(super) fn proposer() -> BlsKeypair { + install_default_crypto_provider(); + BlsKeypair::random() + } + + /// Signs `message` the way the proposer's validator client would. + pub(super) fn sign_block( + message: BeaconBlockGloas, + proposer: &BlsKeypair, ) -> SignedBeaconBlockGloas { + let chain_info = ChainInfo::default(); + let epoch = message.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconProposer, + &fork, + chain_info.genesis_validators_root, + ); + let signing_root = + SigningData { object_root: message.tree_hash_root(), domain }.tree_hash_root(); + let signature = proposer.sk.sign(signing_root); + SignedBeaconBlockGloas { message, signature } + } + + fn block_message(block_hash: B256, builder_index: u64, parent_root: B256) -> BeaconBlockGloas { let chain_info = ChainInfo::default(); let mut message = BeaconBlockGloas::empty(&chain_info.spec); message.parent_root = parent_root; message.body.signed_execution_payload_bid.message.block_hash = ExecutionBlockHash(block_hash); message.body.signed_execution_payload_bid.message.builder_index = builder_index; - SignedBeaconBlockGloas { message, signature: BlsSignature::empty() } + message + } + + fn test_block( + block_hash: B256, + builder_index: u64, + parent_root: B256, + proposer: &BlsKeypair, + ) -> SignedBeaconBlockGloas { + sign_block(block_message(block_hash, builder_index, parent_root), proposer) } fn identity(builder_index: u64) -> GloasBuilderIdentity { @@ -239,12 +353,13 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let block = test_block(block_hash, 7, parent_root); - let store = store_holding(block_hash, held_payload(block_hash)); + let proposer = proposer(); + let block = test_block(block_hash, 7, parent_root, &proposer); + let held = Some(held_payload(block_hash, &proposer)); let identity = identity(7); let signed_envelope = - construct_signed_envelope(&block, &store, &identity, &chain_info).unwrap(); + construct_signed_envelope(&block, held, &identity, &chain_info).unwrap(); assert_eq!(signed_envelope.signed_execution_payload_envelope.message.builder_index, 7); assert_eq!( @@ -265,12 +380,13 @@ mod construct_signed_envelope_tests { fn signature_verifies_against_the_configured_identity() { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x33); - let block = test_block(block_hash, 3, B256::ZERO); - let store = store_holding(block_hash, held_payload(block_hash)); + let proposer = proposer(); + let block = test_block(block_hash, 3, B256::ZERO, &proposer); + let held = Some(held_payload(block_hash, &proposer)); let identity = identity(3); let signed_envelope = - construct_signed_envelope(&block, &store, &identity, &chain_info).unwrap(); + construct_signed_envelope(&block, held, &identity, &chain_info).unwrap(); let epoch = signed_envelope .signed_execution_payload_envelope @@ -290,11 +406,11 @@ mod construct_signed_envelope_tests { fn no_held_payload_is_an_error_not_a_panic() { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x44); - let block = test_block(block_hash, 1, B256::ZERO); - let store = GloasPayloadStore::default(); + let proposer = proposer(); + let block = test_block(block_hash, 1, B256::ZERO, &proposer); let identity = identity(1); - let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + let result = construct_signed_envelope(&block, None, &identity, &chain_info); assert!( matches!(result, Err(ProposerApiError::NoHeldPayloadForBlock(hash)) if hash == block_hash) @@ -306,11 +422,12 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let bid_block_hash = B256::repeat_byte(0x55); let wrong_held_hash = B256::repeat_byte(0x66); - let block = test_block(bid_block_hash, 1, B256::ZERO); - let store = store_holding(bid_block_hash, held_payload(wrong_held_hash)); + let proposer = proposer(); + let block = test_block(bid_block_hash, 1, B256::ZERO, &proposer); + let held = Some(held_payload(wrong_held_hash, &proposer)); let identity = identity(1); - let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + let result = construct_signed_envelope(&block, held, &identity, &chain_info); assert!(matches!( result, @@ -319,15 +436,65 @@ mod construct_signed_envelope_tests { )); } + /// The bid's block hash is public once the proposer gossips its block, so without + /// this check anyone could redeem the bid and, with the equivocation guard, make + /// helix withhold a legitimate reveal. + #[test] + fn a_block_signed_by_anyone_else_is_rejected() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x33); + let attacker = proposer(); + let proposer = proposer(); + // The attacker replays the real bid commitment under its own signature. + let block = test_block(block_hash, 4, B256::ZERO, &attacker); + let held = Some(held_payload(block_hash, &proposer)); + + let result = construct_signed_envelope(&block, held, &identity(4), &chain_info); + + assert!(matches!(result, Err(ProposerApiError::InvalidProposerSignature))); + } + + #[test] + fn an_unsigned_block_is_rejected() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x44); + let proposer = proposer(); + let block = SignedBeaconBlockGloas { + message: block_message(block_hash, 4, B256::ZERO), + signature: helix_types::BlsSignature::empty(), + }; + let held = Some(held_payload(block_hash, &proposer)); + + let result = construct_signed_envelope(&block, held, &identity(4), &chain_info); + + assert!(matches!(result, Err(ProposerApiError::InvalidProposerSignature))); + } + + /// Fail closed: a payload whose proposer helix cannot name must not be redeemable. + #[test] + fn a_payload_with_no_known_proposer_is_not_redeemable() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x55); + let proposer = proposer(); + let block = test_block(block_hash, 4, B256::ZERO, &proposer); + let mut held = held_payload(block_hash, &proposer); + held.proposer_pubkey = None; + + let result = construct_signed_envelope(&block, Some(held), &identity(4), &chain_info); + + assert!(matches!(result, Err(ProposerApiError::UnknownBidProposer(_)))); + } + #[test] fn bid_builder_index_not_matching_configured_identity_is_rejected() { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x77); - let block = test_block(block_hash, 9, B256::ZERO); - let store = store_holding(block_hash, held_payload(block_hash)); + let proposer = proposer(); + let block = test_block(block_hash, 9, B256::ZERO, &proposer); + let held = Some(held_payload(block_hash, &proposer)); let identity = identity(1); - let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + let result = construct_signed_envelope(&block, held, &identity, &chain_info); assert!(matches!( result, diff --git a/crates/relay/src/api/service.rs b/crates/relay/src/api/service.rs index 3441b1c1..b6ed27a2 100644 --- a/crates/relay/src/api/service.rs +++ b/crates/relay/src/api/service.rs @@ -146,7 +146,6 @@ pub async fn run_api_service( registrations_handle, alert_manager, operator_api, - Arc::default(), )); tokio::spawn(process_gossip_messages( diff --git a/crates/relay/src/auctioneer/bid_sorter.rs b/crates/relay/src/auctioneer/bid_sorter.rs index c1d10ed5..e66ad8ff 100644 --- a/crates/relay/src/auctioneer/bid_sorter.rs +++ b/crates/relay/src/auctioneer/bid_sorter.rs @@ -7,7 +7,7 @@ use alloy_primitives::{Address, B256, U256}; use flux::spine::SpineProducers; use flux_profiler::timed; use helix_common::{ - SubmissionTrace, + ForkKey, SubmissionTrace, api::builder_api::TopBidUpdate, metrics::{BID_SORTER_PROCESS_LATENCY_US, TopBidMetrics}, record_submission_step_ns, @@ -28,11 +28,16 @@ pub struct Bid { pub builder_pubkey: BlsPublicKeyBytes, pub block_number: u64, pub parent_hash: B256, + pub parent_root: B256, pub fee_recipient: Address, } impl Bid { - pub fn new(version: SubmissionVersion, submission: &SignedBidSubmission) -> Self { + pub fn new( + version: SubmissionVersion, + submission: &SignedBidSubmission, + parent_root: B256, + ) -> Self { let bid_trace = submission.bid_trace(); Self { @@ -43,11 +48,12 @@ impl Bid { builder_pubkey: bid_trace.builder_pubkey, block_number: submission.block_number(), parent_hash: bid_trace.parent_hash, + parent_root, fee_recipient: submission.fee_recipient(), } } - pub fn from_submission_data(submission: &SubmissionData) -> Self { + pub fn from_submission_data(submission: &SubmissionData, parent_root: B256) -> Self { let bid_trace = submission.bid_trace(); Self { @@ -58,6 +64,7 @@ impl Bid { builder_pubkey: bid_trace.builder_pubkey, block_number: submission.block_number(), parent_hash: bid_trace.parent_hash, + parent_root, fee_recipient: submission.fee_recipient(), } } @@ -165,7 +172,7 @@ pub struct BidSorter { /// Head slot + 1 curr_bid_slot: u64, /// Parent hash -> fork state - forks: FxHashMap, + forks: FxHashMap, /// Demoted builders in this slot for live demotions demotions: FxHashSet, local_telemetry: BidSorterTelemetry, @@ -219,13 +226,13 @@ impl BidSorter { self.process_demotion(demoted, producers); } - /// Value of the bid that would be served for this parent right now. - pub fn top_bid_value(&self, parent_hash: &B256) -> Option { - self.forks.get(parent_hash).and_then(|s| s.curr_bid.as_ref().map(|b| b.value)) + /// Value of the bid that would be served for this fork right now. + pub fn top_bid_value(&self, fork: &ForkKey) -> Option { + self.forks.get(fork).and_then(|s| s.curr_bid.as_ref().map(|b| b.value)) } - pub fn get_header(&self, parent_hash: &B256) -> Option { - self.forks.get(parent_hash).and_then(|s| s.curr_bid.as_ref().map(|b| b.block_hash)) + pub fn get_header(&self, fork: &ForkKey) -> Option { + self.forks.get(fork).and_then(|s| s.curr_bid.as_ref().map(|b| b.block_hash)) } #[timed] @@ -236,7 +243,8 @@ impl BidSorter { is_optimistic: bool, producers: &mut HelixSpineProducers, ) -> bool { - let state = self.forks.entry(new_bid.parent_hash).or_default(); + let fork = ForkKey { parent_hash: new_bid.parent_hash, parent_root: new_bid.parent_root }; + let state = self.forks.entry(fork).or_default(); match state.bids.entry(new_bid.builder_pubkey) { Entry::Occupied(mut entry) => { let entry = entry.get_mut(); @@ -321,7 +329,12 @@ impl BidSorter { let fork_report: Vec<_> = self .forks .iter() - .map(|(k, s)| format!("parent: {k}, subs: {}, top_bids: {}", s.subs, s.top_bids)) + .map(|(k, s)| { + format!( + "parent: {}, root: {}, subs: {}, top_bids: {}", + k.parent_hash, k.parent_root, s.subs, s.top_bids + ) + }) .collect(); info!( @@ -353,6 +366,7 @@ mod tests { }, block_number: 1, parent_hash: B256::repeat_byte(1), + parent_root: B256::ZERO, fee_recipient: Address::ZERO, } } diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index 8535cbbd..5c0e0526 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -38,7 +38,9 @@ use uuid::Uuid; use crate::{ SubmissionDataWithSpan, - api::{FutureBidSubmissionResult, builder::error::BuilderApiError}, + api::{ + FutureBidSubmissionResult, builder::error::BuilderApiError, proposer::GloasBuilderIdentity, + }, auctioneer::{ AuctioneerHandle, BlockMergeResponse, bid_adjustor::BidAdjustor, @@ -62,10 +64,70 @@ pub struct SlotContext { /// builder -> version pub version: FxHashMap, pub hydration_cache: HydrationCache, - pub payloads: FxHashMap, + pub payloads: PayloadStore, pub block_merger: BlockMerger, } +/// The payloads a slot's submissions produced, kept one slot longer than the +/// auction that made them. +/// +/// The relay moves to the next bid slot shortly after a slot begins, but a Gloas +/// proposer redeems its bid part-way through its own slot, so a payload has to +/// outlive the auction it won. Keeping the previous generation covers the whole +/// of that slot; anything older is dropped. +#[derive(Default)] +pub struct PayloadStore { + current: FxHashMap, + previous: FxHashMap, +} + +impl PayloadStore { + fn with_capacity(capacity: usize) -> Self { + Self { + current: FxHashMap::with_capacity_and_hasher(capacity, Default::default()), + previous: FxHashMap::default(), + } + } + + pub fn insert(&mut self, block_hash: B256, entry: PayloadEntry) { + self.current.insert(block_hash, entry); + } + + pub fn or_insert(&mut self, block_hash: B256, entry: PayloadEntry) { + self.current.entry(block_hash).or_insert(entry); + } + + /// The current auction's payloads only: a bid is served from this slot. + pub fn get(&self, block_hash: &B256) -> Option<&PayloadEntry> { + self.current.get(block_hash) + } + + /// As [`Self::get`], falling back to the previous slot. Redemption arrives + /// after the relay has moved on, so the bid it names is usually one back. + pub fn get_for_redemption(&self, block_hash: &B256) -> Option<&PayloadEntry> { + self.current.get(block_hash).or_else(|| self.previous.get(block_hash)) + } + + pub fn len(&self) -> usize { + self.current.len() + } + + pub fn is_empty(&self) -> bool { + self.current.is_empty() && self.previous.is_empty() + } + + /// Starts a new auction, returning the generation that is now too old to + /// redeem so the caller can drop it off the event loop. + #[must_use] + fn rotate(&mut self, capacity: usize) -> FxHashMap { + let just_finished = std::mem::replace( + &mut self.current, + FxHashMap::with_capacity_and_hasher(capacity, Default::default()), + ); + std::mem::replace(&mut self.previous, just_finished) + } +} + pub struct Context { pub chain_info: ChainInfo, pub config: RelayConfig, @@ -88,6 +150,7 @@ pub struct Context { discord_addr: Option, discord_alert: Option, pub builder_preferences: BuilderPreferencesStore, + pub gloas_builder_identity: Arc, } const EXPECTED_PAYLOADS_PER_SLOT: usize = 5000; @@ -111,6 +174,7 @@ impl Context { auctioneer_handle: AuctioneerHandle, alert_manager: Arc, operator_api: Option>, + gloas_builder_identity: Arc, ) -> Self { // Local dev builders have random keys, so none is ever in config. let local_dev = is_local_dev(); @@ -134,10 +198,7 @@ impl Context { Default::default(), ), hydration_cache: HydrationCache::new(), - payloads: FxHashMap::with_capacity_and_hasher( - EXPECTED_PAYLOADS_PER_SLOT, - Default::default(), - ), + payloads: PayloadStore::with_capacity(EXPECTED_PAYLOADS_PER_SLOT), block_merger, }; @@ -166,6 +227,7 @@ impl Context { }), discord_alert: None, builder_preferences: BuilderPreferencesStore::default(), + gloas_builder_identity, } } @@ -286,10 +348,7 @@ impl Context { // map, however that would require us to estimate a hard upper limit on // payloads received, or risk causing a missed slot - let payloads_to_drop = std::mem::replace( - &mut self.payloads, - FxHashMap::with_capacity_and_hasher(EXPECTED_PAYLOADS_PER_SLOT, Default::default()), - ); + let payloads_to_drop = self.payloads.rotate(EXPECTED_PAYLOADS_PER_SLOT); let dealloc_core = self.config.cores.dealloc; std::thread::spawn(move || { // Unpinned, this lands on whatever core the OS picks -- including a @@ -393,12 +452,12 @@ pub(crate) fn merged_validation_request( response: &BlockMergeResponse, slot_data: &SlotData, ) -> Option { - let parent_hash = response.execution_payload.parent_hash; let parent_beacon_block_root = slot_data - .payload_attributes_map - .get(&parent_hash)? - .parent_beacon_block_root - .unwrap_or_default(); + .attrs_for_submission( + &response.execution_payload.parent_hash, + &response.execution_payload.prev_randao, + )? + .parent_root(); Some(MergedValidationRequest { submission_id: Uuid::new_v4(), base_block_hash: response.base_block_hash, @@ -526,3 +585,68 @@ pub fn send_submission_result

( SubmissionRefKind::Internal => {} } } + +#[cfg(test)] +mod payload_store_tests { + use helix_types::{SignedBidSubmission, TestRandomSeed}; + + use super::*; + + fn entry() -> PayloadEntry { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + PayloadEntry::new_submission( + submission, + B256::ZERO, + None, + None, + None, + helix_types::SubmissionVersion::new(0, None), + Default::default(), + None, + ) + } + + /// The relay moves to the next bid slot moments after a slot starts, but the + /// proposer redeems its bid part-way through that slot. Dropping the payload + /// at the auction boundary answered every redemption with "no held payload". + #[test] + fn a_served_bid_is_redeemable_after_the_auction_moves_on() { + let hash = B256::repeat_byte(0x11); + let mut store = PayloadStore::with_capacity(4); + store.insert(hash, entry()); + + let _stale = store.rotate(4); + + assert!(store.get(&hash).is_none(), "the new auction starts clean"); + assert!( + store.get_for_redemption(&hash).is_some(), + "the proposer must still be able to redeem the bid it was served", + ); + } + + /// One generation, not unbounded: a slot later it is gone. + #[test] + fn a_payload_is_dropped_after_two_auctions() { + let hash = B256::repeat_byte(0x11); + let mut store = PayloadStore::with_capacity(4); + store.insert(hash, entry()); + + let _ = store.rotate(4); + let stale = store.rotate(4); + + assert!(store.get_for_redemption(&hash).is_none(), "two slots on, it is not needed"); + assert!(stale.contains_key(&hash), "and it is handed back to be dropped off-thread"); + } + + #[test] + fn the_current_auction_is_served_from_the_new_generation() { + let hash = B256::repeat_byte(0x22); + let mut store = PayloadStore::with_capacity(4); + let _ = store.rotate(4); + store.insert(hash, entry()); + + assert!(store.get(&hash).is_some()); + assert!(store.get_for_redemption(&hash).is_some()); + } +} diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index 883db63a..8d287157 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -1,13 +1,22 @@ -use helix_common::api::proposer_api::GetExecutionPayloadBidParams; +use helix_common::{ + ForkKey, api::proposer_api::GetExecutionPayloadBidParams, chain_info::ChainInfo, +}; +use helix_types::{ + ExecutionBlockHash, ExecutionPayloadBid, SignedExecutionPayloadBid, Slot, + convert_kzg_commitments_to_progressive, execution_requests_to_gloas, +}; use tokio::sync::oneshot; use tracing::warn; +use tree_hash::TreeHash; + +const WEI_PER_GWEI: u64 = 1_000_000_000; use crate::{ - api::proposer::ProposerApiError, + api::proposer::{GloasBuilderIdentity, ProposerApiError}, auctioneer::{ bid_adjustor::BidAdjustor, context::Context, - types::{GetExecutionPayloadBidResult, SlotData}, + types::{GetExecutionPayloadBidResult, PayloadEntry, SlotData}, }, }; @@ -18,57 +27,133 @@ impl Context { slot_data: &SlotData, res_tx: oneshot::Sender, ) { - let _ = res_tx.send(get_execution_payload_bid(¶ms, slot_data)); + let result = check_execution_payload_bid_liveness(¶ms, slot_data).and_then(|()| { + let fork = ForkKey { parent_hash: params.parent_hash, parent_root: params.parent_root }; + let best_block_hash = + self.bid_sorter.get_header(&fork).ok_or(ProposerApiError::NoBidPrepared)?; + let entry = + self.payloads.get(&best_block_hash).ok_or(ProposerApiError::NoBidPrepared)?; + build_signed_bid( + entry, + ¶ms, + &self.gloas_builder_identity, + &self.chain_info, + slot_data, + ) + }); + let _ = res_tx.send(result); } } -/// Checks `params.parent_hash`/`params.parent_root` against currently-live payload attributes, -/// then reports "no bid available" -- serving a real Gloas bid needs step 5's builder->relay -/// submission wire format, not landed yet. -pub(super) fn get_execution_payload_bid( +/// Checks `params.parent_hash`/`params.parent_root` against currently-live payload attributes. +pub(super) fn check_execution_payload_bid_liveness( params: &GetExecutionPayloadBidParams, slot_data: &SlotData, -) -> GetExecutionPayloadBidResult { - let Some(attrs) = slot_data.payload_attributes_map.get(¶ms.parent_hash) else { - warn!( - req =% params.parent_hash, - have =? slot_data.payload_attributes_map.keys(), - "get execution payload bid for unknown parent hash" - ); - return Err(ProposerApiError::NoBidPrepared); - }; +) -> Result<(), ProposerApiError> { + if params.slot != slot_data.bid_slot.as_u64() { + return Err(ProposerApiError::BidRequestSlotMismatch { + expected: slot_data.bid_slot.as_u64(), + actual: params.slot, + }); + } - if attrs.parent_beacon_block_root != Some(params.parent_root) { + let expected_proposer = *slot_data.proposer_pubkey(); + if params.proposer_pubkey != expected_proposer { + return Err(ProposerApiError::UnexpectedProposerPubkey { + expected: expected_proposer, + actual: params.proposer_pubkey, + }); + } + + let fork = ForkKey { parent_hash: params.parent_hash, parent_root: params.parent_root }; + if !slot_data.payload_attributes_map.contains_key(&fork) { warn!( - req =% params.parent_root, - have =? attrs.parent_beacon_block_root, - "get execution payload bid for mismatched parent root" + req =? fork, + have =? slot_data.payload_attributes_map.keys(), + "get execution payload bid for unknown fork" ); return Err(ProposerApiError::NoBidPrepared); } - Err(ProposerApiError::NoBidPrepared) + Ok(()) +} + +/// Builds and signs the `SignedExecutionPayloadBid` for the winning submission held in `entry`, +/// under helix's own configured Gloas builder identity. +pub(super) fn build_signed_bid( + entry: &PayloadEntry, + params: &GetExecutionPayloadBidParams, + identity: &GloasBuilderIdentity, + chain_info: &ChainInfo, + slot_data: &SlotData, +) -> Result { + let slot = Slot::new(params.slot); + + let gloas_data = entry.gloas_data(); + let payload = entry.execution_payload().to_lighthouse_gloas_payload(slot, &gloas_data.block_access_list).map_err(|err| { + warn!(%err, block_hash =% entry.block_hash(), "failed to convert held payload to Gloas shape for bid"); + ProposerApiError::InternalServerError + })?; + + let execution_requests = execution_requests_to_gloas( + entry.bid_data_ref().execution_requests, + &gloas_data.builder_deposits, + &gloas_data.builder_exits, + ); + let execution_requests_root = execution_requests.tree_hash_root(); + + // The proposer is paid in-block, so the enshrined `value` stays 0. + let execution_payment = + (entry.value() / alloy_primitives::U256::from(WEI_PER_GWEI)).saturating_to::(); + + let bid = ExecutionPayloadBid { + parent_block_hash: ExecutionBlockHash(params.parent_hash), + parent_block_root: params.parent_root, + block_hash: ExecutionBlockHash(*entry.block_hash()), + prev_randao: payload.prev_randao, + // The proposer's, not the payload's: the builder keeps the coinbase and pays + // the proposer in-block, and consensus pays any enshrined amount to whatever + // this names. Announcing the coinbase names the builder. + fee_recipient: slot_data.registration_data.entry.registration.message.fee_recipient, + gas_limit: payload.gas_limit, + builder_index: identity.builder_index, + slot, + value: 0, + execution_payment, + blob_kzg_commitments: convert_kzg_commitments_to_progressive( + &entry.payload_and_blobs().blobs_bundle.commitments, + ), + execution_requests_root, + _phantom: std::marker::PhantomData, + }; + + Ok(identity.sign_bid(bid, chain_info)) } #[cfg(test)] mod tests { use alloy_primitives::B256; use helix_common::PayloadAttributesUpdate; - use helix_types::ForkName; - use rustc_hash::FxHashMap; + use helix_types::{BlsPublicKeyBytes, Domain, EthSpec, ForkName, SignedRoot, TestRandomSeed}; use super::*; - fn slot_data(payload_attributes_map: FxHashMap) -> SlotData { + const BID_SLOT: u64 = 1; + + fn slot_data(attrs: Vec) -> SlotData { SlotData { - bid_slot: Default::default(), + bid_slot: Slot::new(BID_SLOT), registration_data: Default::default(), current_fork: ForkName::Gloas, - payload_attributes_map, + payload_attributes_map: attrs.into_iter().map(|a| (a.fork(), a)).collect(), il: Default::default(), } } + fn live_slot_data(parent_hash: B256, parent_root: B256) -> SlotData { + slot_data(vec![attrs_update(parent_hash, Some(parent_root))]) + } + fn attrs_update( parent_hash: B256, parent_beacon_block_root: Option, @@ -96,9 +181,9 @@ mod tests { fn unknown_parent_hash_is_no_bid() { let parent_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let data = slot_data(FxHashMap::default()); + let data = slot_data(vec![]); - let result = get_execution_payload_bid(¶ms(parent_hash, parent_root), &data); + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); } @@ -108,11 +193,10 @@ mod tests { let parent_hash = B256::repeat_byte(0x11); let live_root = B256::repeat_byte(0x22); let requested_root = B256::repeat_byte(0x33); - let mut map = FxHashMap::default(); - map.insert(parent_hash, attrs_update(parent_hash, Some(live_root))); - let data = slot_data(map); + let data = slot_data(vec![attrs_update(parent_hash, Some(live_root))]); - let result = get_execution_payload_bid(¶ms(parent_hash, requested_root), &data); + let result = + check_execution_payload_bid_liveness(¶ms(parent_hash, requested_root), &data); assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); } @@ -121,25 +205,204 @@ mod tests { fn missing_parent_beacon_block_root_is_no_bid() { let parent_hash = B256::repeat_byte(0x11); let requested_root = B256::repeat_byte(0x33); - let mut map = FxHashMap::default(); - map.insert(parent_hash, attrs_update(parent_hash, None)); - let data = slot_data(map); + let data = slot_data(vec![attrs_update(parent_hash, None)]); - let result = get_execution_payload_bid(¶ms(parent_hash, requested_root), &data); + let result = + check_execution_payload_bid_liveness(¶ms(parent_hash, requested_root), &data); assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); } #[test] - fn matching_parent_still_reports_no_bid_until_step_5() { + fn matching_parent_passes_liveness_check() { let parent_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let mut map = FxHashMap::default(); - map.insert(parent_hash, attrs_update(parent_hash, Some(parent_root))); - let data = slot_data(map); + let data = slot_data(vec![attrs_update(parent_hash, Some(parent_root))]); - let result = get_execution_payload_bid(¶ms(parent_hash, parent_root), &data); + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); - assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); + assert!(result.is_ok()); + } + + #[test] + fn each_parent_root_on_a_shared_parent_hash_passes_liveness_check() { + let parent_hash = B256::repeat_byte(0x11); + let missed_root = B256::repeat_byte(0x22); + let empty_root = B256::repeat_byte(0x33); + let data = slot_data(vec![ + attrs_update(parent_hash, Some(missed_root)), + attrs_update(parent_hash, Some(empty_root)), + ]); + + for root in [missed_root, empty_root] { + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, root), &data); + assert!(result.is_ok()); + } + } + + fn payload_entry(block_hash: B256, value: u64) -> PayloadEntry { + use std::sync::Arc; + + use alloy_primitives::U256; + use helix_types::{BlobsBundle, ExecutionPayload, ExecutionRequests, PayloadAndBlobs}; + + let mut payload = ExecutionPayload::test_random(); + payload.block_hash = block_hash; + + PayloadEntry::new_gossip( + PayloadAndBlobs { + execution_payload: Arc::new(payload), + blobs_bundle: Arc::new(BlobsBundle::default()), + }, + helix_types::PayloadBidData { + withdrawals_root: B256::ZERO, + tx_root: None, + execution_requests: Arc::new(ExecutionRequests::default()), + value: U256::from(value), + builder_pubkey: Default::default(), + }, + ) + } + + fn bid_identity(builder_index: u64) -> GloasBuilderIdentity { + helix_common::utils::install_default_crypto_provider(); + GloasBuilderIdentity { builder_index, keypair: helix_types::BlsKeypair::random() } + } + + /// A submission entry carrying a block access list, as Gloas requires. + fn gloas_submission_entry(block_access_list: Vec) -> PayloadEntry { + use helix_types::{ + BlockAccessListBytes, GloasSubmissionData, SignedBidSubmission, TestRandomSeed, + }; + + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + + PayloadEntry::new_submission( + submission, + B256::ZERO, + None, + None, + Some(GloasSubmissionData { + block_access_list: BlockAccessListBytes(block_access_list.into()), + ..Default::default() + }), + helix_types::SubmissionVersion::new(0, None), + Default::default(), + None, + ) + } + + #[test] + fn the_stored_payload_keeps_the_block_access_list_for_the_bid() { + let entry = gloas_submission_entry(vec![5u8; 96]); + + let bal = entry.gloas_data().block_access_list; + + assert_eq!(bal.to_vec(), vec![5u8; 96], "the bid's payload would be invalid without it"); + + // And it reaches the converted Gloas payload. + let payload = entry + .execution_payload() + .to_lighthouse_gloas_payload(Slot::new(1), &bal) + .expect("conversion must succeed"); + assert_eq!(payload.block_access_list.len(), 96); + } + + #[test] + fn a_gossip_entry_has_no_block_access_list() { + let entry = payload_entry(B256::repeat_byte(0x11), 1); + + assert!( + entry.gloas_data().block_access_list.is_empty(), + "a gossiped payload carries none, so Gloas cannot be served from one", + ); + } + + #[test] + fn build_signed_bid_uses_the_entrys_data_and_configured_identity() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x99); + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let entry = payload_entry(block_hash, 42 * WEI_PER_GWEI); + let identity = bid_identity(7); + let params = params(parent_hash, parent_root); + let data = live_slot_data(parent_hash, parent_root); + + let signed_bid = build_signed_bid(&entry, ¶ms, &identity, &chain_info, &data).unwrap(); + + assert_eq!(signed_bid.message.block_hash.0, block_hash); + assert_eq!(signed_bid.message.parent_block_hash.0, parent_hash); + assert_eq!(signed_bid.message.parent_block_root, parent_root); + assert_eq!(signed_bid.message.builder_index, 7); + assert_eq!(signed_bid.message.value, 0, "the proposer is paid in-block"); + assert_eq!(signed_bid.message.execution_payment, 42, "wei converts to gwei"); + assert_eq!( + signed_bid.message.fee_recipient, + data.registration_data.entry.registration.message.fee_recipient, + "consensus pays any enshrined amount here, so it must be the proposer's", + ); + + let epoch = signed_bid.message.slot.epoch(helix_types::MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &fork, + chain_info.genesis_validators_root, + ); + assert!( + signed_bid + .signature + .verify(&identity.keypair.pk, signed_bid.message.signing_root(domain)) + ); + } + + #[test] + fn refuses_a_bid_request_for_another_slot() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = live_slot_data(parent_hash, parent_root); + let mut params = params(parent_hash, parent_root); + params.slot = BID_SLOT + 1; + + let result = check_execution_payload_bid_liveness(¶ms, &data); + + assert!( + matches!( + result, + Err(ProposerApiError::BidRequestSlotMismatch { expected, actual }) + if expected == BID_SLOT && actual == BID_SLOT + 1 + ), + "the relay must not sign a bid for a slot it is not bidding for", + ); + } + + #[test] + fn refuses_a_bid_request_from_another_proposer() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = live_slot_data(parent_hash, parent_root); + let mut params = params(parent_hash, parent_root); + params.proposer_pubkey = BlsPublicKeyBytes::from([7u8; 48]); + + let result = check_execution_payload_bid_liveness(¶ms, &data); + + assert!( + matches!(result, Err(ProposerApiError::UnexpectedProposerPubkey { .. })), + "a valid request auth proves the key, not the right to this slot", + ); + } + + #[test] + fn accepts_the_slots_own_proposer() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = live_slot_data(parent_hash, parent_root); + + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); + + assert!(result.is_ok(), "the expected slot and proposer must pass"); } } diff --git a/crates/relay/src/auctioneer/get_header.rs b/crates/relay/src/auctioneer/get_header.rs index 5ba87f9a..b6de9125 100644 --- a/crates/relay/src/auctioneer/get_header.rs +++ b/crates/relay/src/auctioneer/get_header.rs @@ -38,7 +38,10 @@ impl Context { producers: &mut HelixSpineProducers, is_mev_boost: bool, ) -> GetHeaderResult { - let Some(best_block_hash) = self.bid_sorter.get_header(&parent_hash) else { + let Some(best_block_hash) = slot_data + .fork_for_parent_hash(&parent_hash) + .and_then(|fork| self.bid_sorter.get_header(&fork)) + else { warn!(%parent_hash, "no bids for this fork"); return Err(ProposerApiError::NoBidPrepared); }; diff --git a/crates/relay/src/auctioneer/get_payload.rs b/crates/relay/src/auctioneer/get_payload.rs index 0643927e..f394bcc7 100644 --- a/crates/relay/src/auctioneer/get_payload.rs +++ b/crates/relay/src/auctioneer/get_payload.rs @@ -35,7 +35,7 @@ impl Context { let block_hash = payload.execution_payload.execution_payload.block_hash; let entry = PayloadEntry::new_gossip(payload.execution_payload, payload.bid_data); - self.payloads.entry(block_hash).or_insert(entry); + self.payloads.or_insert(block_hash, entry); } /// If we start broacasting, returns the block hash of the block diff --git a/crates/relay/src/auctioneer/gloas_payload.rs b/crates/relay/src/auctioneer/gloas_payload.rs new file mode 100644 index 00000000..a3d571b6 --- /dev/null +++ b/crates/relay/src/auctioneer/gloas_payload.rs @@ -0,0 +1,47 @@ +use alloy_primitives::B256; +use helix_types::{Slot, execution_requests_to_gloas}; +use tokio::sync::oneshot; +use tracing::warn; + +use crate::{ + api::proposer::HeldGloasPayload, + auctioneer::{bid_adjustor::BidAdjustor, context::Context}, +}; + +impl Context { + /// Looks up the payload a submission held for `block_hash`, converting it to the real Gloas + /// consensus shape for `submitSignedBeaconBlock`'s envelope construction. + pub(super) fn handle_take_held_gloas_payload( + &self, + block_hash: B256, + slot: Slot, + res_tx: oneshot::Sender>, + ) { + let held = self.payloads.get_for_redemption(&block_hash).and_then(|entry| { + let gloas_data = entry.gloas_data(); + let payload = match entry + .execution_payload() + .to_lighthouse_gloas_payload(slot, &gloas_data.block_access_list) + { + Ok(payload) => payload, + Err(err) => { + warn!(%block_hash, %err, "failed to convert held payload to Gloas shape"); + return None; + } + }; + let execution_requests = execution_requests_to_gloas( + entry.bid_data_ref().execution_requests, + &gloas_data.builder_deposits, + &gloas_data.builder_exits, + ); + Some(HeldGloasPayload { + payload, + execution_requests, + blobs_bundle: entry.payload_and_blobs().blobs_bundle.clone(), + proposer_pubkey: entry.proposer_pubkey().copied(), + }) + }); + + let _ = res_tx.send(held); + } +} diff --git a/crates/relay/src/auctioneer/handle.rs b/crates/relay/src/auctioneer/handle.rs index 9f567d6a..30495fd3 100644 --- a/crates/relay/src/auctioneer/handle.rs +++ b/crates/relay/src/auctioneer/handle.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use alloy_primitives::B256; use dashmap::DashMap; use futures::{FutureExt, future::Shared}; use helix_common::{ @@ -10,13 +11,13 @@ use helix_common::{ }; use helix_types::{ BlsPublicKey, BlsPublicKeyBytes, ExecPayload, GetPayloadResponse, SigError, - SignedBlindedBeaconBlock, + SignedBlindedBeaconBlock, Slot, }; use tokio::sync::oneshot::{self, Receiver}; use tracing::trace; use crate::{ - api::proposer::{ProposerApiError, get_payload::ProposerApiVersion}, + api::proposer::{HeldGloasPayload, ProposerApiError, get_payload::ProposerApiVersion}, auctioneer::types::{ Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult, SubmitBuilderPreferencesResult, @@ -108,6 +109,19 @@ impl AuctioneerHandle { Ok(rx) } + pub fn take_held_gloas_payload( + &self, + block_hash: B256, + slot: Slot, + ) -> Result>, ChannelFull> { + let (tx, rx) = oneshot::channel(); + trace!("sending to auctioneer"); + self.auctioneer + .try_send(Event::TakeHeldGloasPayload { block_hash, slot, res_tx: tx }) + .map_err(|_| ChannelFull)?; + Ok(rx) + } + pub fn get_payload( &self, chain_info: &ChainInfo, diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index 5fc27b6e..0138de23 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -6,6 +6,7 @@ mod context; mod get_execution_payload_bid; mod get_header; mod get_payload; +mod gloas_payload; mod handle; mod submit_block; pub(crate) mod types; @@ -23,7 +24,7 @@ use flux_profiler::timed; use flux_utils::SharedVector; pub use handle::{AuctioneerHandle, GetPayloadKind}; use helix_common::{ - PayloadAttributesUpdate, RelayConfig, + ForkKey, PayloadAttributesUpdate, RelayConfig, alerts::AlertManager, api::builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, chain_info::ChainInfo, @@ -46,7 +47,11 @@ pub use types::{ use crate::{ HelixSpine, SubmissionDataWithSpan, - api::{FutureBidSubmissionResult, builder::error::BuilderApiError, proposer::ProposerApiError}, + api::{ + FutureBidSubmissionResult, + builder::error::BuilderApiError, + proposer::{GloasBuilderIdentity, ProposerApiError}, + }, auctioneer::{context::merged_validation_request, types::PendingPayload}, housekeeper::SlotUpdate, simulator::{SimDone, SimResult, Simulators, sim_finish_events}, @@ -95,6 +100,7 @@ impl Auctioneer { merged_blocks: Arc>, alert_manager: Arc, operator_api: Option>, + gloas_builder_identity: Arc, ) -> Self { let ctx = Context::new( chain_info, @@ -111,6 +117,7 @@ impl Auctioneer { auctioneer_handle, alert_manager, operator_api, + gloas_builder_identity, ); Self { ctx, @@ -228,7 +235,7 @@ enum State { Slot { bid_slot: Slot, registration_data: Option, - payload_attributes_map: FxHashMap, + payload_attributes_map: FxHashMap, il: Option, }, @@ -342,14 +349,14 @@ impl State { // check fork let new_forks: Vec<_> = payload_attributes .into_iter() - .filter(|u| !slot_data.payload_attributes_map.contains_key(&u.parent_hash)) + .filter(|u| !slot_data.payload_attributes_map.contains_key(&u.fork())) .collect(); if !new_forks.is_empty() { // ugly clone but should be relatively rare let mut slot_data = slot_data.clone(); for update in new_forks { - info!(bid_slot =% slot_data.bid_slot, received =? update.parent_hash, sorting =? slot_data.payload_attributes_map.keys(), "sorting for an additional fork"); - slot_data.payload_attributes_map.insert(update.parent_hash, update); + info!(bid_slot =% slot_data.bid_slot, received =? update.fork(), sorting =? slot_data.payload_attributes_map.keys(), "sorting for an additional fork"); + slot_data.payload_attributes_map.insert(update.fork(), update); } *self = State::Sorting(slot_data); } else if slot_data.il.is_none() && il.is_some() { @@ -460,9 +467,9 @@ impl State { request_slot: params.slot, bid_slot: slot_data.bid_slot.into(), })); - } else if !slot_data.payload_attributes_map.contains_key(¶ms.parent_hash) { + } else if slot_data.fork_for_parent_hash(¶ms.parent_hash).is_none() { // proposer is on a different fork - warn!(req =% params.parent_hash, have =? slot_data.payload_attributes_map.keys(), "get header for unknown parent hash"); + warn!(req =% params.parent_hash, have =? slot_data.parent_hashes(), "get header for unknown parent hash"); let _ = res_tx.send(Err(ProposerApiError::NoBidPrepared)); } else if slot_data.registration_data.entry.registration.message.pubkey != params.pubkey @@ -735,20 +742,29 @@ impl State { let _ = res_tx.send(Ok(())); } } + + // take_held_gloas_payload (Gloas), valid regardless of state -- payloads are + // block-hash-keyed, so a lookup after the slot has moved on just misses. + ( + State::Slot { .. } | State::Sorting(_) | State::Broadcasting { .. }, + Event::TakeHeldGloasPayload { block_hash, slot, res_tx }, + ) => { + ctx.handle_take_held_gloas_payload(block_hash, slot, res_tx); + } } } #[timed] fn process_slot_data( bid_slot: Slot, - mut payload_attributes_map: FxHashMap, + mut payload_attributes_map: FxHashMap, registration_data: Option, payload_attributes: Vec, il: Option, ctx: &mut Context, ) -> Self { for update in payload_attributes { - payload_attributes_map.insert(update.parent_hash, update); + payload_attributes_map.insert(update.fork(), update); } match (registration_data, payload_attributes_map.is_empty()) { diff --git a/crates/relay/src/auctioneer/submit_block.rs b/crates/relay/src/auctioneer/submit_block.rs index 76590f80..ef9a1619 100644 --- a/crates/relay/src/auctioneer/submit_block.rs +++ b/crates/relay/src/auctioneer/submit_block.rs @@ -74,6 +74,7 @@ impl Context { let version = submission_data.version; let is_pessimistic = submission_data.is_pessimistic; let bid_adjustment_data = submission_data.bid_adjustment_data.clone(); + let gloas_data = submission_data.gloas_data.clone(); let mut trace = submission_data.trace; let (submission, maybe_tx_root) = match self.hydrate(submission_data.submission.clone()) { @@ -106,13 +107,13 @@ impl Context { &builder_info, slot_data, ) { - let bid = Bid::new(version, &submission); + let bid = Bid::new(version, &submission, payload_attributes.parent_root()); let is_top_bid = self.bid_sorter.sort(bid, &mut trace, true, producers); (OptimisticVersion::V1, is_top_bid) } else { let beats_top_bid = self .bid_sorter - .top_bid_value(&submission.message.parent_hash) + .top_bid_value(&payload_attributes.fork()) .is_none_or(|top| submission.message.value > top); (OptimisticVersion::NotOptimistic, beats_top_bid) }; @@ -135,11 +136,10 @@ impl Context { is_optimistic, apply_blacklist: slot_data.registration_data.entry.preferences.filtering.is_regional(), registered_gas_limit: slot_data.registration_data.entry.registration.message.gas_limit, - parent_beacon_block_root: payload_attributes - .parent_beacon_block_root - .unwrap_or_default(), + parent_beacon_block_root: payload_attributes.parent_root(), inclusion_list: slot_data.il.clone().unwrap_or_default(), submission: submission.clone(), + gloas_data: gloas_data.clone(), tx_root: maybe_tx_root, version, trace, @@ -154,6 +154,7 @@ impl Context { payload_attributes.withdrawals_root, maybe_tx_root, bid_adjustment_data, + gloas_data, version, trace, payload_attributes.parent_beacon_block_root, diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index fff714f0..31f4cc1b 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -5,7 +5,7 @@ use flux::type_hash_derive::type_hash_lock; use flux_utils::ArrayStr; use flux_versioned_types::{versioned_enum, versioned_struct}; use helix_common::{ - GetPayloadTrace, PayloadAttributesUpdate, SubmissionTrace, + ForkKey, GetPayloadTrace, PayloadAttributesUpdate, SubmissionTrace, api::{ builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, proposer_api::{GetExecutionPayloadBidParams, GetHeaderParams}, @@ -16,9 +16,10 @@ use helix_common::{ use helix_tcp_types::{BidSubmissionFlags, BidSubmissionHeader}; use helix_types::{ BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, BuilderBid, Compression, - ExecutionPayload, ForkName, GetPayloadResponse, MergeType, PayloadAndBlobs, PayloadBidData, - PayloadBidDataRef, SignedBidSubmission, SignedBlindedBeaconBlock, SignedExecutionPayloadBid, - Slot, Submission, SubmissionVersion, VersionedSignedProposal, mock_public_key_bytes, + ExecutionPayload, ForkName, GetPayloadResponse, GloasSubmissionData, MergeType, + PayloadAndBlobs, PayloadBidData, PayloadBidDataRef, SignedBidSubmission, + SignedBlindedBeaconBlock, SignedExecutionPayloadBid, Slot, Submission, SubmissionVersion, + VersionedSignedProposal, mock_public_key_bytes, }; use http::{ HeaderMap, HeaderValue, @@ -33,7 +34,8 @@ use crate::{ SubmissionDataWithSpan, api::{ HEADER_API_KEY, HEADER_API_TOKEN, HEADER_HYDRATE, HEADER_IS_MERGEABLE, HEADER_MERGE_TYPE, - HEADER_PESSIMISTIC, HEADER_SEQUENCE, HEADER_WITH_ADJUSTMENTS, proposer::ProposerApiError, + HEADER_PESSIMISTIC, HEADER_SEQUENCE, HEADER_WITH_ADJUSTMENTS, + proposer::{HeldGloasPayload, ProposerApiError}, }, auctioneer::MergeResult, gossip::BroadcastPayloadParams, @@ -247,6 +249,7 @@ pub struct SubmissionData { pub submission: Submission, pub merging_data: Option, pub bid_adjustment_data: Option, + pub gloas_data: Option, pub version: SubmissionVersion, pub withdrawals_root: B256, pub trace: SubmissionTrace, @@ -274,6 +277,9 @@ pub struct SubmissionPayload { pub withdrawals_root: B256, pub tx_root: Option, pub bid_adjustment_data: Option, + /// The builder's EIP-7928 list and EIP-8282 builder requests, present only + /// for Gloas submissions. + pub gloas_data: Option, pub is_adjusted: bool, pub submission_version: SubmissionVersion, pub submission_trace: SubmissionTrace, @@ -288,16 +294,19 @@ pub enum PayloadEntry { } impl PayloadEntry { + #[allow(clippy::too_many_arguments)] pub fn new_submission( signed_bid_submission: SignedBidSubmission, withdrawals_root: B256, tx_root: Option, bid_adjustment_data: Option, + gloas_data: Option, submission_version: SubmissionVersion, submission_trace: SubmissionTrace, parent_beacon_block_root: Option, ) -> Self { Self::Submission(SubmissionPayload { + gloas_data, signed_bid_submission, withdrawals_root, tx_root, @@ -342,6 +351,15 @@ impl PayloadEntry { } } + /// The proposer this payload was bid to. Gossiped payloads carry no bid trace, + /// so their proposer is unknown. + pub fn proposer_pubkey(&self) -> Option<&BlsPublicKeyBytes> { + match &self { + Self::Submission(s) => Some(s.signed_bid_submission.proposer_public_key()), + Self::Gossip(_) => None, + } + } + pub fn bid_data_ref(&self) -> PayloadBidDataRef<'_> { match &self { Self::Submission(s) => PayloadBidDataRef { @@ -362,6 +380,17 @@ impl PayloadEntry { } } + /// The submitted Gloas sidecar: the EIP-7928 list and the EIP-8282 builder + /// requests. Empty when the fork does not carry one, in which case a Gloas + /// conversion would produce an invalid payload -- the caller is expected to + /// only reach this on a Gloas submission. + pub fn gloas_data(&self) -> GloasSubmissionData { + match self { + Self::Submission(bid) => bid.gloas_data.clone().unwrap_or_default(), + Self::Gossip(_) => GloasSubmissionData::default(), + } + } + pub fn execution_payload_make_mut(&mut self) -> &mut ExecutionPayload { match self { Self::Submission(bid) => bid.signed_bid_submission.execution_payload_make_mut(), @@ -415,8 +444,8 @@ pub struct SlotData { pub bid_slot: Slot, /// Data about the validator registration pub registration_data: BuilderGetValidatorsResponseEntry, - /// Parent hash -> payload attributes for the incoming blocks - pub payload_attributes_map: FxHashMap, + /// Fork -> payload attributes for the incoming blocks + pub payload_attributes_map: FxHashMap, /// Current fork pub current_fork: ForkName, /// Inclusion list @@ -438,6 +467,28 @@ impl SlotData { pub fn proposer_pubkey(&self) -> &BlsPublicKeyBytes { &self.registration_data.entry.registration.message.pubkey } + + pub fn parent_hashes(&self) -> Vec { + self.payload_attributes_map.keys().map(|k| k.parent_hash).collect() + } + + pub fn fork_for_parent_hash(&self, parent_hash: &B256) -> Option { + self.payload_attributes_map.keys().find(|k| k.parent_hash == *parent_hash).copied() + } + + pub fn attrs_for_submission( + &self, + parent_hash: &B256, + prev_randao: &B256, + ) -> Option<&PayloadAttributesUpdate> { + let mut candidates = + self.payload_attributes_map.values().filter(|a| a.parent_hash == *parent_hash); + let first = candidates.next()?; + if first.prev_randao == *prev_randao { + return Some(first); + } + candidates.find(|a| a.prev_randao == *prev_randao).or(Some(first)) + } } #[allow(clippy::large_enum_variant)] @@ -478,6 +529,13 @@ pub enum Event { max_execution_payment: u64, res_tx: oneshot::Sender, }, + /// Looks up the execution payload a submission held for `block_hash`, so + /// `submitSignedBeaconBlock` can build the envelope fulfilling a proposer's committed bid. + TakeHeldGloasPayload { + block_hash: B256, + slot: Slot, + res_tx: oneshot::Sender>, + }, // Receive multiple of these potentially, assume some light validation GetPayload { block_hash: B256, @@ -505,6 +563,7 @@ impl Event { Event::GetHeader { .. } => "GetHeader", Event::GetExecutionPayloadBid { .. } => "GetExecutionPayloadBid", Event::SubmitBuilderPreferences { .. } => "SubmitBuilderPreferences", + Event::TakeHeldGloasPayload { .. } => "TakeHeldGloasPayload", Event::GetPayload { .. } => "GetPayload", Event::GossipPayload(_) => "GossipPayload", Event::SimResult(_) => "SimResult", @@ -513,3 +572,36 @@ impl Event { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn attrs(parent_hash: B256, parent_root: B256, prev_randao: B256) -> PayloadAttributesUpdate { + let mut update = PayloadAttributesUpdate { parent_hash, ..Default::default() }; + update.payload_attributes.parent_beacon_block_root = Some(parent_root); + update.payload_attributes.prev_randao = prev_randao; + update + } + + #[test] + fn a_submission_gets_the_fork_whose_prev_randao_it_matches() { + let parent_hash = B256::repeat_byte(1); + let forks = [ + attrs(parent_hash, B256::repeat_byte(2), B256::repeat_byte(3)), + attrs(parent_hash, B256::repeat_byte(4), B256::repeat_byte(5)), + ]; + let slot_data = SlotData { + bid_slot: Default::default(), + registration_data: Default::default(), + payload_attributes_map: forks.iter().map(|a| (a.fork(), a.clone())).collect(), + current_fork: ForkName::Gloas, + il: None, + }; + + for fork in &forks { + let got = slot_data.attrs_for_submission(&parent_hash, &fork.prev_randao).unwrap(); + assert_eq!(got.parent_root(), fork.parent_root()); + } + } +} diff --git a/crates/relay/src/auctioneer/validation.rs b/crates/relay/src/auctioneer/validation.rs index caf5ea6e..d36fdc14 100644 --- a/crates/relay/src/auctioneer/validation.rs +++ b/crates/relay/src/auctioneer/validation.rs @@ -1,7 +1,9 @@ use alloy_primitives::B256; use flux_profiler::timed; use helix_common::{BuilderInfo, PayloadAttributesUpdate, is_local_dev}; -use helix_types::{BlockValidationError, BlsPublicKeyBytes, Submission, SubmissionVersion}; +use helix_types::{ + BlockValidationError, BlsPublicKeyBytes, ForkName, Submission, SubmissionVersion, +}; use crate::auctioneer::{ bid_adjustor::BidAdjustor, @@ -27,11 +29,11 @@ impl Context { } let Some(payload_attributes) = - slot_data.payload_attributes_map.get(submission.parent_hash()) + slot_data.attrs_for_submission(submission.parent_hash(), submission.prev_randao()) else { return Err(BlockValidationError::UknnownParentHash { submission: *submission.parent_hash(), - have: slot_data.payload_attributes_map.keys().cloned().collect(), + have: slot_data.parent_hashes(), }); }; @@ -51,6 +53,7 @@ impl Context { self.staleness_check(submission.builder_pubkey(), submission_data.version)?; self.validate_submission_data( submission, + submission_data.decoder_params.fork_name, &withdrawals_root, slot_data, payload_attributes, @@ -64,15 +67,12 @@ impl Context { fn validate_submission_data( &self, payload: &Submission, + decoded_fork: ForkName, withdrawals_root: &B256, slot_data: &SlotData, payload_attributes: &PayloadAttributesUpdate, ) -> Result<(), BlockValidationError> { - if slot_data.current_fork != payload.fork_name() { - return Err(BlockValidationError::InvalidPayloadType { - fork_name: slot_data.current_fork, - }); - } + check_submission_fork(decoded_fork, slot_data.current_fork)?; // checks internal consistency of the payload payload.validate()?; @@ -167,6 +167,18 @@ pub fn check_if_trusted_builder( } } +/// The decoder records the fork it decoded with; a submission shaped for another fork cannot be +/// validated against this slot. +pub(super) fn check_submission_fork( + decoded: ForkName, + slot_fork: ForkName, +) -> Result<(), BlockValidationError> { + if decoded != slot_fork { + return Err(BlockValidationError::InvalidPayloadType { fork_name: slot_fork }); + } + Ok(()) +} + #[cfg(test)] mod tests { use helix_common::{ @@ -175,9 +187,12 @@ mod tests { builder_api::BuilderGetValidatorsResponseEntry, proposer_api::ValidatorRegistrationInfo, }, }; - use helix_types::ForkName; + use helix_types::{BlockValidationError, ForkName}; - use crate::auctioneer::{types::SlotData, validation::check_if_trusted_builder}; + use crate::auctioneer::{ + types::SlotData, + validation::{check_if_trusted_builder, check_submission_fork}, + }; #[test] fn test_check_if_trusted_builder_empty_list() { @@ -276,4 +291,25 @@ mod tests { assert!(check_if_trusted_builder(&builder_info, &slot_data).is_err()); } + + #[test] + fn a_gloas_submission_passes_the_fork_gate_on_a_gloas_slot() { + assert!( + check_submission_fork(ForkName::Gloas, ForkName::Gloas).is_ok(), + "the decoder decoded a Gloas submission for a Gloas slot", + ); + } + + #[test] + fn a_submission_decoded_for_another_fork_is_refused() { + let result = check_submission_fork(ForkName::Fulu, ForkName::Gloas); + + assert!( + matches!( + result, + Err(BlockValidationError::InvalidPayloadType { fork_name: ForkName::Gloas }) + ), + "the slot's fork is the one the submission had to match", + ); + } } diff --git a/crates/relay/src/bid_decoder/tile.rs b/crates/relay/src/bid_decoder/tile.rs index cf2d5154..bcca418a 100644 --- a/crates/relay/src/bid_decoder/tile.rs +++ b/crates/relay/src/bid_decoder/tile.rs @@ -19,8 +19,8 @@ use helix_common::{ utils::utcnow_ns, }; use helix_types::{ - BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, MergeType, SignedBidSubmission, - Submission, SubmissionVersion, + BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, GloasSubmissionData, MergeType, + SignedBidSubmission, Submission, SubmissionVersion, }; use rustc_hash::FxHashMap; use tracing::{info, trace, warn}; @@ -268,6 +268,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, + gloas_data, decoder_params, ) = Self::try_handle_block_submission( cache, @@ -316,6 +317,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, + gloas_data, withdrawals_root, trace, decoder_params, @@ -343,6 +345,7 @@ impl DecoderTile { SubmissionVersion, Option, Option, + Option, SubmissionDecoderParams, ), BuilderApiError, @@ -367,7 +370,7 @@ impl DecoderTile { }; let mut decoder = SubmissionDecoder::new(&decoder_params); - let (mut submission, merging_data, bid_adjustment_data) = + let (mut submission, merging_data, bid_adjustment_data, gloas_data) = decoder.decode(payload, buffer)?; trace.decoded_ns = Nanos::now(); @@ -414,6 +417,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, + gloas_data, decoder_params, )) } diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index d555c61b..ffbc7e2d 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -1226,6 +1226,7 @@ mod tests { signed.blobs_bundle = Arc::new(Default::default()); let submission_data = SubmissionData { submission_id: Uuid::nil(), + gloas_data: None, submission_ref: SubmissionRef::default(), submission: Submission::Full(signed), merging_data: Some(BlockMergingDataV2 { diff --git a/crates/relay/src/housekeeper/mod.rs b/crates/relay/src/housekeeper/mod.rs index dde96804..abca8757 100644 --- a/crates/relay/src/housekeeper/mod.rs +++ b/crates/relay/src/housekeeper/mod.rs @@ -2,6 +2,7 @@ mod chain_head; mod duties; mod payload_attrs; pub mod primev_service; +mod proposer_prefs; pub mod tile; pub mod inclusion_list_service; diff --git a/crates/relay/src/housekeeper/payload_attrs.rs b/crates/relay/src/housekeeper/payload_attrs.rs index 70ce85c2..f9ac4b25 100644 --- a/crates/relay/src/housekeeper/payload_attrs.rs +++ b/crates/relay/src/housekeeper/payload_attrs.rs @@ -1,5 +1,4 @@ -use alloy_primitives::B256; -use helix_common::{PayloadAttributesUpdate, beacon::types::PayloadAttributesEvent}; +use helix_common::{ForkKey, PayloadAttributesUpdate, beacon::types::PayloadAttributesEvent}; use helix_types::Slot; use rustc_hash::FxHashMap; use tracing::info; @@ -10,16 +9,20 @@ use crate::housekeeper::chain_head::ChainHead; pub fn process_payload_attributes( chain_head: &mut ChainHead, event: PayloadAttributesEvent, - known_payload_attributes: &mut FxHashMap<(B256, Slot), PayloadAttributesUpdate>, + known_payload_attributes: &mut FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, ) { // Drop stale payload attributes if chain_head.head() >= event.data.proposal_slot { return; } - // Drop duplicates for the same parent and slot. We may receive multiple events for the same + // Drop duplicates for the same fork and slot. We may receive multiple events for the same // slot - let payload_attributes_key = (event.data.parent_block_hash, event.data.proposal_slot); + let fork = ForkKey { + parent_hash: event.data.parent_block_hash, + parent_root: event.data.payload_attributes.parent_beacon_block_root.unwrap_or_default(), + }; + let payload_attributes_key = (fork, event.data.proposal_slot); if known_payload_attributes.contains_key(&payload_attributes_key) { return; } @@ -28,6 +31,7 @@ pub fn process_payload_attributes( head_slot =% chain_head.head(), payload_attribute_slot =% event.data.proposal_slot, payload_attribute_parent = ?event.data.parent_block_hash, + payload_attribute_parent_root = ?fork.parent_root, "processing payload attribute event", ); @@ -185,4 +189,18 @@ mod tests { process_payload_attributes(&mut ch, make_event(slot, B256::from([8u8; 32])), &mut known); assert_eq!(known.len(), 2); } + + #[test] + fn events_on_the_same_parent_hash_with_different_roots_both_stored() { + let (mut ch, head_slot) = make_chain_head(); + let mut known = FxHashMap::default(); + let slot = head_slot + 1; + let parent = B256::from([9u8; 32]); + for root in [B256::from([10u8; 32]), B256::from([11u8; 32])] { + let mut event = make_event(slot, parent); + event.data.payload_attributes.parent_beacon_block_root = Some(root); + process_payload_attributes(&mut ch, event, &mut known); + } + assert_eq!(known.len(), 2); + } } diff --git a/crates/relay/src/housekeeper/proposer_prefs.rs b/crates/relay/src/housekeeper/proposer_prefs.rs new file mode 100644 index 00000000..1a7ad2d4 --- /dev/null +++ b/crates/relay/src/housekeeper/proposer_prefs.rs @@ -0,0 +1,451 @@ +use helix_common::{ + ProposerDuty, ValidatorPreferences, + api::{ + builder_api::BuilderGetValidatorsResponseEntry, proposer_api::ValidatorRegistrationInfo, + }, + beacon::types::{ProposerPreferences, ProposerPreferencesEvent, SignedProposerPreferences}, + chain_info::ChainInfo, +}; +use helix_types::{SignedValidatorRegistration, Slot}; +use rustc_hash::FxHashMap; + +#[derive(Default)] +pub struct ProposerPreferencesStore { + /// The signature is kept so the proposer's own key can be checked later, once + /// the duty that names the proposer is to hand. + by_slot: FxHashMap, +} + +impl ProposerPreferencesStore { + /// The proposer may resubmit up to an epoch ahead, so a later event wins. + pub fn process(&mut self, head: Slot, event: ProposerPreferencesEvent) { + let signed = event.data; + if signed.message.proposal_slot <= head { + return; + } + self.by_slot.insert(signed.message.proposal_slot, signed); + } + + pub fn get(&self, slot: Slot) -> Option<&ProposerPreferences> { + self.by_slot.get(&slot).map(|signed| &signed.message) + } + + pub fn get_signed(&self, slot: Slot) -> Option<&SignedProposerPreferences> { + self.by_slot.get(&slot) + } + + pub fn on_new_slot(&mut self, bid_slot: Slot) { + self.by_slot.retain(|slot, _| *slot >= bid_slot); + } + + /// Whether the slot's preferences carry the proposer's own signature. + pub fn check_signature(&self, duty: &ProposerDuty, chain_info: &ChainInfo) -> bool { + let Some(signed) = self.get_signed(duty.slot) else { + return false; + }; + let ok = signed.verify(&duty.pubkey, chain_info); + if !ok { + tracing::warn!( + slot = %duty.slot, + validator_index = duty.validator_index, + "proposer preferences failed signature verification", + ); + } + ok + } + + #[cfg(test)] + fn len(&self) -> usize { + self.by_slot.len() + } +} + +/// Whether these preferences were gossiped by the validator that actually proposes the +/// slot. Nothing else binds the two: the fee recipient our builder pays comes straight +/// from here, so preferences from any other index would redirect the payment. +pub fn prefs_match_duty(duty: &ProposerDuty, prefs: &ProposerPreferences) -> bool { + prefs.validator_index == duty.validator_index && prefs.proposal_slot == duty.slot +} + +/// Gloas has no `registerValidator`, so the entry comes from the beacon duty and the gossiped +/// preferences, with this relay's configured preferences as the proposer cannot express its own. +pub fn synthesize_registration( + duty: &ProposerDuty, + prefs: &ProposerPreferences, + defaults: &ValidatorPreferences, +) -> BuilderGetValidatorsResponseEntry { + let mut registration = SignedValidatorRegistration::default(); + registration.message.pubkey = duty.pubkey; + registration.message.fee_recipient = prefs.fee_recipient; + registration.message.gas_limit = prefs.target_gas_limit; + + BuilderGetValidatorsResponseEntry { + slot: duty.slot, + validator_index: duty.validator_index, + entry: ValidatorRegistrationInfo { registration, preferences: defaults.clone() }, + } +} + +/// The duty feed builders poll. Gloas has no registrations, so a slot is served only once its +/// proposer has gossiped preferences for it. +pub fn synthesize_duty_feed( + beacon_duties: &[ProposerDuty], + prefs: &ProposerPreferencesStore, + from_slot: Slot, + defaults: &ValidatorPreferences, + chain_info: &ChainInfo, +) -> Vec { + beacon_duties + .iter() + .filter(|duty| duty.slot >= from_slot) + .filter(|duty| prefs.check_signature(duty, chain_info)) + .filter_map(|duty| { + prefs + .get(duty.slot) + .filter(|prefs| prefs_match_duty(duty, prefs)) + .map(|prefs| synthesize_registration(duty, prefs, defaults)) + }) + .collect() +} + +/// Merges freshly synthesized entries into the feed the builders poll. +/// +/// A synthesized entry is only a snapshot of the last preferences gossiped for +/// the slot, and a proposer may resubmit up to an epoch ahead -- the store keeps +/// the latest on purpose. Keeping the first snapshot instead would leave +/// builders working from a stale fee recipient and gas limit. A real +/// registration, which a proposer signed, is never displaced. +pub fn merge_duty_feed( + existing: Vec, + synthesized: Vec, +) -> Vec { + let refreshed: FxHashMap = + synthesized.into_iter().map(|entry| (entry.slot.as_u64(), entry)).collect(); + + let mut feed: Vec<_> = existing + .into_iter() + .map(|entry| { + let slot = entry.slot.as_u64(); + match refreshed.get(&slot) { + Some(fresh) if !is_registered(&entry) => fresh.clone(), + _ => entry, + } + }) + .collect(); + + let known: rustc_hash::FxHashSet = feed.iter().map(|e| e.slot.as_u64()).collect(); + feed.extend(refreshed.into_values().filter(|entry| !known.contains(&entry.slot.as_u64()))); + feed.sort_by_key(|e| e.slot.as_u64()); + feed +} + +/// Whether the proposer signed this registration itself, rather than it being +/// synthesized from gossiped preferences. +fn is_registered(entry: &BuilderGetValidatorsResponseEntry) -> bool { + entry.entry.registration.signature != helix_types::BlsSignatureBytes::default() +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, address}; + + use super::*; + + const LIVE_EVENT: &str = r#"{ + "version": "gloas", + "data": { + "message": { + "dependent_root": "0x0771be60148934328db0a9078a555c7ce7885f54a34d8c3c998ab7aafc5dfc39", + "proposal_slot": "254171", + "validator_index": "47100", + "fee_recipient": "0xf97e180c050e5ab072211ad2c213eb5aee4df134", + "target_gas_limit": "200000000" + }, + "signature": "0xb36623b3b48d160aeaa1f088d0a60877283f32f0ff1dd375e351c0f600c56f9c888abe926258dabfeff3c67a9818955e0c2e48ce8b4a15ec59bb56cf6e9a95029df258a787191eec7e87d8d5996f8c1e4ec4524b3a37b6f05d5f78ce50d3eec9" + } + }"#; + + fn event(slot: u64, fee_recipient: Address, target_gas_limit: u64) -> ProposerPreferencesEvent { + let mut ev: ProposerPreferencesEvent = serde_json::from_str(LIVE_EVENT).unwrap(); + ev.data.message.proposal_slot = Slot::new(slot); + ev.data.message.fee_recipient = fee_recipient; + ev.data.message.target_gas_limit = target_gas_limit; + ev + } + + /// As `event`, but gossiped by `index` -- the feed only accepts preferences from + /// the validator that actually proposes the slot. + fn event_from( + slot: u64, + index: u64, + fee_recipient: Address, + target_gas_limit: u64, + ) -> ProposerPreferencesEvent { + let mut ev = event(slot, fee_recipient, target_gas_limit); + ev.data.message.validator_index = index; + ev + } + + #[test] + fn parses_a_live_proposer_preferences_event() { + let ev: ProposerPreferencesEvent = serde_json::from_str(LIVE_EVENT).unwrap(); + + assert_eq!(ev.data.message.proposal_slot, Slot::new(254171)); + assert_eq!(ev.data.message.validator_index, 47100); + assert_eq!( + ev.data.message.fee_recipient, + address!("f97e180c050e5ab072211ad2c213eb5aee4df134") + ); + assert_eq!(ev.data.message.target_gas_limit, 200_000_000); + assert_eq!( + ev.data.message.dependent_root, + B256::from_slice(&alloy_primitives::hex!( + "0771be60148934328db0a9078a555c7ce7885f54a34d8c3c998ab7aafc5dfc39" + )) + ); + } + + #[test] + fn synthesizes_the_entry_from_the_duty_and_the_gossiped_preferences() { + let duty = ProposerDuty { + pubkey: helix_types::BlsPublicKeyBytes::from([9u8; 48]), + validator_index: 85292, + slot: Slot::new(101), + }; + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + let prefs = ProposerPreferences { + fee_recipient, + target_gas_limit: 45_000_000, + ..Default::default() + }; + let defaults = ValidatorPreferences { header_delay: false, ..Default::default() }; + + let entry = synthesize_registration(&duty, &prefs, &defaults); + + assert_eq!(entry.slot, Slot::new(101)); + assert_eq!(entry.validator_index, 85292); + assert_eq!(entry.entry.registration.message.pubkey, duty.pubkey, "from the beacon duty"); + assert_eq!(entry.entry.registration.message.fee_recipient, fee_recipient, "from gossip"); + assert_eq!(entry.entry.registration.message.gas_limit, 45_000_000, "from gossip"); + assert!(!entry.entry.preferences.header_delay, "the relay's own preferences apply"); + } + + #[test] + fn keeps_the_preference_for_a_future_slot() { + let mut store = ProposerPreferencesStore::default(); + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + + store.process(Slot::new(100), event(101, fee_recipient, 45_000_000)); + + let prefs = store.get(Slot::new(101)).expect("a future slot must be kept"); + assert_eq!(prefs.fee_recipient, fee_recipient); + assert_eq!(prefs.target_gas_limit, 45_000_000); + assert_eq!(prefs.validator_index, 47100); + } + + #[test] + fn drops_a_preference_for_a_passed_slot() { + let mut store = ProposerPreferencesStore::default(); + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + + store.process(Slot::new(100), event(100, fee_recipient, 45_000_000)); + + assert_eq!(store.len(), 0, "the relay cannot bid for a slot that has started"); + } + + #[test] + fn a_later_event_replaces_the_slots_preference() { + let mut store = ProposerPreferencesStore::default(); + let first = address!("00000000000000000000000000000000000000aa"); + let second = address!("00000000000000000000000000000000000000bb"); + + store.process(Slot::new(100), event(101, first, 45_000_000)); + store.process(Slot::new(100), event(101, second, 60_000_000)); + + let prefs = store.get(Slot::new(101)).expect("the slot must still be known"); + assert_eq!(prefs.fee_recipient, second, "the proposer's latest word wins"); + assert_eq!(prefs.target_gas_limit, 60_000_000); + assert_eq!(store.len(), 1); + } + + #[test] + fn prunes_passed_slots_on_a_new_slot() { + let mut store = ProposerPreferencesStore::default(); + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + + store.process(Slot::new(100), event(101, fee_recipient, 45_000_000)); + store.process(Slot::new(100), event(102, fee_recipient, 45_000_000)); + store.on_new_slot(Slot::new(102)); + + assert!(store.get(Slot::new(101)).is_none(), "slot 101 has passed"); + assert!(store.get(Slot::new(102)).is_some(), "slot 102 is the bid slot"); + assert_eq!(store.len(), 1); + } + + /// Duties now have to carry a real key: the feed only accepts preferences the + /// proposer actually signed. + fn duty_signed_by(keypair: &helix_types::BlsKeypair, slot: u64, index: u64) -> ProposerDuty { + ProposerDuty { + pubkey: keypair.pk.serialize().into(), + validator_index: index, + slot: Slot::new(slot), + } + } + + fn sign_event( + keypair: &helix_types::BlsKeypair, + chain_info: &ChainInfo, + mut ev: ProposerPreferencesEvent, + ) -> ProposerPreferencesEvent { + use helix_types::{EthSpec, MainnetEthSpec, SignedRoot}; + let epoch = ev.data.message.proposal_slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + helix_types::Domain::ProposerPreferences, + &fork, + chain_info.genesis_validators_root, + ); + ev.data.signature = + keypair.sk.sign(ev.data.message.signing_root(domain)).serialize().into(); + ev + } + + /// Builds duties and a matching, properly signed store for `(slot, index)` pairs. + fn duties_and_store( + entries: &[(u64, u64)], + chain_info: &ChainInfo, + ) -> (Vec, ProposerPreferencesStore) { + helix_common::utils::install_default_crypto_provider(); + let mut duties = Vec::new(); + let mut store = ProposerPreferencesStore::default(); + for (slot, index) in entries { + let keypair = helix_types::BlsKeypair::random(); + duties.push(duty_signed_by(&keypair, *slot, *index)); + let ev = event_from( + *slot, + *index, + address!("00000000000000000000000000000000000000aa"), + 45_000_000, + ); + store.process(Slot::new(0), sign_event(&keypair, chain_info, ev)); + } + (duties, store) + } + + #[test] + fn serves_only_the_slots_with_a_gossiped_preference() { + let chain_info = ChainInfo::default(); + let (mut duties, store) = duties_and_store(&[(101, 1), (103, 3)], &chain_info); + duties.push(duty_signed_by(&helix_types::BlsKeypair::random(), 102, 2)); + duties.sort_by_key(|d| d.slot); + + let feed = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + + let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); + assert_eq!(slots, vec![101, 103], "a slot without preferences cannot be served"); + assert_eq!(feed[0].validator_index, 1); + assert_eq!(feed[1].validator_index, 3); + } + + #[test] + fn drops_duties_before_the_bid_slot() { + let chain_info = ChainInfo::default(); + let (duties, store) = duties_and_store(&[(100, 1), (101, 2)], &chain_info); + + let feed = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + + let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); + assert_eq!(slots, vec![101], "builders cannot build a slot that has started"); + } + + /// A proposer may resubmit its preferences, and the store keeps the latest on + /// purpose. Keeping the first snapshot in the feed left builders working from a + /// stale gas limit and fee recipient, which the proposer then rejects. + #[test] + fn a_resubmitted_preference_replaces_the_served_entry() { + let chain_info = ChainInfo::default(); + let (duties, store) = duties_and_store(&[(101, 1)], &chain_info); + let first = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + assert_eq!(first[0].entry.registration.message.gas_limit, 45_000_000); + + // The proposer changes its mind about the gas limit. + let mut later = first.clone(); + later[0].entry.registration.message.gas_limit = 200_000_000; + + let merged = merge_duty_feed(first, later); + + assert_eq!(merged.len(), 1, "the slot must not be duplicated"); + assert_eq!( + merged[0].entry.registration.message.gas_limit, 200_000_000, + "the builder has to see the latest preferences, not the first", + ); + } + + /// A registration the proposer actually signed outranks anything synthesized. + #[test] + fn a_real_registration_is_not_displaced() { + let chain_info = ChainInfo::default(); + let (duties, store) = duties_and_store(&[(101, 1)], &chain_info); + let synthesized = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + let mut registered = synthesized.clone(); + registered[0].entry.registration.signature = + helix_types::BlsSignatureBytes::from([7u8; 96]); + registered[0].entry.registration.message.gas_limit = 36_000_000; + + let merged = merge_duty_feed(registered, synthesized); + + assert_eq!(merged[0].entry.registration.message.gas_limit, 36_000_000); + } + + /// The fee recipient the builder pays comes straight from these preferences, so + /// preferences gossiped by anyone but the slot's proposer would redirect the + /// payment. Nothing else binds the two. + #[test] + fn refuses_preferences_gossiped_by_another_validator() { + helix_common::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let attacker_key = helix_types::BlsKeypair::random(); + let attacker_address = address!("00000000000000000000000000000000000000ff"); + let duties = [duty_signed_by(&helix_types::BlsKeypair::random(), 101, 1)]; + + // Correctly signed, but by someone who does not propose this slot. + let mut store = ProposerPreferencesStore::default(); + let ev = event_from(101, 999, attacker_address, 45_000_000); + store.process(Slot::new(0), sign_event(&attacker_key, &chain_info, ev)); + + let feed = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + + assert!(feed.is_empty(), "a slot must not be served on someone else's preferences"); + } +} diff --git a/crates/relay/src/housekeeper/tile.rs b/crates/relay/src/housekeeper/tile.rs index b9450aba..f7923803 100644 --- a/crates/relay/src/housekeeper/tile.rs +++ b/crates/relay/src/housekeeper/tile.rs @@ -10,19 +10,22 @@ use flux::{ }; use flux_utils::SharedVector; use helix_common::{ - CurrentSlotInfo, InclusionListConfig, PayloadAttributesUpdate, PrimevConfig, ProposerDuty, - RelayConfig, SlotDuties, ValidatorSummary, + CurrentSlotInfo, ForkKey, InclusionListConfig, PayloadAttributesUpdate, PrimevConfig, + ProposerDuty, RelayConfig, SlotDuties, ValidatorPreferences, ValidatorSummary, api::builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, beacon::{ MultiBeaconClient, - types::{BeaconResponse, HeadEventData, PayloadAttributesEvent, SyncStatus}, + types::{ + BeaconResponse, HeadEventData, PayloadAttributesEvent, ProposerPreferencesEvent, + SyncStatus, + }, }, chain_info::ChainInfo, http::client::{HttpClient, PendingResponse, SseStream}, local_cache::LocalCache, metrics::{DUTIES_AGE_SECONDS, DUTIES_FETCH, DUTIES_LOOKAHEAD_SLOTS}, }; -use helix_types::Slot; +use helix_types::{ForkName, Slot}; use rustc_hash::FxHashMap; use tracing::{debug, error, info, warn}; @@ -37,6 +40,10 @@ use crate::{ PRIMEV_BUILDER_ID, PrimevBuildersFetch, PrimevValidatorsFetch, build_primev_builder_configs, }, + proposer_prefs::{ + ProposerPreferencesStore, merge_duty_feed, prefs_match_duty, synthesize_duty_feed, + synthesize_registration, + }, }, network::RelayNetworkManager, spine::messages::SlotMsg, @@ -66,6 +73,8 @@ struct HousekeeperStats { head_sse_events: u32, head_sse_parse_errors: u32, payload_attr_sse_events: u32, + proposer_prefs_sse_events: u32, + proposer_prefs_parse_errors: u32, payload_attr_parse_errors: u32, duties_fetch_ok: u32, duties_fetch_empty: u32, @@ -95,7 +104,10 @@ pub struct HousekeeperTile { beacon_client: Arc, head_sse: Vec, payload_attr_sse: Vec, + proposer_prefs_sse: Vec, + proposer_prefs: ProposerPreferencesStore, primev_config: Option, + validator_preferences: ValidatorPreferences, il_config: Option, // Output @@ -105,7 +117,7 @@ pub struct HousekeeperTile { relay_network_api: Arc, db: DbHandle, - known_payload_attributes: FxHashMap<(B256, Slot), PayloadAttributesUpdate>, + known_payload_attributes: FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, duties: Vec, requested_registrations: RequestedRegistrations, last_dependent_root: Option, @@ -168,13 +180,26 @@ impl HousekeeperTile { }) .collect(); + let proposer_prefs_sse = beacon_client + .beacon_clients + .iter() + .map(|c| { + let mut url = c.config.url.join("/eth/v1/events").unwrap(); + url.set_query(Some("topics=proposer_preferences")); + http_client.sse_stream(url) + }) + .collect(); + let tile = Self { chain_head, http_client, beacon_client, head_sse, payload_attr_sse, + proposer_prefs_sse, + proposer_prefs: ProposerPreferencesStore::default(), primev_config: config.primev_config.clone(), + validator_preferences: config.validator_preferences.clone(), il_config: config.inclusion_list.clone(), slot_events, local_cache, @@ -203,11 +228,51 @@ impl HousekeeperTile { (tile, curr_slot_info) } + /// Gloas proposers never register, so the feed builders poll is filled from the beacon + /// duties and the gossiped preferences, without displacing a real registration. + fn refresh_gloas_duty_feed(&self) { + let from_slot = self.chain_head.head() + 1; + if self.chain_head.chain_info().fork_at_slot(from_slot) != ForkName::Gloas { + return; + } + + let chain_info = self.chain_head.chain_info(); + let synthesized = synthesize_duty_feed( + &self.duties, + &self.proposer_prefs, + from_slot, + &self.validator_preferences, + chain_info, + ); + + let offered = self + .duties + .iter() + .filter(|duty| duty.slot >= from_slot && self.proposer_prefs.get(duty.slot).is_some()) + .count(); + if offered > synthesized.len() { + warn!( + refused = offered - synthesized.len(), + offered, "proposer preferences refused: wrong proposer or bad signature", + ); + } + + if synthesized.is_empty() { + return; + } + + let feed = merge_duty_feed(self.local_cache.get_proposer_duties(), synthesized); + self.local_cache.update_proposer_duties(feed); + } + fn on_new_slot(&mut self) { let slot = self.chain_head.head(); info!(%slot, "new slot started"); self.duties_fetch_attempt = 0; + self.proposer_prefs.on_new_slot(slot + 1); + self.refresh_gloas_duty_feed(); + self.fetch_duties(); self.maybe_fetch_il(); self.report_duties_age(); @@ -318,6 +383,19 @@ impl Tile for HousekeeperTile { // or same iteration). is_new_slot() returned false for this slot. self.on_new_slot(); } + for stream in &mut self.proposer_prefs_sse { + if let Poll::Ready(ev) = stream.poll() { + self.stats.proposer_prefs_sse_events += 1; + match serde_json::from_str::(&ev.data) { + Ok(data) => self.proposer_prefs.process(self.chain_head.head(), data), + Err(e) => { + self.stats.proposer_prefs_parse_errors += 1; + error!(err = %e, "proposer_preferences SSE parse error"); + } + } + } + } + for stream in &mut self.payload_attr_sse { if let Poll::Ready(ev) = stream.poll() { self.stats.payload_attr_sse_events += 1; @@ -373,6 +451,7 @@ impl Tile for HousekeeperTile { self.chain_head.epoch().as_u64(), ); self.duties = duties; + self.refresh_gloas_duty_feed(); self.chain_head.mark_duties_done(); if root_changed { self.stats.duties_dependent_root_changed += 1; @@ -583,6 +662,9 @@ impl Tile for HousekeeperTile { &self.curr_slot_info, &self.slot_events, &self.known_payload_attributes, + &self.proposer_prefs, + &self.duties, + &self.validator_preferences, self.pending_il.take(), std::mem::take(&mut self.stats), ); @@ -601,7 +683,10 @@ fn send_slot_event( local_cache: &LocalCache, curr_slot_info: &CurrentSlotInfo, slot_events: &SharedVector, - known_payload_attributes: &FxHashMap<(B256, Slot), PayloadAttributesUpdate>, + known_payload_attributes: &FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, + proposer_prefs: &ProposerPreferencesStore, + beacon_duties: &[ProposerDuty], + validator_preferences: &ValidatorPreferences, il: Option, stats: HousekeeperStats, ) { @@ -620,7 +705,20 @@ fn send_slot_event( } } - let next_duty = new_duties.iter().find(|d| d.slot.as_u64() == bid_slot.as_u64()).cloned(); + let mut next_duty = new_duties.iter().find(|d| d.slot.as_u64() == bid_slot.as_u64()).cloned(); + + // Gloas drops `registerValidator`, so the entry comes from the duty and the gossiped prefs. + if next_duty.is_none() && chain_head.chain_info().fork_at_slot(bid_slot) == ForkName::Gloas { + next_duty = beacon_duties + .iter() + .find(|d| d.slot.as_u64() == bid_slot.as_u64()) + .zip(proposer_prefs.get(bid_slot)) + .filter(|(duty, prefs)| { + prefs_match_duty(duty, prefs) && + proposer_prefs.check_signature(duty, chain_head.chain_info()) + }) + .map(|(duty, prefs)| synthesize_registration(duty, prefs, validator_preferences)); + } let next_payload_attributes: Vec = known_payload_attributes .iter() .filter_map( @@ -633,10 +731,13 @@ fn send_slot_event( duties_available = !new_duties.is_empty(), has_next_duty = next_duty.is_some(), has_payload_attrs = !next_payload_attributes.is_empty(), + has_proposer_prefs = proposer_prefs.get(bid_slot).is_some(), head_sse_events = stats.head_sse_events, head_sse_parse_errors = stats.head_sse_parse_errors, payload_attr_sse_events = stats.payload_attr_sse_events, payload_attr_parse_errors = stats.payload_attr_parse_errors, + proposer_prefs_sse_events = stats.proposer_prefs_sse_events, + proposer_prefs_parse_errors = stats.proposer_prefs_parse_errors, duties_fetch_ok = stats.duties_fetch_ok, duties_fetch_empty = stats.duties_fetch_empty, duties_fetch_err = stats.duties_fetch_err, diff --git a/crates/relay/src/lib.rs b/crates/relay/src/lib.rs index cb58367e..40068d88 100644 --- a/crates/relay/src/lib.rs +++ b/crates/relay/src/lib.rs @@ -28,7 +28,7 @@ pub use crate::block_merging::{OrderTxs, find_unbundled_txs}; pub use crate::{ api::{ Api, BidAdjustor, DefaultBidAdjustor, FutureBidSubmissionResult, builder::TopBidTile, - start_admin_service, start_api_service, + proposer::GloasBuilderIdentity, start_admin_service, start_api_service, }, auctioneer::{ Auctioneer, AuctioneerHandle, BidSorter, Context, Event, InternalBidSubmissionHeader, diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index 6acba77b..444cd01c 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -28,8 +28,8 @@ use helix_operator::spawn_operator_connection; use helix_relay::{ Api, Auctioneer, AuctioneerHandle, BidSorter, BidSubmissionTcpListener, BlockMergeResponse, BlockMergingTile, BroadcastPayloadParams, DbHandle, DecoderTile, DefaultBidAdjustor, - FutureBidSubmissionResult, GossipedMessage, HelixSpine, HelixSpineConfig, HousekeeperTile, - Lane, NewTcpBidSubmission, RegWorkerHandle, RegistrationTile, RelayConfigExt, + FutureBidSubmissionResult, GloasBuilderIdentity, GossipedMessage, HelixSpine, HelixSpineConfig, + HousekeeperTile, Lane, NewTcpBidSubmission, RegWorkerHandle, RegistrationTile, RelayConfigExt, RelayNetworkManager, Simulators, SlotUpdate, SubmissionDataWithSpan, TopBidTile, UdpTopBidTile, spawn_tokio_monitoring, spine_epoch_path, start_admin_service, start_api_service, start_db_service, @@ -240,7 +240,7 @@ async fn run( local_cache.clone(), current_slot_info, chain_info.clone(), - relay_signing_context, + relay_signing_context.clone(), beacon_client, Arc::new(DefaultApiProvider {}), known_validators_loaded, @@ -354,6 +354,11 @@ async fn run( attach_tile(merging_tile, spine, TileConfig::new(config.cores.block_merging, None)); } + let gloas_builder_identity = Arc::new(GloasBuilderIdentity { + builder_index: config.gloas_builder_index, + keypair: relay_signing_context.keypair.clone(), + }); + let auctioneer_core = config.cores.auctioneer; let auctioneer = Auctioneer::new( chain_info.as_ref().clone(), @@ -374,6 +379,7 @@ async fn run( merged_blocks, alert_manager.clone(), operator_api.clone(), + gloas_builder_identity, ); attach_tile(auctioneer, spine, TileConfig::new(auctioneer_core, None)); } diff --git a/crates/relay/src/simulator/client.rs b/crates/relay/src/simulator/client.rs index 5fc14834..593701b3 100644 --- a/crates/relay/src/simulator/client.rs +++ b/crates/relay/src/simulator/client.rs @@ -57,10 +57,20 @@ impl SimulatorClient { &self.config.url } - pub fn ssz_request_builder(&self) -> Option { + /// `None` for a fork the SSZ validator cannot handle, mirroring + /// [`Self::sim_request_builder`]. The two dispatch kinds have separate fork + /// lists: an SSZ validator is a different implementation from the JSON one. + pub fn ssz_request_builder(&self, fork: ForkName) -> Option { + if !Self::ssz_supports(fork) { + return None; + } self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate"))) } + fn ssz_supports(fork: ForkName) -> bool { + matches!(fork, ForkName::Fulu | ForkName::Gloas) + } + /// Relay-internal merged-block SSZ route; see `sim_method_merged_v5`. pub fn ssz_merged_request_builder(&self) -> Option { self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate_merged"))) @@ -270,6 +280,33 @@ mod test { assert!(sim_client().sim_request_builder(ForkName::Gloas).is_none()); } + fn ssz_client() -> super::SimulatorClient { + super::SimulatorClient::new(reqwest::Client::new(), SimulatorConfig { + url: "http://localhost:8545".into(), + namespace: "relay".into(), + max_concurrent_tasks: 1, + ssz_url: Some("http://localhost:8552".into()), + }) + } + + #[test] + fn ssz_request_builder_routes_fulu() { + assert!(ssz_client().ssz_request_builder(ForkName::Fulu).is_some()); + } + + #[test] + fn ssz_request_builder_routes_gloas() { + assert!(ssz_client().ssz_request_builder(ForkName::Gloas).is_some()); + } + + #[test] + fn ssz_request_builder_refuses_an_unscheduled_fork() { + assert!( + ssz_client().ssz_request_builder(ForkName::Heze).is_none(), + "the SSZ path short-circuited above the fork gate #517 added", + ); + } + #[tokio::test] async fn balance_request() { let sim_client = super::SimulatorClient::new(reqwest::Client::new(), SimulatorConfig { diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index 6a7084cd..82452773 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -10,6 +10,7 @@ use helix_common::{ SimulatorConfig, SubmissionTrace, api::builder_api::InclusionListWithMetadata, bid_submission::OptimisticVersion, + decoder::SubmissionDecoderParams, metrics::SimulatorMetrics, record_submission_step, simulator::{ @@ -22,7 +23,8 @@ use helix_common::{ }; use helix_types::{ BidTrace, BlobsBundle, BlsPublicKeyBytes, BlsSignatureBytes, BuilderInclusionResult, - ExecutionPayload, ExecutionRequests, MergedBlockTrace, SignedBidSubmission, SubmissionVersion, + ExecutionPayload, ExecutionRequests, ForkName, GloasSubmissionData, MergedBlockTrace, + SignedBidSubmission, SignedBidSubmissionGloas, SubmissionVersion, }; use rustc_hash::FxHashMap; use ssz::Encode as _; @@ -74,6 +76,7 @@ pub struct ValidationRequest { pub parent_beacon_block_root: B256, pub inclusion_list: InclusionListWithMetadata, pub submission: SignedBidSubmission, + pub gloas_data: Option, pub tx_root: Option, pub version: SubmissionVersion, pub trace: SubmissionTrace, @@ -363,7 +366,7 @@ impl Simulators { Some(SimulationResultInner { submission_ref: req.submission_ref, optimistic_version: req.optimistic_version(), - bid: Some(Bid::new(req.version, &req.submission)), + bid: Some(Bid::new(req.version, &req.submission, req.parent_beacon_block_root)), result: result.map(|()| req.trace), submission_id: req.submission_id, // Never dispatched: zero skips sim telemetry in `emit_sim_outcome`. @@ -552,45 +555,47 @@ impl Simulators { .or_insert_with(|| SeenBlock::InFlight(Vec::new())); let sim = &mut self.simulators[id]; - let dispatch = if let Some(url) = &sim.client.ssz_url { - SimDispatch::Ssz { - to_send: sim.client.client.post(format!("{url}/validate")), + let fork = submission.fork_name(); + let dispatch = match &sim.client.ssz_url { + Some(url) => sim.client.ssz_request_builder(fork).map(|to_send| SimDispatch::Ssz { + to_send, ssz_url: url.clone(), http: sim.client.client.clone(), - } - } else { - let fork = submission.fork_name(); - let Some((builder, method)) = sim.client.sim_request_builder(fork) else { - warn!(%fork, "no validation RPC method for fork, dropping submission"); - sim.pending += 1; - let result = SimResult::Validate(( - id, - Some(SimulationResultInner { - submission_ref: req.submission_ref, - optimistic_version: req.optimistic_version(), - bid: None, - result: Err(BlockSimError::UnsupportedFork(fork)), - submission_id: req.submission_id, - block_hash: *submission.block_hash(), - txs: Vec::new(), - retried: false, - }), - )); - let started = sim_started_event( - req.submission_id, - *submission.block_hash(), - false, - req.is_top_bid, - ); - let _ = self.task_tx.send(SimulatorsEvent::TaskDone { - id, - error: None, - result: Box::new(result), - elapsed: None, - }); - return (*submission.block_hash() != B256::ZERO).then_some(started); - }; - SimDispatch::Json { to_send: builder, method: method.to_owned() } + }), + None => sim + .client + .sim_request_builder(fork) + .map(|(to_send, method)| SimDispatch::Json { to_send, method: method.to_owned() }), + }; + let Some(dispatch) = dispatch else { + warn!(%fork, "no validation method for fork, dropping submission"); + sim.pending += 1; + let result = SimResult::Validate(( + id, + Some(SimulationResultInner { + submission_ref: req.submission_ref, + optimistic_version: req.optimistic_version(), + bid: None, + result: Err(BlockSimError::UnsupportedFork(fork)), + submission_id: req.submission_id, + block_hash: *submission.block_hash(), + txs: Vec::new(), + retried: false, + }), + )); + let started = sim_started_event( + req.submission_id, + *submission.block_hash(), + false, + req.is_top_bid, + ); + let _ = self.task_tx.send(SimulatorsEvent::TaskDone { + id, + error: None, + result: Box::new(result), + elapsed: None, + }); + return (*submission.block_hash() != B256::ZERO).then_some(started); }; sim.pending += 1; @@ -678,7 +683,7 @@ impl Simulators { record_submission_step("simulation", start_sim.elapsed()); let error = res.as_ref().err().cloned(); - let bid = Bid::new(version, &submission); + let bid = Bid::new(version, &submission, req.parent_beacon_block_root); SimulatorMetrics::sim_builder_outcome( &bid.builder_pubkey.to_string(), req.priority.label(), @@ -1407,23 +1412,36 @@ fn create_ssz_request( req.parent_beacon_block_root, req.inclusion_list.clone(), submission, + req.gloas_data.clone(), ) } +/// A hydrated submission is re-encoded here, so a Gloas one has to go in its own +/// shape: `SignedBidSubmission` has nowhere to put the block access list. The +/// decoder params name that shape, because nothing else on the wire does. +#[allow(clippy::too_many_arguments)] fn ssz_request( apply_blacklist: bool, registered_gas_limit: u64, parent_beacon_block_root: B256, inclusion_list: InclusionListWithMetadata, submission: &SignedBidSubmission, + gloas_data: Option, ) -> SszValidationRequest { + let (decoder_params, signed_bid_submission) = match gloas_data { + Some(gloas_data) => ( + Some(SubmissionDecoderParams::plain(ForkName::Gloas)), + SignedBidSubmissionGloas::join(submission.clone(), gloas_data).as_ssz_bytes(), + ), + None => (None, submission.as_ssz_bytes()), + }; SszValidationRequest { apply_blacklist, registered_gas_limit, parent_beacon_block_root, inclusion_list, - decoder_params: None, - signed_bid_submission: submission.as_ssz_bytes(), + decoder_params, + signed_bid_submission, } } @@ -1446,3 +1464,48 @@ fn ssz_merged_request( base_payment_tx_index, } } + +#[cfg(test)] +mod tests { + use helix_types::TestRandomSeed; + + use super::*; + + /// The SSZ path re-encodes the submission, so this is where a Gloas one + /// would lose its block access list. + #[test] + fn a_gloas_ssz_request_carries_the_block_access_list() { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + let bal = helix_types::BlockAccessListBytes(vec![7u8; 48].into()); + + let request = ssz_request( + false, + 0, + B256::ZERO, + Default::default(), + &submission, + Some(GloasSubmissionData { block_access_list: bal.clone(), ..Default::default() }), + ); + + let params = request.decoder_params.expect("a Gloas request names its shape"); + assert_eq!(params.fork_name, ForkName::Gloas); + let mut buf = Vec::new(); + let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) + .decode(&request.signed_bid_submission, &mut buf) + .expect("the simulator must be able to decode what the relay sends"); + assert_eq!(decoded.expect("the list must survive the re-encode").block_access_list, bal); + } + + /// Every other fork keeps the bare shape, so no simulator sees a new one. + #[test] + fn a_non_gloas_ssz_request_is_unchanged() { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + + let request = ssz_request(false, 0, B256::ZERO, Default::default(), &submission, None); + + assert!(request.decoder_params.is_none()); + assert_eq!(request.signed_bid_submission, submission.as_ssz_bytes()); + } +} diff --git a/crates/simulator/src/ssz_server.rs b/crates/simulator/src/ssz_server.rs index 77d3ef4c..d46b8edd 100644 --- a/crates/simulator/src/ssz_server.rs +++ b/crates/simulator/src/ssz_server.rs @@ -48,7 +48,7 @@ fn decode_submission( Some(decode_params) => { let mut buf = vec![]; let mut decoder = SubmissionDecoder::new(&decode_params); - let (submission, _, _) = decoder.decode(signed_bid_submission, &mut buf)?; + let (submission, _, _, _) = decoder.decode(signed_bid_submission, &mut buf)?; match submission { Submission::Full(s) => Ok(s.into()), Submission::Dehydrated(_) => { diff --git a/crates/types/src/bid_submission.rs b/crates/types/src/bid_submission.rs index 2faf29d1..457fd53d 100644 --- a/crates/types/src/bid_submission.rs +++ b/crates/types/src/bid_submission.rs @@ -20,7 +20,9 @@ use crate::{ PayloadAndBlobs, SszError, TestRandom, bid_adjustment_data::{BidAdjData, BidAdjustmentData, BidAdjustmentDataV1}, error::SigError, - fields::ExecutionRequests, + fields::{ + BlockAccessListBytes, BuilderDepositRequests, BuilderExitRequests, ExecutionRequests, + }, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TreeHash)] @@ -736,6 +738,87 @@ impl SignedBidSubmissionWithAdjustments { } } +/// What a Gloas submission carries beyond the pre-Gloas core: the EIP-7928 +/// block access list and the EIP-8282 builder deposit and exit requests. Kept +/// together so every hand-off that already moved the access list moves all +/// three, rather than silently dropping the builder lists. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GloasSubmissionData { + pub block_access_list: BlockAccessListBytes, + pub builder_deposits: BuilderDepositRequests, + pub builder_exits: BuilderExitRequests, +} + +/// Gloas carries what the builder produced that no earlier fork has: the block +/// access list (EIP-7928) and the builder deposit and exit requests (EIP-8282). +/// Core fields ++ block_access_list ++ builder_deposits ++ builder_exits. +/// +/// A separate type rather than fork-gated fields, because `Encode` is derived +/// on [`SignedBidSubmission`] and an extra field would change the bytes for +/// every fork. `execution_requests` keeps the Electra shape for the three +/// pre-Gloas lists; [`helix_types::execution_requests_to_gloas`] rejoins all +/// five for the payload the proposer signs. +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct SignedBidSubmissionGloas { + pub message: BidTrace, + pub execution_payload: Arc, + pub blobs_bundle: Arc, + pub execution_requests: Arc, + pub signature: BlsSignatureBytes, + pub block_access_list: BlockAccessListBytes, + pub builder_deposits: BuilderDepositRequests, + pub builder_exits: BuilderExitRequests, +} + +impl TestRandom for SignedBidSubmissionGloas { + fn random_for_test(rng: &mut impl rand::RngCore) -> Self { + Self { + message: BidTrace::random_for_test(rng), + execution_payload: ExecutionPayload::random_for_test(rng).into(), + blobs_bundle: BlobsBundle::random_for_test(rng).into(), + execution_requests: ExecutionRequests::random_for_test(rng).into(), + signature: BlsSignatureBytes::random(), + block_access_list: BlockAccessListBytes::random_for_test(rng), + builder_deposits: Default::default(), + builder_exits: Default::default(), + } + } +} + +impl SignedBidSubmissionGloas { + /// Inverse of [`Self::split`], for re-encoding a hydrated submission on the + /// way to an SSZ simulator. + pub fn join(submission: SignedBidSubmission, gloas_data: GloasSubmissionData) -> Self { + Self { + message: submission.message, + execution_payload: submission.execution_payload, + blobs_bundle: submission.blobs_bundle, + execution_requests: submission.execution_requests, + signature: submission.signature, + block_access_list: gloas_data.block_access_list, + builder_deposits: gloas_data.builder_deposits, + builder_exits: gloas_data.builder_exits, + } + } + + pub fn split(self) -> (SignedBidSubmission, GloasSubmissionData) { + ( + SignedBidSubmission { + message: self.message, + execution_payload: self.execution_payload, + blobs_bundle: self.blobs_bundle, + execution_requests: self.execution_requests, + signature: self.signature, + }, + GloasSubmissionData { + block_access_list: self.block_access_list, + builder_deposits: self.builder_deposits, + builder_exits: self.builder_exits, + }, + ) + } +} + /// Flat combination of [`SignedBidSubmissionWithAdjustments`] and merging data: /// core fields ++ bid_adjustment_data ++ merging_data. #[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] @@ -937,3 +1020,114 @@ mod tests { assert!(bytes.ends_with(&tail)); } } + +#[cfg(test)] +mod gloas_submission_tests { + use ssz::{Decode, Encode}; + + use super::*; + use crate::TestRandomSeed; + + /// `BlobsBundle::random_for_test` makes one proof per blob while `Decode` + /// demands 128, so a random bundle cannot round-trip. These tests are about + /// the block access list, so they use an empty one. + fn decodable_gloas_submission() -> SignedBidSubmissionGloas { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + submission + } + + #[test] + fn a_gloas_submission_round_trips_through_ssz() { + let submission = decodable_gloas_submission(); + + let bytes = submission.as_ssz_bytes(); + let decoded = SignedBidSubmissionGloas::from_ssz_bytes(&bytes).unwrap(); + + assert_eq!(decoded.message, submission.message); + assert_eq!(decoded.block_access_list, submission.block_access_list); + assert_eq!(bytes, decoded.as_ssz_bytes()); + } + + fn builder_requests() -> (BuilderDepositRequests, BuilderExitRequests) { + ( + vec![lh_types::BuilderDepositRequest { + pubkey: lh_bls::PublicKeyBytes::empty(), + withdrawal_credentials: B256::repeat_byte(0x11), + amount: 32_000_000_000, + signature: lh_bls::SignatureBytes::empty(), + }] + .into(), + vec![lh_types::BuilderExitRequest { + source_address: alloy_primitives::Address::repeat_byte(0x22), + pubkey: lh_bls::PublicKeyBytes::empty(), + }] + .into(), + ) + } + + #[test] + fn splitting_a_gloas_submission_yields_the_base_and_the_gloas_data() { + let submission = decodable_gloas_submission(); + let expected_bal = submission.block_access_list.clone(); + let expected_hash = submission.message.block_hash; + + let (base, gloas_data) = submission.split(); + + assert_eq!(gloas_data.block_access_list, expected_bal); + assert_eq!(base.message.block_hash, expected_hash); + } + + /// EIP-8282: the builder lists have to survive the hand-off the relay makes + /// between decoding a submission and re-encoding it for the simulator. While + /// they did not exist on this type, a block carrying one could not be bid. + #[test] + fn a_gloas_submission_keeps_its_builder_requests_through_split_and_join() { + let (builder_deposits, builder_exits) = builder_requests(); + let mut submission = decodable_gloas_submission(); + submission.builder_deposits = builder_deposits.clone(); + submission.builder_exits = builder_exits.clone(); + + let (base, gloas_data) = submission.split(); + let rejoined = SignedBidSubmissionGloas::join(base, gloas_data); + + assert_eq!(rejoined.builder_deposits, builder_deposits); + assert_eq!(rejoined.builder_exits, builder_exits); + } + + #[test] + fn a_gloas_submission_with_builder_requests_round_trips_through_ssz() { + let (builder_deposits, builder_exits) = builder_requests(); + let mut submission = decodable_gloas_submission(); + submission.builder_deposits = builder_deposits.clone(); + submission.builder_exits = builder_exits.clone(); + + let bytes = submission.as_ssz_bytes(); + let decoded = SignedBidSubmissionGloas::from_ssz_bytes(&bytes).unwrap(); + + assert_eq!(decoded.builder_deposits, builder_deposits); + assert_eq!(decoded.builder_exits, builder_exits); + } + + #[test] + fn the_gloas_shape_is_distinct_from_fulu() { + // Neither decodes as the other, so no existing Fulu path can silently + // accept a Gloas submission or vice versa. + let mut gloas = decodable_gloas_submission(); + gloas.block_access_list = BlockAccessListBytes(vec![7u8; 64].into()); + + assert!( + SignedBidSubmission::from_ssz_bytes(&gloas.as_ssz_bytes()).is_err(), + "a Gloas submission must not decode as Fulu", + ); + assert!( + SignedBidSubmissionGloas::from_ssz_bytes(&{ + let mut fulu = SignedBidSubmission::test_random(); + fulu.blobs_bundle = Default::default(); + fulu.as_ssz_bytes() + }) + .is_err(), + "a Fulu submission must not decode as Gloas", + ); + } +} diff --git a/crates/types/src/execution_payload.rs b/crates/types/src/execution_payload.rs index eff1535e..782b1d87 100644 --- a/crates/types/src/execution_payload.rs +++ b/crates/types/src/execution_payload.rs @@ -8,7 +8,7 @@ use tree_hash_derive::TreeHash; use crate::{ BlockValidationError, SszError, TestRandom, convert_bloom_to_lighthouse, - convert_transactions_to_lighthouse, + convert_transactions_to_lighthouse, convert_transactions_to_progressive, fields::{Bloom, ExtraData, Transactions, Withdrawals}, }; @@ -155,10 +155,44 @@ impl ExecutionPayload { excess_blob_gas: self.excess_blob_gas, }) } + + /// Converts to the real, progressive-list Gloas execution payload shape used on-chain. + /// + /// `block_access_list` is the opaque EIP-7928 list the builder submitted: only + /// an execution client can produce it, so it travels on the submission. + pub fn to_lighthouse_gloas_payload( + &self, + slot: lh_types::Slot, + block_access_list: &crate::fields::BlockAccessListBytes, + ) -> Result, SszError> { + Ok(lh_types::ExecutionPayloadGloas { + parent_hash: self.parent_hash.into(), + fee_recipient: self.fee_recipient, + state_root: self.state_root, + receipts_root: self.receipts_root, + logs_bloom: convert_bloom_to_lighthouse(&self.logs_bloom), + prev_randao: self.prev_randao, + block_number: self.block_number, + gas_limit: self.gas_limit, + gas_used: self.gas_used, + timestamp: self.timestamp, + extra_data: self.extra_data.to_ssz_type()?, + base_fee_per_gas: self.base_fee_per_gas, + block_hash: self.block_hash.into(), + transactions: convert_transactions_to_progressive(&self.transactions), + withdrawals: self.withdrawals.iter().cloned().collect(), + blob_gas_used: self.blob_gas_used, + excess_blob_gas: self.excess_blob_gas, + block_access_list: block_access_list.iter().copied().collect(), + slot_number: slot, + }) + } } impl ForkVersionDecode for ExecutionPayload { - /// SSZ decode with explicit fork variant. + /// SSZ decode with explicit fork variant. Gloas uses the same bounded-list wire shape as + /// Fulu here -- this is helix's own builder<->relay representation, not the real, + /// progressive-list consensus `ExecutionPayloadGloas`; see `to_lighthouse_gloas_payload`. fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { let builder_bid = match fork_name { ForkName::Altair | @@ -167,13 +201,12 @@ impl ForkVersionDecode for ExecutionPayload { ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Gloas | ForkName::Heze => { return Err(ssz::DecodeError::BytesInvalid(format!( "unsupported fork for ExecutionPayloadHeader: {fork_name}", ))); } - ForkName::Fulu => ExecutionPayload::from_ssz_bytes(bytes)?, + ForkName::Fulu | ForkName::Gloas => ExecutionPayload::from_ssz_bytes(bytes)?, }; Ok(builder_bid) } @@ -328,6 +361,81 @@ mod tests { assert_eq!(our_payload.tree_hash_root(), lh_json_str.tree_hash_root()); } + #[test] + fn test_execution_payload_decodes_under_gloas_fork() { + let our_payload = ExecutionPayload::test_random(); + let ssz_bytes = our_payload.as_ssz_bytes(); + + let decoded = ExecutionPayload::from_ssz_bytes_by_fork(&ssz_bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); + + assert_eq!(our_payload.tree_hash_root(), decoded.tree_hash_root()); + } + + #[test] + fn the_gloas_payload_carries_the_submitted_block_access_list() { + let payload = ExecutionPayload::test_random(); + let bal = crate::fields::BlockAccessListBytes(vec![9u8; 128].into()); + + let gloas = payload.to_lighthouse_gloas_payload(lh_types::Slot::new(1), &bal).unwrap(); + + // Before this it was always empty, which made the envelope invalid. + assert_eq!(gloas.block_access_list.len(), 128); + assert_eq!(gloas.block_access_list.to_vec(), bal.to_vec()); + } + + #[test] + fn an_empty_block_access_list_still_converts() { + let payload = ExecutionPayload::test_random(); + + let gloas = payload + .to_lighthouse_gloas_payload( + lh_types::Slot::new(1), + &crate::fields::BlockAccessListBytes::default(), + ) + .unwrap(); + + assert!(gloas.block_access_list.is_empty()); + } + + #[test] + fn to_lighthouse_gloas_payload_preserves_fields() { + let our_payload = ExecutionPayload::test_random(); + let slot = lh_types::Slot::new(42); + + let bal = crate::fields::BlockAccessListBytes(vec![1u8, 2, 3].into()); + let gloas = our_payload.to_lighthouse_gloas_payload(slot, &bal).unwrap(); + + assert_eq!(gloas.parent_hash.0, our_payload.parent_hash); + assert_eq!(gloas.block_hash.0, our_payload.block_hash); + assert_eq!(gloas.fee_recipient, our_payload.fee_recipient); + assert_eq!(gloas.state_root, our_payload.state_root); + assert_eq!(gloas.receipts_root, our_payload.receipts_root); + assert_eq!(gloas.prev_randao, our_payload.prev_randao); + assert_eq!(gloas.block_number, our_payload.block_number); + assert_eq!(gloas.gas_limit, our_payload.gas_limit); + assert_eq!(gloas.gas_used, our_payload.gas_used); + assert_eq!(gloas.timestamp, our_payload.timestamp); + assert_eq!(gloas.base_fee_per_gas, our_payload.base_fee_per_gas); + assert_eq!(gloas.blob_gas_used, our_payload.blob_gas_used); + assert_eq!(gloas.excess_blob_gas, our_payload.excess_blob_gas); + assert_eq!(gloas.slot_number, slot); + + assert_eq!(gloas.transactions.len(), our_payload.transactions.len()); + for (converted, original) in + gloas.transactions.as_slice().iter().zip(our_payload.transactions.iter()) + { + assert_eq!(converted.as_slice(), original.as_ref()); + } + + assert_eq!(gloas.withdrawals.len(), our_payload.withdrawals.len()); + for (converted, original) in + gloas.withdrawals.as_slice().iter().zip(our_payload.withdrawals.iter()) + { + assert_eq!(converted, original); + } + } + #[test] fn test_execution_payload_header() { test_execution_payload_header_variant(ForkName::Fulu); diff --git a/crates/types/src/fields.rs b/crates/types/src/fields.rs index 2c7fa64b..208ab0f9 100644 --- a/crates/types/src/fields.rs +++ b/crates/types/src/fields.rs @@ -1,18 +1,21 @@ +use std::marker::PhantomData; + use alloy_primitives::FixedBytes; use lh_types::{EthSpec, MainnetEthSpec}; use rand::Rng; -use ssz_types::{FixedVector, VariableList}; +use ssz_types::{FixedVector, ProgressiveVariableList, VariableList}; -use crate::{SszError, TestRandom, ssz_bytes_wrapper}; +use crate::{ExecutionRequestsGloas, SszError, TestRandom, ssz_bytes_wrapper}; pub type Withdrawal = lh_types::Withdrawal; pub type Withdrawals = lh_types::Withdrawals; // `ExecutionRequests` is a fork-versioned superstruct as of Gloas (which adds -// `builder_deposits`/`builder_exits`, EIP-8282). helix's active fork is still pre-Gloas, so -- -// mirroring how `ExecutionPayload` is pinned to a flat, non-fork-versioned shape elsewhere in -// this crate -- we pin to the Electra shape here too. Revisit once helix actually needs to -// serve Gloas submissions. +// `builder_deposits`/`builder_exits`, EIP-8282). The three pre-Gloas lists keep the Electra +// shape; a Gloas submission carries the two builder lists beside it, the way it carries the +// block access list, so the pre-Gloas bytes never move. pub type ExecutionRequests = lh_types::ExecutionRequestsElectra; +pub type BuilderDepositRequests = ProgressiveVariableList; +pub type BuilderExitRequests = ProgressiveVariableList; pub type KzgCommitment = alloy_consensus::Bytes48; pub type KzgCommitments = VariableList::MaxBlobCommitmentsPerBlock>; @@ -32,6 +35,39 @@ pub fn convert_transactions_to_lighthouse( VariableList::new(new) } +/// Real, progressive-list Gloas transactions shape, per EIP-7688. +pub fn convert_transactions_to_progressive( + txs: &Transactions, +) -> lh_types::ProgressiveTransactions { + ProgressiveVariableList::new( + txs.iter().map(|tx| ProgressiveVariableList::new(tx.as_ref().to_vec())).collect(), + ) +} + +/// Real, progressive-list Gloas KZG commitments shape, per EIP-7688. +pub fn convert_kzg_commitments_to_progressive( + commitments: &KzgCommitments, +) -> lh_types::ProgressiveKzgCommitments { + ProgressiveVariableList::new(commitments.iter().map(|c| lh_types::KzgCommitment(c.0)).collect()) +} + +/// Converts helix's Electra-shaped builder-submission execution requests, plus the EIP-8282 +/// builder lists a Gloas submission carries beside them, into the real Gloas shape. +pub fn execution_requests_to_gloas( + requests: &ExecutionRequests, + builder_deposits: &BuilderDepositRequests, + builder_exits: &BuilderExitRequests, +) -> ExecutionRequestsGloas { + ExecutionRequestsGloas { + deposits: requests.deposits.iter().cloned().collect(), + withdrawals: requests.withdrawals.iter().cloned().collect(), + consolidations: requests.consolidations.iter().cloned().collect(), + builder_deposits: builder_deposits.iter().cloned().collect(), + builder_exits: builder_exits.iter().cloned().collect(), + _phantom: PhantomData, + } +} + const LOGS_BLOOM_SIZE: usize = 256; pub type Bloom = FixedBytes; // FixedVector; @@ -53,6 +89,23 @@ ssz_bytes_wrapper! { max = ::MaxBytesPerTransaction; } +ssz_bytes_wrapper! { + /// The opaque encoded EIP-7928 block access list, as the builder produced + /// it. Gloas's own `BlockAccessList` is a `ProgressiveVariableList`, + /// so nothing here mirrors its structure. + pub struct BlockAccessListBytes; + max = ::MaxBytesPerTransaction; +} + +impl TestRandom for BlockAccessListBytes { + fn random_for_test(rng: &mut impl rand::RngCore) -> Self { + let n = rng.random_range(0..=1000) as usize; + let mut bytes = vec![0u8; n]; + rng.fill_bytes(&mut bytes); + Self(bytes.into()) + } +} + impl TestRandom for Transaction { fn random_for_test(rng: &mut impl rand::RngCore) -> Self { let n = rng.random_range(0..=1000) as usize; @@ -124,4 +177,59 @@ mod tests { let lh_tree_hash = lh_transaction.tree_hash_root(); assert_eq!(our_tree_hash, lh_tree_hash, "Tree hash root should match lighthouse"); } + + #[test] + fn convert_transactions_to_progressive_preserves_bytes() { + let txs = Transactions::random_for_test(&mut rand::rng()); + + let progressive = convert_transactions_to_progressive(&txs); + + assert_eq!(progressive.len(), txs.len()); + for (converted, original) in progressive.as_slice().iter().zip(txs.iter()) { + assert_eq!(converted.as_slice(), original.as_ref()); + } + } + + #[test] + fn convert_kzg_commitments_to_progressive_preserves_bytes() { + let commitments = KzgCommitments::new(vec![ + KzgCommitment::repeat_byte(0x11), + KzgCommitment::repeat_byte(0x22), + ]) + .unwrap(); + + let progressive = convert_kzg_commitments_to_progressive(&commitments); + + assert_eq!(progressive.len(), commitments.len()); + for (converted, original) in progressive.as_slice().iter().zip(commitments.iter()) { + assert_eq!(converted.0, original.0); + } + } + + /// EIP-8282's builder deposits and exits reach the consensus shape the proposer signs. + /// While they were dropped here, a block carrying one could not be bid at all. + #[test] + fn execution_requests_to_gloas_carries_every_list() { + let requests = ExecutionRequests::random_for_test(&mut rand::rng()); + let builder_deposits: BuilderDepositRequests = vec![lh_types::BuilderDepositRequest { + pubkey: lh_bls::PublicKeyBytes::empty(), + withdrawal_credentials: alloy_primitives::B256::repeat_byte(0x11), + amount: 32_000_000_000, + signature: lh_bls::SignatureBytes::empty(), + }] + .into(); + let builder_exits: BuilderExitRequests = vec![lh_types::BuilderExitRequest { + source_address: alloy_primitives::Address::repeat_byte(0x22), + pubkey: lh_bls::PublicKeyBytes::empty(), + }] + .into(); + + let gloas = execution_requests_to_gloas(&requests, &builder_deposits, &builder_exits); + + assert!(gloas.deposits.iter().eq(requests.deposits.iter())); + assert!(gloas.withdrawals.iter().eq(requests.withdrawals.iter())); + assert!(gloas.consolidations.iter().eq(requests.consolidations.iter())); + assert!(gloas.builder_deposits.iter().eq(builder_deposits.iter())); + assert!(gloas.builder_exits.iter().eq(builder_exits.iter())); + } } diff --git a/crates/types/src/hydration.rs b/crates/types/src/hydration.rs index 20fde4fb..fb11ea5a 100644 --- a/crates/types/src/hydration.rs +++ b/crates/types/src/hydration.rs @@ -26,6 +26,8 @@ pub enum DehydratedBidSubmission { } impl ForkVersionDecode for DehydratedBidSubmission { + /// Gloas uses the same bounded-list wire shape as Fulu here -- this is helix's own + /// builder<->relay representation, not the real Gloas consensus shape. fn from_ssz_bytes_by_fork(bytes: &[u8], fork: ForkName) -> Result { match fork { ForkName::Base | @@ -34,10 +36,11 @@ impl ForkVersionDecode for DehydratedBidSubmission { ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Gloas | ForkName::Heze => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluV1::from_ssz_bytes(bytes) - .map(|v1| DehydratedBidSubmission::Fulu(v1.into())), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluV1::from_ssz_bytes(bytes) + .map(|v1| DehydratedBidSubmission::Fulu(v1.into())) + } } } } @@ -283,10 +286,11 @@ impl ForkVersionDecode for DehydratedBidSubmissionFuluWithAdjustments { ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | - ForkName::Gloas | ForkName::Heze | ForkName::Electra => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluWithAdjustments::from_ssz_bytes(bytes), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluWithAdjustments::from_ssz_bytes(bytes) + } } } } @@ -329,10 +333,11 @@ impl ForkVersionDecode for DehydratedBidSubmissionFuluWithMergingData { ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | - ForkName::Gloas | ForkName::Heze | ForkName::Electra => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes(bytes), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes(bytes) + } } } } @@ -881,4 +886,33 @@ mod tests { assert_eq!(split_merging_data, expected_merging_data); assert!(matches!(dehydrated, DehydratedBidSubmission::Fulu(_))); } + + #[test] + fn dehydrated_with_merging_data_decodes_under_gloas_fork() { + let submission = + DehydratedBidSubmissionFuluWithMergingData::random_for_test(&mut rand::rng()); + let bytes = submission.as_ssz_bytes(); + + let decoded = DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes_by_fork( + &bytes, + ForkName::Gloas, + ) + .expect("Gloas should decode via the same shape as Fulu"); + + assert_eq!(decoded.message, submission.message); + assert_eq!(decoded.merging_data, submission.merging_data); + } + + #[test] + fn dehydrated_bid_submission_decodes_under_gloas_fork() { + let v1 = DehydratedBidSubmissionFuluV1::random_for_test(&mut rand::rng()); + let bytes = v1.as_ssz_bytes(); + let dehydrated = DehydratedBidSubmission::Fulu(v1.into()); + + let decoded = DehydratedBidSubmission::from_ssz_bytes_by_fork(&bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); + + assert!(matches!(decoded, DehydratedBidSubmission::Fulu(_))); + assert_eq!(decoded.bid_trace(), dehydrated.bid_trace()); + } } diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 3e75e99f..a7baeeff 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -33,7 +33,7 @@ pub use hydration::*; pub use lh_kzg::{KzgCommitment, KzgProof}; pub use lh_types::{ Config as LhConfig, EmptyBlock, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, - ForkVersionDecode, MainnetEthSpec, SignedRoot, + ForkVersionDecode, MainnetEthSpec, SignedRoot, SigningData, }; pub use operator::*; pub use request_auth::*; @@ -55,6 +55,8 @@ pub type BlsSignature = lh_bls::Signature; pub type BlsSignatureBytes = alloy_rpc_types::beacon::BlsSignature; pub type BlsSecretKey = lh_bls::SecretKey; pub type BlsKeypair = lh_bls::Keypair; +pub type BlsPublicKeyBytesLh = lh_bls::PublicKeyBytes; +pub type BlsSignatureBytesLh = lh_bls::SignatureBytes; // Blobs // pub type BlobsBundle = lh_eth2::types::BlobsBundle; @@ -71,6 +73,7 @@ pub type BeaconBlockGloas = lh_types::BeaconBlockGloas; pub type BeaconBlockRef<'a> = lh_types::BeaconBlockRef<'a, MainnetEthSpec>; pub type ExecutionPayloadGloas = lh_types::ExecutionPayloadGloas; pub type ExecutionRequestsGloas = lh_types::ExecutionRequestsGloas; +pub use lh_types::{BuilderDepositRequest, BuilderExitRequest, RequestType}; pub type ExecutionPayloadEnvelope = lh_types::ExecutionPayloadEnvelope; pub type SignedExecutionPayloadEnvelope = lh_types::SignedExecutionPayloadEnvelope; pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; @@ -84,6 +87,7 @@ pub type BeaconBlockBodyFulu = lh_types::BeaconBlockBodyFulu; pub type SignedBuilderBid = crate::builder_bid::SignedBuilderBid; /// Response object of GET `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` pub type GetHeaderResponse = lh_eth2::ForkVersionedResponse; +pub type GetExecutionPayloadBidResponse = lh_eth2::ForkVersionedResponse; // Get payload /// Request object of POST `/eth/v1/builder/blinded_blocks` diff --git a/docs/gloas-testnet.md b/docs/gloas-testnet.md new file mode 100644 index 00000000..2c8adc24 --- /dev/null +++ b/docs/gloas-testnet.md @@ -0,0 +1,285 @@ +# Running helix on a Gloas testnet + +How to exercise the relay's whole path — submit, simulate, `get_header`, +`get_payload`, publish — on a Gloas consensus layer with an Amsterdam execution +layer, using the ethrex-based `helix-builder` for both the builder and the +simulator. + +Three roles take part. Block merging does not: see +[What is not supported](#what-is-not-supported). + +| component | what runs it | +| --- | --- | +| relay | `helix-relay` | +| simulator | `helix-builder --sim.config` | +| builder | `helix-builder --build.config` | + +## What Gloas changes + +Two forks arrive together and both matter here. + +**Gloas (consensus, EIP-7732)** moves the execution payload into a separately +bid and revealed envelope. helix's builder-facing wire shape is unchanged except +for one addition: a Gloas submission carries the EIP-7928 block access list +beside `blobs_bundle` and `execution_requests`. + +**Amsterdam (execution)** adds two header fields no `ExecutionPayloadV3` carries: + +- `block_access_list_hash` (EIP-7928), the keccak of the encoded access list +- `slot_number` (EIP-7843), the proposal slot + +The block hash commits to both, so the simulator has to reconstruct them from +the submission. The list arrives as opaque bytes and is **hashed exactly as +received, never re-encoded** — re-encoding could change the hash and break the +commitment the builder signed. The slot number comes from `BidTrace.slot`, so +the bid trace and the header must name the same slot. + +The builder computes the list while building and the simulator recomputes it +during validation, so a list that does not describe execution is rejected rather +than trusted. + +**The two forks are selected independently and must agree.** The block's shape +follows the execution layer (`amsterdamTime` in the EL genesis) and the +submission's shape follows the consensus layer (the Gloas fork epoch in the CL +config). A submission whose shape cannot carry its block's list is refused +before it is sent, because sending it would drop the list and every bid would +die as an unexplained block hash mismatch. + +## Execution layer + +### glamsterdam-devnet-8 (`plataberget`) + +The ethrex pin supports it by name: + +``` +--network plataberget +``` + +| | | +| --- | --- | +| chain id | `7091047534` | +| Amsterdam activation | timestamp `1787212224` (2026-08-20T07:50:24Z) | +| network configs | [ethpandaops/glamsterdam-devnets, `network-configs/devnet-8`](https://github.com/ethpandaops/glamsterdam-devnets/tree/master/network-configs/devnet-8) | + +An unrecognised `--network` value is **not** an error: ethrex falls back to +treating it as a path to a genesis file (`Network::GenesisPath`). A misspelling +therefore fails as a missing file rather than as an unknown network, so check +the spelling — it is `plataberget`, with an `l`. + +### The EIP-8282 predeploys + +Amsterdam runs two system contracts, the EIP-8282 builder deposit and builder +exit predeploys. **Empty code at either address invalidates every Amsterdam +block**, with `SystemContractCallFailed: ... has no code after deployment`. + +They are Nick's-method addresses, so on a real devnet they are deployed on +chain by an ordinary transaction before the fork activates — devnet-8's own +genesis does not allocate them. On a synced node there is nothing to do; if +Amsterdam blocks are failing, confirm with `eth_getCode` at both addresses +before looking anywhere else. + +**The addresses moved between devnet-7 and devnet-8**, so do not copy them from +older notes. Read them from the ethrex you are building against — +`ethrex_vm::system_contracts::{BUILDER_DEPOSIT_CONTRACT_ADDRESS, +BUILDER_EXIT_CONTRACT_ADDRESS}` — which is what +`crates/builder/src/testing.rs::deploy_amsterdam_predeploys` does, precisely so +a pin bump cannot silently invalidate every block. At v26.0.0 they are: + +| address | contract | +| --- | --- | +| `0x0000BFF46984E3725691FA540A8C7589300D8282` | builder deposit | +| `0x000064D678505AD48F8CCB093BC65613800E8282` | builder exit | + +The runtime bytecode did not change across that move. + +### A local genesis instead + +Only a genesis you build yourself needs the predeploys allocated, because it +has no pre-fork history in which to deploy them. Activate the fork and allocate +both contracts: + +```json +{ + "config": { + "shanghaiTime": 0, + "cancunTime": 0, + "pragueTime": 0, + "osakaTime": 0, + "amsterdamTime": 0 + } +} +``` + +An explicit `blobSchedule.amsterdam` entry is optional: with none, ethrex falls +through the BPO chain to Osaka's schedule. + +## Consensus layer + +Set the Gloas fork epoch in the beacon chain config. The builder reads the spec +and genesis from the beacon node (`get_chain_info`) and derives both the builder +signing domain and the fork at each slot from it, so there is no compiled-in +fork version to keep in step — but the beacon node's spec must actually schedule +Gloas, or the builder will send pre-Gloas submissions for Amsterdam blocks and +refuse them itself. + +The relay picks the decode fork from `ChainInfo::current_fork_name()`, i.e. from +the same spec by wall clock. Relay and builder must read the same beacon node, +or the same config. + +## Relay configuration + +### Every simulator needs `ssz_url` + +The ethrex simulation role serves the SSZ routes (`/validate`, +`/validate_merged`) and no JSON-RPC validation method. Simulator dispatch is +per-simulator: a simulator entry without `ssz_url` takes the JSON-RPC path, and +`sim_request_builder` returns `None` for Gloas, so **that simulator silently +drops every Gloas submission** with `BlockSimError::UnsupportedFork`. + +Any reth-based simulator in the pool has the same effect, because reth has no +Amsterdam support yet (see #518). For a Gloas testnet, every `simulators` entry +must point at an ethrex simulation role. + +```yaml +simulators: + # `url` is still required: the relay uses it for eth_syncing and + # eth_getBalance. Point it at the same node's ethrex JSON-RPC + # (--http.addr/--http.port, default 127.0.0.1:8545). + - url: http://127.0.0.1:8545 + ssz_url: http://127.0.0.1:8552 +``` + +`ssz_url` is the simulation role's `ssz_addr`. + +### Merging off + +`block_merging_config.is_enabled` defaults to `false`; leave it there. With it +on, the merging role declines every Amsterdam base block by name and the merge +tile does no useful work. + +### The builder must be registered + +Add the building role's BLS pubkey to `builders` with an `api_key`, which the +builder sends as `x-api-key`. + +## Running the simulator and builder + +Both roles share one embedded node, so they can run in one process: + +```bash +helix-builder --sim.config sim-config.yml --build.config build-config.yml \ + --http.port 8545 --authrpc.port 8551 +``` + +The node needs a beacon node driving its Engine API on `--authrpc.port`, exactly +as any execution client does. + +**Release builds only for these two roles.** A blob is 128 KiB and crosses the +stack several times in a debug build, which overflows tokio's default worker +stack. + +### Keys + +| variable | needed by | +| --- | --- | +| `BUILDER_BLS_KEY` | building — signs the bid, and is the pubkey to register | +| `BUILDER_PAYOUT_KEY` | building — secp256k1, funds the proposer payment | +| `RELAY_KEY` | merging only, so not needed here | + +The payout key's account must hold enough to cover the bid plus the payout's own +gas. Both keys are loaded at startup, before the node boots, so a bad key is a +startup error rather than a first-slot failure. + +### `payout_gas_reserve` under Amsterdam + +The building role ends each block with a plain transfer to the proposer's +registered fee recipient: + +``` +payout = tips + subsidy_wei - payout_gas * base_fee +``` + +`payout_gas_reserve` (default 21000) covers that transfer. **Paying an address +for the first time also creates its account, and EIP-8037 charges state gas for +that** — 120 state bytes at 1530 gas each, so 183600 on top, for a total of +204600. A fee recipient that has never been paid is the normal case on a fresh +testnet. + +The builder handles this: it reads the recipient from in-block state and adds +the creation charge only when the account is absent. `payout_gas_reserve` should +stay at 21000 unless the fee recipient is a contract that needs more for the +transfer itself. A contract needing more than the reserve makes the payout fail +and the slot is skipped. + +`subsidy_wei` (default 1e15) exists because the relay rejects a zero-value +block. It must exceed the payout's own gas cost or every idle-mempool block is +skipped with `NoPayout`; at 204600 gas the default covers base fees up to +roughly 4.9 gwei. Set it to 0 only if you want bids solely from real tips. + +## Generating traffic + +**A flat 21000-gas transfer to a fresh address fails under Amsterdam.** This is +EIP-8037 applying to all traffic, not something helix can change: the intrinsic +cost of the transfer is still 21000, but creating the recipient adds 183600 in +state gas. Such transactions are included in blocks with failed receipts, which +is correct behaviour and looks like a broken builder if you are not expecting +it. + +Funding scripts and traffic generators aimed at a Gloas testnet need a gas limit +above 204600 for any transfer to an address that does not exist yet. + +## Verifying it works + +1. **The simulator accepts a block.** The relay logs a successful simulation, or + the simulation role returns `200` on `/validate`. A `501` means the fork gate + refused the submission: the relay sent a fork this role does not serve. +2. **The relay accepts the submission.** `200 OK` on + `/relay/v1/builder/blocks`. A `400` carries the reason in its body, which is + how an operator learns the blocks are bad. +3. **The bid is visible** through the relay's data API. +4. **`get_header` returns the bid** for that slot. +5. **`get_payload` and publication complete**, which is the point of the whole + exercise. + +If step 1 fails with a block hash mismatch, suspect fork disagreement first: an +`amsterdamTime` that does not line up with the CL's Gloas epoch. A missing +EIP-8282 predeploy shows up differently, as `SystemContractCallFailed` during +execution rather than as a hash mismatch. + +## What is not supported + +- **Block merging.** Merging protocol v1 carries an `ExecutionPayloadV3`, which + has nowhere to put the access list or slot number, so the merging role + declines Amsterdam bases by name and the merged validation route refuses + Gloas. Tracked in #576. This is a missing capability, not a broken one: the + role refuses rather than merging into a block whose hash it cannot reproduce. +- **The reth simulator.** No Amsterdam support; tracked in #518. +- **Bid adjustments on Gloas, and dehydrated or mergeable Gloas submissions.** + Each is refused explicitly rather than decoded into a Fulu shape that would + drop the access list. +- **Inclusion lists** in the ethrex simulation role. A block violating a + submitted list passes here and fails in `crates/simulator`. + +## Reference: the access list's size + +Measured against ethrex's encoding at the pinned revision, since it decides +whether the builder sending the list is affordable. Per item, counted the way +EIP-7928's cap counts: + +| item | bytes | +| --- | --- | +| account (address, balance and nonce change) | ~70 | +| storage read (slot only) | ~33 | +| storage change (slot, value, index) | ~71 | + +A realistic busy block is small: 400 transfers to 400 distinct recipients +produced a 29.7 KB list. The worst case is bounded by EIP-7928 itself, which +caps items at `gas_limit / 2000` — roughly 0.4–0.9 MB at a 25M gas limit, +0.7–1.6 MB at 45M and 1.0–2.1 MB at 60M, spanning an all-reads and an +all-changes list. + +Even that maximum is an order of magnitude inside the relay's 20 MB +`MAX_PAYLOAD_LENGTH`, and far inside `BlockAccessListBytes`'s own SSZ bound of +1 GB. This is why the builder sends the list rather than the simulator returning +it: the bandwidth is affordable, and a list the builder committed to is worth +more than one nobody signed.