From 604ac4b5e6a6f78b23d5f3841ecd84827e4746c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:08:16 -0300 Subject: [PATCH 01/18] feat(beacon): add the builder API's signing domain Registrations and builder bids are signed under builder-specs' DOMAIN_APPLICATION_BUILDER, an application domain outside the specification's own. The mask's doc said this crate never uses one; it now does, so the doc names it. --- crates/common/types/src/beacon/constants.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/common/types/src/beacon/constants.rs b/crates/common/types/src/beacon/constants.rs index 171ddece9..0051ae3b9 100644 --- a/crates/common/types/src/beacon/constants.rs +++ b/crates/common/types/src/beacon/constants.rs @@ -127,9 +127,14 @@ pub const DOMAIN_AGGREGATE_AND_PROOF: DomainType = [0x06, 0x00, 0x00, 0x00]; /// application-specific domains outside the specification. Every /// specification-defined `DomainType` masks to zero against this; a /// `DomainType` that does not is reserved for other uses (for example -/// re-purposing beacon chain signatures on an application layer) and this -/// crate never produces or expects one. +/// re-purposing beacon chain signatures on an application layer). The one +/// such domain this crate uses is [`DOMAIN_APPLICATION_BUILDER`]. pub const DOMAIN_APPLICATION_MASK: DomainType = [0x00, 0x00, 0x00, 0x01]; +/// Domain for the builder API's own messages (`ValidatorRegistrationV1`, +/// `BuilderBid`), builder-specs `bellatrix/builder.md`. Always computed +/// against the chain's genesis fork version and a zero validators root, so +/// one signature stays valid across every fork of its chain. +pub const DOMAIN_APPLICATION_BUILDER: DomainType = [0x00, 0x00, 0x00, 0x01]; /// Domain for a `SyncCommitteeMessage` (altair). pub const DOMAIN_SYNC_COMMITTEE: DomainType = [0x07, 0x00, 0x00, 0x00]; /// Domain for a sync committee aggregator's selection proof (altair). @@ -461,6 +466,18 @@ mod tests { } } + #[test] + fn the_builder_domain_is_an_application_domain() { + // builder-specs' `DomainType('0x00000001')`: the reserved bit set, so + // no specification domain can collide with it. + let masked: Vec = DOMAIN_APPLICATION_BUILDER + .iter() + .zip(DOMAIN_APPLICATION_MASK) + .map(|(domain, mask)| domain & mask) + .collect(); + assert_eq!(masked, [0u8, 0, 0, 1]); + } + #[test] fn custody_settings_match_both_shipped_configs() { // Identical in configs/mainnet.yaml and configs/minimal.yaml, which is From fc34140cb7afc2782895855c79227806150fddad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:09:09 -0300 Subject: [PATCH 02/18] feat(beacon): build an execution payload header from its payload A blinded block carries the header of a payload this node never sees, and blinding a locally built block needs the same conversion. The header also needs Deserialize now, since bids and blinded blocks arrive as JSON. --- .../types/src/beacon/containers/deneb.rs | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/common/types/src/beacon/containers/deneb.rs b/crates/common/types/src/beacon/containers/deneb.rs index 195dbeb20..ea9af2a81 100644 --- a/crates/common/types/src/beacon/containers/deneb.rs +++ b/crates/common/types/src/beacon/containers/deneb.rs @@ -46,6 +46,7 @@ use super::shared::{ StateRoots, Validators, }; use crate::beacon::preset; +use crate::beacon::primitives::HashTreeRoot as _; use crate::beacon::primitives::{ BlobIndex, BlsSignature, Bytes32, ExecutionAddress, ExecutionBlockHash, KzgCommitment, KzgProof, Root, Slot, Uint256, ValidatorIndex, VersionedHash, WithdrawalIndex, @@ -134,7 +135,17 @@ pub struct ExecutionPayload { /// [`ExecutionPayload`] with the bulky fields replaced by their roots, which /// is what the state retains once a payload is no longer the newest one. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct ExecutionPayloadHeader { pub parent_hash: ExecutionBlockHash, pub fee_recipient: ExecutionAddress, @@ -163,6 +174,34 @@ pub struct ExecutionPayloadHeader { pub excess_blob_gas: u64, } +/// The header the state caches for `payload`, and what a blinded block carries +/// in its place: every field copied, the two lists replaced by their roots. +/// Built the same way `process_execution_payload` builds the cached header, so +/// `hash_tree_root(header) == hash_tree_root(payload)`. +impl From<&ExecutionPayload> for ExecutionPayloadHeader { + fn from(payload: &ExecutionPayload) -> Self { + Self { + parent_hash: payload.parent_hash, + fee_recipient: payload.fee_recipient, + state_root: payload.state_root, + receipts_root: payload.receipts_root, + logs_bloom: payload.logs_bloom.clone(), + prev_randao: payload.prev_randao, + block_number: payload.block_number, + gas_limit: payload.gas_limit, + gas_used: payload.gas_used, + timestamp: payload.timestamp, + extra_data: payload.extra_data.clone(), + base_fee_per_gas: payload.base_fee_per_gas, + block_hash: payload.block_hash, + transactions_root: payload.transactions.hash_tree_root(), + withdrawals_root: payload.withdrawals.hash_tree_root(), + blob_gas_used: payload.blob_gas_used, + excess_blob_gas: payload.excess_blob_gas, + } + } +} + /// What `process_execution_payload` hands `execution_engine.verify_and_notify_new_payload` /// to validate a proposed payload, deneb `beacon-chain.md`. /// @@ -459,6 +498,64 @@ mod tests { use super::*; + /// A payload whose every field differs from the empty one, with a + /// transaction and a withdrawal, so both list roots are non-trivial. + fn sample_payload() -> ExecutionPayload { + let transaction: super::super::bellatrix::Transaction = + vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(); + let withdrawal = super::super::capella::Withdrawal { + index: 1, + validator_index: 2, + address: ExecutionAddress::repeat_byte(3), + amount: 4, + }; + ExecutionPayload { + parent_hash: ExecutionBlockHash::repeat_byte(1), + fee_recipient: ExecutionAddress::repeat_byte(2), + state_root: Bytes32::repeat_byte(3), + receipts_root: Bytes32::repeat_byte(4), + logs_bloom: LogsBloom::try_from(vec![5u8; preset::BYTES_PER_LOGS_BLOOM]).unwrap(), + prev_randao: Bytes32::repeat_byte(6), + block_number: 7, + gas_limit: 8, + gas_used: 9, + timestamp: 10, + extra_data: vec![11u8; 4].try_into().unwrap(), + base_fee_per_gas: Uint256::from(12u64), + block_hash: ExecutionBlockHash::repeat_byte(13), + transactions: vec![transaction].try_into().unwrap(), + withdrawals: vec![withdrawal].try_into().unwrap(), + blob_gas_used: 14, + excess_blob_gas: 15, + } + } + + #[test] + fn a_payloads_header_has_the_payloads_root() { + use crate::beacon::primitives::HashTreeRoot as _; + let payload = sample_payload(); + let header = ExecutionPayloadHeader::from(&payload); + assert_eq!(header.hash_tree_root(), payload.hash_tree_root()); + assert_eq!( + header.transactions_root, + payload.transactions.hash_tree_root() + ); + assert_eq!( + header.withdrawals_root, + payload.withdrawals.hash_tree_root() + ); + } + + #[test] + fn a_header_round_trips_through_json() { + let header = ExecutionPayloadHeader::from(&sample_payload()); + let json = serde_json::to_string(&header).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + header + ); + } + #[test] fn blob_identifier_round_trips_through_ssz() { let identifier = BlobIdentifier { From 0d68cd9d70cfb55ded1dc37035ad8e7701ee10dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:09:24 -0300 Subject: [PATCH 03/18] test(beacon): vendor builder-specs' example containers Real encodings to decode against, so the builder containers are checked against what relays and other clients send rather than only against themselves. --- .../tests/fixtures/builder_specs/README.md | 10 + .../signed_validator_registrations.json | 13 + .../signed_validator_registrations.ssz | Bin 0 -> 180 bytes .../fulu/signed_blinded_beacon_block.json | 222 ++++++++++++++++++ .../fulu/signed_blinded_beacon_block.ssz | Bin 0 -> 4320 bytes .../fulu/signed_builder_bid.json | 60 +++++ .../builder_specs/fulu/signed_builder_bid.ssz | Bin 0 -> 1252 bytes 7 files changed, 305 insertions(+) create mode 100644 crates/common/types/tests/fixtures/builder_specs/README.md create mode 100644 crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.json create mode 100644 crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.ssz create mode 100644 crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.json create mode 100644 crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.ssz create mode 100644 crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.json create mode 100644 crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.ssz diff --git a/crates/common/types/tests/fixtures/builder_specs/README.md b/crates/common/types/tests/fixtures/builder_specs/README.md new file mode 100644 index 000000000..3156172e0 --- /dev/null +++ b/crates/common/types/tests/fixtures/builder_specs/README.md @@ -0,0 +1,10 @@ +# builder-specs examples + +Copied unchanged from `ethereum/builder-specs` at `fce6446` (2026-10-20), +`examples//`. CC0 1.0, like the rest of that repository. + +Each `.json` is an API response body: the object sits under `value`, and the +bid adds a `{version, data}` envelope. Each `.ssz` is the bare object. + +The registration example lives under `bellatrix/` upstream because +`ValidatorRegistrationV1` is not fork-versioned. diff --git a/crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.json b/crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.json new file mode 100644 index 000000000..542fe4cdd --- /dev/null +++ b/crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.json @@ -0,0 +1,13 @@ +{ + "value": [ + { + "message": { + "fee_recipient": "0x000102030405060708090a0b0c0d0e0f10111213", + "gas_limit": "100", + "timestamp": "100", + "pubkey": "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f" + }, + "signature": "0x606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + } + ] +} diff --git a/crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.ssz b/crates/common/types/tests/fixtures/builder_specs/bellatrix/signed_validator_registrations.ssz new file mode 100644 index 0000000000000000000000000000000000000000..590315a36ecc8270715eea68ae88ac3571e4ada4 GIT binary patch literal 180 zcmZQzWMXDvWn<^yMC+6cSEhfB+cHfJKdnsF=8fq?ELbtem`pqLQ+Ts+zin zrk1vjuAY8EVp4KSYFc_mW>$7iZeD&tVNr2OX<2ziWmR=eZC!msV^ecWYg>CqXIFPm zZ(sj}iIXNznL2IyjG41$&zU=K{(^;z7B5-4Z25|nt5&aByKeo4jhi-a*}84}j-9)9 J@7cR=KLGd>JHG$` literal 0 HcmV?d00001 diff --git a/crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.json b/crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.json new file mode 100644 index 000000000..4c23a022b --- /dev/null +++ b/crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.json @@ -0,0 +1,222 @@ +{ + "value": { + "message": { + "slot": "1", + "proposer_index": "1", + "parent_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "body": { + "randao_reveal": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", + "eth1_data": { + "deposit_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "deposit_count": "1", + "block_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "graffiti": "0xdeadbeefc0ffeedeadbeefc0ffeedeadbeefc0ffeedeadbeefc0ffeedeadbeef", + "proposer_slashings": [ + { + "signed_header_1": { + "message": { + "slot": "1", + "proposer_index": "1", + "parent_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "body_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + }, + "signed_header_2": { + "message": { + "slot": "1", + "proposer_index": "1", + "parent_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "body_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + } + } + ], + "attester_slashings": [ + { + "attestation_1": { + "attesting_indices": [ + "1" + ], + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", + "data": { + "slot": "1", + "index": "1", + "beacon_block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "source": { + "epoch": "1", + "root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "target": { + "epoch": "1", + "root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + } + } + }, + "attestation_2": { + "attesting_indices": [ + "1" + ], + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", + "data": { + "slot": "1", + "index": "1", + "beacon_block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "source": { + "epoch": "1", + "root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "target": { + "epoch": "1", + "root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + } + } + } + } + ], + "attestations": [ + { + "aggregation_bits": "0xffffffffffffffffffffffffffffffff", + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", + "data": { + "slot": "1", + "index": "1", + "beacon_block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "source": { + "epoch": "1", + "root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "target": { + "epoch": "1", + "root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + } + }, + "committee_bits": "0x0000000000000001" + } + ], + "deposits": [ + { + "proof": [ + "0xeeffb6c21a01d3abf09cd6c56e5d48f5ea0fc3bb0de906e3beea3e73776329cb", + "0x601c3b24a99d023224d50811bed19449890febb719a31d09ac414c4632f3c0ba", + "0xbb5e485e0a366e16510de33731d71204ad2fe0f7c600861fc2ac4685212c34e3", + "0x0006964745296a3e6ebf3954a1541e73205f1eefaddfc48ca9dc856bf159bca2", + "0x2c6020f1f9712b89f59550aec05b7c23cb1b113762399c0ca5b8fdd2fa85ce57", + "0x1c15634783e1d9d2cb969da66fd72cafca5026191d911b83211318d183c5ea59", + "0xdfbdf99a1fde57899df1545be1f91bc8a8a9f46c4bac619e28e92aff276de41f", + "0xfe9b0f0c05fde6bd26ce63d394058844ad4451f70b6d2547f49c5c2a5c7891a1", + "0x165f84ee467d18dbafdb07275dc42fb988ab696b0a7ad94c52f4d7a27144b994", + "0x506d86582d252405b840018792cad2bf1259f1ef5aa5f887e13cb2f0094f51e1", + "0xecdbe5e5056b968aa726a08f1aa33f5d41540eed42f59ace020431cf38a5144e", + "0xc4498c5eb1feeb0b225a3f332bdf523dbc013a5b336a851fce1c055b4019a457", + "0xb7d05f875f140027ef5118a2247bbb84ce8f2f0f1123623085daf7960c329f5f", + "0x8a9b66ad79116c9fc6eed14bde76e8f486669e59b0b5bb0c60a6b3caea38b83d", + "0x267c5455e4806b5d0ad5573552d0162e0983595bac25dacd9078174a2766643a", + "0x27e0c6357985de4d6026d6da14f31e8bfe14524056fec69dc06d6f8a239344af", + "0xf8455aebc24849bea870fbcef1235e2d27c8fd27db24e26d30d0173f3b207874", + "0xaba01bf7fe57be4373f47ff8ea6adc4348fab087b69b2518ce630820f95f4150", + "0xd47152335d9460f2b6fb7aba05ced32a52e9f46659ccd3daa2059661d75a6308", + "0xf893e908917775b62bff23294dbbe3a1cd8e6cc1c35b4801887b646a6f81f17f", + "0xcddba7b592e3133393c16194fac7431abf2f5485ed711db282183c819e08ebaa", + "0x8a8d7fe3af8caa085a7639a832001457dfb9128a8061142ad0335629ff23ff9c", + "0xfeb3c337d7a51a6fbf00b9e34c52e1c9195c969bd4e7a0bfd51d5c5bed9c1167", + "0xe71f0aa83cc32edfbefa9f4d3e0174ca85182eec9f3a09f6a6c0df6377a510d7", + "0x31206fa80a50bb6abe29085058f16212212a60eec8f049fecb92d8c8e0a84bc0", + "0x21352bfecbeddde993839f614c3dac0a3ee37543f9b412b16199dc158e23b544", + "0x619e312724bb6d7c3153ed9de791d764a366b389af13c58bf8a8d90481a46765", + "0x7cdd2986268250628d0c10e385c58c6191e6fbe05191bcc04f133f2cea72c1c4", + "0x848930bd7ba8cac54661072113fb278869e07bb8587f91392933374d017bcbe1", + "0x8869ff2c22b28cc10510d9853292803328be4fb0e80495e8bb8d271f5b889636", + "0xb5fe28e79f1b850f8658246ce9b6a1e7b49fc06db7143e8fe0b4f2b0c5523a5c", + "0x985e929f70af28d0bdd1a90a808f977f597c7c778c489e98d3bd8910d31ac0f7", + "0xf7ed070000000000000000000000000000000000000000000000000000000000" + ], + "data": { + "pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "withdrawal_credentials": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "amount": "1", + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + } + } + ], + "voluntary_exits": [ + { + "message": { + "epoch": "1", + "validator_index": "1" + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + } + ], + "sync_aggregate": { + "sync_committee_bits": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffbfffffffffffffffffffffffbffffffffffffffffffffbffffffffffffff", + "sync_committee_signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + }, + "execution_payload_header": { + "parent_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "fee_recipient": "0xabcf8e0d4e9587369b2301d0790347320302cc09", + "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "receipts_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "logs_bloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "block_number": "1", + "gas_limit": "1", + "gas_used": "1", + "timestamp": "1", + "extra_data": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "base_fee_per_gas": "1", + "blob_gas_used": "1", + "excess_blob_gas": "1", + "block_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "transactions_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "withdrawals_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "bls_to_execution_changes": [ + { + "message": { + "validator_index": "1", + "from_bls_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "to_execution_address": "0xabcf8e0d4e9587369b2301d0790347320302cc09" + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + } + ], + "blob_kzg_commitments": [ + "0xa94170080872584e54a1cf092d845703b13907f2e6b3b1c0ad573b910530499e3bcd48c6378846b80d2bfa58c81cf3d5" + ], + "execution_requests": { + "deposits": [ + { + "pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "withdrawal_credentials": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "amount": "1", + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", + "index": "1" + } + ], + "withdrawals": [ + { + "source_address": "0xabcf8e0d4e9587369b2301d0790347320302cc09", + "validator_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "amount": "1" + } + ], + "consolidations": [ + { + "source_address": "0xabcf8e0d4e9587369b2301d0790347320302cc09", + "source_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "target_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a" + } + ] + } + } + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + } + } + \ No newline at end of file diff --git a/crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.ssz b/crates/common/types/tests/fixtures/builder_specs/fulu/signed_blinded_beacon_block.ssz new file mode 100644 index 0000000000000000000000000000000000000000..e85969a4b0cc613a78556f1d98d01a59c8d4473e GIT binary patch literal 4320 zcmYdcU|^6=TO+?M`Q)C+fEjMr-mscP-{g3^+%BE#N5?J4_2QzeXB=e{RrR->wqL+l zoj$*NlR>oFqSjyP<{wMbdU^j(JVQu7BhU^IfYImsc>Sifo6S~cyim#PZp6%VhST}# z!VcF2v$`gZn+l%@$%g>VB4j6}ZYJagV*PS&?Y{R1{=dUX^#BdhVP;?uU?oC|wf|uLfYFq?mymv1 z`-cUXUpRpH3lKj6Vwk^RG$Hp(C7V{^mnLM&_bAACQ?&?bLqja#dtj>%&NwrTFJnpt*v z`z=EjhR78w#LK;3d;dt!$~&#uA0ziH(n(PG__I*E^Xt@rbqAtrlut_wnkQM#;aR%l z@1T-raJp zU!Px4Imw{)*7s>VM)TvlW~Z&K6wH}_?A=B0du1=aw5839+^}^wPr|azr(Rj?uvM!G z34PL#9m{n!+%)Kdm>y?yWb_)qyC0>t8=J+xBCC9|_(rw!Rl{I~LKVEpYFX!OlXb;AY>XfYf#*g)9Z!h0E>9Mf! zE<2;VntGasYAFuCO#Sv9zxx$D+B>etPp{|BR5v>cx zVVeJy|Ihii`LOx*rBeC(8FoJQ33_-^GG^NBE6*3~zbYFO{dSIE`g3`%6*h2ga9|TU^bHHENUguTO z!6Pl5279YloI2{3$gU{-TfHOmLG_M^`iYjB#^%0^)u$hJWd7Gt+SGH9Rp4f;(WC}r zjeY(bUa(AkvAb7YKDuL?+17s=&*w|G^0!5(XAgy!_`~i#-Lw7uN^nD{+-{F1x&_)GpqxTjrnT)_mtg+3#M&)`|40%V2dHt?eV^ z@6}ioU=bpzJ&z;GMvO3_@PoxYF4}_$(Wb){XoT#bR36=Jgv^7fCpBMUOMkS^6Du7H zI5>(T{6ZF<=hSTpXWnSZ{^{A~jR)3-TTf&)@SJCT*5jCYhuaQb?OzcmWIkWz0Y<_Z eAeI1nl2rc@8*a4rGc10vg*T~wqNZ7-+6@51OQ!b# literal 0 HcmV?d00001 diff --git a/crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.json b/crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.json new file mode 100644 index 000000000..382762729 --- /dev/null +++ b/crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.json @@ -0,0 +1,60 @@ +{ + "value": { + "version": "fulu", + "data": { + "message": { + "header": { + "parent_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "fee_recipient": "0xabcf8e0d4e9587369b2301d0790347320302cc09", + "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "receipts_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "logs_bloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "block_number": "1", + "gas_limit": "1", + "gas_used": "1", + "timestamp": "1", + "extra_data": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "base_fee_per_gas": "1", + "blob_gas_used": "1", + "excess_blob_gas": "1", + "block_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "transactions_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "withdrawals_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" + }, + "blob_kzg_commitments": [ + "0xa94170080872584e54a1cf092d845703b13907f2e6b3b1c0ad573b910530499e3bcd48c6378846b80d2bfa58c81cf3d5" + ], + "execution_requests": { + "deposits": [ + { + "pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "withdrawal_credentials": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + "amount": "1", + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", + "index": "1" + } + ], + "withdrawals": [ + { + "source_address": "0xabcf8e0d4e9587369b2301d0790347320302cc09", + "validator_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "amount": "1" + } + ], + "consolidations": [ + { + "source_address": "0xabcf8e0d4e9587369b2301d0790347320302cc09", + "source_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a", + "target_pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a" + } + ] + }, + "value": "1", + "pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a" + }, + "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" + } + } + } + \ No newline at end of file diff --git a/crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.ssz b/crates/common/types/tests/fixtures/builder_specs/fulu/signed_builder_bid.ssz new file mode 100644 index 0000000000000000000000000000000000000000..cd853a077c6eeb38c14e148ea8359b51e75a887a GIT binary patch literal 1252 zcmYdcU|^6=TO+?M`Q)C+fEjMr-mscP-{g3^+%BE#N5?J4_2QzeXB=e{RrR->wqL+l zoj$*NlR>oFqSjyP<{wMbdU^j(JVQu-4A71vObiTPfS3`;#s?;=)GKkWK6O5PdsN`9 zU(d^b-nH0M5PWfcP`(mZN$0Z5i%RX{?Yd?DS#HgDPL%!bb-s_+Z)&^QY-Pp^mCWu& z%uHuEov$wJa7{3)YvQ=6@M$#`1z3bo^bnHA=l+o(M<{$?ag2-hAR-+QvV&52bhi;Q zZ>3`a2S-taU&zAqoVqRH%o{D)KRw&L@xa<}>xrxep7X5FdK@$FaNEJF{VU>x%;&2- kz=UuHh$Vm)Q9Dh;+)gOpQJQaI_F&6Hr22`PW|3+)09##cyZ`_I literal 0 HcmV?d00001 From e1299e75a382acc353c49442b14c66f552e131cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:35 -0300 Subject: [PATCH 04/18] test(beacon): describe the builder examples as what they are Only the bid is a response; the registrations and the blinded block are request bodies, and the `value` key is OpenAPI's example wrapper. Record the full commit, since nothing else in the repo pins it. The header's JSON round trip moves beside the other round-trip tests. --- .../types/src/beacon/containers/deneb.rs | 18 ++++-------------- .../types/src/beacon/containers/json_tests.rs | 7 +++++++ .../tests/fixtures/builder_specs/README.md | 16 ++++++++++++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/crates/common/types/src/beacon/containers/deneb.rs b/crates/common/types/src/beacon/containers/deneb.rs index ea9af2a81..75af4ef47 100644 --- a/crates/common/types/src/beacon/containers/deneb.rs +++ b/crates/common/types/src/beacon/containers/deneb.rs @@ -496,14 +496,15 @@ pub struct BlobSidecar { mod tests { use libssz::{SszDecode as _, SszEncode as _}; + use super::super::bellatrix::Transaction; + use super::super::capella::Withdrawal; use super::*; /// A payload whose every field differs from the empty one, with a /// transaction and a withdrawal, so both list roots are non-trivial. fn sample_payload() -> ExecutionPayload { - let transaction: super::super::bellatrix::Transaction = - vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(); - let withdrawal = super::super::capella::Withdrawal { + let transaction: Transaction = vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(); + let withdrawal = Withdrawal { index: 1, validator_index: 2, address: ExecutionAddress::repeat_byte(3), @@ -532,7 +533,6 @@ mod tests { #[test] fn a_payloads_header_has_the_payloads_root() { - use crate::beacon::primitives::HashTreeRoot as _; let payload = sample_payload(); let header = ExecutionPayloadHeader::from(&payload); assert_eq!(header.hash_tree_root(), payload.hash_tree_root()); @@ -546,16 +546,6 @@ mod tests { ); } - #[test] - fn a_header_round_trips_through_json() { - let header = ExecutionPayloadHeader::from(&sample_payload()); - let json = serde_json::to_string(&header).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - header - ); - } - #[test] fn blob_identifier_round_trips_through_ssz() { let identifier = BlobIdentifier { diff --git a/crates/common/types/src/beacon/containers/json_tests.rs b/crates/common/types/src/beacon/containers/json_tests.rs index 5506897b8..e1e413901 100644 --- a/crates/common/types/src/beacon/containers/json_tests.rs +++ b/crates/common/types/src/beacon/containers/json_tests.rs @@ -373,6 +373,13 @@ fn a_fulu_block_round_trips_through_json() { round_trip(&electra_block()); } +#[test] +fn an_execution_payload_header_round_trips_through_json() { + round_trip(&super::deneb::ExecutionPayloadHeader::from( + &electra_payload(), + )); +} + #[test] fn a_gloas_block_round_trips_through_json() { round_trip(&gloas_block()); diff --git a/crates/common/types/tests/fixtures/builder_specs/README.md b/crates/common/types/tests/fixtures/builder_specs/README.md index 3156172e0..b55e49e28 100644 --- a/crates/common/types/tests/fixtures/builder_specs/README.md +++ b/crates/common/types/tests/fixtures/builder_specs/README.md @@ -1,10 +1,18 @@ # builder-specs examples -Copied unchanged from `ethereum/builder-specs` at `fce6446` (2026-10-20), -`examples//`. CC0 1.0, like the rest of that repository. +Copied unchanged from `ethereum/builder-specs` at +`fce64468a81a87892d7cb9b4858272bb3d010e50` (2026-10-20), `examples//`. +CC0 1.0, like the rest of that repository. -Each `.json` is an API response body: the object sits under `value`, and the -bid adds a `{version, data}` envelope. Each `.ssz` is the bare object. +These are the OpenAPI examples the spec's endpoints cite. Each `.json` holds +its object under `value`, OpenAPI's example wrapper, which is never sent on +the wire. Each `.ssz` is the same object's SSZ encoding. + +| Example | What it is | +|---|---| +| `bellatrix/signed_validator_registrations` | `registerValidator` request body: a list of registrations | +| `fulu/signed_builder_bid` | `getHeader` response; the JSON keeps the real `{version, data}` envelope, the SSZ is the bare `SignedBuilderBid` | +| `fulu/signed_blinded_beacon_block` | `submitBlindedBlockV2` request body | The registration example lives under `bellatrix/` upstream because `ValidatorRegistrationV1` is not fork-versioned. From 780ba2d3a9987267f942f031c84c49f0111786f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:01 -0300 Subject: [PATCH 05/18] docs(beacon): tell the two builder domains apart The builder API's domain and gloas's in-protocol builder domain share a name and nothing else. Copying an existing get_domain call for the builder API would sign under the wrong one, and the only symptom is a relay rejecting every signature. --- crates/common/types/src/beacon/constants.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/common/types/src/beacon/constants.rs b/crates/common/types/src/beacon/constants.rs index 0051ae3b9..7e810e75d 100644 --- a/crates/common/types/src/beacon/constants.rs +++ b/crates/common/types/src/beacon/constants.rs @@ -134,6 +134,11 @@ pub const DOMAIN_APPLICATION_MASK: DomainType = [0x00, 0x00, 0x00, 0x01]; /// `BuilderBid`), builder-specs `bellatrix/builder.md`. Always computed /// against the chain's genesis fork version and a zero validators root, so /// one signature stays valid across every fork of its chain. +/// +/// Not [`DOMAIN_BEACON_BUILDER`], gloas's in-protocol builder domain: that one +/// goes through `get_domain`, which mixes in the current fork and the chain's +/// validators root, so signing a builder API message with it produces a +/// signature no relay accepts. pub const DOMAIN_APPLICATION_BUILDER: DomainType = [0x00, 0x00, 0x00, 0x01]; /// Domain for a `SyncCommitteeMessage` (altair). pub const DOMAIN_SYNC_COMMITTEE: DomainType = [0x07, 0x00, 0x00, 0x00]; @@ -147,6 +152,8 @@ pub const DOMAIN_CONTRIBUTION_AND_PROOF: DomainType = [0x09, 0x00, 0x00, 0x00]; /// [`ETH1_ADDRESS_WITHDRAWAL_PREFIX`]. pub const DOMAIN_BLS_TO_EXECUTION_CHANGE: DomainType = [0x0a, 0x00, 0x00, 0x00]; /// Domain for a `SignedExecutionPayloadBid` (gloas). +/// +/// Not the builder API's [`DOMAIN_APPLICATION_BUILDER`]. pub const DOMAIN_BEACON_BUILDER: DomainType = [0x0b, 0x00, 0x00, 0x00]; /// Domain for a `PayloadAttestationMessage` (gloas). pub const DOMAIN_PTC_ATTESTER: DomainType = [0x0c, 0x00, 0x00, 0x00]; From a2d4a1dc8bd1110e50310bdf64d561d43952b072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:24:06 -0300 Subject: [PATCH 06/18] feat(beacon): add the builder API's validator registration What a validator client signs and its beacon node forwards to relays through registerValidator, checked against builder-specs' example in both encodings. --- crates/common/types/src/beacon/builder.rs | 106 ++++++++++++++++++++++ crates/common/types/src/beacon/mod.rs | 1 + 2 files changed, 107 insertions(+) create mode 100644 crates/common/types/src/beacon/builder.rs diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs new file mode 100644 index 000000000..3c066f5c5 --- /dev/null +++ b/crates/common/types/src/beacon/builder.rs @@ -0,0 +1,106 @@ +//! The builder API's own containers (`ethereum/builder-specs`), which the +//! MEV-Boost relay flow exchanges between a validator client, this node and a +//! relay. +//! +//! Not consensus containers. Nothing here enters a state, and only +//! [`BlindedBeaconBlock`] has a root anything depends on: it equals the full +//! block's, because a payload header's root is the root of the payload it +//! summarizes. That equality is what lets a validator sign a blinded block for +//! a full one it never sees. +//! +//! Electra and fulu shapes only, which are identical, as the consensus +//! containers they extend are. + +use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::beacon::preset; +use crate::beacon::primitives::{BlsPubkey, BlsSignature, ExecutionAddress}; + +// --------------------------------------------------------------------------- +// Registrations +// --------------------------------------------------------------------------- + +/// A validator's building preferences, as it sends them to builders through +/// its beacon node (`registerValidator`), builder-specs `bellatrix/builder.md`. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct ValidatorRegistrationV1 { + pub fee_recipient: ExecutionAddress, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub gas_limit: u64, + /// Seconds since the Unix epoch. Builders keep a validator's latest + /// registration by this, so re-sending an unchanged one must reuse it. + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub timestamp: u64, + pub pubkey: BlsPubkey, +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct SignedValidatorRegistrationV1 { + pub message: ValidatorRegistrationV1, + pub signature: BlsSignature, +} + +/// The `registerValidator` request body's SSZ form, in both the Beacon API +/// and the builder API. +pub type SignedValidatorRegistrations = + SszList; + +#[cfg(test)] +mod tests { + use libssz::{SszDecode as _, SszEncode as _}; + + use super::*; + + /// A vendored builder-specs example file (see the fixtures' README). + macro_rules! example { + ($path:literal) => { + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/builder_specs/", + $path + )) + }; + } + + /// The object an example `.json` holds under its `value` key. + fn example_value(json: &[u8]) -> serde_json::Value { + let mut document: serde_json::Value = serde_json::from_slice(json).unwrap(); + document["value"].take() + } + + #[test] + fn the_registration_example_decodes_from_both_encodings() { + let ssz = example!("bellatrix/signed_validator_registrations.ssz"); + let from_ssz = SignedValidatorRegistrations::from_ssz_bytes(ssz).unwrap(); + let from_json: Vec = serde_json::from_value( + example_value(example!("bellatrix/signed_validator_registrations.json")), + ) + .unwrap(); + + assert_eq!(from_ssz.to_vec(), from_json); + assert_eq!(from_ssz.to_ssz(), ssz.to_vec()); + assert_eq!(from_json[0].message.gas_limit, 100); + assert_eq!(from_json[0].message.timestamp, 100); + } +} diff --git a/crates/common/types/src/beacon/mod.rs b/crates/common/types/src/beacon/mod.rs index 4b33915db..2dbab74ea 100644 --- a/crates/common/types/src/beacon/mod.rs +++ b/crates/common/types/src/beacon/mod.rs @@ -6,6 +6,7 @@ //! transition lives //! under this module so both sets can coexist. +pub mod builder; pub mod committees; pub mod config; pub mod constants; From 7ceacb8ad61026ff458fbd04cac378bb6221da09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:26:00 -0300 Subject: [PATCH 07/18] feat(beacon): add the builder API's bid The signed offer getHeader returns: a payload header, the blob commitments and execution requests that go into the body with it, and the value paid to the proposer. --- crates/common/types/src/beacon/builder.rs | 64 ++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs index 3c066f5c5..c4721f78d 100644 --- a/crates/common/types/src/beacon/builder.rs +++ b/crates/common/types/src/beacon/builder.rs @@ -14,8 +14,10 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::SszList; +use crate::beacon::containers::deneb::{ExecutionPayloadHeader, KzgCommitments}; +use crate::beacon::containers::electra::ExecutionRequests; use crate::beacon::preset; -use crate::beacon::primitives::{BlsPubkey, BlsSignature, ExecutionAddress}; +use crate::beacon::primitives::{BlsPubkey, BlsSignature, ExecutionAddress, Uint256}; // --------------------------------------------------------------------------- // Registrations @@ -66,6 +68,52 @@ pub struct SignedValidatorRegistrationV1 { pub type SignedValidatorRegistrations = SszList; +// --------------------------------------------------------------------------- +// Bids +// --------------------------------------------------------------------------- + +/// A relay's offer of an execution payload, builder-specs `electra/builder.md` +/// (fulu leaves it unchanged): the payload's header, its blob commitments and +/// execution requests, and what it pays. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct BuilderBid { + pub header: ExecutionPayloadHeader, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub blob_kzg_commitments: KzgCommitments, + pub execution_requests: ExecutionRequests, + /// What the proposer is paid, in Wei. + pub value: Uint256, + /// The key the bid is signed with, under + /// [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). + pub pubkey: BlsPubkey, +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct SignedBuilderBid { + pub message: BuilderBid, + pub signature: BlsSignature, +} + #[cfg(test)] mod tests { use libssz::{SszDecode as _, SszEncode as _}; @@ -103,4 +151,18 @@ mod tests { assert_eq!(from_json[0].message.gas_limit, 100); assert_eq!(from_json[0].message.timestamp, 100); } + + #[test] + fn the_bid_example_decodes_from_both_encodings() { + let ssz = example!("fulu/signed_builder_bid.ssz"); + let from_ssz = SignedBuilderBid::from_ssz_bytes(ssz).unwrap(); + let value = example_value(example!("fulu/signed_builder_bid.json")); + assert_eq!(value["version"], "fulu"); + let from_json: SignedBuilderBid = serde_json::from_value(value["data"].clone()).unwrap(); + + assert_eq!(from_ssz, from_json); + assert_eq!(from_ssz.to_ssz(), ssz.to_vec()); + assert_eq!(from_json.message.value, Uint256::from(1u64)); + assert_eq!(from_json.message.blob_kzg_commitments.len(), 1); + } } From dd0aab83ca8b4d41bcfdc1e259d0e52e5a2c9a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:27:43 -0300 Subject: [PATCH 08/18] feat(beacon): add blinded beacon blocks The block a validator signs when a relay supplies the payload. Its root equals the full block's because a payload header's root is the payload's, which is what lets one signature serve both; the tests pin that, and builder-specs' example pins the encoding. --- crates/common/types/src/beacon/builder.rs | 219 +++++++++++++++++++++- 1 file changed, 214 insertions(+), 5 deletions(-) diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs index c4721f78d..afafe2fa5 100644 --- a/crates/common/types/src/beacon/builder.rs +++ b/crates/common/types/src/beacon/builder.rs @@ -14,10 +14,17 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::SszList; +use crate::beacon::containers::altair::SyncAggregate; +use crate::beacon::containers::capella::SignedBLSToExecutionChange; use crate::beacon::containers::deneb::{ExecutionPayloadHeader, KzgCommitments}; -use crate::beacon::containers::electra::ExecutionRequests; +use crate::beacon::containers::electra::{ + self, Attestations, AttesterSlashings, ExecutionRequests, +}; +use crate::beacon::containers::shared::{Deposit, Eth1Data, ProposerSlashing, SignedVoluntaryExit}; use crate::beacon::preset; -use crate::beacon::primitives::{BlsPubkey, BlsSignature, ExecutionAddress, Uint256}; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Bytes32, ExecutionAddress, Root, Slot, Uint256, ValidatorIndex, +}; // --------------------------------------------------------------------------- // Registrations @@ -114,6 +121,150 @@ pub struct SignedBuilderBid { pub signature: BlsSignature, } +// --------------------------------------------------------------------------- +// Blinded blocks +// --------------------------------------------------------------------------- + +/// [`electra::BeaconBlockBody`] with the payload's header in place of the +/// payload, builder-specs `electra/builder.md`. Otherwise the same container +/// field for field, which is why its root equals the full body's. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct BlindedBeaconBlockBody { + pub randao_reveal: BlsSignature, + pub eth1_data: Eth1Data, + pub graffiti: Bytes32, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub proposer_slashings: SszList, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub attester_slashings: AttesterSlashings, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub attestations: Attestations, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub deposits: SszList, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub voluntary_exits: SszList, + pub sync_aggregate: SyncAggregate, + pub execution_payload_header: ExecutionPayloadHeader, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub bls_to_execution_changes: + SszList, + #[serde(with = "crate::beacon::serde_helpers::seq")] + pub blob_kzg_commitments: KzgCommitments, + pub execution_requests: ExecutionRequests, +} + +impl BlindedBeaconBlockBody { + /// `body` with its payload swapped for `execution_payload_header`. + /// + /// The header is taken as given, so the result's root equals `body`'s only + /// when the header is `body.execution_payload`'s own. Block production + /// relies on that: it builds the body around a stand-in payload and blinds + /// it with the builder's header. + pub fn from_parts( + body: electra::BeaconBlockBody, + execution_payload_header: ExecutionPayloadHeader, + ) -> Self { + Self { + randao_reveal: body.randao_reveal, + eth1_data: body.eth1_data, + graffiti: body.graffiti, + proposer_slashings: body.proposer_slashings, + attester_slashings: body.attester_slashings, + attestations: body.attestations, + deposits: body.deposits, + voluntary_exits: body.voluntary_exits, + sync_aggregate: body.sync_aggregate, + execution_payload_header, + bls_to_execution_changes: body.bls_to_execution_changes, + blob_kzg_commitments: body.blob_kzg_commitments, + execution_requests: body.execution_requests, + } + } +} + +/// Blinds a full body: its payload replaced by that payload's own header. +impl From for BlindedBeaconBlockBody { + fn from(body: electra::BeaconBlockBody) -> Self { + let header = ExecutionPayloadHeader::from(&body.execution_payload); + Self::from_parts(body, header) + } +} + +/// [`electra::BeaconBlock`] around a [`BlindedBeaconBlockBody`]. What a +/// validator signs when a builder supplies the payload. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct BlindedBeaconBlock { + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub slot: Slot, + #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] + pub proposer_index: ValidatorIndex, + pub parent_root: Root, + pub state_root: Root, + pub body: BlindedBeaconBlockBody, +} + +impl BlindedBeaconBlock { + /// `block` with its body's payload swapped for `execution_payload_header`; + /// see [`BlindedBeaconBlockBody::from_parts`]. + pub fn from_parts( + block: electra::BeaconBlock, + execution_payload_header: ExecutionPayloadHeader, + ) -> Self { + Self { + slot: block.slot, + proposer_index: block.proposer_index, + parent_root: block.parent_root, + state_root: block.state_root, + body: BlindedBeaconBlockBody::from_parts(block.body, execution_payload_header), + } + } +} + +/// Blinds a full block: its payload replaced by that payload's own header, +/// keeping the block's root. +impl From for BlindedBeaconBlock { + fn from(block: electra::BeaconBlock) -> Self { + let header = ExecutionPayloadHeader::from(&block.body.execution_payload); + Self::from_parts(block, header) + } +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] +pub struct SignedBlindedBeaconBlock { + pub message: BlindedBeaconBlock, + pub signature: BlsSignature, +} + #[cfg(test)] mod tests { use libssz::{SszDecode as _, SszEncode as _}; @@ -141,9 +292,9 @@ mod tests { fn the_registration_example_decodes_from_both_encodings() { let ssz = example!("bellatrix/signed_validator_registrations.ssz"); let from_ssz = SignedValidatorRegistrations::from_ssz_bytes(ssz).unwrap(); - let from_json: Vec = serde_json::from_value( - example_value(example!("bellatrix/signed_validator_registrations.json")), - ) + let from_json: Vec = serde_json::from_value(example_value( + example!("bellatrix/signed_validator_registrations.json"), + )) .unwrap(); assert_eq!(from_ssz.to_vec(), from_json); @@ -165,4 +316,62 @@ mod tests { assert_eq!(from_json.message.value, Uint256::from(1u64)); assert_eq!(from_json.message.blob_kzg_commitments.len(), 1); } + + use crate::beacon::containers::bellatrix::Transaction; + use crate::beacon::containers::deneb::ExecutionPayload; + use crate::beacon::primitives::{Bytes32, ExecutionBlockHash, HashTreeRoot as _, Root}; + + /// A block whose payload has a transaction and a non-zero hash, so its + /// header's roots are not the empty ones. + fn full_block() -> electra::BeaconBlock { + let transaction: Transaction = vec![1u8, 2, 3].try_into().unwrap(); + let mut body = electra::BeaconBlockBody::empty(); + body.graffiti = Bytes32::repeat_byte(7); + body.execution_payload.block_hash = ExecutionBlockHash::repeat_byte(0xab); + body.execution_payload.transactions = vec![transaction].try_into().unwrap(); + electra::BeaconBlock { + slot: 5, + proposer_index: 3, + parent_root: Root::repeat_byte(1), + state_root: Root::repeat_byte(2), + body, + } + } + + #[test] + fn a_blinded_block_has_the_full_blocks_root() { + let block = full_block(); + let blinded = BlindedBeaconBlock::from(block.clone()); + assert_eq!(blinded.body.hash_tree_root(), block.body.hash_tree_root()); + assert_eq!(blinded.hash_tree_root(), block.hash_tree_root()); + assert_eq!(blinded.state_root, block.state_root); + } + + #[test] + fn from_parts_keeps_the_header_it_is_given() { + let block = full_block(); + let other = ExecutionPayloadHeader::from(&ExecutionPayload { + block_number: 99, + ..block.body.execution_payload.clone() + }); + let blinded = BlindedBeaconBlock::from_parts(block.clone(), other.clone()); + assert_eq!(blinded.body.execution_payload_header, other); + assert_ne!(blinded.hash_tree_root(), block.hash_tree_root()); + } + + // The example's sync aggregate is mainnet-sized (`SYNC_COMMITTEE_SIZE` + // bits), which the minimal preset's container does not decode. + #[cfg(not(feature = "preset-minimal"))] + #[test] + fn the_blinded_block_example_decodes_from_both_encodings() { + let ssz = example!("fulu/signed_blinded_beacon_block.ssz"); + let from_ssz = SignedBlindedBeaconBlock::from_ssz_bytes(ssz).unwrap(); + let from_json: SignedBlindedBeaconBlock = serde_json::from_value(example_value(example!( + "fulu/signed_blinded_beacon_block.json" + ))) + .unwrap(); + + assert_eq!(from_ssz, from_json); + assert_eq!(from_ssz.to_ssz(), ssz.to_vec()); + } } From 575e797e0bde449ba9a1594c34268a53ed7c10f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:38:01 -0300 Subject: [PATCH 09/18] test(beacon): make the builder container tests catch reordered fields The root-equality test used a mostly empty body, and empty lists with the same limit share a root, so swapping two of them still passed under the minimal preset. Build it from the fully populated JSON test block instead. The example tests now also check the JSON this node would send a relay, not only the JSON it reads. --- crates/common/types/src/beacon/builder.rs | 41 +++++++------------ .../types/src/beacon/containers/json_tests.rs | 2 +- .../common/types/src/beacon/containers/mod.rs | 2 +- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs index afafe2fa5..ea6cdf6e4 100644 --- a/crates/common/types/src/beacon/builder.rs +++ b/crates/common/types/src/beacon/builder.rs @@ -270,6 +270,8 @@ mod tests { use libssz::{SszDecode as _, SszEncode as _}; use super::*; + use crate::beacon::containers::deneb::ExecutionPayload; + use crate::beacon::primitives::HashTreeRoot as _; /// A vendored builder-specs example file (see the fixtures' README). macro_rules! example { @@ -292,12 +294,13 @@ mod tests { fn the_registration_example_decodes_from_both_encodings() { let ssz = example!("bellatrix/signed_validator_registrations.ssz"); let from_ssz = SignedValidatorRegistrations::from_ssz_bytes(ssz).unwrap(); - let from_json: Vec = serde_json::from_value(example_value( - example!("bellatrix/signed_validator_registrations.json"), - )) - .unwrap(); + let value = example_value(example!("bellatrix/signed_validator_registrations.json")); + let from_json: Vec = + serde_json::from_value(value.clone()).unwrap(); assert_eq!(from_ssz.to_vec(), from_json); + // What this node would send a relay. + assert_eq!(serde_json::to_value(from_ssz.to_vec()).unwrap(), value); assert_eq!(from_ssz.to_ssz(), ssz.to_vec()); assert_eq!(from_json[0].message.gas_limit, 100); assert_eq!(from_json[0].message.timestamp, 100); @@ -312,30 +315,17 @@ mod tests { let from_json: SignedBuilderBid = serde_json::from_value(value["data"].clone()).unwrap(); assert_eq!(from_ssz, from_json); + assert_eq!(serde_json::to_value(&from_ssz).unwrap(), value["data"]); assert_eq!(from_ssz.to_ssz(), ssz.to_vec()); assert_eq!(from_json.message.value, Uint256::from(1u64)); assert_eq!(from_json.message.blob_kzg_commitments.len(), 1); } - use crate::beacon::containers::bellatrix::Transaction; - use crate::beacon::containers::deneb::ExecutionPayload; - use crate::beacon::primitives::{Bytes32, ExecutionBlockHash, HashTreeRoot as _, Root}; - - /// A block whose payload has a transaction and a non-zero hash, so its - /// header's roots are not the empty ones. + /// A block with every list populated. Empty lists with equal limits share + /// a root, so a body with empty ones would not notice two of its fields + /// swapping places. fn full_block() -> electra::BeaconBlock { - let transaction: Transaction = vec![1u8, 2, 3].try_into().unwrap(); - let mut body = electra::BeaconBlockBody::empty(); - body.graffiti = Bytes32::repeat_byte(7); - body.execution_payload.block_hash = ExecutionBlockHash::repeat_byte(0xab); - body.execution_payload.transactions = vec![transaction].try_into().unwrap(); - electra::BeaconBlock { - slot: 5, - proposer_index: 3, - parent_root: Root::repeat_byte(1), - state_root: Root::repeat_byte(2), - body, - } + crate::beacon::containers::json_tests::electra_block().message } #[test] @@ -366,12 +356,11 @@ mod tests { fn the_blinded_block_example_decodes_from_both_encodings() { let ssz = example!("fulu/signed_blinded_beacon_block.ssz"); let from_ssz = SignedBlindedBeaconBlock::from_ssz_bytes(ssz).unwrap(); - let from_json: SignedBlindedBeaconBlock = serde_json::from_value(example_value(example!( - "fulu/signed_blinded_beacon_block.json" - ))) - .unwrap(); + let value = example_value(example!("fulu/signed_blinded_beacon_block.json")); + let from_json: SignedBlindedBeaconBlock = serde_json::from_value(value.clone()).unwrap(); assert_eq!(from_ssz, from_json); + assert_eq!(serde_json::to_value(&from_ssz).unwrap(), value); assert_eq!(from_ssz.to_ssz(), ssz.to_vec()); } } diff --git a/crates/common/types/src/beacon/containers/json_tests.rs b/crates/common/types/src/beacon/containers/json_tests.rs index e1e413901..73f9d0a6a 100644 --- a/crates/common/types/src/beacon/containers/json_tests.rs +++ b/crates/common/types/src/beacon/containers/json_tests.rs @@ -188,7 +188,7 @@ fn electra_requests() -> electra::ExecutionRequests { } } -fn electra_block() -> electra::SignedBeaconBlock { +pub(crate) fn electra_block() -> electra::SignedBeaconBlock { let mut body = electra::BeaconBlockBody::empty(); body.randao_reveal = signature(1); body.graffiti = [9; 32].into(); diff --git a/crates/common/types/src/beacon/containers/mod.rs b/crates/common/types/src/beacon/containers/mod.rs index a7859de2e..d1f926edf 100644 --- a/crates/common/types/src/beacon/containers/mod.rs +++ b/crates/common/types/src/beacon/containers/mod.rs @@ -1965,7 +1965,7 @@ impl DataColumnSidecar { } #[cfg(test)] -mod json_tests; +pub(crate) mod json_tests; #[cfg(test)] mod tests { From 00b9f2844d2ec7aa892a3baf840d8a6a8102dddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:38:58 -0300 Subject: [PATCH 10/18] docs(beacon): correct the builder containers' docs Registrations and bids are signed over their own roots, relays reject an older registration rather than requiring an unchanged one, and from_parts copies a state root its caller must recompute. --- crates/common/types/src/beacon/builder.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs index ea6cdf6e4..0848d54ec 100644 --- a/crates/common/types/src/beacon/builder.rs +++ b/crates/common/types/src/beacon/builder.rs @@ -2,11 +2,11 @@ //! MEV-Boost relay flow exchanges between a validator client, this node and a //! relay. //! -//! Not consensus containers. Nothing here enters a state, and only -//! [`BlindedBeaconBlock`] has a root anything depends on: it equals the full -//! block's, because a payload header's root is the root of the payload it -//! summarizes. That equality is what lets a validator sign a blinded block for -//! a full one it never sees. +//! Not consensus containers: nothing here enters a state. Registrations and +//! bids are signed over their own roots, and [`BlindedBeaconBlock`]'s root must +//! also equal the full block's, which it does because a payload header's root +//! is the root of the payload it summarizes. That equality is what lets a +//! validator sign a blinded block for a full one it never sees. //! //! Electra and fulu shapes only, which are identical, as the consensus //! containers they extend are. @@ -47,13 +47,14 @@ pub struct ValidatorRegistrationV1 { pub fee_recipient: ExecutionAddress, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub gas_limit: u64, - /// Seconds since the Unix epoch. Builders keep a validator's latest - /// registration by this, so re-sending an unchanged one must reuse it. + /// Seconds since the Unix epoch. Builders sequence a validator's + /// registrations by this and reject one older than the latest they hold. #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub timestamp: u64, pub pubkey: BlsPubkey, } +/// A [`ValidatorRegistrationV1`] signed by its validator under [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). #[derive( Debug, Clone, @@ -105,6 +106,7 @@ pub struct BuilderBid { pub pubkey: BlsPubkey, } +/// A [`BuilderBid`] signed by its `pubkey` under [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). #[derive( Debug, Clone, @@ -225,7 +227,9 @@ pub struct BlindedBeaconBlock { impl BlindedBeaconBlock { /// `block` with its body's payload swapped for `execution_payload_header`; - /// see [`BlindedBeaconBlockBody::from_parts`]. + /// see [`BlindedBeaconBlockBody::from_parts`]. The block's `state_root` is + /// copied as is, so a caller passing a header other than the payload's own + /// must recompute it, as blinded block production does. pub fn from_parts( block: electra::BeaconBlock, execution_payload_header: ExecutionPayloadHeader, @@ -249,6 +253,7 @@ impl From for BlindedBeaconBlock { } } +/// A [`BlindedBeaconBlock`] signed by its proposer under the full block's domain, `DOMAIN_BEACON_PROPOSER`, since the two share a root. #[derive( Debug, Clone, From bd314cb6f4ce4dd29eb8c728a76df05b18df8a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:40:50 -0300 Subject: [PATCH 11/18] refactor(beacon): split block assembly into its steps A blinded block needs the same body, framing and process_block run as a full one, then patches the post-state before taking its root. No behavior change. --- .../src/beacon/block_production.rs | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 9c3d0f8ce..69077aa64 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -514,7 +514,21 @@ pub fn assemble_block( config: &Config, options: stf::block::BlockProcessingOptions, ) -> Result { - let body = BeaconBlockBody { + let body = full_body(state, inputs)?; + let mut block = unsigned_block(state, body)?; + let mut post = post_state(state, &block, config, options)?; + // Fold the block's writes into their trees first, so the root is computed + // on (and cached in) the state's own nodes rather than a throwaway copy, + // as `state_transition` does before it checks a block's state root. + post.apply_pending_mutations(); + block.state_root = post.hash_tree_root(); + Ok(block) +} + +/// The body for `inputs`, voting the state's own `eth1_data` and carrying no +/// deposits; see [`assemble_block`]. +fn full_body(state: &BeaconState, inputs: BlockInputs) -> Result { + Ok(BeaconBlockBody { randao_reveal: inputs.randao_reveal, eth1_data: state.eth1_data().clone(), graffiti: inputs.graffiti, @@ -554,8 +568,13 @@ pub fn assemble_block( .map_err(|_| Error::SpecAssert("len(blob_kzg_commitments) within bound"))?, execution_requests: inputs.execution_requests, ..BeaconBlockBody::empty() - }; - let mut block = BeaconBlock { + }) +} + +/// `body` framed as the block for the slot `state` has been advanced to, its +/// state root still zero. +fn unsigned_block(state: &BeaconState, body: BeaconBlockBody) -> Result { + Ok(BeaconBlock { slot: state.slot(), proposer_index: get_beacon_proposer_index(state)?, // `process_slots` filled the header's state root on the way out of @@ -563,8 +582,19 @@ pub fn assemble_block( parent_root: state.latest_block_header().hash_tree_root(), state_root: Root::ZERO, body, - }; + }) +} +/// A copy of `state` with `block` applied by `process_block`, under an +/// execution engine that accepts its payload. Pending mutations are left +/// unapplied, so the caller can still edit the state before computing its +/// root. +fn post_state( + state: &BeaconState, + block: &BeaconBlock, + config: &Config, + options: stf::block::BlockProcessingOptions, +) -> Result { let signed = electra::SignedBeaconBlock { message: block.clone(), signature: BlsSignature::default(), @@ -594,12 +624,7 @@ pub fn assemble_block( &ActiveBalanceCache::default(), options, )?; - // Fold the block's writes into their trees first, so the root is computed - // on (and cached in) the state's own nodes rather than a throwaway copy, - // as `state_transition` does before it checks a block's state root. - post.apply_pending_mutations(); - block.state_root = post.hash_tree_root(); - Ok(block) + Ok(post) } #[cfg(test)] From 9064f582e9921bb6ae8828eb9b91564f40994bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:43:38 -0300 Subject: [PATCH 12/18] feat(beacon): compute a fulu blinded block's state root The state transition takes full payloads only. Rather than thread a payload abstraction through it, run it on a stand-in payload built from the builder's header and patch the two post-state fields that depend on the payload itself. Safe because fulu, the last fork with blinded blocks, is final; the guard test fails on any drift. --- .../src/beacon/block_production.rs | 178 +++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 69077aa64..15d04b164 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -13,12 +13,13 @@ //! `validator.md` describes (`compute_new_state_root`). use ethlambda_types::beacon::{ + builder::BlindedBeaconBlock, constants, containers::{ BeaconState, SignedBeaconBlock, altair::SyncAggregate, capella::{self, Withdrawal}, - deneb::ExecutionPayload, + deneb::{ExecutionPayload, ExecutionPayloadHeader}, electra::{ self, AggregationBits, Attestation, BeaconBlock, BeaconBlockBody, CommitteeBits, ConsolidationRequest, DepositRequest, ExecutionRequests, WithdrawalRequest, @@ -44,6 +45,7 @@ use super::helpers::accessors::{ use super::helpers::electra::{ get_attesting_indices, get_indexed_attestation, is_valid_indexed_attestation, }; +use super::helpers::fulu::fulu_state; use super::helpers::misc::compute_signing_root; use super::helpers::predicates::is_slashable_validator; use super::lean_boundary::lean_state_unreachable; @@ -627,6 +629,104 @@ fn post_state( Ok(post) } +/// What a blinded block's body carries: [`BlockInputs`] with a builder's +/// payload header, and the bid's blob commitments and execution requests, in +/// place of a payload this node built. +#[derive(Debug, Clone)] +pub struct BlindedBlockInputs { + pub randao_reveal: BlsSignature, + pub graffiti: Bytes32, + pub attestations: Vec, + /// Slashings, exits and credential changes, as [`pack_operations`] returns + /// them. + pub operations: Operations, + pub execution_payload_header: ExecutionPayloadHeader, + pub blob_kzg_commitments: Vec, + pub execution_requests: ExecutionRequests, + pub sync_aggregate: SyncAggregate, +} + +/// The unsigned blinded block for the slot `state` has been advanced to, +/// around a builder's payload header, with its state root computed. +/// +/// # A state root without the payload +/// +/// The state transition takes full payloads only, so this runs it on a body +/// whose payload is a *stand-in*, then patches the post-state fields that +/// depend on the payload itself rather than on what `process_block` checks of +/// it. Fulu's `process_block`, step by step: +/// +/// | Step | Reads of the payload | With the stand-in | +/// |---|---|---| +/// | `process_block_header` | the body's root | wrong `body_root`: patched | +/// | `process_withdrawals` | `payload.withdrawals == expected` | identical: every write uses the expected withdrawals | +/// | `process_execution_payload` | parent hash, prev_randao, timestamp; then caches the header | checks run on the header's own values; wrong cached header: patched | +/// | randao, eth1, operations, sync aggregate | none | identical | +/// +/// Two things follow: +/// - The stand-in copies the three checked fields **from the header**. Copied +/// from the state, they would let any header through. +/// - The stand-in carries the expected withdrawals, so the state transition's +/// own withdrawals check passes trivially. That is why this function checks +/// the header's `withdrawals_root` against them itself. +/// +/// Fulu only. The table describes fulu's `process_block`, and fulu is the last +/// fork with blinded blocks (gloas has none), so it cannot change under this +/// function. `blinded_and_full_blocks_reach_the_same_state_root` is the guard. +pub fn assemble_blinded_block( + state: &BeaconState, + inputs: BlindedBlockInputs, + config: &Config, + options: stf::block::BlockProcessingOptions, +) -> Result { + if !matches!(state, BeaconState::Fulu(_)) { + return Err(Error::SpecAssert("blinded block production is fulu only")); + } + let header = inputs.execution_payload_header; + + let expected_withdrawals: capella::Withdrawals = stf::electra::get_expected_withdrawals(state)? + .0 + .try_into() + .map_err(|_| { + Error::SpecAssert("len(expected_withdrawals) <= MAX_WITHDRAWALS_PER_PAYLOAD") + })?; + verify( + header.withdrawals_root == expected_withdrawals.hash_tree_root(), + "header.withdrawals_root == hash_tree_root(get_expected_withdrawals(state))", + )?; + + let stand_in = ExecutionPayload { + parent_hash: header.parent_hash, + prev_randao: header.prev_randao, + timestamp: header.timestamp, + withdrawals: expected_withdrawals, + ..BeaconBlockBody::empty().execution_payload + }; + let body = full_body( + state, + BlockInputs { + randao_reveal: inputs.randao_reveal, + graffiti: inputs.graffiti, + attestations: inputs.attestations, + operations: inputs.operations, + execution_payload: stand_in, + blob_kzg_commitments: inputs.blob_kzg_commitments, + execution_requests: inputs.execution_requests, + sync_aggregate: inputs.sync_aggregate, + }, + )?; + let block = unsigned_block(state, body)?; + let mut post = post_state(state, &block, config, options)?; + + let mut blinded = BlindedBeaconBlock::from_parts(block, header); + let fields = fulu_state(&mut post, "assemble_blinded_block")?; + fields.latest_execution_payload_header = blinded.body.execution_payload_header.clone(); + fields.latest_block_header.body_root = blinded.body.hash_tree_root(); + post.apply_pending_mutations(); + blinded.state_root = post.hash_tree_root(); + Ok(blinded) +} + #[cfg(test)] mod tests { use super::*; @@ -1287,4 +1387,80 @@ mod tests { .all(|b| *b == 0) ); } + + use ethlambda_types::beacon::builder::BlindedBeaconBlock; + use ethlambda_types::beacon::containers::bellatrix::Transaction; + use ethlambda_types::beacon::containers::deneb::ExecutionPayloadHeader; + use ethlambda_types::beacon::primitives::ExecutionAddress; + + /// [`payload_for`], with every field the stand-in payload does not copy + /// from the header set to something else, so a missed patch changes the + /// state root instead of passing unnoticed. + fn distinctive_payload_for(state: &BeaconState) -> ExecutionPayload { + let transaction: Transaction = vec![0xde, 0xad].try_into().unwrap(); + ExecutionPayload { + fee_recipient: ExecutionAddress::repeat_byte(0x11), + state_root: Bytes32::repeat_byte(0x22), + block_number: 42, + gas_limit: 60_000_000, + gas_used: 21_000, + block_hash: ExecutionBlockHash::repeat_byte(0x33), + transactions: vec![transaction].try_into().unwrap(), + ..payload_for(state) + } + } + + fn full_inputs(state: &BeaconState) -> BlockInputs { + BlockInputs { + sync_aggregate: empty_sync_aggregate(), + randao_reveal: randao_reveal(state), + graffiti: Bytes32::repeat_byte(7), + attestations: Vec::new(), + operations: Operations::default(), + execution_payload: distinctive_payload_for(state), + blob_kzg_commitments: vec![KzgCommitment::default()], + execution_requests: ExecutionRequests::default(), + } + } + + /// `inputs` as a builder would offer them: the payload's header instead + /// of the payload. + fn blinded(inputs: &BlockInputs) -> BlindedBlockInputs { + BlindedBlockInputs { + randao_reveal: inputs.randao_reveal, + graffiti: inputs.graffiti, + attestations: inputs.attestations.clone(), + operations: inputs.operations.clone(), + execution_payload_header: ExecutionPayloadHeader::from(&inputs.execution_payload), + blob_kzg_commitments: inputs.blob_kzg_commitments.clone(), + execution_requests: inputs.execution_requests.clone(), + sync_aggregate: inputs.sync_aggregate.clone(), + } + } + + /// The guard for [`assemble_blinded_block`]'s two patches: around the same + /// payload, the blinded block lands on the full block's state root and + /// has its block root, so a signature over one is a signature over the + /// other. + #[test] + fn blinded_and_full_blocks_reach_the_same_state_root() { + let state = state_to_build_on(); + let inputs = full_inputs(&state); + let blinded_inputs = blinded(&inputs); + let config = Config::mainnet(); + + let full = + assemble_block(&state, inputs, &config, BlockProcessingOptions::default()).unwrap(); + let blinded = assemble_blinded_block( + &state, + blinded_inputs, + &config, + BlockProcessingOptions::default(), + ) + .unwrap(); + + assert_eq!(blinded.state_root, full.state_root); + assert_eq!(blinded.hash_tree_root(), full.hash_tree_root()); + assert_eq!(blinded, BlindedBeaconBlock::from(full)); + } } From 4de9df72cdbdfa99856c6d16a4b09ada9d01f7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:44:03 -0300 Subject: [PATCH 13/18] test(beacon): pin the checks a blinded block's header goes through The stand-in payload is what makes process_block's parent hash, prev_randao, timestamp and blob checks apply to a builder's header, and only if it copies the right fields. One test per check, plus the withdrawals root and the fork gate, which this path checks itself. --- .../src/beacon/block_production.rs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 15d04b164..8a980c4c9 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -1463,4 +1463,90 @@ mod tests { assert_eq!(blinded.hash_tree_root(), full.hash_tree_root()); assert_eq!(blinded, BlindedBeaconBlock::from(full)); } + + /// The specification condition a refused blinded block failed. + fn refusal(state: &BeaconState, inputs: BlindedBlockInputs) -> &'static str { + match assemble_blinded_block( + state, + inputs, + &Config::mainnet(), + BlockProcessingOptions::default(), + ) { + Err(Error::SpecAssert(what)) => what, + other => panic!("expected a SpecAssert refusal, got {other:?}"), + } + } + + fn blinded_inputs_for(state: &BeaconState) -> BlindedBlockInputs { + blinded(&full_inputs(state)) + } + + #[test] + fn a_header_with_the_wrong_withdrawals_root_is_refused() { + let state = state_to_build_on(); + let mut inputs = blinded_inputs_for(&state); + inputs.execution_payload_header.withdrawals_root = Root::repeat_byte(0xee); + assert_eq!( + refusal(&state, inputs), + "header.withdrawals_root == hash_tree_root(get_expected_withdrawals(state))" + ); + } + + #[test] + fn a_header_on_the_wrong_parent_is_refused() { + let state = state_to_build_on(); + let mut inputs = blinded_inputs_for(&state); + inputs.execution_payload_header.parent_hash = ExecutionBlockHash::repeat_byte(0xee); + assert_eq!( + refusal(&state, inputs), + "payload.parent_hash == state.latest_execution_payload_header.block_hash" + ); + } + + #[test] + fn a_header_with_the_wrong_prev_randao_is_refused() { + let state = state_to_build_on(); + let mut inputs = blinded_inputs_for(&state); + inputs.execution_payload_header.prev_randao = Bytes32::repeat_byte(0xee); + assert_eq!( + refusal(&state, inputs), + "payload.prev_randao == get_randao_mix(state, get_current_epoch(state))" + ); + } + + #[test] + fn a_header_with_the_wrong_timestamp_is_refused() { + let state = state_to_build_on(); + let mut inputs = blinded_inputs_for(&state); + inputs.execution_payload_header.timestamp += 1; + assert_eq!( + refusal(&state, inputs), + "payload.timestamp == compute_time_at_slot(state, state.slot)" + ); + } + + #[test] + fn a_bid_over_the_blob_limit_is_refused() { + let state = state_to_build_on(); + let limit = Config::mainnet().max_blobs_per_block(get_current_epoch(&state)); + let mut inputs = blinded_inputs_for(&state); + inputs.blob_kzg_commitments = vec![KzgCommitment::default(); limit as usize + 1]; + assert_eq!( + refusal(&state, inputs), + "len(body.blob_kzg_commitments) <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block" + ); + } + + #[test] + fn a_state_before_fulu_is_refused() { + // Only the fork gate runs: it comes before anything reads the inputs, + // so building them from a fulu state does not matter, and no electra + // state transition is exercised. + let inputs = blinded_inputs_for(&state_to_build_on()); + let electra = with_signing_validators_at(ForkName::Electra, 64); + assert_eq!( + refusal(&electra, inputs), + "blinded block production is fulu only" + ); + } } From be555d89b152cfd50686612b829a8d6ddff4f99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:54:56 -0300 Subject: [PATCH 14/18] docs(beacon): say what assemble_blinded_block does not check Both block paths pass a constant execution engine, so on the blinded path no one checks the builder's header beyond the fields the stand-in copies. That is right for the state root but easy to mistake for bid validation, so the doc now says the caller owns the rest. --- .../state_transition/src/beacon/block_production.rs | 7 +++++++ .../state_transition/src/beacon/helpers/fulu.rs | 10 +++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 8a980c4c9..f1a77b556 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -661,6 +661,7 @@ pub struct BlindedBlockInputs { /// | `process_block_header` | the body's root | wrong `body_root`: patched | /// | `process_withdrawals` | `payload.withdrawals == expected` | identical: every write uses the expected withdrawals | /// | `process_execution_payload` | parent hash, prev_randao, timestamp; then caches the header | checks run on the header's own values; wrong cached header: patched | +/// | the execution engine | the whole payload, in the specification | a constant here, as for a local block: no one checks the builder's payload | /// | randao, eth1, operations, sync aggregate | none | identical | /// /// Two things follow: @@ -670,6 +671,12 @@ pub struct BlindedBlockInputs { /// own withdrawals check passes trivially. That is why this function checks /// the header's `withdrawals_root` against them itself. /// +/// This is not bid validation. Beyond what the table checks, the header is +/// trusted as given: the caller verifies the bid's signature, value and gas +/// limit before building on it. +/// +/// `options` is as in [`assemble_block`]. +/// /// Fulu only. The table describes fulu's `process_block`, and fulu is the last /// fork with blinded blocks (gloas has none), so it cannot change under this /// function. `blinded_and_full_blocks_reach_the_same_state_root` is the guard. diff --git a/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs b/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs index 3ecbd7cf3..0948a2805 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs @@ -222,13 +222,9 @@ pub fn initialize_proposer_lookahead(state: &BeaconState) -> Result( state: &'a mut BeaconState, function: &'static str, From cce588eec9ddeb5ce6d2d2b7caeff3e7ff0abdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:28 -0300 Subject: [PATCH 15/18] test(beacon): make the blinded guard exercise withdrawals and requests The guard's state had no withdrawal due and its body no execution request, so the withdrawals root was only ever checked against the empty list and the "identical" rows of the doc's table were argued rather than tested. Six payload fields the stand-in leaves empty were empty in the test payload too. --- .../src/beacon/block_production.rs | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index f1a77b556..e7bf4965e 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -744,9 +744,11 @@ mod tests { use crate::beacon::helpers::misc::compute_signing_root; use crate::beacon::helpers::test_state::{sign_for, with_signing_validators_at}; use crate::beacon::stf::block::{BlockProcessingOptions, RandaoVerification}; + use ethlambda_types::beacon::containers::bellatrix::Transaction; use ethlambda_types::beacon::containers::shared::{ AttestationData, BeaconBlockHeader, Checkpoint, SignedBeaconBlockHeader, VoluntaryExit, }; + use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionAddress}; /// A fulu state one epoch in, its lookahead and sync committee filled from /// its real registry (the builder leaves both as placeholders), advanced one @@ -1395,11 +1397,6 @@ mod tests { ); } - use ethlambda_types::beacon::builder::BlindedBeaconBlock; - use ethlambda_types::beacon::containers::bellatrix::Transaction; - use ethlambda_types::beacon::containers::deneb::ExecutionPayloadHeader; - use ethlambda_types::beacon::primitives::ExecutionAddress; - /// [`payload_for`], with every field the stand-in payload does not copy /// from the header set to something else, so a missed patch changes the /// state root instead of passing unnoticed. @@ -1413,10 +1410,33 @@ mod tests { gas_used: 21_000, block_hash: ExecutionBlockHash::repeat_byte(0x33), transactions: vec![transaction].try_into().unwrap(), + receipts_root: Bytes32::repeat_byte(0x44), + logs_bloom: vec![0x55; preset::BYTES_PER_LOGS_BLOOM].try_into().unwrap(), + extra_data: vec![0x66, 0x77].try_into().unwrap(), + base_fee_per_gas: 7.into(), + // Any distinct non-zero value; no gas-per-blob constant exists here. + blob_gas_used: 131_072, + excess_blob_gas: 262_144, ..payload_for(state) } } + /// [`state_to_build_on`] with one validator due a partial withdrawal: ETH1 + /// credentials and a balance above what it may keep. + fn state_with_a_withdrawal_due() -> BeaconState { + let mut state = state_to_build_on(); + let validator = state.validator_mut(3).unwrap(); + validator.withdrawal_credentials = { + let mut credentials = Bytes32::repeat_byte(0x12); + credentials.0[0] = constants::ETH1_ADDRESS_WITHDRAWAL_PREFIX; + credentials + }; + validator.effective_balance = preset::MIN_ACTIVATION_BALANCE; + *state.balance_mut(3).unwrap() = preset::MIN_ACTIVATION_BALANCE + 1_000_000_000; + state.apply_pending_mutations(); + state + } + fn full_inputs(state: &BeaconState) -> BlockInputs { BlockInputs { sync_aggregate: empty_sync_aggregate(), @@ -1426,7 +1446,18 @@ mod tests { operations: Operations::default(), execution_payload: distinctive_payload_for(state), blob_kzg_commitments: vec![KzgCommitment::default()], - execution_requests: ExecutionRequests::default(), + execution_requests: ExecutionRequests { + deposits: vec![DepositRequest { + pubkey: BlsPubkey([0x88; 48]), + withdrawal_credentials: Bytes32::repeat_byte(0x99), + amount: preset::MIN_ACTIVATION_BALANCE, + signature: BlsSignature::default(), + index: 0, + }] + .try_into() + .unwrap(), + ..ExecutionRequests::default() + }, } } @@ -1451,7 +1482,14 @@ mod tests { /// other. #[test] fn blinded_and_full_blocks_reach_the_same_state_root() { - let state = state_to_build_on(); + let state = state_with_a_withdrawal_due(); + assert!( + !stf::electra::get_expected_withdrawals(&state) + .unwrap() + .0 + .is_empty(), + "the guard needs a withdrawal due" + ); let inputs = full_inputs(&state); let blinded_inputs = blinded(&inputs); let config = Config::mainnet(); From 23327e5408fd308da8f5364f3b9bc65800410dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:57:46 -0300 Subject: [PATCH 16/18] style(beacon): wrap the builder wrappers' doc comments They were single lines past the width the rest of the file wraps at. --- crates/common/types/src/beacon/builder.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs index 0848d54ec..c39437643 100644 --- a/crates/common/types/src/beacon/builder.rs +++ b/crates/common/types/src/beacon/builder.rs @@ -54,7 +54,8 @@ pub struct ValidatorRegistrationV1 { pub pubkey: BlsPubkey, } -/// A [`ValidatorRegistrationV1`] signed by its validator under [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). +/// A [`ValidatorRegistrationV1`] signed by its validator under +/// [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). #[derive( Debug, Clone, @@ -106,7 +107,8 @@ pub struct BuilderBid { pub pubkey: BlsPubkey, } -/// A [`BuilderBid`] signed by its `pubkey` under [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). +/// A [`BuilderBid`] signed by its `pubkey` under +/// [`DOMAIN_APPLICATION_BUILDER`](crate::beacon::constants::DOMAIN_APPLICATION_BUILDER). #[derive( Debug, Clone, @@ -253,7 +255,8 @@ impl From for BlindedBeaconBlock { } } -/// A [`BlindedBeaconBlock`] signed by its proposer under the full block's domain, `DOMAIN_BEACON_PROPOSER`, since the two share a root. +/// A [`BlindedBeaconBlock`] signed by its proposer under the full block's +/// domain, `DOMAIN_BEACON_PROPOSER`, since the two share a root. #[derive( Debug, Clone, From bc8d8a233f9faea1970a6e9c3febc07fc3ecb63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:58:04 -0300 Subject: [PATCH 17/18] docs: point at the builder containers and the blinded state root The stand-in-and-patch approach is unusual (every other client threads a payload abstraction through its STF), so the next person to touch fulu's process_block should find the reason before the guard test does. --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a171704a8..b2812fffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -669,6 +669,12 @@ existing once per chain. validator: helpers that build the active-index `Vec` and then read each index back were the largest cost left in the import profile (`docs/beacon_stf.md`, "Registry and balances"). +- **`beacon::builder` holds builder-specs containers** (`ValidatorRegistrationV1`, + `BuilderBid`, `BlindedBeaconBlock`), not consensus ones: the MEV-Boost relay + flow's wire types. `BlindedBeaconBlock`'s root equals the full block's, which + is what lets a validator sign a block whose payload it never sees. Electra and + fulu shapes only. Tests decode builder-specs' own examples, vendored under + `crates/common/types/tests/fixtures/builder_specs/`. ## Beacon Chain STF (`crates/blockchain/state_transition/src/beacon/`) @@ -872,6 +878,13 @@ transitions are in `ethlambda-types`, per the section above. Nothing above that is neither parseable nor listed, so a new fork forces a decision. - A fixture case with no `post` state asserts the input must be **rejected**. That rule lives in `check_transition`; do not add a runner that ignores it. +- **`assemble_blinded_block` does not use a blinded STF.** It runs `process_block` + on a stand-in payload built from the builder's header, then patches the two + post-state fields that depend on the payload itself + (`latest_execution_payload_header`, `latest_block_header.body_root`). Fulu + only, since it is the last fork with blinded blocks. Read the function's doc + before touching fulu's `process_block`; + `blinded_and_full_blocks_reach_the_same_state_root` fails on any drift. ## Configuration Files From e6e990600b8085b0693790fa0d60c5ef776567b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:10:40 -0300 Subject: [PATCH 18/18] docs(beacon): fix doc drift across the builder groundwork fulu_state's doc named a field it deliberately does not cover and a step that has since been written. assemble_blinded_block's list of what the caller must still check was short, and A2 codes against it. The rest are small accuracy fixes found reviewing the branch whole. --- CLAUDE.md | 4 +++- .../src/beacon/block_production.rs | 12 ++++++++---- .../state_transition/src/beacon/helpers/fulu.rs | 17 ++++++++++------- crates/common/types/src/beacon/builder.rs | 6 +++--- crates/common/types/src/beacon/constants.rs | 4 ++-- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b2812fffd..a775bae50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -881,7 +881,9 @@ transitions are in `ethlambda-types`, per the section above. Nothing above - **`assemble_blinded_block` does not use a blinded STF.** It runs `process_block` on a stand-in payload built from the builder's header, then patches the two post-state fields that depend on the payload itself - (`latest_execution_payload_header`, `latest_block_header.body_root`). Fulu + (`latest_execution_payload_header`, `latest_block_header.body_root`). It + also checks the header's `withdrawals_root` itself: the stand-in carries the + expected withdrawals, so the STF's own withdrawals check passes trivially. Fulu only, since it is the last fork with blinded blocks. Read the function's doc before touching fulu's `process_block`; `blinded_and_full_blocks_reach_the_same_state_root` fails on any drift. diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index e7bf4965e..f77b76198 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -7,7 +7,8 @@ //! electra's block containers unchanged. //! //! The pieces the execution client supplies (the payload, its blob commitments -//! and its request list) arrive already built; this module decides everything +//! and its request list) arrive already built; `assemble_blinded_block` takes +//! them from a builder's bid instead, fulu only. This module decides everything //! the consensus layer decides, and computes the state root by running the //! block through `process_block` on a copy of the state, the way //! `validator.md` describes (`compute_new_state_root`). @@ -614,7 +615,7 @@ fn post_state( BeaconState::Gloas(_) => { return Err(Error::SpecAssert("block production does not support gloas")); } - BeaconState::Lean(_) => lean_state_unreachable("assemble_block"), + BeaconState::Lean(_) => lean_state_unreachable("post_state"), }; let mut post = state.clone(); stf::block::process_block( @@ -643,6 +644,8 @@ pub struct BlindedBlockInputs { pub execution_payload_header: ExecutionPayloadHeader, pub blob_kzg_commitments: Vec, pub execution_requests: ExecutionRequests, + /// The block's sync aggregate: [`empty_sync_aggregate`], or what + /// [`verified_sync_aggregate`] vouched for. pub sync_aggregate: SyncAggregate, } @@ -672,8 +675,9 @@ pub struct BlindedBlockInputs { /// the header's `withdrawals_root` against them itself. /// /// This is not bid validation. Beyond what the table checks, the header is -/// trusted as given: the caller verifies the bid's signature, value and gas -/// limit before building on it. +/// trusted as given: the caller verifies everything else it needs from the bid +/// (at least the response's fork version, the signature, the value, the gas +/// limit and `blob_gas_used`) before building on it. /// /// `options` is as in [`assemble_block`]. /// diff --git a/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs b/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs index 0948a2805..1a5bee5bf 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/fulu.rs @@ -48,10 +48,9 @@ //! function mutates state at an epoch boundary, and every other fork's //! equivalent epoch-processing step lives in `crate::beacon::stf`, not in a //! `helpers::` module (see [`super::altair`]'s module docs, which make -//! the same point about `process_attestation`). [`fulu_state`] is kept -//! alongside [`fulu_state_ref`] for whenever that step is written, the same -//! way altair keeps a mutable state projection ready for its own -//! not-yet-written processing steps. +//! the same point about `process_attestation`). It is +//! `crate::beacon::stf::epoch::fulu::process_proposer_lookahead`, which writes +//! the window through [`proposer_lookahead_mut`] rather than [`fulu_state`]. use crate::beacon::constants; use crate::beacon::containers::{BeaconState, fulu}; @@ -222,9 +221,13 @@ pub fn initialize_proposer_lookahead(state: &BeaconState) -> Result( state: &'a mut BeaconState, function: &'static str, diff --git a/crates/common/types/src/beacon/builder.rs b/crates/common/types/src/beacon/builder.rs index c39437643..dba64d0f8 100644 --- a/crates/common/types/src/beacon/builder.rs +++ b/crates/common/types/src/beacon/builder.rs @@ -171,9 +171,9 @@ impl BlindedBeaconBlockBody { /// `body` with its payload swapped for `execution_payload_header`. /// /// The header is taken as given, so the result's root equals `body`'s only - /// when the header is `body.execution_payload`'s own. Block production - /// relies on that: it builds the body around a stand-in payload and blinds - /// it with the builder's header. + /// when the header is `body.execution_payload`'s own. Blinded block + /// production relies on the header being kept as given: it builds the body + /// around a stand-in payload and blinds it with the builder's header. pub fn from_parts( body: electra::BeaconBlockBody, execution_payload_header: ExecutionPayloadHeader, diff --git a/crates/common/types/src/beacon/constants.rs b/crates/common/types/src/beacon/constants.rs index 7e810e75d..2b285c1b3 100644 --- a/crates/common/types/src/beacon/constants.rs +++ b/crates/common/types/src/beacon/constants.rs @@ -127,8 +127,8 @@ pub const DOMAIN_AGGREGATE_AND_PROOF: DomainType = [0x06, 0x00, 0x00, 0x00]; /// application-specific domains outside the specification. Every /// specification-defined `DomainType` masks to zero against this; a /// `DomainType` that does not is reserved for other uses (for example -/// re-purposing beacon chain signatures on an application layer). The one -/// such domain this crate uses is [`DOMAIN_APPLICATION_BUILDER`]. +/// re-purposing beacon chain signatures on an application layer). [`DOMAIN_APPLICATION_BUILDER`] +/// is one. pub const DOMAIN_APPLICATION_MASK: DomainType = [0x00, 0x00, 0x00, 0x01]; /// Domain for the builder API's own messages (`ValidatorRegistrationV1`, /// `BuilderBid`), builder-specs `bellatrix/builder.md`. Always computed