diff --git a/bolt-sidecar/bin/sidecar.rs b/bolt-sidecar/bin/sidecar.rs index 94708ac8..30decb89 100644 --- a/bolt-sidecar/bin/sidecar.rs +++ b/bolt-sidecar/bin/sidecar.rs @@ -73,19 +73,16 @@ async fn main() -> eyre::Result<()> { let validator_index = match consensus_state.validate_request(&request) { Ok(index) => index, Err(e) => { - tracing::error!("Failed to validate request: {:?}", e); + tracing::error!(err = ?e, "Failed to validate request"); let _ = response_tx.send(Err(ApiError::Custom(e.to_string()))); continue; } }; - let sender = match execution_state - .validate_commitment_request(&request) - .await - { + let sender = match execution_state.validate_commitment_request(&request).await { Ok(sender) => sender, Err(e) => { - tracing::error!("Failed to commit request: {:?}", e); + tracing::error!(err = ?e, "Failed to commit request"); let _ = response_tx.send(Err(ApiError::Custom(e.to_string()))); continue; } diff --git a/bolt-sidecar/src/client/rpc.rs b/bolt-sidecar/src/client/rpc.rs index 11332f87..deac4704 100644 --- a/bolt-sidecar/src/client/rpc.rs +++ b/bolt-sidecar/src/client/rpc.rs @@ -10,7 +10,7 @@ use std::{ use alloy::ClientBuilder; use alloy_eips::BlockNumberOrTag; -use alloy_primitives::{Address, B256, U256, U64}; +use alloy_primitives::{Address, Bytes, B256, U256, U64}; use alloy_rpc_client::{self as alloy, Waiter}; use alloy_rpc_types::{Block, EIP1186AccountProofResponse, FeeHistory, TransactionRequest}; use alloy_rpc_types_trace::parity::{TraceResults, TraceType}; @@ -100,16 +100,22 @@ impl RpcClient { .add_call("eth_getTransactionCount", &(address, tag)) .expect("Correct parameters"); + let code = batch + .add_call("eth_getCode", &(address, tag)) + .expect("Correct parameters"); + // After the batch is complete, we can get the results. // Note that requests may error separately! batch.send().await?; let tx_count: U64 = tx_count.await?; let balance: U256 = balance.await?; + let code: Bytes = code.await?; Ok(AccountState { balance, transaction_count: tx_count.to(), + has_code: !code.is_empty(), }) } @@ -260,4 +266,18 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_smart_contract_code() -> eyre::Result<()> { + let rpc_url = Url::parse("https://cloudflare-eth.com")?; + let rpc_client = RpcClient::new(rpc_url); + + // random deployed smart contract address + let addr = Address::from_str("0xBA12222222228d8Ba445958a75a0704d566BF2C8")?; + let account = rpc_client.get_account_state(&addr, None).await?; + + assert!(account.has_code); + + Ok(()) + } } diff --git a/bolt-sidecar/src/common.rs b/bolt-sidecar/src/common.rs index 8caa8b7c..73bed101 100644 --- a/bolt-sidecar/src/common.rs +++ b/bolt-sidecar/src/common.rs @@ -59,6 +59,11 @@ pub fn validate_transaction( return Err(ValidationError::InsufficientBalance); } + // Check if the account has code (i.e. is a smart contract) + if account_state.has_code { + return Err(ValidationError::AccountHasCode); + } + Ok(()) } diff --git a/bolt-sidecar/src/primitives/commitment.rs b/bolt-sidecar/src/primitives/commitment.rs index da8c10c5..03a37da6 100644 --- a/bolt-sidecar/src/primitives/commitment.rs +++ b/bolt-sidecar/src/primitives/commitment.rs @@ -14,6 +14,18 @@ pub enum CommitmentRequest { Inclusion(InclusionRequest), } +impl CommitmentRequest { + /// Returns a reference to the inner request if this is an inclusion request, otherwise `None`. + pub fn as_inclusion_request(&self) -> Option<&InclusionRequest> { + match self { + CommitmentRequest::Inclusion(req) => Some(req), + // TODO: remove this when we have more request types + #[allow(unreachable_patterns)] + _ => None, + } + } +} + /// Request to include a transaction at a specific slot. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InclusionRequest { diff --git a/bolt-sidecar/src/primitives/mod.rs b/bolt-sidecar/src/primitives/mod.rs index 75c91eb9..92085601 100644 --- a/bolt-sidecar/src/primitives/mod.rs +++ b/bolt-sidecar/src/primitives/mod.rs @@ -17,7 +17,9 @@ use ethereum_consensus::{ types::mainnet::ExecutionPayload, Fork, }; -use reth_primitives::{BlobTransactionSidecar, PooledTransactionsElement, TxType}; +use reth_primitives::{ + BlobTransactionSidecar, Bytes, PooledTransactionsElement, TransactionKind, TxType, +}; use tokio::sync::{mpsc, oneshot}; /// Commitment types, received by users wishing to receive preconfirmations. @@ -37,7 +39,10 @@ pub type Slot = u64; pub struct AccountState { /// The nonce of the account. This is the number of transactions sent from this account pub transaction_count: u64, + /// The balance of the account in wei pub balance: U256, + /// Flag to indicate if the account is a smart contract or an EOA + pub has_code: bool, } #[derive(Debug, Default, Clone, SimpleSerialize, serde::Serialize, serde::Deserialize)] @@ -227,8 +232,11 @@ pub trait TransactionExt { fn gas_limit(&self) -> u64; fn value(&self) -> U256; fn tx_type(&self) -> TxType; + fn tx_kind(&self) -> TransactionKind; + fn input(&self) -> &Bytes; fn chain_id(&self) -> Option; fn blob_sidecar(&self) -> Option<&BlobTransactionSidecar>; + fn size(&self) -> usize; } impl TransactionExt for PooledTransactionsElement { @@ -259,6 +267,24 @@ impl TransactionExt for PooledTransactionsElement { } } + fn tx_kind(&self) -> TransactionKind { + match self { + PooledTransactionsElement::Legacy { transaction, .. } => transaction.to, + PooledTransactionsElement::Eip2930 { transaction, .. } => transaction.to, + PooledTransactionsElement::Eip1559 { transaction, .. } => transaction.to, + PooledTransactionsElement::BlobTransaction(blob_tx) => blob_tx.transaction.to, + } + } + + fn input(&self) -> &Bytes { + match self { + PooledTransactionsElement::Legacy { transaction, .. } => &transaction.input, + PooledTransactionsElement::Eip2930 { transaction, .. } => &transaction.input, + PooledTransactionsElement::Eip1559 { transaction, .. } => &transaction.input, + PooledTransactionsElement::BlobTransaction(blob_tx) => &blob_tx.transaction.input, + } + } + fn chain_id(&self) -> Option { match self { PooledTransactionsElement::Legacy { transaction, .. } => transaction.chain_id, @@ -276,4 +302,13 @@ impl TransactionExt for PooledTransactionsElement { _ => None, } } + + fn size(&self) -> usize { + match self { + PooledTransactionsElement::Legacy { transaction, .. } => transaction.size(), + PooledTransactionsElement::Eip2930 { transaction, .. } => transaction.size(), + PooledTransactionsElement::Eip1559 { transaction, .. } => transaction.size(), + PooledTransactionsElement::BlobTransaction(blob_tx) => blob_tx.transaction.size(), + } + } } diff --git a/bolt-sidecar/src/state/execution.rs b/bolt-sidecar/src/state/execution.rs index 9fcd68ec..265de6ed 100644 --- a/bolt-sidecar/src/state/execution.rs +++ b/bolt-sidecar/src/state/execution.rs @@ -10,7 +10,7 @@ use thiserror::Error; use crate::{ builder::BlockTemplate, common::{calculate_max_basefee, validate_transaction}, - primitives::{AccountState, CommitmentRequest, SignedConstraints, Slot}, + primitives::{AccountState, CommitmentRequest, SignedConstraints, Slot, TransactionExt}, }; use super::fetcher::StateFetcher; @@ -27,12 +27,27 @@ pub enum ValidationError { /// The transaction blob is invalid. #[error(transparent)] BlobValidation(#[from] BlobTransactionValidationError), + /// The max basefee calculation incurred an overflow error. + #[error("Invalid max basefee calculation: overflow")] + MaxBaseFeeCalcOverflow, /// The transaction nonce is too low. #[error("Transaction nonce too low")] NonceTooLow, /// The transaction nonce is too high. #[error("Transaction nonce too high")] NonceTooHigh, + /// The sender account is a smart contract and has code. + #[error("Account has code")] + AccountHasCode, + /// The gas limit is too high. + #[error("Gas limit too high")] + GasLimitTooHigh, + /// The transaction input size is too high. + #[error("Transaction input size too high")] + TransactionSizeTooHigh, + /// Max priority fee per gas is greater than max fee per gas. + #[error("Max priority fee per gas is greater than max fee per gas")] + MaxPriorityFeePerGasTooHigh, /// The sender does not have enough balance to pay for the transaction. #[error("Not enough balance to pay for value + maximum fee")] InsufficientBalance, @@ -87,7 +102,6 @@ pub struct ExecutionState { /// These only contain the canonical account states at the head block, /// not the intermediate states. account_states: HashMap, - /// The block templates by target SLOT NUMBER. /// We have multiple block templates because in rare cases we might have multiple /// proposal duties for a single lookahead. @@ -100,6 +114,26 @@ pub struct ExecutionState { kzg_settings: EnvKzgSettings, /// The state fetcher client. client: C, + /// Other values used for validation + validation_params: ValidationParams, +} + +/// Other values used for validation. +#[derive(Debug)] +pub struct ValidationParams { + block_gas_limit: u64, + max_tx_input_bytes: usize, + max_init_code_byte_size: usize, +} + +impl Default for ValidationParams { + fn default() -> Self { + Self { + block_gas_limit: 30_000_000, + max_tx_input_bytes: 4 * 32 * 1024, + max_init_code_byte_size: 2 * 24576, + } + } } impl ExecutionState { @@ -109,21 +143,27 @@ impl ExecutionState { client: C, max_commitments_per_slot: NonZero, ) -> Result { - let (basefee, blob_basefee) = - tokio::try_join!(client.get_basefee(None), client.get_blob_basefee(None))?; + let (basefee, blob_basefee, block_number, chain_id) = tokio::try_join!( + client.get_basefee(None), + client.get_blob_basefee(None), + client.get_head(), + client.get_chain_id() + )?; Ok(Self { basefee, blob_basefee, - block_number: client.get_head().await?, + block_number, + chain_id, + max_commitments_per_slot, + client, slot: 0, account_states: HashMap::new(), block_templates: HashMap::new(), - chain_id: client.get_chain_id().await?, // Load the default KZG settings kzg_settings: EnvKzgSettings::default(), - max_commitments_per_slot, - client, + // TODO: add a way to configure these values from CLI + validation_params: ValidationParams::default(), }) } @@ -138,6 +178,7 @@ impl ExecutionState { } /// Validates the commitment request against state (historical + intermediate). + /// /// NOTE: This function only simulates against execution state, it does not consider /// timing or proposer slot targets. /// @@ -168,22 +209,49 @@ impl ExecutionState { } } - let sender = req.tx.recover_signer().ok_or(ValidationError::Internal( - "Failed to recover signer from transaction".to_string(), - ))?; + // Check if the transaction size exceeds the maximum + if req.tx.size() > self.validation_params.max_tx_input_bytes { + return Err(ValidationError::TransactionSizeTooHigh); + } + + // Check if the transaction is a contract creation and the init code size exceeds the maximum + if req.tx.tx_kind().is_create() + && req.tx.input().len() > self.validation_params.max_init_code_byte_size + { + return Err(ValidationError::TransactionSizeTooHigh); + } + + // Check if the gas limit is higher than the maximum block gas limit + if req.tx.gas_limit() > self.validation_params.block_gas_limit { + return Err(ValidationError::GasLimitTooHigh); + } + + // Ensure max_priority_fee_per_gas is less than max_fee_per_gas, if any + if req + .tx + .max_priority_fee_per_gas() + .is_some_and(|max_priority_fee| max_priority_fee > req.tx.max_fee_per_gas()) + { + return Err(ValidationError::MaxPriorityFeePerGasTooHigh); + } + + let sender = req + .tx + .recover_signer() + .ok_or(ValidationError::RecoverSigner)?; tracing::debug!(%sender, target_slot = req.slot, "Trying to commit inclusion request to block template"); // Check if the max_fee_per_gas would cover the maximum possible basefee. - let slot_diff = req.slot - self.slot; + let slot_diff = req.slot.saturating_sub(self.slot); // Calculate the max possible basefee given the slot diff let max_basefee = calculate_max_basefee(self.basefee, slot_diff) - .ok_or(reject_internal("Overflow calculating max basefee"))?; + .ok_or(ValidationError::MaxBaseFeeCalcOverflow)?; // Validate the base fee if !req.validate_basefee(max_basefee) { - return Err(ValidationError::BaseFeeTooLow(max_basefee as u128)); + return Err(ValidationError::BaseFeeTooLow(max_basefee)); } // If we have the account state, use it here @@ -194,11 +262,13 @@ impl ExecutionState { } else { tracing::debug!(address = %sender, "Unknown account state"); // If we don't have the account state, we need to fetch it - let account_state = self - .client - .get_account_state(&sender, None) - .await - .map_err(|e| reject_internal(&e.to_string()))?; + let account_state = + self.client + .get_account_state(&sender, None) + .await + .map_err(|e| { + ValidationError::Internal(format!("Failed to fetch account state: {:?}", e)) + })?; tracing::debug!(address = %sender, "Fetched account state: {account_state:?}"); @@ -223,7 +293,7 @@ impl ExecutionState { // Calculate max possible increase in blob basefee let max_blob_basefee = calculate_max_basefee(self.blob_basefee, slot_diff) - .ok_or(reject_internal("Overflow calculating max blob basefee"))?; + .ok_or(ValidationError::MaxBaseFeeCalcOverflow)?; if blob_transaction.transaction.max_fee_per_blob_gas < max_blob_basefee { return Err(ValidationError::BlobBaseFeeTooLow(max_blob_basefee)); @@ -259,7 +329,6 @@ impl ExecutionState { ) -> Result<(), TransportError> { self.slot = slot; - // TODO: invalidate any state that we don't need anymore (will be based on block template) let accounts = self.account_states.keys().collect::>(); let update = self.client.get_state_update(accounts, block_number).await?; @@ -338,7 +407,3 @@ pub struct StateUpdate { pub min_blob_basefee: u128, pub block_number: u64, } - -fn reject_internal(reason: &str) -> ValidationError { - ValidationError::Internal(reason.to_string()) -} diff --git a/bolt-sidecar/src/state/fetcher.rs b/bolt-sidecar/src/state/fetcher.rs index 6f9a5341..cebf5b02 100644 --- a/bolt-sidecar/src/state/fetcher.rs +++ b/bolt-sidecar/src/state/fetcher.rs @@ -5,7 +5,7 @@ use std::{collections::HashMap, time::Duration}; use alloy_eips::BlockNumberOrTag; -use alloy_primitives::{Address, U256, U64}; +use alloy_primitives::{Address, Bytes, U256, U64}; use alloy_transport::TransportError; use futures::{stream::FuturesOrdered, StreamExt}; use reqwest::Url; @@ -78,6 +78,7 @@ impl StateFetcher for StateClient { let mut nonce_futs = FuturesOrdered::new(); let mut balance_futs = FuturesOrdered::new(); + let mut code_futs = FuturesOrdered::new(); let block_number = if let Some(block_number) = block_number { block_number @@ -94,10 +95,14 @@ impl StateFetcher for StateClient { let balance = batch .add_call("eth_getBalance", &(addr, tag)) .expect("Invalid parameters"); + let code = batch + .add_call("eth_getCode", &(addr, tag)) + .expect("Invalid parameters"); // Push the futures onto ordered list nonce_futs.push_back(nonce); balance_futs.push_back(balance); + code_futs.push_back(code); } // Make sure to send the batch! @@ -110,9 +115,10 @@ impl StateFetcher for StateClient { let blob_basefee = self.client.get_blob_basefee(None); // Collect the results - let (nonce_vec, balance_vec, basefee, blob_basefee) = tokio::join!( + let (nonce_vec, balance_vec, code_vec, basefee, blob_basefee) = tokio::join!( nonce_futs.collect::>(), balance_futs.collect::>(), + code_futs.collect::>(), basefee, blob_basefee, ); @@ -129,6 +135,7 @@ impl StateFetcher for StateClient { .or_insert(AccountState { transaction_count: nonce.to(), balance: U256::ZERO, + has_code: false, }); } @@ -143,6 +150,22 @@ impl StateFetcher for StateClient { .or_insert(AccountState { transaction_count: 0, balance, + has_code: false, + }); + } + + for (addr, code) in addresses.iter().zip(code_vec) { + let code: Bytes = code?; + + account_states + .entry(**addr) + .and_modify(|s: &mut AccountState| { + s.has_code = !code.is_empty(); + }) + .or_insert(AccountState { + transaction_count: 0, + balance: U256::ZERO, + has_code: !code.is_empty(), }); } diff --git a/bolt-sidecar/src/state/mod.rs b/bolt-sidecar/src/state/mod.rs index 7a4f274f..767b4582 100644 --- a/bolt-sidecar/src/state/mod.rs +++ b/bolt-sidecar/src/state/mod.rs @@ -73,20 +73,16 @@ mod tests { use alloy_consensus::constants::ETH_TO_WEI; use alloy_eips::eip2718::Encodable2718; use alloy_network::EthereumWallet; - use alloy_primitives::{hex, uint, Uint}; + use alloy_primitives::{uint, Uint}; use alloy_provider::{network::TransactionBuilder, Provider, ProviderBuilder}; - use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use execution::{ExecutionState, ValidationError}; - use fetcher::StateClient; - use reqwest::Url; - use reth_primitives::PooledTransactionsElement; - use tracing_subscriber::fmt; + use fetcher::{StateClient, StateFetcher}; use crate::{ crypto::{bls::Signer, SignableBLS, SignerBLS}, - primitives::{CommitmentRequest, ConstraintsMessage, InclusionRequest, SignedConstraints}, - test_util::{default_test_transaction, launch_anvil}, + primitives::{ConstraintsMessage, SignedConstraints}, + test_util::{create_signed_commitment_request, default_test_transaction, launch_anvil}, }; use super::*; @@ -107,208 +103,161 @@ mod tests { } #[tokio::test] - async fn test_valid_inclusion_request() { - let _ = fmt::try_init(); + async fn test_valid_inclusion_request() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); - // let mut state = State::new(get_client()).await.unwrap(); let anvil = launch_anvil(); - let client = StateClient::new(Url::parse(&anvil.endpoint()).unwrap()); + let client = StateClient::new(anvil.endpoint_url()); - let mut state = ExecutionState::new(client, NonZero::new(1024).expect("valid non-zero")) - .await - .unwrap(); + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); - - let sender = anvil.addresses()[0]; + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); - let tx = default_test_transaction(sender, None); + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; - let sig = wallet.sign_message_sync(&hex!("abcd")).unwrap(); - - let signer: EthereumWallet = wallet.into(); - let signed = tx.build(&signer).await.unwrap(); + let tx = default_test_transaction(*sender, None); - // Trick to parse into the TransactionSigned type - let tx_signed_bytes = signed.encoded_2718(); - let tx_signed = - PooledTransactionsElement::decode_enveloped(&mut tx_signed_bytes.as_slice()).unwrap(); - - let request = CommitmentRequest::Inclusion(InclusionRequest { - slot: 10, - tx: tx_signed, - signature: sig, - }); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; assert!(state.validate_commitment_request(&request).await.is_ok()); + + Ok(()) } #[tokio::test] - async fn test_invalid_inclusion_request_nonce() { - let _ = fmt::try_init(); + async fn test_invalid_inclusion_request_nonce() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); let anvil = launch_anvil(); - let client = StateClient::new(Url::parse(&anvil.endpoint()).unwrap()); - - let mut state = ExecutionState::new(client, NonZero::new(1024).expect("valid non-zero")) - .await - .unwrap(); + let client = StateClient::new(anvil.endpoint_url()); - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); - - let sender = anvil.addresses()[0]; + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; - let tx = default_test_transaction(sender, Some(1)); + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); - let sig = wallet.sign_message_sync(&hex!("abcd")).unwrap(); - - let signer: EthereumWallet = wallet.into(); - let signed = tx.build(&signer).await.unwrap(); + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; - // Trick to parse into the TransactionSigned type - let tx_signed_bytes = signed.encoded_2718(); - let tx_signed = - PooledTransactionsElement::decode_enveloped(&mut tx_signed_bytes.as_slice()).unwrap(); + // Create a transaction with a nonce that is too high + let tx = default_test_transaction(*sender, Some(1)); - let request = CommitmentRequest::Inclusion(InclusionRequest { - slot: 10, - tx: tx_signed, - signature: sig, - }); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; assert!(matches!( state.validate_commitment_request(&request).await, Err(ValidationError::NonceTooHigh) )); + + Ok(()) } #[tokio::test] - async fn test_invalid_inclusion_request_balance() { - let _ = fmt::try_init(); + async fn test_invalid_inclusion_request_balance() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); let anvil = launch_anvil(); - let client = StateClient::new(Url::parse(&anvil.endpoint()).unwrap()); + let client = StateClient::new(anvil.endpoint_url()); - let mut state = ExecutionState::new(client, NonZero::new(1024).expect("valid non-zero")) - .await - .unwrap(); + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); - let sender = anvil.addresses()[0]; + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; - let tx = default_test_transaction(sender, None) + // Create a transaction with a value that is too high + let tx = default_test_transaction(*sender, None) .with_value(uint!(11_000_U256 * Uint::from(ETH_TO_WEI))); - let sig = wallet.sign_message_sync(&hex!("abcd")).unwrap(); - - let signer: EthereumWallet = wallet.into(); - let signed = tx.build(&signer).await.unwrap(); - - // Trick to parse into the TransactionSigned type - let tx_signed_bytes = signed.encoded_2718(); - let tx_signed = - PooledTransactionsElement::decode_enveloped(&mut tx_signed_bytes.as_slice()).unwrap(); - - let request = CommitmentRequest::Inclusion(InclusionRequest { - slot: 10, - tx: tx_signed, - signature: sig, - }); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; assert!(matches!( state.validate_commitment_request(&request).await, Err(ValidationError::InsufficientBalance) )); + + Ok(()) } #[tokio::test] - async fn test_invalid_inclusion_request_basefee() { - let _ = fmt::try_init(); + async fn test_invalid_inclusion_request_basefee() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); let anvil = launch_anvil(); - let client = StateClient::new(Url::parse(&anvil.endpoint()).unwrap()); + let client = StateClient::new(anvil.endpoint_url()); - let mut state = ExecutionState::new(client, NonZero::new(1024).expect("valid non-zero")) - .await - .unwrap(); + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; let basefee = state.basefee(); - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); - - let sender = anvil.addresses()[0]; - - let tx = default_test_transaction(sender, None).with_max_fee_per_gas(basefee - 1); + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); - let sig = wallet.sign_message_sync(&hex!("abcd")).unwrap(); + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; - let signer: EthereumWallet = wallet.into(); - let signed = tx.build(&signer).await.unwrap(); - - // Trick to parse into the TransactionSigned type - let tx_signed_bytes = signed.encoded_2718(); - let tx_signed = - PooledTransactionsElement::decode_enveloped(&mut tx_signed_bytes.as_slice()).unwrap(); + // Create a transaction with a basefee that is too low + let tx = default_test_transaction(*sender, None).with_max_fee_per_gas(basefee - 1); - let request = CommitmentRequest::Inclusion(InclusionRequest { - slot: 10, - tx: tx_signed, - signature: sig, - }); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; assert!(matches!( state.validate_commitment_request(&request).await, Err(ValidationError::BaseFeeTooLow(_)) )); + + Ok(()) } #[tokio::test] - async fn test_invalidate_inclusion_request() { - let _ = fmt::try_init(); - - let target_slot = 10; + async fn test_invalidate_inclusion_request() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); let anvil = launch_anvil(); - let client = StateClient::new(Url::parse(&anvil.endpoint()).unwrap()); - - let mut state = ExecutionState::new(client, NonZero::new(1024).expect("valid non-zero")) - .await - .unwrap(); + let client = StateClient::new(anvil.endpoint_url()); + let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; - let sender = anvil.addresses()[0]; + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); - let tx = default_test_transaction(sender, None); + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; - let sig = wallet.sign_message_sync(&hex!("abcd")).unwrap(); + let tx = default_test_transaction(*sender, None); + // build the signed transaction for submission later + let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); let signer: EthereumWallet = wallet.into(); - let signed = tx.build(&signer).await.unwrap(); - - let bls_signer = Signer::random(); - - // Trick to parse into the TransactionSigned type - let tx_signed_bytes = signed.encoded_2718(); - let tx_signed = - PooledTransactionsElement::decode_enveloped(&mut tx_signed_bytes.as_slice()).unwrap(); - - let inclusion_request = InclusionRequest { - slot: target_slot, - tx: tx_signed, - signature: sig, - }; + let signed = tx.clone().build(&signer).await?; - let request = CommitmentRequest::Inclusion(inclusion_request.clone()); + let target_slot = 10; + let request = create_signed_commitment_request(tx, sender_pk, target_slot).await?; + let inclusion_request = request.as_inclusion_request().unwrap().clone(); assert!(state.validate_commitment_request(&request).await.is_ok()); - let message = ConstraintsMessage::build(0, inclusion_request, sender); + let bls_signer = Signer::random(); + let message = ConstraintsMessage::build(0, inclusion_request, *sender); let signature = bls_signer.sign(&message.digest()).unwrap().to_string(); let signed_constraints = SignedConstraints { message, signature }; - state.add_constraint(target_slot, signed_constraints.clone()); + state.add_constraint(target_slot, signed_constraints); assert!( state @@ -319,76 +268,60 @@ mod tests { == 1 ); - let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); - let notif = provider .send_raw_transaction(&signed.encoded_2718()) - .await - .unwrap(); + .await?; // Wait for confirmation - let receipt = notif.get_receipt().await.unwrap(); + let receipt = notif.get_receipt().await?; // Update the head, which should invalidate the transaction due to a nonce conflict state .update_head(receipt.block_number, receipt.block_number.unwrap()) - .await - .unwrap(); + .await?; let transactions_len = state .block_templates() .get(&target_slot) .unwrap() .transactions_len(); + assert!(transactions_len == 0); + + Ok(()) } #[tokio::test] - async fn test_invalidate_stale_template() { - let _ = fmt::try_init(); - - let target_slot = 10; + async fn test_invalidate_stale_template() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); let anvil = launch_anvil(); - let client = StateClient::new(Url::parse(&anvil.endpoint()).unwrap()); - - let mut state = ExecutionState::new(client, NonZero::new(1024).expect("valid non-zero")) - .await - .unwrap(); - - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); - - let sender = anvil.addresses()[0]; - - let tx = default_test_transaction(sender, None); - - let sig = wallet.sign_message_sync(&hex!("abcd")).unwrap(); + let client = StateClient::new(anvil.endpoint_url()); - let signer: EthereumWallet = wallet.into(); - let signed = tx.build(&signer).await.unwrap(); + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; - let bls_signer = Signer::random(); + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); - // Trick to parse into the TransactionSigned type - let tx_signed_bytes = signed.encoded_2718(); - let tx_signed = - PooledTransactionsElement::decode_enveloped(&mut tx_signed_bytes.as_slice()).unwrap(); + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; - let inclusion_request = InclusionRequest { - slot: target_slot, - tx: tx_signed, - signature: sig, - }; + let tx = default_test_transaction(*sender, None); - let request = CommitmentRequest::Inclusion(inclusion_request.clone()); + let target_slot = 10; + let request = create_signed_commitment_request(tx, sender_pk, target_slot).await?; + let inclusion_request = request.as_inclusion_request().unwrap().clone(); assert!(state.validate_commitment_request(&request).await.is_ok()); - let message = ConstraintsMessage::build(0, inclusion_request, sender); + let bls_signer = Signer::random(); + let message = ConstraintsMessage::build(0, inclusion_request, *sender); let signature = bls_signer.sign(&message.digest()).unwrap().to_string(); let signed_constraints = SignedConstraints { message, signature }; - state.add_constraint(target_slot, signed_constraints.clone()); + state.add_constraint(target_slot, signed_constraints); assert!( state @@ -399,9 +332,12 @@ mod tests { == 1 ); - // Update the head, which should invalidate the transaction due to a nonce conflict - state.update_head(None, target_slot).await.unwrap(); + // fast-forward the head to the target slot, which should invalidate the entire template + // because it's now stale + state.update_head(None, target_slot).await?; assert!(state.block_templates().get(&target_slot).is_none()); + + Ok(()) } } diff --git a/bolt-sidecar/src/test_util.rs b/bolt-sidecar/src/test_util.rs index 9708b24f..a17ab1ea 100644 --- a/bolt-sidecar/src/test_util.rs +++ b/bolt-sidecar/src/test_util.rs @@ -1,12 +1,20 @@ -use alloy_network::TransactionBuilder; +use alloy_eips::eip2718::Encodable2718; +use alloy_network::{EthereumWallet, TransactionBuilder}; use alloy_node_bindings::{Anvil, AnvilInstance}; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{keccak256, Address, B256, U256}; use alloy_rpc_types::TransactionRequest; +use alloy_signer::{ + k256::{ecdsa::SigningKey as K256SigningKey, SecretKey as K256SecretKey}, + Signer, +}; +use alloy_signer_local::PrivateKeySigner; use blst::min_pk::SecretKey; +use reth_primitives::PooledTransactionsElement; use secp256k1::Message; use crate::{ crypto::{ecdsa::SignableECDSA, SignableBLS}, + primitives::{CommitmentRequest, InclusionRequest}, Config, }; @@ -130,3 +138,36 @@ impl SignableECDSA for TestSignableData { Message::from_digest_slice(as_32.as_slice()).expect("valid message") } } + +/// Create a valid signed commitment request for testing purposes +/// from the given transaction, private key of the sender, and slot. +pub(crate) async fn create_signed_commitment_request( + tx: TransactionRequest, + sk: &K256SecretKey, + slot: u64, +) -> eyre::Result { + let sk = K256SigningKey::from_slice(sk.to_bytes().as_slice())?; + let signer = PrivateKeySigner::from_signing_key(sk.clone()); + let wallet = EthereumWallet::from(signer.clone()); + + let tx_signed = tx.build(&wallet).await?; + let raw_encoded = tx_signed.encoded_2718(); + let tx_pooled = PooledTransactionsElement::decode_enveloped(&mut raw_encoded.as_slice())?; + + let tx_hash = tx_pooled.hash(); + + let message_digest = { + let mut data = Vec::new(); + data.extend_from_slice(&slot.to_le_bytes()); + data.extend_from_slice(tx_hash.as_slice()); + B256::from(keccak256(data)) + }; + + let signature = signer.sign_hash(&message_digest).await?; + + Ok(CommitmentRequest::Inclusion(InclusionRequest { + tx: tx_pooled, + slot, + signature, + })) +}