diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index ff575bc97..cc91f2b32 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -505,11 +505,14 @@ pub struct BlockInputs { /// sync aggregate it is given. The block is run through `process_block` on a copy of /// `state` with an execution engine that accepts the payload, which is the /// node's own execution client's payload; that run is also what rejects a body -/// the network would, before anything is signed. +/// the network would, before anything is signed. `options` is what that run may +/// leave unchecked: only a caller that set `skip_randao_verification` passes +/// anything but the default. pub fn assemble_block( state: &BeaconState, inputs: BlockInputs, config: &Config, + options: stf::block::BlockProcessingOptions, ) -> Result { let body = BeaconBlockBody { randao_reveal: inputs.randao_reveal, @@ -588,6 +591,7 @@ pub fn assemble_block( config, &ExecutionEngine::valid(), &CommitteeCache::default(), + options, )?; block.state_root = post.hash_tree_root(); Ok(block) @@ -602,6 +606,7 @@ mod tests { use crate::beacon::helpers::misc::compute_domain; 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::shared::{ AttestationData, BeaconBlockHeader, Checkpoint, SignedBeaconBlockHeader, VoluntaryExit, }; @@ -665,7 +670,13 @@ mod tests { blob_kzg_commitments: Vec::new(), execution_requests: ExecutionRequests::default(), }; - let block = assemble_block(&state, inputs, &Config::mainnet()).unwrap(); + let block = assemble_block( + &state, + inputs, + &Config::mainnet(), + BlockProcessingOptions::default(), + ) + .unwrap(); assert_eq!(block.slot, state.slot()); assert_eq!( @@ -685,6 +696,70 @@ mod tests { &Config::mainnet(), &ExecutionEngine::valid(), &CommitteeCache::default(), + BlockProcessingOptions::default(), + ) + .unwrap(); + assert_eq!(block.state_root, post.hash_tree_root()); + } + + fn skip_randao() -> BlockProcessingOptions { + BlockProcessingOptions { + randao: RandaoVerification::Skip, + } + } + + fn inputs_with_infinity_reveal(state: &BeaconState) -> BlockInputs { + BlockInputs { + randao_reveal: BlsSignature(bls::G2_POINT_AT_INFINITY), + graffiti: Bytes32::ZERO, + attestations: Vec::new(), + operations: Operations::default(), + execution_payload: payload_for(state), + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + sync_aggregate: empty_sync_aggregate(), + } + } + + #[test] + fn a_block_with_the_infinity_reveal_is_produced_only_when_randao_is_skipped() { + let state = state_to_build_on(); + let config = Config::mainnet(); + assert!( + assemble_block( + &state, + inputs_with_infinity_reveal(&state), + &config, + BlockProcessingOptions::default(), + ) + .is_err() + ); + + let block = assemble_block( + &state, + inputs_with_infinity_reveal(&state), + &config, + skip_randao(), + ) + .unwrap(); + assert_eq!( + block.body.randao_reveal, + BlsSignature(bls::G2_POINT_AT_INFINITY) + ); + + // Importing it with the same option lands on the state root it names. + let mut post = state.clone(); + let signed = SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message: block.clone(), + signature: BlsSignature::default(), + }); + stf::block::process_block( + &mut post, + &signed, + &config, + &ExecutionEngine::valid(), + &CommitteeCache::default(), + skip_randao(), ) .unwrap(); assert_eq!(block.state_root, post.hash_tree_root()); @@ -705,7 +780,15 @@ mod tests { blob_kzg_commitments: Vec::new(), execution_requests: ExecutionRequests::default(), }; - assert!(assemble_block(&state, inputs, &Config::mainnet()).is_err()); + assert!( + assemble_block( + &state, + inputs, + &Config::mainnet(), + BlockProcessingOptions::default(), + ) + .is_err() + ); } /// An attestation at `slot` voting for this state's own chain: sourced from @@ -1071,7 +1154,8 @@ mod tests { execution_requests: ExecutionRequests::default(), sync_aggregate: empty_sync_aggregate(), }; - let block = assemble_block(&state, inputs, &config).unwrap(); + let block = + assemble_block(&state, inputs, &config, BlockProcessingOptions::default()).unwrap(); assert_eq!(block.body.voluntary_exits.len(), 1); assert_eq!(block.body.voluntary_exits[0].message.validator_index, 4); } @@ -1141,7 +1225,13 @@ mod tests { execution_requests: ExecutionRequests::default(), sync_aggregate: verified.clone(), }; - let block = assemble_block(&state, inputs, &Config::mainnet()).unwrap(); + let block = assemble_block( + &state, + inputs, + &Config::mainnet(), + BlockProcessingOptions::default(), + ) + .unwrap(); assert_eq!(block.body.sync_aggregate, verified); } diff --git a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs index fe760a21f..6abaed5af 100644 --- a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs @@ -371,11 +371,13 @@ pub struct GloasBidBlockInputs { /// a copy fills `state_root` and enforces `process_execution_payload_bid`: /// the builder is active, covers the bid and signed it, and the bid's slot, /// parent hash and root and randao are the state's. There is no envelope: the -/// builder reveals the payload itself. +/// builder reveals the payload itself. `options` is what that run may leave +/// unchecked, as in [`assemble_gloas_block`]. pub fn assemble_gloas_block_on_bid( state: &BeaconState, inputs: GloasBidBlockInputs, config: &Config, + options: stf::block::BlockProcessingOptions, ) -> Result { verify( matches!(state, BeaconState::Gloas(_)), @@ -400,7 +402,13 @@ pub fn assemble_gloas_block_on_bid( body, }; let mut post = state.clone(); - stf::gloas::process_block(&mut post, &block, config, &CommitteeCache::default())?; + stf::gloas::process_block( + &mut post, + &block, + config, + &CommitteeCache::default(), + options, + )?; block.state_root = post.hash_tree_root(); Ok(block) } @@ -463,11 +471,14 @@ pub struct GloasProduced { /// is also what rejects a body the network would, before anything is signed. /// The envelope is then checked against that post-state with every condition /// `verify_execution_payload_envelope` holds it to except the signature, so a -/// malformed build fails here and not on the network. +/// malformed build fails here and not on the network. `options` is what the +/// `process_block` run may leave unchecked: only a caller that set +/// `skip_randao_verification` passes anything but the default. pub fn assemble_gloas_block( state: &BeaconState, inputs: GloasBlockInputs, config: &Config, + options: stf::block::BlockProcessingOptions, ) -> Result { verify( matches!(state, BeaconState::Gloas(_)), @@ -516,7 +527,13 @@ pub fn assemble_gloas_block( let BeaconState::Gloas(_) = &post else { unreachable!("checked above") }; - stf::gloas::process_block(&mut post, &block, config, &CommitteeCache::default())?; + stf::gloas::process_block( + &mut post, + &block, + config, + &CommitteeCache::default(), + options, + )?; block.state_root = post.hash_tree_root(); let envelope = ExecutionPayloadEnvelope { @@ -608,6 +625,7 @@ pub mod test_support { use crate::beacon::helpers::gloas::compute_ptc; 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; use ethlambda_types::beacon::containers::bellatrix::{ExtraData, LogsBloom}; use ethlambda_types::beacon::primitives::Uint256; @@ -717,13 +735,21 @@ pub mod test_support { execution_requests: ExecutionRequests::default(), }, &config(), + BlockProcessingOptions::default(), ) } /// The state after `block` is applied to `state`, as the chain stores it. pub fn post_state(state: &BeaconState, block: &BeaconBlock) -> BeaconState { let mut post = state.clone(); - stf::gloas::process_block(&mut post, block, &config(), &CommitteeCache::default()).unwrap(); + stf::gloas::process_block( + &mut post, + block, + &config(), + &CommitteeCache::default(), + BlockProcessingOptions::default(), + ) + .unwrap(); post } @@ -760,6 +786,7 @@ mod gloas_block_production_tests { use crate::beacon::helpers::accessors::{get_block_root_at_slot, get_domain}; use crate::beacon::helpers::misc::compute_signing_root; use crate::beacon::helpers::test_state::sign_for; + use crate::beacon::stf::block::{BlockProcessingOptions, RandaoVerification}; /// A vote on the parent block (slot 32) signed by `validator`. fn ptc_message( @@ -848,6 +875,7 @@ mod gloas_block_production_tests { execution_requests: ExecutionRequests::default(), }, &config(), + BlockProcessingOptions::default(), ) .unwrap(); assert_eq!(produced.block.body.sync_aggregate, good); @@ -882,6 +910,7 @@ mod gloas_block_production_tests { &config(), &ExecutionEngine::valid(), &CommitteeCache::default(), + BlockProcessingOptions::default(), ) .unwrap(); assert_eq!(block.state_root, post.hash_tree_root()); @@ -972,6 +1001,53 @@ mod gloas_block_production_tests { assert_eq!(parent_hash(&on_empty), empty.head_block_hash); } + #[test] + fn a_gloas_block_with_the_infinity_reveal_is_produced_only_when_randao_is_skipped() { + let state = state_to_build_on(); + let skip = BlockProcessingOptions { + randao: RandaoVerification::Skip, + }; + let produce_with = |options| { + let requests = ExecutionRequests::default(); + let inputs = gloas_payload_inputs(&state, true, &requests, &config()).unwrap(); + assemble_gloas_block( + &state, + GloasBlockInputs { + sync_aggregate: empty_sync_aggregate(), + randao_reveal: BlsSignature(bls::G2_POINT_AT_INFINITY), + graffiti: Bytes32::ZERO, + attestations: Vec::new(), + payload_attestations: Vec::new(), + parent_execution_requests: requests, + execution_payload: payload_for(&inputs), + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + }, + &config(), + options, + ) + }; + assert!(produce_with(BlockProcessingOptions::default()).is_err()); + + let produced = produce_with(skip).unwrap(); + assert_eq!( + produced.block.body.randao_reveal, + BlsSignature(bls::G2_POINT_AT_INFINITY) + ); + + // Importing it with the same option lands on the state root it names. + let mut post = state.clone(); + stf::gloas::process_block( + &mut post, + &produced.block, + &config(), + &CommitteeCache::default(), + skip, + ) + .unwrap(); + assert_eq!(produced.block.state_root, post.hash_tree_root()); + } + #[test] fn a_payload_on_the_wrong_parent_is_refused_before_signing() { let state = state_to_build_on(); @@ -993,6 +1069,7 @@ mod gloas_block_production_tests { execution_requests: ExecutionRequests::default(), }, &config(), + BlockProcessingOptions::default(), ); assert!(result.is_err()); } @@ -1024,6 +1101,7 @@ mod gloas_block_production_tests { execution_requests: ExecutionRequests::default(), }, &config(), + BlockProcessingOptions::default(), ); assert!(produced.is_err()); } @@ -1178,6 +1256,7 @@ mod gloas_block_production_tests { execution_requests: ExecutionRequests::default(), }, &config(), + BlockProcessingOptions::default(), ) .unwrap(); } @@ -1252,6 +1331,7 @@ mod bid_assembly_tests { use crate::beacon::builder_market::test_support::{ builder_secret, gloas_state_with_builder, sign_bid, }; + use crate::beacon::stf::block::BlockProcessingOptions; use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionAddress}; /// A state at slot 33 with a funded, active builder 0, and a bid on its @@ -1302,8 +1382,13 @@ mod bid_assembly_tests { &bid, &ExecutionRequests::default() )); - let block = - assemble_gloas_block_on_bid(&state, inputs(&state, bid.clone()), &config()).unwrap(); + let block = assemble_gloas_block_on_bid( + &state, + inputs(&state, bid.clone()), + &config(), + BlockProcessingOptions::default(), + ) + .unwrap(); assert_eq!(block.body.signed_execution_payload_bid, bid); assert_eq!(block.slot, state.slot()); assert_ne!(block.state_root, Root::ZERO); @@ -1331,7 +1416,15 @@ mod bid_assembly_tests { &bid, &ExecutionRequests::default() )); - assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err()); + assert!( + assemble_gloas_block_on_bid( + &state, + inputs(&state, bid), + &config(), + BlockProcessingOptions::default(), + ) + .is_err() + ); } #[test] @@ -1345,7 +1438,15 @@ mod bid_assembly_tests { &bid, &ExecutionRequests::default() )); - assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err()); + assert!( + assemble_gloas_block_on_bid( + &state, + inputs(&state, bid), + &config(), + BlockProcessingOptions::default(), + ) + .is_err() + ); } #[test] @@ -1357,7 +1458,15 @@ mod bid_assembly_tests { &bid, &ExecutionRequests::default() )); - assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err()); + assert!( + assemble_gloas_block_on_bid( + &state, + inputs(&state, bid), + &config(), + BlockProcessingOptions::default(), + ) + .is_err() + ); } #[test] @@ -1370,7 +1479,15 @@ mod bid_assembly_tests { &bid, &ExecutionRequests::default() )); - assert!(assemble_gloas_block_on_bid(&state, inputs(&state, bid), &config()).is_err()); + assert!( + assemble_gloas_block_on_bid( + &state, + inputs(&state, bid), + &config(), + BlockProcessingOptions::default(), + ) + .is_err() + ); } #[test] diff --git a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs index ddb320905..3a9c9de85 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs @@ -67,6 +67,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( state, @@ -78,7 +79,7 @@ pub fn process_block( if is_execution_enabled(state, &block.body.execution_payload)? { process_execution_payload(state, &block.body.execution_payload, config, engine)?; } - super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; super::operations::process_operations( state, diff --git a/crates/blockchain/state_transition/src/beacon/stf/block.rs b/crates/blockchain/state_transition/src/beacon/stf/block.rs index bacb60dfa..6831c5460 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/block.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/block.rs @@ -46,34 +46,35 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + options: BlockProcessingOptions, ) -> Result<()> { match signed_block { containers::SignedBeaconBlock::Phase0(signed) => { - process_block_phase0(state, &signed.message, config, committees) + process_block_phase0(state, &signed.message, config, committees, options) } containers::SignedBeaconBlock::Altair(signed) => { - process_block_altair(state, &signed.message, config, committees) + process_block_altair(state, &signed.message, config, committees, options) } containers::SignedBeaconBlock::Bellatrix(signed) => { - bellatrix::process_block(state, &signed.message, config, engine, committees) + bellatrix::process_block(state, &signed.message, config, engine, committees, options) } containers::SignedBeaconBlock::Capella(signed) => { - capella::process_block(state, &signed.message, config, engine, committees) + capella::process_block(state, &signed.message, config, engine, committees, options) } containers::SignedBeaconBlock::Deneb(signed) => { - deneb::process_block(state, &signed.message, config, engine, committees) + deneb::process_block(state, &signed.message, config, engine, committees, options) } containers::SignedBeaconBlock::Electra(signed) => { - electra::process_block(state, &signed.message, config, engine, committees) + electra::process_block(state, &signed.message, config, engine, committees, options) } containers::SignedBeaconBlock::Fulu(signed) => { - fulu::process_block(state, &signed.message, config, engine, committees) + fulu::process_block(state, &signed.message, config, engine, committees, options) } // ePBS (EIP-7732) restructures block processing around a builder's // bid rather than an embedded payload: a gloas block carries no // payload for `engine` to answer for, so this arm never reaches it. containers::SignedBeaconBlock::Gloas(signed) => { - gloas::process_block(state, &signed.message, config, committees) + gloas::process_block(state, &signed.message, config, committees, options) } containers::SignedBeaconBlock::Lean(_) => { crate::beacon::lean_block_unreachable("process_block") @@ -88,6 +89,7 @@ pub fn process_block_phase0( block: &phase0::BeaconBlock, config: &Config, committees: &CommitteeCache, + options: BlockProcessingOptions, ) -> Result<()> { process_block_header( state, @@ -96,7 +98,7 @@ pub fn process_block_phase0( block.parent_root, block.body.hash_tree_root(), )?; - process_randao(state, &block.body.randao_reveal)?; + process_randao(state, &block.body.randao_reveal, options.randao)?; process_eth1_data(state, &block.body.eth1_data)?; crate::beacon::stf::operations::process_operations( state, @@ -122,6 +124,7 @@ pub fn process_block_altair( block: &altair::BeaconBlock, config: &Config, committees: &CommitteeCache, + options: BlockProcessingOptions, ) -> Result<()> { process_block_header( state, @@ -130,7 +133,7 @@ pub fn process_block_altair( block.parent_root, block.body.hash_tree_root(), )?; - process_randao(state, &block.body.randao_reveal)?; + process_randao(state, &block.body.randao_reveal, options.randao)?; process_eth1_data(state, &block.body.eth1_data)?; crate::beacon::stf::operations::process_operations( state, @@ -204,24 +207,48 @@ pub fn process_block_header( Ok(()) } -/// Verifies the proposer's RANDAO reveal and mixes it into the current epoch's -/// randao mix. +/// What `process_block` may leave unchecked. The default checks everything the +/// specification does, and is the only value block import uses. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct BlockProcessingOptions { + pub randao: RandaoVerification, +} + +/// Whether `process_randao` verifies the proposer's reveal before mixing it in. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RandaoVerification { + #[default] + Verify, + /// Block production for a caller that set `skip_randao_verification`: the + /// reveal is the point at infinity, which verifies against no key, so the + /// state root can only be computed by mixing it in unchecked. + Skip, +} + +/// Verifies the proposer's RANDAO reveal (unless `verification` skips it) and +/// mixes it into the current epoch's randao mix. /// /// The reveal signs the epoch rather than the slot, so the same signature would /// verify for every slot of the epoch: the mix it feeds is per-epoch, not /// per-slot, and a skipped slot leaves nothing for the next proposer to reveal /// differently. -pub fn process_randao(state: &mut BeaconState, randao_reveal: &BlsSignature) -> Result<()> { +pub fn process_randao( + state: &mut BeaconState, + randao_reveal: &BlsSignature, + verification: RandaoVerification, +) -> Result<()> { let epoch = get_current_epoch(state); let proposer_index = get_beacon_proposer_index(state)?; let domain = get_domain(state, constants::DOMAIN_RANDAO, None); let signing_root = compute_signing_root(epoch.hash_tree_root(), domain); let proposer = state.validator(proposer_index)?; - verify( - bls::verify(&proposer.pubkey, signing_root, randao_reveal), - "RANDAO reveal signature", - )?; + if verification == RandaoVerification::Verify { + verify( + bls::verify(&proposer.pubkey, signing_root, randao_reveal), + "RANDAO reveal signature", + )?; + } let mix = xor(get_randao_mix(state, epoch), hash(&randao_reveal.0)); let position = epoch as usize % preset::EPOCHS_PER_HISTORICAL_VECTOR; @@ -324,4 +351,24 @@ mod tests { .is_err() ); } + + #[test] + fn skipping_randao_verification_mixes_in_the_infinity_reveal() { + let mut state = crate::beacon::helpers::test_state::with_validators(8); + let epoch = get_current_epoch(&state); + let reveal = BlsSignature(bls::G2_POINT_AT_INFINITY); + let expected = xor(get_randao_mix(&state, epoch), hash(&reveal.0)); + + process_randao(&mut state, &reveal, RandaoVerification::Skip).unwrap(); + + assert_eq!(get_randao_mix(&state, epoch), expected); + } + + #[test] + fn verifying_randao_rejects_the_infinity_reveal() { + let mut state = crate::beacon::helpers::test_state::with_validators(8); + let reveal = BlsSignature(bls::G2_POINT_AT_INFINITY); + + assert!(process_randao(&mut state, &reveal, RandaoVerification::Verify).is_err()); + } } diff --git a/crates/blockchain/state_transition/src/beacon/stf/capella.rs b/crates/blockchain/state_transition/src/beacon/stf/capella.rs index 9fedd3649..2c075659b 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/capella.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/capella.rs @@ -76,6 +76,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( state, @@ -86,7 +87,7 @@ pub fn process_block( )?; process_withdrawals(state, &block.body.execution_payload)?; process_execution_payload(state, &block.body.execution_payload, config, engine)?; - super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; process_operations( state, diff --git a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs index e90ca6a6a..895c033fa 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs @@ -77,6 +77,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( state, @@ -93,7 +94,7 @@ pub fn process_block( config, engine, )?; - super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; process_operations( state, diff --git a/crates/blockchain/state_transition/src/beacon/stf/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/electra.rs index 104b2dd23..bc7bf0224 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/electra.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/electra.rs @@ -1836,6 +1836,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( state, @@ -1846,7 +1847,7 @@ pub fn process_block( )?; process_withdrawals(state, &block.body.execution_payload)?; process_execution_payload(state, &block.body, config, engine)?; - super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; process_operations(state, &block.body, config, committees)?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; diff --git a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs index 3d720ac33..f2618905e 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs @@ -65,6 +65,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( state, @@ -75,7 +76,7 @@ pub fn process_block( )?; super::electra::process_withdrawals(state, &block.body.execution_payload)?; process_execution_payload(state, &block.body, config, engine)?; - super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; process_operations(state, &block.body, config, committees)?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; diff --git a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs index fe982ed14..2ae164fc2 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs @@ -102,6 +102,7 @@ pub fn process_block( block: &gloas::BeaconBlock, config: &Config, committees: &CommitteeCache, + options: super::block::BlockProcessingOptions, ) -> Result<()> { // [New in Gloas:EIP7732] let parent_slot = state.latest_block_header().slot; @@ -120,7 +121,7 @@ pub fn process_block( // [Modified in Gloas:EIP7732] Removed `process_execution_payload`. // [New in Gloas:EIP7732] process_execution_payload_bid(state, &block.body.signed_execution_payload_bid, config)?; - super::block::process_randao(state, &block.body.randao_reveal)?; + super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; // [Modified in Gloas:EIP7732] process_operations(state, &block.body, parent_slot, config, committees)?; diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index ea23506a1..6687d6f59 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -155,7 +155,14 @@ pub fn state_transition( )?; } - block::process_block(state, signed_block, config, engine, committees)?; + block::process_block( + state, + signed_block, + config, + engine, + committees, + block::BlockProcessingOptions::default(), + )?; // As in `process_slot`: the post-state's root, checked below and needed // again in the next slot, is then computed on the state's own nodes. state.apply_pending_mutations(); diff --git a/crates/net/rpc/src/beacon/builder_market_tests.rs b/crates/net/rpc/src/beacon/builder_market_tests.rs index 4f2369e37..4f89fd600 100644 --- a/crates/net/rpc/src/beacon/builder_market_tests.rs +++ b/crates/net/rpc/src/beacon/builder_market_tests.rs @@ -29,6 +29,7 @@ use ethlambda_state_transition::beacon::{ test_state::{secret_key_for, sign_for}, }, payload_attestation_pool::SharedPayloadAttestationPool, + stf::block::RandaoVerification, sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::Store; @@ -292,6 +293,7 @@ impl World { &self.market, SLOT, randao_reveal(&self.advanced()), + RandaoVerification::Verify, ) .unwrap() } @@ -450,6 +452,19 @@ async fn produce( .await } +/// A request for [`SLOT`] carrying `reveal` and `flag` (a raw query suffix such +/// as `&skip_randao_verification`), with a self-built bare block wanted back. +fn produce_request_with(reveal: BlsSignature, flag: &str) -> Request { + Request::post(format!( + "/eth/v4/validator/blocks/{SLOT}?randao_reveal=0x{}&include_payload=false{flag}", + hex::encode(reveal.0) + )) + .header("eth-consensus-version", "gloas") + .header("content-type", "application/json") + .body(Body::from(builder_config(0, 100))) + .unwrap() +} + /// The bid the produced block commits to, from a JSON response either way: a /// bare block, or contents carrying it. fn produced_block_bid(reply: &Reply) -> ExecutionPayloadBid { @@ -1248,3 +1263,33 @@ async fn the_new_routes_are_wired_with_the_production_layers() { .await; assert_eq!(reply.status, StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn the_infinity_reveal_is_produced_only_when_the_flag_skips_its_verification() { + use ethlambda_state_transition::beacon::bls::G2_POINT_AT_INFINITY; + + let world = World::new(); + let engine = fake_engine(GWEI, false).await; + let app = world.app(Some(&engine)); + let infinity = BlsSignature(G2_POINT_AT_INFINITY); + + for flag in ["&skip_randao_verification=", "&skip_randao_verification"] { + let reply = send(&app, produce_request_with(infinity, flag)).await; + assert_eq!( + reply.status, + StatusCode::OK, + "{flag}: {}", + String::from_utf8_lossy(&reply.body) + ); + assert_eq!( + reply.json()["data"]["body"]["randao_reveal"], + format!("0x{}", hex::encode(G2_POINT_AT_INFINITY)), + "{flag}" + ); + } + + // Without the flag the same reveal is the caller's mistake, as before. + let reply = send(&app, produce_request_with(infinity, "")).await; + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!(reply.json()["message"], "invalid randao_reveal"); +} diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs index c62255633..51b57d76e 100644 --- a/crates/net/rpc/src/beacon/gloas_proposal.rs +++ b/crates/net/rpc/src/beacon/gloas_proposal.rs @@ -71,7 +71,10 @@ use ethlambda_state_transition::beacon::{ misc::compute_signing_root, }, payload_attestation_pool::SharedPayloadAttestationPool, - stf::gloas::verify_execution_payload_envelope_signature, + stf::{ + block::{BlockProcessingOptions, RandaoVerification}, + gloas::verify_execution_payload_envelope_signature, + }, sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::Store; @@ -106,7 +109,7 @@ use crate::beacon::{ bid_selection::{LocalCandidate, PayloadChoice, bid_total_gwei, choose_payload, wei_u128}, builder_config::{BuilderConfig, decode_builder_config}, graffiti::{self, OwnVersion}, - proposal::{Blobs, CellKzgProofs, decimal, require_gloas_slot}, + proposal::{Blobs, CellKzgProofs, decimal, randao_verification, require_gloas_slot}, validator::{FeeRecipients, head}, }; use crate::shared::content::{Encoding, ssz_response, with_consensus_version}; @@ -203,10 +206,10 @@ struct ProduceQuery { #[serde(default)] graffiti: Option, include_payload: bool, - /// Accepted for compatibility. Not honored: the block's state root comes - /// from running it through the state transition, which verifies the reveal. + /// A flag with no value, validated by [`randao_verification`] rather than + /// parsed here, so a bad value gets its own message and not the generic + /// query rejection. #[serde(default)] - #[allow(dead_code)] skip_randao_verification: Option, } @@ -239,6 +242,15 @@ async fn post_produce_block( if consensus_version(&headers).is_some_and(|fork| fork != ForkName::Gloas) { return ApiError::BadRequest("Eth-Consensus-Version must be gloas").into_response(); } + // Ahead of the engine check, like the slot check below: a bad flag is the + // caller's mistake, a 400 on any node. + let randao = match randao_verification( + query.skip_randao_verification.as_deref(), + &query.randao_reveal, + ) { + Ok(randao) => randao, + Err(err) => return err.into_response(), + }; // Ahead of the engine check: a slot this node cannot build is a 400 on any // node, not a 503 on one with no execution client. if let Err(err) = require_gloas_slot( @@ -278,6 +290,7 @@ async fn post_produce_block( &own_version, slot, query.randao_reveal, + randao, graffiti, ) .await; @@ -418,6 +431,9 @@ struct Prepared { /// The proposer's signed preferences for the slot, when this node holds /// them under the slot's dependent root. preferences: Option, + /// What the block's state transition may leave unchecked: the reveal, when + /// the caller set `skip_randao_verification`. + options: BlockProcessingOptions, } #[allow(clippy::too_many_arguments)] @@ -433,12 +449,13 @@ async fn produce( own_version: &ClientVersionV1, slot: Slot, randao_reveal: BlsSignature, + randao: RandaoVerification, graffiti: Bytes32, ) -> Result { let prepare_store = store.clone(); let prepare_market = market.clone(); let prepared = tokio::task::spawn_blocking(move || { - prepare(&prepare_store, &prepare_market, slot, randao_reveal) + prepare(&prepare_store, &prepare_market, slot, randao_reveal, randao) }) .await .map_err(|_| ApiError::Internal("preparing the block failed"))??; @@ -618,6 +635,7 @@ fn prepare( market: &SharedBuilderMarket, slot: Slot, randao_reveal: BlsSignature, + randao: RandaoVerification, ) -> Result { let config = store.config(); let (head_root, head_state) = head(store)?; @@ -665,7 +683,9 @@ fn prepare( .map_err(|_| ApiError::Internal("advancing the head state failed"))?; let proposer = get_beacon_proposer_index(&state) .map_err(|_| ApiError::Internal("no proposer for the slot"))?; - verify_randao_reveal(&state, proposer, randao_reveal)?; + if randao == RandaoVerification::Verify { + verify_randao_reveal(&state, proposer, randao_reveal)?; + } let mut inputs = gloas_payload_inputs(&state, build_on_full, &parent_requests, &config) .map_err(|_| ApiError::Internal("computing the payload attributes failed"))?; @@ -686,6 +706,7 @@ fn prepare( head_slot, parent_requests, preferences, + options: BlockProcessingOptions { randao }, }) } @@ -843,6 +864,7 @@ fn assemble( head_root, head_slot, parent_requests, + options, .. } = prepared; let attestations = pack_gloas_attestations(state, candidates); @@ -869,7 +891,7 @@ fn assemble( }; let operations = attestations.len() + payload_attestations.len(); let attempt = inputs(attestations, payload_attestations, sync_aggregate); - match assemble_gloas_block(state, attempt, config) { + match assemble_gloas_block(state, attempt, config, *options) { Ok(produced) => Ok(produced), // The packers check every operation's signature against this state, and // the sync aggregate was verified above, so this should not happen; but @@ -877,7 +899,7 @@ fn assemble( Err(err) if operations > 0 || has_sync_aggregate => { warn!(slot = state.slot(), %err, "Block with operations failed to build; retrying without"); let bare = inputs(Vec::new(), Vec::new(), empty_sync_aggregate()); - assemble_gloas_block(state, bare, config) + assemble_gloas_block(state, bare, config, *options) .map_err(|_| ApiError::Internal("the block failed to build")) } Err(_) => Err(ApiError::Internal("the block failed to build")), @@ -900,6 +922,7 @@ fn assemble_on_bid( head_root, head_slot, parent_requests, + options, .. } = prepared; let attestations = pack_gloas_attestations(state, candidates); @@ -914,11 +937,16 @@ fn assemble_on_bid( signed_bid: signed_bid.clone(), }; let operations = attestations.len() + payload_attestations.len(); - match assemble_gloas_block_on_bid(state, inputs(attestations, payload_attestations), config) { + match assemble_gloas_block_on_bid( + state, + inputs(attestations, payload_attestations), + config, + *options, + ) { Ok(block) => Ok(block), Err(err) if operations > 0 => { warn!(slot = state.slot(), %err, "Bid block with operations failed to build; retrying without"); - assemble_gloas_block_on_bid(state, inputs(Vec::new(), Vec::new()), config) + assemble_gloas_block_on_bid(state, inputs(Vec::new(), Vec::new()), config, *options) .map_err(|_| ApiError::Internal("the block failed to build")) } Err(_) => Err(ApiError::Internal("the block failed to build")), @@ -1242,6 +1270,51 @@ mod tests { assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); } + #[tokio::test] + async fn the_randao_skip_flag_is_refused_with_a_value_or_a_real_reveal() { + let (store, head_slot) = gloas_scheduled_store(); + let slot = head_slot + 1; + let infinity = hex::encode(bls::G2_POINT_AT_INFINITY); + let with_reveal = |reveal: &str, flag: &str| { + Request::post(format!( + "/eth/v4/validator/blocks/{slot}?randao_reveal=0x{reveal}&include_payload=true{flag}" + )) + .header("eth-consensus-version", "gloas") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(EMPTY_CONFIG)) + .unwrap() + }; + + // A reveal that is not the point at infinity cannot ask to skip. + let (status, json) = respond( + produce_app(store.clone(), None), + with_reveal(&"00".repeat(96), "&skip_randao_verification"), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + json["message"], + "randao_reveal must be the point at infinity when skip_randao_verification is set" + ); + + // The flag takes no value, `true` included. + let (status, json) = respond( + produce_app(store.clone(), None), + with_reveal(&infinity, "&skip_randao_verification=true"), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + json["message"], + "skip_randao_verification does not take a value" + ); + + // Without the flag the infinity reveal is checked like any other, and + // the request gets as far as the engine check. + let (status, _) = respond(produce_app(store, None), with_reveal(&infinity, "")).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + } + #[tokio::test] async fn a_missing_include_payload_or_an_undecodable_body_is_a_400() { let (store, head_slot) = gloas_scheduled_store(); @@ -1460,6 +1533,7 @@ mod tests { execution_requests: ExecutionRequests::default(), }, &config(), + ethlambda_state_transition::beacon::stf::block::BlockProcessingOptions::default(), ) .unwrap() } @@ -1513,6 +1587,7 @@ mod tests { head_slot: state.slot() - 1, parent_requests: requests.clone(), preferences: None, + options: BlockProcessingOptions::default(), }; assemble( &config(), @@ -1795,6 +1870,7 @@ mod tests { execution_requests: ExecutionRequests::default(), }, &config(), + ethlambda_state_transition::beacon::stf::block::BlockProcessingOptions::default(), ) .unwrap(); WithBlob { diff --git a/crates/net/rpc/src/beacon/proposal.rs b/crates/net/rpc/src/beacon/proposal.rs index 26d562ee5..8dcd6115d 100644 --- a/crates/net/rpc/src/beacon/proposal.rs +++ b/crates/net/rpc/src/beacon/proposal.rs @@ -37,9 +37,13 @@ use ethlambda_state_transition::beacon::{ pack_attestations, pack_operations, parse_execution_requests, payload_inputs, verified_sync_aggregate, }, + bls, data_columns::{self, SidecarError}, helpers::accessors::get_beacon_proposer_index, - stf::verify_block_signature, + stf::{ + block::{BlockProcessingOptions, RandaoVerification}, + verify_block_signature, + }, sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::Store; @@ -329,6 +333,39 @@ struct ProduceQuery { randao_reveal: BlsSignature, #[serde(default)] graffiti: Option, + /// A flag with no value, validated by [`randao_verification`]. + #[serde(default)] + skip_randao_verification: Option, +} + +/// Whether the block's RANDAO reveal is verified, from the request's +/// `skip_randao_verification` flag, the way Lighthouse reads it. +/// +/// The flag takes no value, so `?skip_randao_verification` and +/// `?skip_randao_verification=` set it and any other value is refused rather +/// than guessed at. Skipping is only for a reveal that cannot verify against +/// any key (`bls::G2_POINT_AT_INFINITY`), so a caller that sets the flag with a +/// real reveal has misunderstood it, and the block would otherwise be produced +/// against a reveal that was never checked. +pub(super) fn randao_verification( + skip: Option<&str>, + randao_reveal: &BlsSignature, +) -> Result { + match skip { + None => Ok(RandaoVerification::Verify), + Some("") => { + if randao_reveal.0 == bls::G2_POINT_AT_INFINITY { + Ok(RandaoVerification::Skip) + } else { + Err(ApiError::BadRequest( + "randao_reveal must be the point at infinity when skip_randao_verification is set", + )) + } + } + Some(_) => Err(ApiError::BadRequest( + "skip_randao_verification does not take a value", + )), + } } /// Refuses a slot the schedule does not place at fulu, before anything @@ -363,6 +400,15 @@ async fn get_block( let Ok(slot) = slot.parse::() else { return ApiError::BadRequest("invalid slot").into_response(); }; + // Ahead of the engine check, like the slot check below: a bad flag is the + // caller's mistake, a 400 on any node. + let randao = match randao_verification( + query.skip_randao_verification.as_deref(), + &query.randao_reveal, + ) { + Ok(randao) => randao, + Err(err) => return err.into_response(), + }; // Ahead of the engine check: a slot this node cannot build is a 400 on // any node, not a 503 on one with no execution client. if let Err(err) = require_fulu_slot( @@ -387,6 +433,7 @@ async fn get_block( &own_version, slot, query.randao_reveal, + BlockProcessingOptions { randao }, graffiti, ) .await; @@ -431,6 +478,7 @@ async fn produce( own_version: &ClientVersionV1, slot: Slot, randao_reveal: BlsSignature, + options: BlockProcessingOptions, graffiti: Bytes32, ) -> Result<(FuluBlockContents, String, ForkName), ApiError> { let config = store.config(); @@ -528,6 +576,7 @@ async fn produce( &state, inputs(attestations, operations, sync_aggregate), &config, + options, ) { Ok(block) => block, // `pack_attestations` and `pack_operations` check every candidate @@ -540,6 +589,7 @@ async fn produce( &state, inputs(Vec::new(), Operations::default(), empty_sync_aggregate()), &config, + options, ) .map_err(|_| ApiError::Internal("the block failed to build"))? } @@ -671,6 +721,114 @@ mod tests { (status, serde_json::from_slice(&body).unwrap_or_default()) } + /// A fulu head with its proposer lookahead and sync committee filled in, + /// which a block built on it needs, under a schedule that puts every slot + /// at fulu. + fn fulu_store() -> (Store, Slot) { + use ethlambda_state_transition::beacon::helpers::{ + altair::get_next_sync_committee, fulu::initialize_proposer_lookahead, + }; + let mut state = with_signing_validators_at(ForkName::Fulu, 64); + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + let sync_committee = get_next_sync_committee(&state).unwrap(); + let BeaconState::Fulu(inner) = &mut state else { + unreachable!("built as fulu") + }; + inner.proposer_lookahead = lookahead.try_into().unwrap(); + inner.current_sync_committee = sync_committee.clone(); + inner.next_sync_committee = sync_committee; + let head_slot = state.slot(); + let (store, _root) = + beacon_store_with_config(state, Config::mainnet().with_fork_epoch(ForkName::Fulu, 0)); + (store, head_slot) + } + + fn produce_app(store: Store, engine: Option) -> Router { + routes() + .with_state(store) + .layer(Extension(engine)) + .layer(Extension(SharedSyncCommitteePool::default())) + .layer(Extension(FeeRecipients::default())) + .layer(Extension(OwnVersion(Arc::new( + ethlambda_engine::types::ClientVersionV1 { + code: "LA".to_string(), + name: "ethlambda".to_string(), + version: "ethlambda/test".to_string(), + commit: "0x3c4d7e8f".to_string(), + }, + )))) + } + + fn infinity_reveal() -> String { + hex::encode(bls::G2_POINT_AT_INFINITY) + } + + fn produce_uri(slot: Slot, reveal: &str, flag: &str) -> String { + format!("/eth/v3/validator/blocks/{slot}?randao_reveal=0x{reveal}{flag}") + } + + #[tokio::test] + async fn the_infinity_reveal_is_produced_only_when_the_flag_skips_its_verification() { + let (store, head_slot) = fulu_store(); + let slot = head_slot + 1; + let engine = super::super::validator_client_tests::fake_execution_client(0).await; + + for flag in ["&skip_randao_verification=", "&skip_randao_verification"] { + let app = produce_app(store.clone(), Some(engine.clone())); + let uri = produce_uri(slot, &infinity_reveal(), flag); + let (status, json) = respond(app, Request::get(uri).body(Body::empty()).unwrap()).await; + assert_eq!(status, StatusCode::OK, "{flag}: {json}"); + assert_eq!( + json["data"]["block"]["body"]["randao_reveal"], + format!("0x{}", infinity_reveal()), + "{flag}" + ); + } + + // Without the flag the reveal is verified, and the infinity one fails. + let app = produce_app(store, Some(engine)); + let uri = produce_uri(slot, &infinity_reveal(), ""); + let (status, _) = respond(app, Request::get(uri).body(Body::empty()).unwrap()).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn the_randao_skip_flag_is_refused_with_a_value_or_a_real_reveal() { + let (store, head_slot) = fulu_store(); + let slot = head_slot + 1; + let get = |uri: String| Request::get(uri).body(Body::empty()).unwrap(); + + let (status, json) = respond( + produce_app(store.clone(), None), + get(produce_uri( + slot, + &"00".repeat(96), + "&skip_randao_verification", + )), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + json["message"], + "randao_reveal must be the point at infinity when skip_randao_verification is set" + ); + + let (status, json) = respond( + produce_app(store, None), + get(produce_uri( + slot, + &infinity_reveal(), + "&skip_randao_verification=true", + )), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + json["message"], + "skip_randao_verification does not take a value" + ); + } + #[tokio::test] async fn producing_a_gloas_slot_is_a_400_even_with_no_execution_client() { let (store, head_slot) = gloas_scheduled_store(); diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index f75b071af..bdb1e82f3 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -407,7 +407,7 @@ fn valid_blob(seed: u8) -> Vec { /// /// The fulu bundle carries `blob_count` valid blobs with their commitments and /// cell proofs (flattened blob by blob, as `BlobsBundleV2` does). -async fn fake_execution_client(blob_count: usize) -> ethlambda_engine::EngineClient { +pub(super) async fn fake_execution_client(blob_count: usize) -> ethlambda_engine::EngineClient { fake_execution_client_with(blob_count, None).await }