diff --git a/bolt-sidecar/bin/sidecar.rs b/bolt-sidecar/bin/sidecar.rs index 30decb895..66d22d48d 100644 --- a/bolt-sidecar/bin/sidecar.rs +++ b/bolt-sidecar/bin/sidecar.rs @@ -68,7 +68,6 @@ async fn main() -> eyre::Result<()> { tokio::select! { Some(ApiEvent { request, response_tx }) = api_events_rx.recv() => { let start = std::time::Instant::now(); - tracing::info!("Received commitment request: {:?}", request); let validator_index = match consensus_state.validate_request(&request) { Ok(index) => index, @@ -79,13 +78,10 @@ async fn main() -> eyre::Result<()> { } }; - let sender = match execution_state.validate_commitment_request(&request).await { - Ok(sender) => sender, - Err(e) => { - tracing::error!(err = ?e, "Failed to commit request"); - let _ = response_tx.send(Err(ApiError::Custom(e.to_string()))); - continue; - } + if let Err(e) = execution_state.validate_commitment_request(&request).await { + tracing::error!(err = ?e, "Failed to commit request"); + let _ = response_tx.send(Err(ApiError::Custom(e.to_string()))); + continue; }; // TODO: match when we have more request types @@ -99,7 +95,7 @@ async fn main() -> eyre::Result<()> { // TODO: review all this `clone` usage // parse the request into constraints and sign them with the sidecar signer - let message = ConstraintsMessage::build(validator_index, request.clone(), sender); + let message = ConstraintsMessage::build(validator_index, request.clone()); let signature = signer.sign(&message.digest())?.to_string(); let signed_constraints = SignedConstraints { message, signature }; @@ -123,7 +119,7 @@ async fn main() -> eyre::Result<()> { Some(slot) = consensus_state.commitment_deadline.wait() => { tracing::info!(slot, "Commitment deadline reached, starting to build local block"); - let Some(template) = execution_state.get_block_template(slot) else { + let Some(template) = execution_state.remove_block_template(slot) else { tracing::warn!("No block template found for slot {slot} when requested"); continue; }; diff --git a/bolt-sidecar/src/builder/template.rs b/bolt-sidecar/src/builder/template.rs index 7fe4eb95a..f424c431d 100644 --- a/bolt-sidecar/src/builder/template.rs +++ b/bolt-sidecar/src/builder/template.rs @@ -30,15 +30,15 @@ use crate::{ #[derive(Debug, Default)] pub struct BlockTemplate { /// The state diffs per address given the list of commitments. - state_diff: StateDiff, + pub(crate) state_diff: StateDiff, /// The signed constraints associated to the block pub signed_constraints_list: Vec, } impl BlockTemplate { /// Return the state diff of the block template. - pub fn state_diff(&self) -> &StateDiff { - &self.state_diff + pub fn get_diff(&self, address: &Address) -> Option<(u64, U256)> { + self.state_diff.get_diff(address) } /// Returns the cloned list of transactions from the constraints. @@ -206,7 +206,7 @@ impl BlockTemplate { pub struct StateDiff { /// Map of diffs per address. Each diff is a tuple of the nonce and balance diff /// that should be applied to the current state. - diffs: HashMap, + pub(crate) diffs: HashMap, } impl StateDiff { diff --git a/bolt-sidecar/src/client/rpc.rs b/bolt-sidecar/src/client/rpc.rs index deac47047..6acf3246f 100644 --- a/bolt-sidecar/src/client/rpc.rs +++ b/bolt-sidecar/src/client/rpc.rs @@ -193,6 +193,11 @@ impl RpcClient { self.0.request("debug_traceCall", params).await } + + /// Send a raw transaction to the network. + pub async fn send_raw_transaction(&self, raw: Bytes) -> TransportResult { + self.0.request("eth_sendRawTransaction", [raw]).await + } } impl Deref for RpcClient { diff --git a/bolt-sidecar/src/common.rs b/bolt-sidecar/src/common.rs index 73bed1016..e73c8dbd4 100644 --- a/bolt-sidecar/src/common.rs +++ b/bolt-sidecar/src/common.rs @@ -47,11 +47,17 @@ pub fn validate_transaction( ) -> Result<(), ValidationError> { // Check if the nonce is correct (should be the same as the transaction count) if transaction.nonce() < account_state.transaction_count { - return Err(ValidationError::NonceTooLow); + return Err(ValidationError::NonceTooLow( + account_state.transaction_count, + transaction.nonce(), + )); } if transaction.nonce() > account_state.transaction_count { - return Err(ValidationError::NonceTooHigh); + return Err(ValidationError::NonceTooHigh( + account_state.transaction_count, + transaction.nonce(), + )); } // Check if the balance is enough diff --git a/bolt-sidecar/src/json_rpc/api.rs b/bolt-sidecar/src/json_rpc/api.rs index ac7bbffaa..2b4f88aa6 100644 --- a/bolt-sidecar/src/json_rpc/api.rs +++ b/bolt-sidecar/src/json_rpc/api.rs @@ -104,7 +104,7 @@ impl CommitmentsRpc for JsonRpcApi { let request = serde_json::from_value::(params)?; #[allow(irrefutable_let_patterns)] // TODO: remove this when we have more request types - let CommitmentRequest::Inclusion(request) = request + let CommitmentRequest::Inclusion(mut request) = request else { return Err(ApiError::Custom( "request must be an inclusion request".to_string(), @@ -113,7 +113,9 @@ impl CommitmentsRpc for JsonRpcApi { info!(?request, "received inclusion commitment request"); - let tx_sender = request.tx.recover_signer().ok_or(ApiError::Custom( + // NOTE: request.sender is skipped from deserialization and initialized as Address::ZERO + // by the default Deserialization. It must be set here. + request.sender = request.tx.recover_signer().ok_or(ApiError::Custom( "failed to recover signer from transaction".to_string(), ))?; @@ -124,9 +126,9 @@ impl CommitmentsRpc for JsonRpcApi { // TODO: relax this check to allow for external signers to request commitments // about transactions that they did not sign themselves - if signer_address != tx_sender { + if signer_address != request.sender { return Err(ApiError::SignaturePubkeyMismatch { - expected: tx_sender.to_string(), + expected: request.sender.to_string(), got: signer_address.to_string(), }); } diff --git a/bolt-sidecar/src/primitives/commitment.rs b/bolt-sidecar/src/primitives/commitment.rs index 03a37da6a..9db058198 100644 --- a/bolt-sidecar/src/primitives/commitment.rs +++ b/bolt-sidecar/src/primitives/commitment.rs @@ -1,7 +1,7 @@ use serde::{de, Deserialize, Deserializer, Serialize}; use std::str::FromStr; -use alloy_primitives::{keccak256, Signature, B256}; +use alloy_primitives::{keccak256, Address, Signature, B256}; use reth_primitives::PooledTransactionsElement; use super::TransactionExt; @@ -37,11 +37,12 @@ pub struct InclusionRequest { /// The signature over the "slot" and "tx" fields by the user. /// A valid signature is the only proof that the user actually requested /// this specific commitment to be included at the given slot. - #[serde( - deserialize_with = "deserialize_from_str", - serialize_with = "signature_as_str" - )] + #[serde(deserialize_with = "deserialize_sig", serialize_with = "serialize_sig")] pub signature: Signature, + /// The ec-recovered address of the signature, for internal use. + /// If not explicitly set, this defaults to `Address::ZERO`. + #[serde(skip)] + pub sender: Address, } impl InclusionRequest { @@ -79,7 +80,7 @@ where serializer.serialize_str(&format!("0x{}", hex::encode(&data))) } -fn deserialize_from_str<'de, D, T>(deserializer: D) -> Result +fn deserialize_sig<'de, D, T>(deserializer: D) -> Result where D: Deserializer<'de>, T: FromStr, @@ -89,10 +90,7 @@ where T::from_str(s.trim_start_matches("0x")).map_err(de::Error::custom) } -fn signature_as_str( - sig: &Signature, - serializer: S, -) -> Result { +fn serialize_sig(sig: &Signature, serializer: S) -> Result { let parity = sig.v(); // As bytes encodes the parity as 27/28, need to change that. let mut bytes = sig.as_bytes(); diff --git a/bolt-sidecar/src/primitives/constraint.rs b/bolt-sidecar/src/primitives/constraint.rs index 9281ed102..f1073ce53 100644 --- a/bolt-sidecar/src/primitives/constraint.rs +++ b/bolt-sidecar/src/primitives/constraint.rs @@ -55,8 +55,12 @@ pub struct ConstraintsMessage { impl ConstraintsMessage { /// Builds a constraints message from an inclusion request and metadata - pub fn build(validator_index: u64, request: InclusionRequest, sender: Address) -> Self { - let constraints = vec![Constraint::from_transaction(request.tx, None, sender)]; + pub fn build(validator_index: u64, request: InclusionRequest) -> Self { + let constraints = vec![Constraint::from_transaction( + request.tx, + None, + request.sender, + )]; Self { validator_index, slot: request.slot, diff --git a/bolt-sidecar/src/state/execution.rs b/bolt-sidecar/src/state/execution.rs index 265de6ed9..bec286b5c 100644 --- a/bolt-sidecar/src/state/execution.rs +++ b/bolt-sidecar/src/state/execution.rs @@ -1,5 +1,5 @@ use alloy_eips::eip4844::MAX_BLOBS_PER_BLOCK; -use alloy_primitives::{Address, SignatureError}; +use alloy_primitives::{Address, SignatureError, U256}; use alloy_transport::TransportError; use reth_primitives::{ revm_primitives::EnvKzgSettings, BlobTransactionValidationError, PooledTransactionsElement, @@ -31,11 +31,11 @@ pub enum ValidationError { #[error("Invalid max basefee calculation: overflow")] MaxBaseFeeCalcOverflow, /// The transaction nonce is too low. - #[error("Transaction nonce too low")] - NonceTooLow, + #[error("Transaction nonce too low. Expected {0}, got {1}")] + NonceTooLow(u64, u64), /// The transaction nonce is too high. #[error("Transaction nonce too high")] - NonceTooHigh, + NonceTooHigh(u64, u64), /// The sender account is a smart contract and has code. #[error("Account has code")] AccountHasCode, @@ -55,6 +55,9 @@ pub enum ValidationError { #[error("Too many EIP-4844 transactions in target block")] Eip4844Limit, /// The maximum commitments have been reached for the slot. + #[error("Already requested a preconfirmation for slot {0}. Slot must be >= {0}")] + SlotTooLow(u64), + /// The maximum commitments have been reached for the slot. #[error("Max commitments reached for slot {0}: {1}")] MaxCommitmentsReachedForSlot(u64, usize), /// The signature is invalid. @@ -172,11 +175,6 @@ impl ExecutionState { self.basefee } - /// Returns the current block templates mapped by slot number - pub fn block_templates(&self) -> &HashMap { - &self.block_templates - } - /// Validates the commitment request against state (historical + intermediate). /// /// NOTE: This function only simulates against execution state, it does not consider @@ -194,13 +192,16 @@ impl ExecutionState { ) -> Result { let CommitmentRequest::Inclusion(req) = request; + let sender = req.sender; + let target_slot = req.slot; + // Validate the chain ID if !req.validate_chain_id(self.chain_id) { return Err(ValidationError::ChainIdMismatch); } // Check if there is room for more commitments - if let Some(template) = self.get_block_template(req.slot) { + if let Some(template) = self.get_block_template(target_slot) { if template.transactions_len() >= self.max_commitments_per_slot.get() { return Err(ValidationError::MaxCommitmentsReachedForSlot( self.slot, @@ -235,15 +236,10 @@ impl ExecutionState { 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"); + tracing::debug!(%sender, target_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.saturating_sub(self.slot); + let slot_diff = target_slot.saturating_sub(self.slot); // Calculate the max possible basefee given the slot diff let max_basefee = calculate_max_basefee(self.basefee, slot_diff) @@ -254,34 +250,66 @@ impl ExecutionState { return Err(ValidationError::BaseFeeTooLow(max_basefee)); } - // If we have the account state, use it here - if let Some(account_state) = self.account_state(&sender) { - // Validate the transaction against the account state - tracing::debug!(address = %sender, "Known account state: {account_state:?}"); - validate_transaction(&account_state, &req.tx)?; - } 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| { - ValidationError::Internal(format!("Failed to fetch account state: {:?}", e)) - })?; - - tracing::debug!(address = %sender, "Fetched account state: {account_state:?}"); - - // Record the account state for later - self.account_states.insert(sender, account_state); - - // Validate the transaction against the account state - validate_transaction(&account_state, &req.tx)?; + // From previous preconfirmations requests retrieve + // - the nonce difference from the account state. + // - the balance difference from the account state. + // - the highest slot number for which the user has requested a preconfirmation. + // + // If the templates do not exist, or this is the first request for this sender, + // its diffs will be zero. + let (nonce_diff, balance_diff, highest_slot) = self.block_templates.iter().fold( + (0, U256::ZERO, 0), + |(nonce_diff_acc, balance_diff_acc, highest_slot), (slot, block_template)| { + let (nonce_diff, balance_diff, slot) = block_template + .get_diff(&sender) + .map(|(nonce, balance)| (nonce, balance, *slot)) + .unwrap_or((0, U256::ZERO, 0)); + + ( + nonce_diff_acc + nonce_diff, + balance_diff_acc.saturating_add(balance_diff), + u64::max(highest_slot, slot), + ) + }, + ); + + if target_slot < highest_slot { + return Err(ValidationError::SlotTooLow(highest_slot)); } + tracing::trace!(%sender, nonce_diff, %balance_diff, "Applying diffs to account state"); + + let account_state = match self.account_state(&sender).copied() { + Some(account) => account, + None => { + // Fetch the account state from the client if it does not exist + let account = match self.client.get_account_state(&sender, None).await { + Ok(account) => account, + Err(err) => { + return Err(ValidationError::Internal(format!( + "Error fetching account state: {:?}", + err + ))) + } + }; + + self.account_states.insert(sender, account); + account + } + }; + + let account_state_with_diffs = AccountState { + transaction_count: account_state.transaction_count.saturating_add(nonce_diff), + balance: account_state.balance.saturating_sub(balance_diff), + has_code: account_state.has_code, + }; + + // Validate the transaction against the account state with existing diffs + validate_transaction(&account_state_with_diffs, &req.tx)?; + // Check EIP-4844-specific limits if let Some(transaction) = req.tx.as_eip4844() { - if let Some(template) = self.block_templates.get(&req.slot) { + if let Some(template) = self.block_templates.get(&target_slot) { if template.blob_count() >= MAX_BLOBS_PER_BLOCK { return Err(ValidationError::Eip4844Limit); } @@ -335,7 +363,7 @@ impl ExecutionState { self.apply_state_update(update); // Remove any block templates that are no longer valid - self.block_templates.remove(&slot); + self.remove_block_template(slot); Ok(()) } @@ -362,7 +390,7 @@ impl ExecutionState { template.retain(*address, *account_state); // Update the account state with the remaining state diff for the next iteration. - if let Some((nonce_diff, balance_diff)) = template.state_diff().get_diff(address) { + if let Some((nonce_diff, balance_diff)) = template.get_diff(address) { // Nonce will always be increased account_state.transaction_count += nonce_diff; // Balance will always be decreased @@ -372,30 +400,20 @@ impl ExecutionState { } } - /// Returns the account state for the given address INCLUDING any intermediate block templates state. - fn account_state(&self, address: &Address) -> Option { - let account_state = self.account_states.get(address).copied(); - - if let Some(mut account_state) = account_state { - // Iterate over all block templates and apply the state diff - for (_, template) in self.block_templates.iter() { - if let Some((nonce_diff, balance_diff)) = template.state_diff().get_diff(address) { - // Nonce will always be increased - account_state.transaction_count += nonce_diff; - // Balance will always be decreased - account_state.balance -= balance_diff; - } - } + /// Returns the cached account state for the given address + fn account_state(&self, address: &Address) -> Option<&AccountState> { + self.account_states.get(address) + } - Some(account_state) - } else { - None - } + /// Gets the block template for the given slot number. + pub fn get_block_template(&mut self, slot: u64) -> Option<&BlockTemplate> { + self.block_templates.get(&slot) } /// Gets the block template for the given slot number and removes it from the cache. - /// This should be called when we need to propose a block for the given slot. - pub fn get_block_template(&mut self, slot: u64) -> Option { + /// This should be called when we need to propose a block for the given slot, + /// or when a new head comes in which makes an older block template useless. + pub fn remove_block_template(&mut self, slot: u64) -> Option { self.block_templates.remove(&slot) } } @@ -407,3 +425,392 @@ pub struct StateUpdate { pub min_blob_basefee: u128, pub block_number: u64, } + +#[cfg(test)] +mod tests { + use crate::builder::template::StateDiff; + use std::str::FromStr; + use std::{num::NonZero, time::Duration}; + + use alloy_consensus::constants::ETH_TO_WEI; + use alloy_eips::eip2718::Encodable2718; + use alloy_network::EthereumWallet; + use alloy_primitives::{uint, Uint}; + use alloy_provider::{network::TransactionBuilder, Provider, ProviderBuilder}; + use alloy_signer_local::PrivateKeySigner; + use fetcher::{StateClient, StateFetcher}; + + use crate::{ + crypto::{bls::Signer, SignableBLS, SignerBLS}, + primitives::{ConstraintsMessage, SignedConstraints}, + state::fetcher, + test_util::{create_signed_commitment_request, default_test_transaction, launch_anvil}, + }; + + use super::*; + + #[tokio::test] + async fn test_valid_inclusion_request() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // 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); + + 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_slot() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + + // Create a transaction with a nonce that is too high + let tx = default_test_transaction(*sender, Some(1)); + + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; + + // Insert a constraint diff for slot 11 + let mut diffs = HashMap::new(); + diffs.insert(*sender, (1, U256::ZERO)); + state.block_templates.insert( + 11, + BlockTemplate { + state_diff: StateDiff { diffs }, + signed_constraints_list: vec![], + }, + ); + + assert!(matches!( + state.validate_commitment_request(&request).await, + Err(ValidationError::SlotTooLow(11)) + )); + + Ok(()) + } + + #[tokio::test] + async fn test_invalid_inclusion_request_nonce() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + + // Insert a constraint diff for slot 9 to simulate nonce increment + let mut diffs = HashMap::new(); + diffs.insert(*sender, (1, U256::ZERO)); + state.block_templates.insert( + 9, + BlockTemplate { + state_diff: StateDiff { diffs }, + signed_constraints_list: vec![], + }, + ); + + // Create a transaction with a nonce that is too low + let tx = default_test_transaction(*sender, Some(0)); + + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; + + assert!(matches!( + state.validate_commitment_request(&request).await, + Err(ValidationError::NonceTooLow(1, 0)) + )); + + assert!(state.account_states.get(sender).unwrap().transaction_count == 0); + + // Create a transaction with a nonce that is too high + let tx = default_test_transaction(*sender, Some(2)); + + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; + + assert!(matches!( + state.validate_commitment_request(&request).await, + Err(ValidationError::NonceTooHigh(1, 2)) + )); + + Ok(()) + } + + #[tokio::test] + async fn test_invalid_inclusion_request_balance() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + + // 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 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_balance_multiple() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + let signer = Signer::random(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + + // Set the sender balance to just enough to pay for 1 transaction + let balance = U256::from_str("500000000000000").unwrap(); // leave just 0.0005 ETH + let sender_account = client.get_account_state(sender, None).await.unwrap(); + let balance_to_burn = sender_account.balance - balance; + + // burn the balance + let tx = default_test_transaction(*sender, Some(0)).with_value(uint!(balance_to_burn)); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; + let tx_bytes = request + .as_inclusion_request() + .unwrap() + .tx + .envelope_encoded(); + let _ = client.inner().send_raw_transaction(tx_bytes).await?; + + // wait for the transaction to be included to update the sender balance + tokio::time::sleep(Duration::from_secs(2)).await; + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + + // create a new transaction and request a preconfirmation for it + let tx = default_test_transaction(*sender, Some(1)); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; + + assert!(state.validate_commitment_request(&request).await.is_ok()); + + let message = ConstraintsMessage::build(0, request.as_inclusion_request().unwrap().clone()); + let signature = signer.sign(&message.digest())?.to_string(); + let signed_constraints = SignedConstraints { message, signature }; + state.add_constraint(10, signed_constraints); + + // create a new transaction and request a preconfirmation for it + let tx = default_test_transaction(*sender, Some(2)); + let request = create_signed_commitment_request(tx, sender_pk, 10).await?; + + // this should fail because the balance is insufficient as we spent + // all of it on the previous preconfirmation + assert!(matches!( + state.validate_commitment_request(&request).await, + Err(ValidationError::InsufficientBalance) + )); + + Ok(()) + } + + #[tokio::test] + async fn test_invalid_inclusion_request_basefee() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let basefee = state.basefee(); + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + + // Create a transaction with a basefee that is too low + let tx = default_test_transaction(*sender, None) + .with_max_fee_per_gas(basefee - 1) + .with_max_priority_fee_per_gas(basefee / 2); + + 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() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // 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); + + // build the signed transaction for submission later + let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); + let signer: EthereumWallet = wallet.into(); + let signed = tx.clone().build(&signer).await?; + + 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 bls_signer = Signer::random(); + let message = ConstraintsMessage::build(0, inclusion_request); + let signature = bls_signer.sign(&message.digest()).unwrap().to_string(); + let signed_constraints = SignedConstraints { message, signature }; + + state.add_constraint(target_slot, signed_constraints); + + assert!( + state + .get_block_template(target_slot) + .unwrap() + .transactions_len() + == 1 + ); + + let notif = provider + .send_raw_transaction(&signed.encoded_2718()) + .await?; + + // Wait for confirmation + 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?; + + let transactions_len = state + .get_block_template(target_slot) + .unwrap() + .transactions_len(); + + assert!(transactions_len == 0); + + Ok(()) + } + + #[tokio::test] + async fn test_invalidate_stale_template() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let max_comms = NonZero::new(10).unwrap(); + let mut state = ExecutionState::new(client.clone(), max_comms).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + + // 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); + + 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 bls_signer = Signer::random(); + let message = ConstraintsMessage::build(0, inclusion_request); + let signature = bls_signer.sign(&message.digest()).unwrap().to_string(); + let signed_constraints = SignedConstraints { message, signature }; + + state.add_constraint(target_slot, signed_constraints); + + assert!( + state + .get_block_template(target_slot) + .unwrap() + .transactions_len() + == 1 + ); + + // 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.get_block_template(target_slot).is_none()); + + Ok(()) + } +} diff --git a/bolt-sidecar/src/state/fetcher.rs b/bolt-sidecar/src/state/fetcher.rs index cebf5b021..2513c383c 100644 --- a/bolt-sidecar/src/state/fetcher.rs +++ b/bolt-sidecar/src/state/fetcher.rs @@ -217,6 +217,13 @@ impl StateFetcher for StateClient { } } +#[cfg(test)] +impl StateClient { + pub fn inner(&self) -> &RpcClient { + &self.client + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/bolt-sidecar/src/state/mod.rs b/bolt-sidecar/src/state/mod.rs index 767b4582f..02dfa0237 100644 --- a/bolt-sidecar/src/state/mod.rs +++ b/bolt-sidecar/src/state/mod.rs @@ -68,22 +68,6 @@ impl Future for CommitmentDeadline { #[cfg(test)] mod tests { - use std::num::NonZero; - - use alloy_consensus::constants::ETH_TO_WEI; - use alloy_eips::eip2718::Encodable2718; - use alloy_network::EthereumWallet; - use alloy_primitives::{uint, Uint}; - use alloy_provider::{network::TransactionBuilder, Provider, ProviderBuilder}; - use alloy_signer_local::PrivateKeySigner; - use execution::{ExecutionState, ValidationError}; - use fetcher::{StateClient, StateFetcher}; - - use crate::{ - crypto::{bls::Signer, SignableBLS, SignerBLS}, - primitives::{ConstraintsMessage, SignedConstraints}, - test_util::{create_signed_commitment_request, default_test_transaction, launch_anvil}, - }; use super::*; @@ -101,243 +85,4 @@ mod tests { println!("Deadline reached. Passed {:?}", time.elapsed()); assert_eq!(slot, None); } - - #[tokio::test] - async fn test_valid_inclusion_request() -> eyre::Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let anvil = launch_anvil(); - let client = StateClient::new(anvil.endpoint_url()); - - let max_comms = NonZero::new(10).unwrap(); - let mut state = ExecutionState::new(client.clone(), max_comms).await?; - - let sender = anvil.addresses().first().unwrap(); - let sender_pk = anvil.keys().first().unwrap(); - - // 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); - - 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() -> eyre::Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let anvil = launch_anvil(); - let client = StateClient::new(anvil.endpoint_url()); - - let max_comms = NonZero::new(10).unwrap(); - let mut state = ExecutionState::new(client.clone(), max_comms).await?; - - let sender = anvil.addresses().first().unwrap(); - let sender_pk = anvil.keys().first().unwrap(); - - // initialize the state by updating the head once - let slot = client.get_head().await?; - state.update_head(None, slot).await?; - - // Create a transaction with a nonce that is too high - let tx = default_test_transaction(*sender, Some(1)); - - 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() -> eyre::Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let anvil = launch_anvil(); - let client = StateClient::new(anvil.endpoint_url()); - - let max_comms = NonZero::new(10).unwrap(); - let mut state = ExecutionState::new(client.clone(), max_comms).await?; - - let sender = anvil.addresses().first().unwrap(); - let sender_pk = anvil.keys().first().unwrap(); - - // initialize the state by updating the head once - let slot = client.get_head().await?; - state.update_head(None, slot).await?; - - // 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 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() -> eyre::Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let anvil = launch_anvil(); - let client = StateClient::new(anvil.endpoint_url()); - - let max_comms = NonZero::new(10).unwrap(); - let mut state = ExecutionState::new(client.clone(), max_comms).await?; - - let basefee = state.basefee(); - - let sender = anvil.addresses().first().unwrap(); - let sender_pk = anvil.keys().first().unwrap(); - - // initialize the state by updating the head once - let slot = client.get_head().await?; - state.update_head(None, slot).await?; - - // 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 = 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() -> eyre::Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let anvil = launch_anvil(); - let client = StateClient::new(anvil.endpoint_url()); - let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); - - let max_comms = NonZero::new(10).unwrap(); - let mut state = ExecutionState::new(client.clone(), max_comms).await?; - - let sender = anvil.addresses().first().unwrap(); - let sender_pk = anvil.keys().first().unwrap(); - - // 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); - - // build the signed transaction for submission later - let wallet: PrivateKeySigner = anvil.keys()[0].clone().into(); - let signer: EthereumWallet = wallet.into(); - let signed = tx.clone().build(&signer).await?; - - 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 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); - - assert!( - state - .block_templates() - .get(&target_slot) - .unwrap() - .transactions_len() - == 1 - ); - - let notif = provider - .send_raw_transaction(&signed.encoded_2718()) - .await?; - - // Wait for confirmation - 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?; - - 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() -> eyre::Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let anvil = launch_anvil(); - let client = StateClient::new(anvil.endpoint_url()); - - let max_comms = NonZero::new(10).unwrap(); - let mut state = ExecutionState::new(client.clone(), max_comms).await?; - - let sender = anvil.addresses().first().unwrap(); - let sender_pk = anvil.keys().first().unwrap(); - - // 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); - - 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 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); - - assert!( - state - .block_templates() - .get(&target_slot) - .unwrap() - .transactions_len() - == 1 - ); - - // 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 a17ab1ea3..86b01d747 100644 --- a/bolt-sidecar/src/test_util.rs +++ b/bolt-sidecar/src/test_util.rs @@ -169,5 +169,6 @@ pub(crate) async fn create_signed_commitment_request( tx: tx_pooled, slot, signature, + sender: signer.address(), })) }