test(engine): consensus engine tests (#648)

This commit is contained in:
Roman Krasiuk
2022-12-30 13:17:27 +02:00
committed by GitHub
parent f5ae970e63
commit a7d8059357
8 changed files with 491 additions and 20 deletions

3
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"]

View File

@@ -189,18 +189,22 @@ impl<Client: HeaderProvider + BlockProvider + StateProvider> 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<F: FnOnce(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
);
}
}
}

View File

@@ -100,20 +100,23 @@ pub fn sign_message(secret: H256, message: H256) -> Result<Signature, secp256k1:
/// transactions in the block.
///
/// The ommer headers are not assumed to be valid.
pub fn random_block(number: u64, parent: Option<H256>, tx_count: Option<u8>) -> SealedBlock {
pub fn random_block(
number: u64,
parent: Option<H256>,
tx_count: Option<u8>,
ommers_count: Option<u8>,
) -> SealedBlock {
let mut rng = thread_rng();
// Generate transactions
let tx_count = tx_count.unwrap_or(rand::random::<u8>());
let transactions: Vec<TransactionSigned> =
(0..tx_count).into_iter().map(|_| random_signed_tx()).collect();
let tx_count = tx_count.unwrap_or(rng.gen::<u8>());
let transactions: Vec<TransactionSigned> = (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::<Vec<_>>();
// Calculate roots
let transactions_root = proofs::calculate_transaction_root(transactions.iter());
@@ -127,6 +130,7 @@ pub fn random_block(number: u64, parent: Option<H256>, tx_count: Option<u8>) ->
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

View File

@@ -15,4 +15,5 @@ reth-rlp = { path = "../../common/rlp" }
# misc
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = "1.0"
bytes = "1.2"

View File

@@ -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<Withdrawal>,
}
impl From<SealedBlock> 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: <https://github.com/ethereum/execution-apis/blob/6709c2a795b707202e93c4f2867fa0bf2640a84f/src/engine/shanghai.md#withdrawalv1>

View File

@@ -161,7 +161,7 @@ mod tests {
(stage_progress..input.previous_stage_progress() + 1)
.map(|number| -> Result<SealedBlock, TestRunnerError> {
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)
})

View File

@@ -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<Item = (H256, Block)>) {
for (hash, block) in iter.into_iter() {
self.add_header(hash, block.header.clone());
self.add_block(hash, block)
}
}