diff --git a/bolt-sidecar/src/api/commitments/server/mod.rs b/bolt-sidecar/src/api/commitments/server/mod.rs index e424ef16e..cb7c60246 100644 --- a/bolt-sidecar/src/api/commitments/server/mod.rs +++ b/bolt-sidecar/src/api/commitments/server/mod.rs @@ -206,7 +206,7 @@ mod test { let sk = SecretKey::random(&mut rand::thread_rng()); let signer = PrivateKeySigner::from(sk.clone()); let tx = default_test_transaction(signer.address(), None); - let req = create_signed_inclusion_request(&[tx], &sk, 12).await.unwrap(); + let req = create_signed_inclusion_request(&[tx], &sk.to_bytes(), 12).await.unwrap(); let payload = json!({ "jsonrpc": "2.0", @@ -249,7 +249,7 @@ mod test { let sk = SecretKey::random(&mut rand::thread_rng()); let signer = PrivateKeySigner::from(sk.clone()); let tx = default_test_transaction(signer.address(), None); - let req = create_signed_inclusion_request(&[tx], &sk, 12).await.unwrap(); + let req = create_signed_inclusion_request(&[tx], &sk.to_bytes(), 12).await.unwrap(); let sig = req.signature.unwrap().to_hex(); diff --git a/bolt-sidecar/src/builder/template.rs b/bolt-sidecar/src/builder/template.rs index 36d227da6..64bd9a8c4 100644 --- a/bolt-sidecar/src/builder/template.rs +++ b/bolt-sidecar/src/builder/template.rs @@ -7,12 +7,14 @@ use ethereum_consensus::{ deneb::mainnet::{Blob, BlobsBundle}, }; use reth_primitives::TransactionSigned; -use std::collections::HashMap; use tracing::warn; use crate::{ common::transactions::max_transaction_cost, - primitives::{AccountState, FullTransaction, SignedConstraints, TransactionExt}, + primitives::{ + diffs::{AccountDiff, BalanceDiff, StateDiff}, + AccountState, FullTransaction, SignedConstraints, TransactionExt, + }, }; /// A block template that serves as a fallback block, but is also used @@ -34,7 +36,7 @@ pub struct BlockTemplate { impl BlockTemplate { /// Return the state diff of the block template. - pub fn get_diff(&self, address: &Address) -> Option<(u64, U256)> { + pub fn get_diff(&self, address: &Address) -> Option { self.state_diff.get_diff(address) } @@ -123,14 +125,46 @@ impl BlockTemplate { pub fn add_constraints(&mut self, constraints: SignedConstraints) { for constraint in &constraints.message.transactions { let max_cost = max_transaction_cost(constraint); + + // Increase the nonce and decrease the balance of the sender self.state_diff .diffs .entry(*constraint.sender().expect("recovered sender")) - .and_modify(|(nonce, balance)| { - *nonce += 1; - *balance += max_cost; + .and_modify(|diff| { + *diff = AccountDiff::new( + diff.nonce().saturating_add(1), + BalanceDiff::new( + diff.balance().increase(), + diff.balance().decrease().saturating_add(max_cost), + ), + ) }) - .or_insert((1, max_cost)); + .or_insert(AccountDiff::new(1, BalanceDiff::new(U256::ZERO, max_cost))); + + // If there is an ETH transfer and it's not a contract creation, increase the balance + // of the recipient so that it can send inclusion requests on this preconfirmed state. + let value = constraint.tx.value(); + if value.is_zero() { + continue; + } + let Some(recipient) = constraint.to() else { continue }; + + self.state_diff + .diffs + .entry(recipient) + .and_modify(|diff| { + *diff = AccountDiff::new( + diff.nonce().saturating_add(1), + BalanceDiff::new( + diff.balance().increase().saturating_add(constraint.tx.value()), + diff.balance().decrease(), + ), + ) + }) + .or_insert(AccountDiff::new( + 0, + BalanceDiff::new(constraint.tx.value(), U256::ZERO), + )); } self.signed_constraints_list.push(constraints); @@ -141,13 +175,38 @@ impl BlockTemplate { let constraints = self.signed_constraints_list.remove(index); for constraint in &constraints.message.transactions { + let max_cost = max_transaction_cost(constraint); + self.state_diff .diffs .entry(*constraint.sender().expect("recovered sender")) - .and_modify(|(nonce, balance)| { - *nonce = nonce.saturating_sub(1); - *balance -= max_transaction_cost(constraint); + .and_modify(|diff| { + *diff = AccountDiff::new( + diff.nonce().saturating_sub(1), + BalanceDiff::new( + diff.balance().increase(), + diff.balance().decrease().saturating_sub(max_cost), + ), + ) }); + + // If there is an ETH transfer and it's not a contract creation, remove the balance + // increase of the recipient. + let value = constraint.tx.value(); + if value.is_zero() { + continue; + } + let Some(recipient) = constraint.to() else { continue }; + + self.state_diff.diffs.entry(recipient).and_modify(|diff| { + *diff = AccountDiff::new( + diff.nonce().saturating_sub(1), + BalanceDiff::new( + diff.balance().increase().saturating_sub(constraint.tx.value()), + diff.balance().decrease(), + ), + ) + }); } } @@ -196,20 +255,3 @@ impl BlockTemplate { } } } - -/// StateDiff tracks the intermediate changes to the state according to the block template. -#[derive(Debug, Default)] -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. - pub(crate) diffs: HashMap, -} - -impl StateDiff { - /// Returns a tuple of the nonce and balance diff for the given address. - /// The nonce diff should be added to the current nonce, the balance diff should be subtracted - /// from the current balance. - pub fn get_diff(&self, address: &Address) -> Option<(u64, U256)> { - self.diffs.get(address).copied() - } -} diff --git a/bolt-sidecar/src/chain_io/utils.rs b/bolt-sidecar/src/chain_io/utils.rs index f8b3b2973..6e1ef9442 100644 --- a/bolt-sidecar/src/chain_io/utils.rs +++ b/bolt-sidecar/src/chain_io/utils.rs @@ -48,7 +48,7 @@ fn pubkey_hash_digest(key: &BlsPublicKey) -> B512 { /// /// Example usage: /// -/// ```rust no_run +/// ```rust ignore /// sol! { /// library ErrorLib { /// error SomeError(uint256 code); diff --git a/bolt-sidecar/src/primitives/diffs.rs b/bolt-sidecar/src/primitives/diffs.rs new file mode 100644 index 000000000..222a1babc --- /dev/null +++ b/bolt-sidecar/src/primitives/diffs.rs @@ -0,0 +1,98 @@ +use std::collections::HashMap; + +use alloy::primitives::{Address, U256}; + +/// StateDiff tracks the intermediate changes to the state according to the block template. +#[derive(Debug, Default)] +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. + pub(crate) diffs: HashMap, +} + +impl StateDiff { + /// Returns a tuple of the nonce and balance diff for the given address. + /// The nonce diff should be added to the current nonce, the balance diff should be subtracted + /// from the current balance. + pub fn get_diff(&self, address: &Address) -> Option { + self.diffs.get(address).copied() + } +} + +/// AccountDiff tracks the changes to an account's nonce and balance. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct AccountDiff { + /// The nonce of the account. + nonce: u64, + /// The balance diff of the account. + balance: BalanceDiff, +} + +impl AccountDiff { + /// Creates a new account diff with the given nonce and balance diff. + pub fn new(nonce: u64, balance: BalanceDiff) -> Self { + Self { nonce, balance } + } + + /// Returns the nonce diff of the account. + pub fn nonce(&self) -> u64 { + self.nonce + } + + /// Returns the balance diff of the account. + pub fn balance(&self) -> BalanceDiff { + self.balance + } +} + +/// A balance diff is a tuple of consisting of a balance increase and a balance decrease. +/// +/// An `increase` should be _added_ to the current balance, while a `decrease` should be _subtracted_. +/// +/// Example: +/// ```rs +/// let balance = U256::from(100); +/// let balance_diff = BalanceDiff::new(U256::from(50), U256::from(10)); +/// assert_eq!(balance_diff.apply(balance), U256::from(140)); +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct BalanceDiff { + /// The balance increase. + increase: U256, + /// The balance decrease. + decrease: U256, +} + +impl BalanceDiff { + /// Creates a new balance diff with the given increase and decrease. + pub fn new(increase: U256, decrease: U256) -> Self { + Self { increase, decrease } + } + + /// Returns the increase of the balance diff. + pub fn increase(&self) -> U256 { + self.increase + } + + /// Returns the decrease of the balance diff. + pub fn decrease(&self) -> U256 { + self.decrease + } + + /// Applies the balance diff to the given balance. + pub fn apply(&self, balance: U256) -> U256 { + balance.saturating_add(self.increase).saturating_sub(self.decrease) + } +} + +/// A trait for applying a balance diff to a U256 balance. +pub trait BalanceDiffApplier { + /// Applies the balance diff to the given balance. + fn apply_diff(&self, diff: BalanceDiff) -> U256; +} + +impl BalanceDiffApplier for U256 { + fn apply_diff(&self, diff: BalanceDiff) -> U256 { + self.saturating_add(diff.increase).saturating_sub(diff.decrease) + } +} diff --git a/bolt-sidecar/src/primitives/mod.rs b/bolt-sidecar/src/primitives/mod.rs index 22b2a156a..937cb0813 100644 --- a/bolt-sidecar/src/primitives/mod.rs +++ b/bolt-sidecar/src/primitives/mod.rs @@ -45,6 +45,9 @@ pub mod signature; /// JSON-RPC helper types and functions. pub mod jsonrpc; +/// Types and utilties relates to calculating account diffs. +pub mod diffs; + /// An alias for a Beacon Chain slot number pub type Slot = u64; diff --git a/bolt-sidecar/src/primitives/transaction.rs b/bolt-sidecar/src/primitives/transaction.rs index a32f6f623..af150e1a1 100644 --- a/bolt-sidecar/src/primitives/transaction.rs +++ b/bolt-sidecar/src/primitives/transaction.rs @@ -5,7 +5,7 @@ use alloy::{ }, eips::eip2718::{Decodable2718, Encodable2718}, hex, - primitives::{Address, U256}, + primitives::Address, }; use reth_primitives::TransactionSigned; use serde::{de, ser::SerializeSeq}; @@ -14,9 +14,6 @@ use std::{borrow::Cow, fmt}; /// Trait that exposes additional information on transaction types that don't already do it /// by themselves (e.g. [`PooledTransaction`]). pub trait TransactionExt { - /// Returns the value of the transaction. - fn value(&self) -> U256; - /// Returns the blob sidecar of the transaction, if any. fn blob_sidecar(&self) -> Option<&BlobTransactionSidecar>; @@ -25,16 +22,6 @@ pub trait TransactionExt { } impl TransactionExt for PooledTransaction { - fn value(&self) -> U256 { - match self { - Self::Legacy(transaction) => transaction.tx().value, - Self::Eip1559(transaction) => transaction.tx().value, - Self::Eip2930(transaction) => transaction.tx().value, - Self::Eip4844(transaction) => transaction.tx().tx().value, - Self::Eip7702(transaction) => transaction.tx().value, - } - } - fn blob_sidecar(&self) -> Option<&BlobTransactionSidecar> { match self { Self::Eip4844(transaction) => Some(&transaction.tx().sidecar), diff --git a/bolt-sidecar/src/state/execution.rs b/bolt-sidecar/src/state/execution.rs index c702e9946..09cef784b 100644 --- a/bolt-sidecar/src/state/execution.rs +++ b/bolt-sidecar/src/state/execution.rs @@ -9,14 +9,16 @@ use thiserror::Error; use tracing::{debug, error, trace, warn}; use crate::{ - builder::BlockTemplate, + builder::template::BlockTemplate, common::{ score_cache::ScoreCache, transactions::{calculate_max_basefee, max_transaction_cost, validate_transaction}, }, config::limits::LimitsOpts, primitives::{ - signature::SignatureError, AccountState, InclusionRequest, SignedConstraints, Slot, + diffs::{AccountDiff, BalanceDiff, BalanceDiffApplier}, + signature::SignatureError, + AccountState, InclusionRequest, SignedConstraints, Slot, }, state::pricing, telemetry::ApiMetrics, @@ -144,7 +146,7 @@ pub struct ExecutionState { basefee: u128, /// The blob basefee at the head block. blob_basefee: u128, - /// The cached account states. This should never be read directly. These only contain the + /// The cached account states. These only contain the /// canonical account states at the head block, not the intermediate states. /// /// INVARIANT: the entries are modified only when receiving a new head. @@ -231,6 +233,23 @@ impl ExecutionState { self.basefee } + /// Get the canonical account state at the head of the block for the given address from the + /// [AccountStateCache]. If not available, it's fetched from the EL client, added to the cache + /// and returned. + pub async fn get_or_fetch_account_state( + &mut self, + address: Address, + ) -> Result { + match self.account_states.get(&address) { + Some(account) => Ok(*account), + None => { + let account = self.client.get_account_state(&address, None).await?; + self.account_states.insert(address, account); + Ok(account) + } + } + } + /// Validates the commitment request against state (historical + intermediate). /// /// NOTE: This function only simulates against execution state, it does not consider @@ -337,7 +356,7 @@ impl ExecutionState { for tx in &req.txs { let sender = tx.sender().expect("Recovered sender"); - let (nonce_diff, balance_diff, highest_slot_for_account) = + let (account_diff, highest_slot_for_account) = compute_diffs(&self.block_templates, sender); if target_slot < highest_slot_for_account { @@ -345,26 +364,11 @@ impl ExecutionState { return Err(ValidationError::SlotTooLow(highest_slot_for_account)); } - let account_state = match self.account_states.get(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 = self.get_or_fetch_account_state(*sender).await.map_err(|e| { + ValidationError::Internal(format!("Error fetching account state: {:?}", e)) + })?; - debug!(?account_state, ?nonce_diff, ?balance_diff, "Validating transaction"); + debug!(?account_state, ?account_diff, "Validating transaction"); let sender_nonce_diff = bundle_nonce_diff_map.entry(sender).or_insert(0); let sender_balance_diff = bundle_balance_diff_map.entry(sender).or_insert(U256::ZERO); @@ -374,14 +378,13 @@ impl ExecutionState { let account_state_with_diffs = AccountState { transaction_count: account_state .transaction_count - .saturating_add(nonce_diff) + .saturating_add(account_diff.nonce()) .saturating_add(*sender_nonce_diff), - balance: account_state - .balance - .saturating_sub(balance_diff) + balance: account_diff + .balance() + .apply(account_state.balance) .saturating_sub(*sender_balance_diff), - has_code: account_state.has_code, }; @@ -522,11 +525,12 @@ impl ExecutionState { template.retain(address, expected_account_state); // Update the account state with the remaining state diff for the next iteration. - if let Some((nonce_diff, balance_diff)) = template.get_diff(&address) { + if let Some(account_diff) = template.get_diff(&address) { // Nonce will always be increased - expected_account_state.transaction_count += nonce_diff; - // Balance will always be decreased - expected_account_state.balance -= balance_diff; + expected_account_state.transaction_count += account_diff.nonce(); + // Re-apply balance diffs + expected_account_state.balance = + expected_account_state.balance.apply_diff(account_diff.balance()) } } } @@ -581,21 +585,26 @@ pub struct StateUpdate { fn compute_diffs( block_templates: &HashMap, sender: &Address, -) -> (u64, U256, u64) { +) -> (AccountDiff, u64) { block_templates.iter().fold( - (0, U256::ZERO, 0), - |(nonce_diff_acc, balance_diff_acc, highest_slot), (slot, block_template)| { - let (nonce_diff, balance_diff, current_slot) = block_template + (AccountDiff::default(), 0), + |(diff_acc, highest_slot), (slot, block_template)| { + let (diff, current_slot) = block_template .get_diff(sender) - .map(|(nonce, balance)| (nonce, balance, *slot)) - .unwrap_or((0, U256::ZERO, 0)); + .map(|diff| (diff, *slot)) + .unwrap_or((AccountDiff::default(), 0)); // This might be noisy but it is a critical part in validation logic and // hard to debug. - trace!(?nonce_diff, ?balance_diff, ?slot, ?sender, "found diffs"); + trace!(account_diff = ?diff, ?slot, ?sender, "found diffs"); ( - nonce_diff_acc + nonce_diff, - balance_diff_acc.saturating_add(balance_diff), + AccountDiff::new( + diff_acc.nonce() + diff.nonce(), + BalanceDiff::new( + diff_acc.balance().increase().saturating_add(diff.balance().increase()), + diff_acc.balance().decrease().saturating_add(diff.balance().decrease()), + ), + ), u64::max(highest_slot, current_slot), ) }, @@ -606,7 +615,7 @@ fn compute_diffs( mod tests { use super::*; use crate::{ - builder::template::StateDiff, config::limits::DEFAULT_MAX_COMMITTED_GAS, + config::limits::DEFAULT_MAX_COMMITTED_GAS, primitives::diffs::StateDiff, signer::local::LocalSigner, }; use std::{num::NonZero, str::FromStr, time::Duration}; @@ -619,6 +628,7 @@ mod tests { providers::{network::TransactionBuilder, Provider, ProviderBuilder}, signers::local::PrivateKeySigner, }; + use alloy_node_bindings::WEI_IN_ETHER; use fetcher::{StateClient, StateFetcher}; use tracing::info; @@ -629,15 +639,29 @@ mod tests { test_util::{create_signed_inclusion_request, default_test_transaction, launch_anvil}, }; + fn add_constraint( + request: InclusionRequest, + state: &mut ExecutionState, + signer: &LocalSigner, + target_slot: u64, + ) -> eyre::Result<()> { + let message = ConstraintsMessage::build(Default::default(), request.clone()); + let signature = signer.sign_commit_boost_root(message.digest())?; + let signed_constraints = SignedConstraints { message, signature }; + state.add_constraint(target_slot, signed_constraints); + + Ok(()) + } + #[test] fn test_compute_diff_no_templates() { let block_templates = HashMap::new(); let sender = Address::random(); - let (nonce_diff, balance_diff, highest_slot) = compute_diffs(&block_templates, &sender); + let (account_diff, highest_slot) = compute_diffs(&block_templates, &sender); - assert_eq!(nonce_diff, 0); - assert_eq!(balance_diff, U256::ZERO); + assert_eq!(account_diff.nonce(), 0); + assert_eq!(account_diff.balance().decrease(), U256::ZERO); assert_eq!(highest_slot, 0); } @@ -648,7 +672,8 @@ mod tests { let nonce = 1; let balance_diff = U256::from(2); let mut diffs = HashMap::new(); - diffs.insert(sender, (nonce, balance_diff)); + let account_diff = AccountDiff::new(nonce, BalanceDiff::new(U256::ZERO, balance_diff)); + diffs.insert(sender, account_diff); // Insert StateDiff entry let state_diff = StateDiff { diffs }; @@ -658,10 +683,10 @@ mod tests { let block_template = BlockTemplate { state_diff, signed_constraints_list: vec![] }; block_templates.insert(10, block_template); - let (nonce_diff, balance_diff, highest_slot) = compute_diffs(&block_templates, &sender); + let (computed_account_diff, highest_slot) = compute_diffs(&block_templates, &sender); - assert_eq!(nonce_diff, 1); - assert_eq!(balance_diff, U256::from(2)); + assert_eq!(computed_account_diff.nonce(), 1); + assert_eq!(computed_account_diff.balance().decrease(), U256::from(2)); assert_eq!(highest_slot, 10); } @@ -683,7 +708,7 @@ mod tests { let tx = default_test_transaction(*sender, None); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(state.validate_request(&mut request).await.is_ok()); @@ -709,11 +734,11 @@ mod tests { // Create a transaction with a nonce that is too high let tx = default_test_transaction(*sender, Some(1)); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; // Insert a constraint diff for slot 11 let mut diffs = HashMap::new(); - diffs.insert(*sender, (1, U256::ZERO)); + diffs.insert(*sender, AccountDiff::new(1, BalanceDiff::default())); state.block_templates.insert( 11, BlockTemplate { state_diff: StateDiff { diffs }, signed_constraints_list: vec![] }, @@ -746,7 +771,7 @@ mod tests { // Insert a constraint diff for slot 9 to simulate nonce increment let mut diffs = HashMap::new(); - diffs.insert(*sender, (1, U256::ZERO)); + diffs.insert(*sender, AccountDiff::new(1, BalanceDiff::default())); state.block_templates.insert( 9, BlockTemplate { state_diff: StateDiff { diffs }, signed_constraints_list: vec![] }, @@ -755,7 +780,7 @@ mod tests { // Create a transaction with a nonce that is too low let tx = default_test_transaction(*sender, Some(0)); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -767,7 +792,7 @@ mod tests { // Create a transaction with a nonce that is too high let tx = default_test_transaction(*sender, Some(2)); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -797,7 +822,7 @@ mod tests { let tx = default_test_transaction(*sender, None) .with_value(uint!(11_000_U256 * Uint::from(ETH_TO_WEI))); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -831,7 +856,7 @@ mod tests { // burn the balance let tx = default_test_transaction(*sender, Some(0)).with_value(uint!(balance_to_burn)); - let request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; let tx_bytes = request.txs.first().unwrap().encoded_2718(); let _ = client.inner().send_raw_transaction(tx_bytes.into()).await?; @@ -842,7 +867,7 @@ mod tests { // create a new transaction and request a preconfirmation for it let tx = default_test_transaction(*sender, Some(1)); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; let validation = state.validate_request(&mut request).await; assert!(validation.is_ok(), "Validation failed: {validation:?}"); @@ -854,7 +879,7 @@ mod tests { // create a new transaction and request a preconfirmation for it let tx = default_test_transaction(*sender, Some(2)); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; // this should fail because the balance is insufficient as we spent // all of it on the previous preconfirmation @@ -869,6 +894,204 @@ mod tests { Ok(()) } + /// Tests that a balance increase allows the recipient to send a transaction using preconfirmed + /// state. + #[tokio::test] + async fn test_balance_increase() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let mut state = ExecutionState::new(client.clone(), LimitsOpts::default()).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + let signer = LocalSigner::random(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + let target_slot = 10; + + let recipient_sk = PrivateKeySigner::random(); + let recipient_pk = recipient_sk.address(); + + // Create a transfer of 1 ETH to the recipient + let tx = default_test_transaction(*sender, Some(0)) + .with_to(recipient_pk) + .with_value(WEI_IN_ETHER); + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; + + let validation = state.validate_request(&mut request).await; + assert!(validation.is_ok(), "Validation failed: {validation:?}"); + + add_constraint(request.clone(), &mut state, &signer, target_slot)?; + + // Now the sender should have enough balance to send a transaction + let tx = default_test_transaction(recipient_pk, Some(0)) + .with_value(WEI_IN_ETHER.div_ceil(U256::from(2))); + let mut request = + create_signed_inclusion_request(&[tx], recipient_sk.to_bytes().as_slice(), 10).await?; + + let validation_result = state.validate_request(&mut request).await; + + add_constraint(request, &mut state, &signer, target_slot)?; + + assert!(validation_result.is_ok(), "validation failed: {validation_result:?}"); + + // The recipient cannot afford a second transfer of 0.5 ETH + let tx = default_test_transaction(recipient_pk, Some(1)) + .with_value(WEI_IN_ETHER.div_ceil(U256::from(2))); + let mut request = + create_signed_inclusion_request(&[tx], recipient_sk.to_bytes().as_slice(), 10).await?; + + let validation_result = state.validate_request(&mut request).await; + assert!( + matches!(validation_result, Err(ValidationError::InsufficientBalance)), + "Expected InsufficientBalance error, got {:?}", + validation_result + ); + + Ok(()) + } + + /// Tests that a balance increase is dropped if the preconfirmation is cancelled. + #[tokio::test] + async fn test_balance_increase_dropped() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let mut state = ExecutionState::new(client.clone(), LimitsOpts::default()).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + let signer = LocalSigner::random(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + let target_slot = slot; + + let recipient_sk = PrivateKeySigner::random(); + let recipient_pk = recipient_sk.address(); + + // Create a transfer of 1 ETH to the recipient + let tx = default_test_transaction(*sender, Some(0)) + .with_to(recipient_pk) + .with_value(WEI_IN_ETHER); + let mut request = + create_signed_inclusion_request(&[tx.clone()], &sender_pk.to_bytes(), 10).await?; + + let validation = state.validate_request(&mut request).await; + assert!(validation.is_ok(), "Validation failed: {validation:?}"); + + add_constraint(request.clone(), &mut state, &signer, target_slot)?; + + // Send a cancel tx + let tx = default_test_transaction(*sender, Some(0)) + .with_to(*sender) + .with_max_priority_fee_per_gas(tx.max_priority_fee_per_gas.unwrap() + 1); + + let _ = client.inner().send_transaction(tx).await?; + + // Wait 1s, update the head to include the cancel tx; the constraint should be dropped + tokio::time::sleep(Duration::from_secs(1)).await; + state.update_head(None, slot + 1).await?; + + // Now the recipient should not have balance + let tx = default_test_transaction(recipient_pk, Some(0)).with_value(U256::from(1)); + let mut request = create_signed_inclusion_request( + &[tx], + recipient_sk.to_bytes().as_slice(), + target_slot + 1, + ) + .await?; + + let validation_result = state.validate_request(&mut request).await; + + assert!( + matches!(validation_result, Err(ValidationError::InsufficientBalance)), + "Expected InsufficientBalance error, got {:?}", + validation_result + ); + + Ok(()) + } + + /// Tests that a chained preconfirmation is dropped if the previous one is cancelled. + #[tokio::test] + async fn test_chained_preconfs_dropped() -> eyre::Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let anvil = launch_anvil(); + let client = StateClient::new(anvil.endpoint_url()); + + let mut state = ExecutionState::new(client.clone(), LimitsOpts::default()).await?; + + let sender = anvil.addresses().first().unwrap(); + let sender_pk = anvil.keys().first().unwrap(); + let signer = LocalSigner::random(); + + // initialize the state by updating the head once + let slot = client.get_head().await?; + state.update_head(None, slot).await?; + let target_slot = slot + 2; + + let recipient_sk = PrivateKeySigner::random(); + let recipient_pk = recipient_sk.address(); + + // Create a transfer of 1 ETH to the recipient + let tx_1 = default_test_transaction(*sender, Some(0)) + .with_to(recipient_pk) + .with_value(WEI_IN_ETHER); + let mut request = + create_signed_inclusion_request(&[tx_1.clone()], &sender_pk.to_bytes(), 10).await?; + + let validation = state.validate_request(&mut request).await; + assert!(validation.is_ok(), "Validation failed: {validation:?}"); + + add_constraint(request.clone(), &mut state, &signer, target_slot)?; + + // Now the sender should have enough balance to send a transaction + let tx_2 = default_test_transaction(recipient_pk, Some(0)) + .with_value(WEI_IN_ETHER.div_ceil(U256::from(2))); + let mut request = + create_signed_inclusion_request(&[tx_2], recipient_sk.to_bytes().as_slice(), 10) + .await?; + + let validation_result = state.validate_request(&mut request).await; + + add_constraint(request, &mut state, &signer, target_slot)?; + + assert!(validation_result.is_ok(), "validation failed: {validation_result:?}"); + + // Cancel the first preconfirmation request so that both preconfs are dropped. + + // Send a cancel tx + let tx_3 = default_test_transaction(*sender, Some(0)) + .with_to(*sender) + .with_max_priority_fee_per_gas(tx_1.max_priority_fee_per_gas.unwrap() + 1); + + let _ = client.inner().send_transaction(tx_3).await?; + + // Wait 1s, update the head to include the cancel tx; the constraint should be dropped + tokio::time::sleep(Duration::from_secs(1)).await; + state.update_head(None, slot + 1).await?; + + // Check that both preconfs have been dropped + + let template = state.block_templates.get(&target_slot).unwrap(); + assert!( + template.signed_constraints_list.is_empty(), + "block template should be empty, but got: {template:?}" + ); + + Ok(()) + } + #[tokio::test] async fn test_invalid_inclusion_request_basefee() -> eyre::Result<()> { let _ = tracing_subscriber::fmt::try_init(); @@ -893,7 +1116,7 @@ mod tests { .with_max_fee_per_gas(basefee - 1) .with_max_priority_fee_per_gas(basefee / 2); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -925,7 +1148,7 @@ mod tests { let tx = default_test_transaction(*sender, None).with_gas_limit(6_000_000); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -955,7 +1178,7 @@ mod tests { let tx = default_test_transaction(*sender, None) .with_max_priority_fee_per_gas(GWEI_TO_WEI as u128 / 2); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -966,7 +1189,7 @@ mod tests { let tx = default_test_transaction(*sender, None) .with_max_priority_fee_per_gas(4 * GWEI_TO_WEI as u128); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(state.validate_request(&mut request).await.is_ok()); @@ -998,7 +1221,7 @@ mod tests { let tx = default_test_transaction(*sender, None) .with_gas_price(max_base_fee + GWEI_TO_WEI as u128 / 2); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -1009,7 +1232,7 @@ mod tests { let tx = default_test_transaction(*sender, None) .with_gas_price(max_base_fee + 4 * GWEI_TO_WEI as u128); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(state.validate_request(&mut request).await.is_ok()); @@ -1041,7 +1264,8 @@ mod tests { let tx = default_test_transaction(*sender, None) .with_gas_price(max_base_fee + 4 * GWEI_TO_WEI as u128); - let mut request = create_signed_inclusion_request(&[tx.clone(), tx], sender_pk, 10).await?; + let mut request = + create_signed_inclusion_request(&[tx.clone(), tx], &sender_pk.to_bytes(), 10).await?; let response = state.validate_request(&mut request).await; println!("{response:?}"); @@ -1076,7 +1300,8 @@ mod tests { let signed = tx.clone().build(&signer).await?; let target_slot = 10; - let mut request = create_signed_inclusion_request(&[tx], sender_pk, target_slot).await?; + let mut request = + create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), target_slot).await?; let inclusion_request = request.clone(); assert!(state.validate_request(&mut request).await.is_ok()); @@ -1124,7 +1349,8 @@ mod tests { let tx = default_test_transaction(*sender, None); let target_slot = 10; - let mut request = create_signed_inclusion_request(&[tx], sender_pk, target_slot).await?; + let mut request = + create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), target_slot).await?; let inclusion_request = request.clone(); assert!(state.validate_request(&mut request).await.is_ok()); @@ -1168,7 +1394,8 @@ mod tests { .with_gas_limit(limits.max_committed_gas_per_slot.get() - 1); let target_slot = 10; - let mut request = create_signed_inclusion_request(&[tx], sender_pk, target_slot).await?; + let mut request = + create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), target_slot).await?; let inclusion_request = request.clone(); let validation = state.validate_request(&mut request).await; @@ -1186,7 +1413,7 @@ mod tests { // This tx will exceed the committed gas limit let tx = default_test_transaction(*sender, Some(1)); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, 10).await?; + let mut request = create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -1216,7 +1443,8 @@ mod tests { let tx2 = default_test_transaction(*sender, Some(1)); let tx3 = default_test_transaction(*sender, Some(2)); - let mut request = create_signed_inclusion_request(&[tx1, tx2, tx3], sender_pk, 10).await?; + let mut request = + create_signed_inclusion_request(&[tx1, tx2, tx3], &sender_pk.to_bytes(), 10).await?; assert!(state.validate_request(&mut request).await.is_ok()); @@ -1243,7 +1471,8 @@ mod tests { let tx2 = default_test_transaction(*sender, Some(1)); let tx3 = default_test_transaction(*sender, Some(3)); // wrong nonce, should be 2 - let mut request = create_signed_inclusion_request(&[tx1, tx2, tx3], sender_pk, 10).await?; + let mut request = + create_signed_inclusion_request(&[tx1, tx2, tx3], &sender_pk.to_bytes(), 10).await?; assert!(matches!( state.validate_request(&mut request).await, @@ -1274,7 +1503,8 @@ mod tests { let tx3 = default_test_transaction(*sender, Some(2)) .with_value(uint!(11_000_U256 * Uint::from(ETH_TO_WEI))); - let mut request = create_signed_inclusion_request(&[tx1, tx2, tx3], sender_pk, 10).await?; + let mut request = + create_signed_inclusion_request(&[tx1, tx2, tx3], &sender_pk.to_bytes(), 10).await?; let validation_result = state.validate_request(&mut request).await; assert!( @@ -1309,7 +1539,8 @@ mod tests { let target_slot = 32; let tx = default_test_transaction(*sender, None).with_gas_price(ETH_TO_WEI / 1_000_000); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, target_slot).await?; + let mut request = + create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), target_slot).await?; let inclusion_request = request.clone(); let request_validation = state.validate_request(&mut request).await; @@ -1330,7 +1561,8 @@ mod tests { // 2. Send an inclusion request at `slot + 1` with `target_slot`. let tx = default_test_transaction(*sender, Some(1)).with_gas_price(ETH_TO_WEI / 1_000_000); - let mut request = create_signed_inclusion_request(&[tx], sender_pk, target_slot).await?; + let mut request = + create_signed_inclusion_request(&[tx], &sender_pk.to_bytes(), target_slot).await?; let inclusion_request = request.clone(); let request_validation = state.validate_request(&mut request).await; diff --git a/bolt-sidecar/src/test_util.rs b/bolt-sidecar/src/test_util.rs index 656e5f8c1..71264881e 100644 --- a/bolt-sidecar/src/test_util.rs +++ b/bolt-sidecar/src/test_util.rs @@ -5,11 +5,7 @@ use alloy::{ network::{EthereumWallet, TransactionBuilder}, primitives::{Address, U256}, rpc::types::TransactionRequest, - signers::{ - k256::{ecdsa::SigningKey as K256SigningKey, SecretKey as K256SecretKey}, - local::PrivateKeySigner, - Signer, - }, + signers::{k256::ecdsa::SigningKey as K256SigningKey, local::PrivateKeySigner, Signer}, }; use alloy_node_bindings::{Anvil, AnvilInstance}; use blst::min_pk::SecretKey; @@ -106,7 +102,7 @@ pub(crate) async fn get_test_config() -> Option { Some(opts) } -/// Launch a local instance of the Anvil test chain. +/// Launch a local instance of the Anvil test chain with 1 second block time pub(crate) fn launch_anvil() -> AnvilInstance { Anvil::new().block_time(1).chain_id(1337).spawn() } @@ -155,10 +151,10 @@ impl SignableECDSA for TestSignableData { /// from the given transaction, private key of the sender, and slot. pub(crate) async fn create_signed_inclusion_request( txs: &[TransactionRequest], - sk: &K256SecretKey, + sk: &[u8], slot: u64, ) -> eyre::Result { - let sk = K256SigningKey::from_slice(sk.to_bytes().as_slice())?; + let sk = K256SigningKey::from_slice(sk)?; let signer = PrivateKeySigner::from_signing_key(sk.clone()); let wallet = EthereumWallet::from(signer.clone());