diff --git a/Cargo.lock b/Cargo.lock index c02c878086..edd37ef37e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3457,8 +3457,10 @@ dependencies = [ name = "reth-consensus" version = "0.1.0" dependencies = [ + "assert_matches", "async-trait", "auto_impl", + "bytes", "futures", "reth-executor", "reth-interfaces", @@ -3911,6 +3913,7 @@ dependencies = [ name = "reth-rpc-types" version = "0.1.0" dependencies = [ + "bytes", "reth-primitives", "reth-rlp", "serde", diff --git a/crates/consensus/Cargo.toml b/crates/consensus/Cargo.toml index 1a71f7f835..d988a39e22 100644 --- a/crates/consensus/Cargo.toml +++ b/crates/consensus/Cargo.toml @@ -26,5 +26,11 @@ thiserror = "1.0.37" auto_impl = "1.0" serde = { version = "1.0", optional = true } +[dev-dependencies] +reth-interfaces = { path = "../interfaces", features = ["test-utils"] } +reth-provider = { path = "../storage/provider", features = ["test-utils"] } +assert_matches = "1.5.0" +bytes = "1.2" + [features] serde = ["dep:serde"] diff --git a/crates/consensus/src/engine/mod.rs b/crates/consensus/src/engine/mod.rs index 3eaf8e162a..af63bd4b68 100644 --- a/crates/consensus/src/engine/mod.rs +++ b/crates/consensus/src/engine/mod.rs @@ -189,18 +189,22 @@ impl ConsensusEngine return Ok(PayloadStatus::from_status(PayloadStatusEnum::Syncing)) }; - let parent_td = self.client.header_td(&block.parent_hash)?; - if parent_td.unwrap_or_default() <= self.config.merge_terminal_total_difficulty.into() { - return Ok(PayloadStatus::from_status(PayloadStatusEnum::Invalid { - validation_error: EngineApiError::PayloadPreMerge.to_string(), - })) + if let Some(parent_td) = self.client.header_td(&block.parent_hash)? { + if parent_td <= self.config.merge_terminal_total_difficulty.into() { + return Ok(PayloadStatus::from_status(PayloadStatusEnum::Invalid { + validation_error: EngineApiError::PayloadPreMerge.to_string(), + })) + } } if block.timestamp <= parent.timestamp { - return Err(EngineApiError::PayloadTimestamp { - invalid: block.timestamp, - latest: parent.timestamp, - }) + return Ok(PayloadStatus::from_status(PayloadStatusEnum::Invalid { + validation_error: EngineApiError::PayloadTimestamp { + invalid: block.timestamp, + latest: parent.timestamp, + } + .to_string(), + })) } let (header, body, _) = block.split(); @@ -322,3 +326,420 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use reth_interfaces::test_utils::generators::random_block; + use reth_primitives::H256; + use reth_provider::test_utils::MockEthProvider; + use tokio::sync::mpsc::unbounded_channel; + + mod new_payload { + use super::*; + use bytes::{Bytes, BytesMut}; + use reth_interfaces::test_utils::generators::random_header; + use reth_primitives::Block; + use reth_rlp::DecodeError; + + fn transform_block Block>(src: SealedBlock, f: F) -> SealedBlock { + let unsealed = src.unseal(); + let mut transformed: Block = f(unsealed); + // Recalculate roots + transformed.header.transactions_root = + proofs::calculate_transaction_root(transformed.body.iter()); + transformed.header.ommers_hash = + proofs::calculate_ommers_root(transformed.ommers.iter()); + SealedBlock { + header: transformed.header.seal(), + body: transformed.body, + ommers: transformed.ommers.into_iter().map(Header::seal).collect(), + } + } + + #[tokio::test] + async fn payload_validation() { + let (_tx, rx) = unbounded_channel(); + let engine = EthConsensusEngine { + client: Arc::new(MockEthProvider::default()), + config: Config::default(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + let block = random_block(100, Some(H256::random()), Some(3), Some(0)); + + // Valid extra data + let block_with_valid_extra_data = transform_block(block.clone(), |mut b| { + b.header.extra_data = BytesMut::zeroed(32).freeze().into(); + b + }); + assert_matches!(engine.try_construct_block(block_with_valid_extra_data.into()), Ok(_)); + + // Invalid extra data + let block_with_invalid_extra_data: Bytes = BytesMut::zeroed(33).freeze().into(); + let invalid_extra_data_block = transform_block(block.clone(), |mut b| { + b.header.extra_data = block_with_invalid_extra_data.clone(); + b + }); + assert_matches!( + engine.try_construct_block(invalid_extra_data_block.into()), + Err(EngineApiError::PayloadExtraData(data)) if data == block_with_invalid_extra_data + ); + + // Zero base fee + let block_with_zero_base_fee = transform_block(block.clone(), |mut b| { + b.header.base_fee_per_gas = Some(0); + b + }); + assert_matches!( + engine.try_construct_block(block_with_zero_base_fee.into()), + Err(EngineApiError::PayloadBaseFee(val)) if val == 0.into() + ); + + // Invalid encoded transactions + let mut payload_with_invalid_txs: ExecutionPayload = block.clone().into(); + payload_with_invalid_txs.transactions.iter_mut().for_each(|tx| { + *tx = Bytes::new().into(); + }); + assert_matches!( + engine.try_construct_block(payload_with_invalid_txs), + Err(EngineApiError::Decode(DecodeError::InputTooShort)) + ); + + // Non empty ommers + let block_with_ommers = transform_block(block.clone(), |mut b| { + b.ommers.push(random_header(100, None).unseal()); + b + }); + assert_matches!( + engine.try_construct_block(block_with_ommers.clone().into()), + Err(EngineApiError::PayloadBlockHash { consensus, .. }) + if consensus == block_with_ommers.hash() + ); + + // None zero difficulty + let block_with_difficulty = transform_block(block.clone(), |mut b| { + b.header.difficulty = 1.into(); + b + }); + assert_matches!( + engine.try_construct_block(block_with_difficulty.clone().into()), + Err(EngineApiError::PayloadBlockHash { consensus, .. }) + if consensus == block_with_difficulty.hash() + ); + + // None zero nonce + let block_with_nonce = transform_block(block.clone(), |mut b| { + b.header.nonce = 1; + b + }); + assert_matches!( + engine.try_construct_block(block_with_nonce.clone().into()), + Err(EngineApiError::PayloadBlockHash { consensus, .. }) + if consensus == block_with_nonce.hash() + ); + + // Valid block + let valid_block = block.clone(); + assert_matches!(engine.try_construct_block(valid_block.into()), Ok(_)); + } + + #[tokio::test] + async fn payload_known() { + let (tx, rx) = unbounded_channel(); + let client = Arc::new(MockEthProvider::default()); + let engine = EthConsensusEngine { + client: client.clone(), + config: Config::default(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let block = random_block(100, Some(H256::random()), None, Some(0)); // payload must have no ommers + let block_hash = block.hash(); + let execution_payload = block.clone().into(); + + client.add_header(block_hash, block.header.unseal()); + + let (result_tx, result_rx) = oneshot::channel(); + tx.send(EngineMessage::NewPayload(execution_payload, result_tx)) + .expect("failed to send engine msg"); + + let result = result_rx.await; + assert_matches!(result, Ok(Ok(_))); + let expected_result = PayloadStatus::new(PayloadStatusEnum::Valid, block_hash); + assert_eq!(result.unwrap().unwrap(), expected_result); + } + + #[tokio::test] + async fn payload_parent_unknown() { + let (tx, rx) = unbounded_channel(); + let engine = EthConsensusEngine { + client: Arc::new(MockEthProvider::default()), + config: Config::default(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let (result_tx, result_rx) = oneshot::channel(); + let block = random_block(100, Some(H256::random()), None, Some(0)); // payload must have no ommers + tx.send(EngineMessage::NewPayload(block.into(), result_tx)) + .expect("failed to send engine msg"); + + let result = result_rx.await; + assert_matches!(result, Ok(Ok(_))); + let expected_result = PayloadStatus::from_status(PayloadStatusEnum::Syncing); + assert_eq!(result.unwrap().unwrap(), expected_result); + } + + #[tokio::test] + async fn payload_pre_merge() { + let (tx, rx) = unbounded_channel(); + let config = Config::default(); + let client = Arc::new(MockEthProvider::default()); + let engine = EthConsensusEngine { + client: client.clone(), + config: config.clone(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let (result_tx, result_rx) = oneshot::channel(); + let parent = transform_block(random_block(100, None, None, Some(0)), |mut b| { + b.header.difficulty = config.merge_terminal_total_difficulty.into(); + b + }); + let block = random_block(101, Some(parent.hash()), None, Some(0)); + + client.add_block(parent.hash(), parent.clone().unseal()); + + tx.send(EngineMessage::NewPayload(block.clone().into(), result_tx)) + .expect("failed to send engine msg"); + + let result = result_rx.await; + assert_matches!(result, Ok(Ok(_))); + let expected_result = PayloadStatus::from_status(PayloadStatusEnum::Invalid { + validation_error: EngineApiError::PayloadPreMerge.to_string(), + }); + assert_eq!(result.unwrap().unwrap(), expected_result); + } + + #[tokio::test] + async fn invalid_payload_timestamp() { + let (tx, rx) = unbounded_channel(); + let config = Config::default(); + let client = Arc::new(MockEthProvider::default()); + let engine = EthConsensusEngine { + client: client.clone(), + config: config.clone(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let (result_tx, result_rx) = oneshot::channel(); + let block_timestamp = 100; + let parent_timestamp = block_timestamp + 10; + let parent = transform_block(random_block(100, None, None, Some(0)), |mut b| { + b.header.timestamp = parent_timestamp; + b.header.difficulty = (config.merge_terminal_total_difficulty + 1).into(); + b + }); + let block = + transform_block(random_block(101, Some(parent.hash()), None, Some(0)), |mut b| { + b.header.timestamp = block_timestamp; + b + }); + + client.add_block(parent.hash(), parent.clone().unseal()); + + tx.send(EngineMessage::NewPayload(block.clone().into(), result_tx)) + .expect("failed to send engine msg"); + + let result = result_rx.await; + assert_matches!(result, Ok(Ok(_))); + let expected_result = PayloadStatus::from_status(PayloadStatusEnum::Invalid { + validation_error: EngineApiError::PayloadTimestamp { + invalid: block_timestamp, + latest: parent_timestamp, + } + .to_string(), + }); + assert_eq!(result.unwrap().unwrap(), expected_result); + } + + // TODO: add execution tests + } + + // non exhaustive tests for engine_getPayload + // TODO: amend when block building is implemented + mod get_payload { + use super::*; + + #[tokio::test] + async fn payload_unknown() { + let (tx, rx) = unbounded_channel(); + let engine = EthConsensusEngine { + client: Arc::new(MockEthProvider::default()), + config: Config::default(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let payload_id = H64::random(); + + let (result_tx, result_rx) = oneshot::channel(); + tx.send(EngineMessage::GetPayload(payload_id, result_tx)) + .expect("failed to send engine msg"); + + assert_matches!(result_rx.await, Ok(Err(EngineApiError::PayloadUnknown))); + } + } + + // https://github.com/ethereum/execution-apis/blob/main/src/engine/paris.md#specification-3 + mod exchange_transition_configuration { + use super::*; + + #[tokio::test] + async fn terminal_td_mismatch() { + let (tx, rx) = unbounded_channel(); + let config = Config::default(); + let engine = EthConsensusEngine { + client: Arc::new(MockEthProvider::default()), + config: config.clone(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let transition_config = TransitionConfiguration { + terminal_total_difficulty: (config.merge_terminal_total_difficulty + 1).into(), + ..Default::default() + }; + + let (result_tx, result_rx) = oneshot::channel(); + tx.send(EngineMessage::ExchangeTransitionConfiguration( + transition_config.clone(), + result_tx, + )) + .expect("failed to send engine msg"); + + assert_matches!( + result_rx.await, + Ok(Err(EngineApiError::TerminalTD { execution, consensus })) + if execution == config.merge_terminal_total_difficulty.into() + && consensus == transition_config.terminal_total_difficulty.into() + ); + } + + #[tokio::test] + async fn terminal_block_hash_mismatch() { + let (tx, rx) = unbounded_channel(); + let client = Arc::new(MockEthProvider::default()); + let config = Config::default(); + let engine = EthConsensusEngine { + client: client.clone(), + config: config.clone(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let terminal_block_number = 1000; + let consensus_terminal_block = random_block(terminal_block_number, None, None, None); + let execution_terminal_block = random_block(terminal_block_number, None, None, None); + + let transition_config = TransitionConfiguration { + terminal_total_difficulty: config.merge_terminal_total_difficulty.into(), + terminal_block_hash: consensus_terminal_block.hash(), + terminal_block_number, + }; + + // Unknown block number + let (result_tx, result_rx) = oneshot::channel(); + tx.send(EngineMessage::ExchangeTransitionConfiguration( + transition_config.clone(), + result_tx, + )) + .expect("failed to send engine msg"); + + assert_matches!( + result_rx.await, + Ok(Err(EngineApiError::TerminalBlockHash { execution, consensus })) + if execution.is_none() + && consensus == transition_config.terminal_block_hash + ); + + // Add block and to provider local store and test for mismatch + client.add_block( + execution_terminal_block.hash(), + execution_terminal_block.clone().unseal(), + ); + + let (result_tx, result_rx) = oneshot::channel(); + tx.send(EngineMessage::ExchangeTransitionConfiguration( + transition_config.clone(), + result_tx, + )) + .expect("failed to send engine msg"); + + assert_matches!( + result_rx.await, + Ok(Err(EngineApiError::TerminalBlockHash { execution, consensus })) + if execution == Some(execution_terminal_block.hash()) + && consensus == transition_config.terminal_block_hash + ); + } + + #[tokio::test] + async fn configurations_match() { + let (tx, rx) = unbounded_channel(); + let client = Arc::new(MockEthProvider::default()); + let config = Config::default(); + let engine = EthConsensusEngine { + client: client.clone(), + config: config.clone(), + local_store: Default::default(), + rx: UnboundedReceiverStream::new(rx), + }; + + tokio::spawn(engine); + + let terminal_block_number = 1000; + let terminal_block = random_block(terminal_block_number, None, None, None); + + let transition_config = TransitionConfiguration { + terminal_total_difficulty: config.merge_terminal_total_difficulty.into(), + terminal_block_hash: terminal_block.hash(), + terminal_block_number, + }; + + client.add_block(terminal_block.hash(), terminal_block.clone().unseal()); + + let (result_tx, result_rx) = oneshot::channel(); + tx.send(EngineMessage::ExchangeTransitionConfiguration( + transition_config.clone(), + result_tx, + )) + .expect("failed to send engine msg"); + + assert_matches!( + result_rx.await, + Ok(Ok(config)) if config == transition_config + ); + } + } +} diff --git a/crates/interfaces/src/test_utils/generators.rs b/crates/interfaces/src/test_utils/generators.rs index f27fe88a0e..527d425d8c 100644 --- a/crates/interfaces/src/test_utils/generators.rs +++ b/crates/interfaces/src/test_utils/generators.rs @@ -100,20 +100,23 @@ pub fn sign_message(secret: H256, message: H256) -> Result, tx_count: Option) -> SealedBlock { +pub fn random_block( + number: u64, + parent: Option, + tx_count: Option, + ommers_count: Option, +) -> SealedBlock { let mut rng = thread_rng(); // Generate transactions - let tx_count = tx_count.unwrap_or(rand::random::()); - let transactions: Vec = - (0..tx_count).into_iter().map(|_| random_signed_tx()).collect(); + let tx_count = tx_count.unwrap_or(rng.gen::()); + let transactions: Vec = (0..tx_count).map(|_| random_signed_tx()).collect(); let total_gas = transactions.iter().fold(0, |sum, tx| sum + tx.transaction.gas_limit()); // Generate ommers - let mut ommers = Vec::new(); - for _ in 0..rng.gen_range(0..2) { - ommers.push(random_header(number, parent).unseal()); - } + let ommers_count = ommers_count.unwrap_or(rng.gen_range(0..2)); + let ommers = + (0..ommers_count).map(|_| random_header(number, parent).unseal()).collect::>(); // Calculate roots let transactions_root = proofs::calculate_transaction_root(transactions.iter()); @@ -127,6 +130,7 @@ pub fn random_block(number: u64, parent: Option, tx_count: Option) -> gas_limit: total_gas, transactions_root, ommers_hash, + base_fee_per_gas: Some(rng.gen()), ..Default::default() } .seal(), @@ -154,6 +158,7 @@ pub fn random_block_range( idx, Some(blocks.last().map(|block: &SealedBlock| block.header.hash()).unwrap_or(head)), Some(tx_count.clone().sample_single(&mut rng)), + None, )); } blocks diff --git a/crates/net/rpc-types/Cargo.toml b/crates/net/rpc-types/Cargo.toml index d15be8923b..7c566ae5d4 100644 --- a/crates/net/rpc-types/Cargo.toml +++ b/crates/net/rpc-types/Cargo.toml @@ -15,4 +15,5 @@ reth-rlp = { path = "../../common/rlp" } # misc serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" \ No newline at end of file +serde_json = "1.0" +bytes = "1.2" \ No newline at end of file diff --git a/crates/net/rpc-types/src/eth/engine.rs b/crates/net/rpc-types/src/eth/engine.rs index 0715c59e73..16f3bdef53 100644 --- a/crates/net/rpc-types/src/eth/engine.rs +++ b/crates/net/rpc-types/src/eth/engine.rs @@ -2,7 +2,9 @@ #![allow(missing_docs)] -use reth_primitives::{Address, BlockNumber, Bloom, Bytes, H256, H64, U256, U64}; +use bytes::BytesMut; +use reth_primitives::{Address, BlockNumber, Bloom, Bytes, SealedBlock, H256, H64, U256, U64}; +use reth_rlp::Encodable; use serde::{Deserialize, Serialize}; /// This structure maps on the ExecutionPayload structure of the beacon chain spec. @@ -31,6 +33,37 @@ pub struct ExecutionPayload { pub withdrawal: Option, } +impl From for ExecutionPayload { + fn from(value: SealedBlock) -> Self { + let transactions = value + .body + .iter() + .map(|tx| { + let mut encoded = BytesMut::new(); + tx.encode(&mut encoded); + encoded.freeze().into() + }) + .collect(); + ExecutionPayload { + parent_hash: value.parent_hash, + fee_recipient: value.beneficiary, + state_root: value.state_root, + receipts_root: value.receipts_root, + logs_bloom: value.logs_bloom, + prev_randao: value.mix_hash, + block_number: value.number.into(), + gas_limit: value.gas_limit.into(), + gas_used: value.gas_used.into(), + timestamp: value.timestamp.into(), + extra_data: value.extra_data.clone().into(), + base_fee_per_gas: value.base_fee_per_gas.unwrap_or_default().into(), + block_hash: value.hash(), + transactions, + withdrawal: None, + } + } +} + /// This structure maps onto the validator withdrawal object from the beacon chain spec. /// /// See also: diff --git a/crates/stages/src/stages/sender_recovery.rs b/crates/stages/src/stages/sender_recovery.rs index 1526a350b2..e0128f7a6f 100644 --- a/crates/stages/src/stages/sender_recovery.rs +++ b/crates/stages/src/stages/sender_recovery.rs @@ -161,7 +161,7 @@ mod tests { (stage_progress..input.previous_stage_progress() + 1) .map(|number| -> Result { let tx_count = Some((number == stage_progress + 10) as u8); - let block = random_block(number, None, tx_count); + let block = random_block(number, None, tx_count, None); current_tx_id = runner.insert_block(current_tx_id, &block, false)?; Ok(block) }) diff --git a/crates/storage/provider/src/test_utils/mock.rs b/crates/storage/provider/src/test_utils/mock.rs index 2bc3fe812f..4744d70018 100644 --- a/crates/storage/provider/src/test_utils/mock.rs +++ b/crates/storage/provider/src/test_utils/mock.rs @@ -52,12 +52,14 @@ impl ExtendedAccount { impl MockEthProvider { /// Add block to local block store pub fn add_block(&self, hash: H256, block: Block) { + self.add_header(hash, block.header.clone()); self.blocks.lock().insert(hash, block); } /// Add multiple blocks to local block store pub fn extend_blocks(&self, iter: impl IntoIterator) { for (hash, block) in iter.into_iter() { + self.add_header(hash, block.header.clone()); self.add_block(hash, block) } }