diff --git a/bolt-sidecar/.dockerignore b/bolt-sidecar/.dockerignore index 2d2c3db58..12739157b 100644 --- a/bolt-sidecar/.dockerignore +++ b/bolt-sidecar/.dockerignore @@ -2,3 +2,4 @@ target .git Dockerfile .dockerignore +benches diff --git a/bolt-sidecar/Cargo.lock b/bolt-sidecar/Cargo.lock index b5a0bef7c..853f4f4b4 100644 --- a/bolt-sidecar/Cargo.lock +++ b/bolt-sidecar/Cargo.lock @@ -1215,6 +1215,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.18" @@ -1902,6 +1908,7 @@ dependencies = [ "cb-common", "clap", "commit-boost", + "criterion", "dotenvy", "eth2_keystore 0.1.0 (git+https://github.com/sigp/lighthouse?rev=a87f19d)", "ethereum-consensus", @@ -2028,6 +2035,12 @@ dependencies = [ "tree_hash 0.6.0", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cb-cli" version = "0.1.0" @@ -2179,6 +2192,33 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.3.0" @@ -2451,6 +2491,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -3956,6 +4032,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -5612,6 +5698,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "oorandom" +version = "11.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" + [[package]] name = "op-alloy-consensus" version = "0.6.5" @@ -5982,6 +6074,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "polyval" version = "0.5.3" @@ -6978,6 +7098,15 @@ dependencies = [ "cipher 0.3.0", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scale-info" version = "2.11.5" @@ -8066,6 +8195,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -8760,6 +8899,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -8950,6 +9099,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/bolt-sidecar/Cargo.toml b/bolt-sidecar/Cargo.toml index a7788a660..a4137f5b9 100644 --- a/bolt-sidecar/Cargo.toml +++ b/bolt-sidecar/Cargo.toml @@ -79,12 +79,19 @@ commit-boost = { git = "https://github.com/Commit-Boost/commit-boost-client", re cb-common = { git = "https://github.com/Commit-Boost/commit-boost-client", rev = "45ce8f1" } [dev-dependencies] -alloy-node-bindings = "0.6.4" # must match alloy version +alloy-node-bindings = "0.6.4" # must match alloy version +criterion = { version = "0.5", features = ["html_reports"] } [package.metadata.cargo-machete] ignored = ["ethereum_ssz"] +[[bench]] +name = "score_cache" +path = "benches/score_cache.rs" +harness = false + + [[bin]] name = "bolt-sidecar" path = "bin/sidecar.rs" diff --git a/bolt-sidecar/benches/score_cache.rs b/bolt-sidecar/benches/score_cache.rs new file mode 100644 index 000000000..edbb0ab02 --- /dev/null +++ b/bolt-sidecar/benches/score_cache.rs @@ -0,0 +1,139 @@ +use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; +use std::collections::HashMap; + +use bolt_sidecar::common::score_cache::ScoreCache; + +const GET_SCORE: isize = 1; +const INSERT_SCORE: isize = 2; +const UPDATE_SCORE: isize = 3; + +fn bench_scorecache_vs_hashmap(c: &mut Criterion) { + let mut group = c.benchmark_group("ScoreCache vs HashMap"); + + let sizes = vec![1_000]; + for size in sizes { + // Insert benchmark + group.bench_function(format!("ScoreCache Insert -- size: {}", size), |b| { + b.iter_batched( + || create_score_cache(size), + |mut score_cache| { + for i in 0..size { + score_cache.insert(i, i); + } + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function(format!("HashMap Insert -- size: {}", size), |b| { + b.iter_batched( + || create_hashmap(size), + |mut hash_map| { + for i in 0..size { + hash_map.insert(i, i); + } + }, + BatchSize::SmallInput, + ); + }); + + // Get benchmark + group.bench_function(format!("ScoreCache Get -- size: {}", size), |b| { + b.iter_batched( + || create_score_cache_filled(size), + |mut score_cache| { + for i in 0..size { + let _ = black_box(score_cache.get(&i)); + } + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function(format!("HashMap Get -- size: {}", size), |b| { + b.iter_batched( + || create_hashmap_filled(size), + |hash_map| { + for i in 0..size { + let _ = black_box(hash_map.get(&i)); + } + }, + BatchSize::SmallInput, + ); + }); + + // Update benchmark + let mut score_cache = create_score_cache(size); + for i in 0..size { + score_cache.insert(i, i); + } + group.bench_function(format!("ScoreCache Update -- size: {}", size), |b| { + b.iter_batched( + || create_score_cache_filled(size), + |mut score_cache| { + for i in 0..size { + if let Some(value) = score_cache.get_mut(&i) { + *value += 1; + } + } + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function(format!("HashMap Update -- size: {}", size), |b| { + b.iter_batched( + || create_hashmap_filled(size), + |mut hash_map| { + for i in 0..size { + if let Some(value) = hash_map.get_mut(&i) { + *value += 1; + } + } + }, + BatchSize::SmallInput, + ); + }); + } +} + +// Actual size is doubled so we're sure to not it more than 50% capacity +fn create_score_cache( + size: usize, +) -> ScoreCache { + ScoreCache::with_capacity_and_len(size * 2, size * 2) +} + +fn create_score_cache_filled( + size: usize, +) -> ScoreCache { + let mut score_cache = ScoreCache::with_capacity_and_len(size * 2, size * 2); + for i in 0..size { + score_cache.insert(i, i); + } + score_cache +} + +// Actual size is doubled so we're sure to not it more than 50% capacity +fn create_hashmap(size: usize) -> HashMap { + HashMap::with_capacity(size * 2) +} + +fn create_hashmap_filled(size: usize) -> HashMap { + let mut hash_map = HashMap::with_capacity(size * 2); + for i in 0..size { + hash_map.insert(i, i); + } + hash_map +} + +fn configure_criterion() -> Criterion { + Criterion::default().measurement_time(std::time::Duration::from_secs(10)) // Increase to 10 seconds +} + +criterion_group!( + name = benches; + config = configure_criterion(); + targets = bench_scorecache_vs_hashmap +); +criterion_main!(benches); diff --git a/bolt-sidecar/src/api/builder.rs b/bolt-sidecar/src/api/builder.rs index 96044f5c0..afc1e19ac 100644 --- a/bolt-sidecar/src/api/builder.rs +++ b/bolt-sidecar/src/api/builder.rs @@ -195,7 +195,7 @@ where if let Some(local_payload) = server.local_payload.lock().take() { check_locally_built_payload_integrity(&signed_blinded_block, &local_payload)?; - info!("Valid local block found, returning: {local_payload:?}"); + info!("Valid local block found, returning: {:?}", local_payload.block_hash()); ApiMetrics::increment_local_blocks_proposed(); return Ok(Json(local_payload)); diff --git a/bolt-sidecar/src/builder/mod.rs b/bolt-sidecar/src/builder/mod.rs index cbaec13ae..410e1ba37 100644 --- a/bolt-sidecar/src/builder/mod.rs +++ b/bolt-sidecar/src/builder/mod.rs @@ -7,7 +7,7 @@ use ethereum_consensus::{ }; use crate::{ - common::BlsSecretKeyWrapper, + common::secrets::BlsSecretKeyWrapper, config::{ChainConfig, Opts}, primitives::{ BuilderBid, GetPayloadResponse, PayloadAndBid, PayloadAndBlobs, SignedBuilderBid, diff --git a/bolt-sidecar/src/builder/payload_builder.rs b/bolt-sidecar/src/builder/payload_builder.rs index 213a24ffb..1cc2819f5 100644 --- a/bolt-sidecar/src/builder/payload_builder.rs +++ b/bolt-sidecar/src/builder/payload_builder.rs @@ -416,8 +416,8 @@ pub fn secret_to_bearer_header(secret: &JwtSecret) -> HeaderValue { "Bearer {}", secret .encode(&Claims { - iat: (SystemTime::now().duration_since(UNIX_EPOCH).unwrap() + - Duration::from_secs(60)) + iat: (SystemTime::now().duration_since(UNIX_EPOCH).unwrap() + + Duration::from_secs(60)) .as_secs(), exp: None, }) @@ -481,9 +481,9 @@ mod tests { let raw_encoded = tx_signed.encoded_2718(); let tx_signed_reth = TransactionSigned::decode_2718(&mut raw_encoded.as_slice())?; - let slot = genesis_time + - (SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / cfg.chain.slot_time()) + - 1; + let slot = genesis_time + + (SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / cfg.chain.slot_time()) + + 1; let block = builder.build_fallback_payload(slot, &[tx_signed_reth]).await?; assert_eq!(block.body.transactions.len(), 1); diff --git a/bolt-sidecar/src/builder/template.rs b/bolt-sidecar/src/builder/template.rs index bf86ed84b..6079f03c1 100644 --- a/bolt-sidecar/src/builder/template.rs +++ b/bolt-sidecar/src/builder/template.rs @@ -9,7 +9,7 @@ use reth_primitives::TransactionSigned; use tracing::warn; use crate::{ - common::max_transaction_cost, + common::transactions::max_transaction_cost, primitives::{AccountState, FullTransaction, SignedConstraints, TransactionExt}, }; diff --git a/bolt-sidecar/src/common.rs b/bolt-sidecar/src/common.rs deleted file mode 100644 index 0bfa6dbcc..000000000 --- a/bolt-sidecar/src/common.rs +++ /dev/null @@ -1,377 +0,0 @@ -use std::{ - fmt::{self, Display}, - fs::read_to_string, - future::Future, - ops::Deref, - path::Path, - time::Duration, -}; - -use alloy::{hex, primitives::U256, signers::k256::ecdsa::SigningKey}; -use blst::min_pk::SecretKey; -use rand::{Rng, RngCore}; -use reth_primitives::PooledTransactionsElement; -use serde::{Deserialize, Deserializer}; -use tokio_retry::{ - strategy::{jitter, ExponentialBackoff}, - Retry, -}; - -use crate::{ - primitives::{AccountState, TransactionExt}, - state::ValidationError, -}; - -/// The version of the Bolt sidecar binary. -pub const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Calculates the max_basefee `slot_diff` blocks in the future given a current basefee (in wei). -/// Returns None if an overflow would occur. -/// Cfr. https://github.com/flashbots/ethers-provider-flashbots-bundle/blob/7ddaf2c9d7662bef400151e0bfc89f5b13e72b4c/src/index.ts#L308 -/// -/// NOTE: this increase is correct also for the EIP-4844 blob base fee: -/// See https://eips.ethereum.org/EIPS/eip-4844#base-fee-per-blob-gas-update-rule -pub fn calculate_max_basefee(current: u128, block_diff: u64) -> Option { - // Define the multiplier and divisor for fixed-point arithmetic - let multiplier: u128 = 1125; // Represents 112.5% - let divisor: u128 = 1000; - let mut max_basefee = current; - - for _ in 0..block_diff { - // Check for potential overflow when multiplying - if max_basefee > u128::MAX / multiplier { - return None; // Overflow would occur - } - - // Perform the multiplication and division (and add 1 to round up) - max_basefee = max_basefee * multiplier / divisor + 1; - } - - Some(max_basefee) -} - -/// Calculates the max transaction cost (gas + value) in wei. -/// -/// - For EIP-1559 transactions: `max_fee_per_gas * gas_limit + tx_value`. -/// - For legacy transactions: `gas_price * gas_limit + tx_value`. -/// - For EIP-4844 blob transactions: `max_fee_per_gas * gas_limit + tx_value + max_blob_fee_per_gas -/// * blob_gas_used`. -pub fn max_transaction_cost(transaction: &PooledTransactionsElement) -> U256 { - let gas_limit = transaction.gas_limit() as u128; - - let mut fee_cap = transaction.max_fee_per_gas(); - fee_cap += transaction.max_priority_fee_per_gas().unwrap_or(0); - - if let Some(eip4844) = transaction.as_eip4844() { - fee_cap += eip4844.max_fee_per_blob_gas + eip4844.blob_gas() as u128; - } - - U256::from(gas_limit * fee_cap) + transaction.value() -} - -/// This function validates a transaction against an account state. It checks 2 things: -/// 1. The nonce of the transaction must be higher than the account's nonce, but not higher than -/// current + 1. -/// 2. The balance of the account must be higher than the transaction's max cost. -pub fn validate_transaction( - account_state: &AccountState, - transaction: &PooledTransactionsElement, -) -> 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( - account_state.transaction_count, - transaction.nonce(), - )); - } - - if transaction.nonce() > account_state.transaction_count { - return Err(ValidationError::NonceTooHigh( - account_state.transaction_count, - transaction.nonce(), - )); - } - - // Check if the balance is enough - if max_transaction_cost(transaction) > account_state.balance { - 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(()) -} - -#[derive(Clone, Debug)] -pub struct BlsSecretKeyWrapper(pub SecretKey); - -impl BlsSecretKeyWrapper { - pub fn random() -> Self { - let mut rng = rand::thread_rng(); - let mut ikm = [0u8; 32]; - rng.fill_bytes(&mut ikm); - Self(SecretKey::key_gen(&ikm, &[]).unwrap()) - } -} - -impl<'de> Deserialize<'de> for BlsSecretKeyWrapper { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let sk = String::deserialize(deserializer)?; - Ok(Self::from(sk.as_str())) - } -} - -impl From<&str> for BlsSecretKeyWrapper { - fn from(sk: &str) -> Self { - let hex_sk = sk.strip_prefix("0x").unwrap_or(sk); - let sk = SecretKey::from_bytes(&hex::decode(hex_sk).expect("valid hex")).expect("valid sk"); - Self(sk) - } -} - -impl Deref for BlsSecretKeyWrapper { - type Target = SecretKey; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl fmt::Display for BlsSecretKeyWrapper { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", hex::encode_prefixed(self.0.to_bytes())) - } -} - -#[derive(Clone, Debug)] -pub struct EcdsaSecretKeyWrapper(pub SigningKey); - -impl EcdsaSecretKeyWrapper { - /// Generate a new random ECDSA secret key. - #[allow(dead_code)] - pub fn random() -> Self { - Self(SigningKey::random(&mut rand::thread_rng())) - } -} - -impl<'de> Deserialize<'de> for EcdsaSecretKeyWrapper { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let sk = String::deserialize(deserializer)?; - Ok(Self::from(sk.as_str())) - } -} - -impl From<&str> for EcdsaSecretKeyWrapper { - fn from(sk: &str) -> Self { - let hex_sk = sk.strip_prefix("0x").unwrap_or(sk); - let bytes = hex::decode(hex_sk).expect("valid hex"); - let sk = SigningKey::from_slice(&bytes).expect("valid sk"); - Self(sk) - } -} - -impl Display for EcdsaSecretKeyWrapper { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", hex::encode_prefixed(self.0.to_bytes())) - } -} - -impl Deref for EcdsaSecretKeyWrapper { - type Target = SigningKey; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[derive(Debug, Clone)] -pub struct JwtSecretConfig(pub String); - -impl Default for JwtSecretConfig { - fn default() -> Self { - let random_bytes: [u8; 32] = rand::thread_rng().gen(); - let secret = hex::encode(random_bytes); - Self(secret) - } -} - -impl From<&str> for JwtSecretConfig { - fn from(jwt: &str) -> Self { - let jwt = if jwt.starts_with("0x") { - jwt.trim_start_matches("0x").to_string() - } else if Path::new(&jwt).exists() { - read_to_string(jwt) - .unwrap_or_else(|_| panic!("Failed reading JWT secret file: {:?}", jwt)) - .trim_start_matches("0x") - .to_string() - } else { - jwt.to_string() - }; - - assert!(jwt.len() == 64, "Engine JWT secret must be a 32 byte hex string"); - - Self(jwt) - } -} - -impl<'de> Deserialize<'de> for JwtSecretConfig { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let jwt = String::deserialize(deserializer)?; - Ok(Self::from(jwt.as_str())) - } -} - -impl Deref for JwtSecretConfig { - type Target = str; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Display for JwtSecretConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "0x{}", self.0) - } -} - -/// Retry a future with exponential backoff and jitter. -pub async fn retry_with_backoff(max_retries: usize, fut: impl Fn() -> F) -> Result -where - F: Future>, -{ - let backoff = ExponentialBackoff::from_millis(100) - .factor(2) - .max_delay(Duration::from_secs(1)) - .take(max_retries) - .map(jitter); - - Retry::spawn(backoff, fut).await -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use thiserror::Error; - use tokio::{ - sync::Mutex, - time::{Duration, Instant}, - }; - - use super::*; - - #[test] - fn test_calculate_max_basefee() { - let current = 10_000_000_000; // 10 gwei - let slot_diff = 9; // 9 full blocks in the future - - let result = calculate_max_basefee(current, slot_diff); - assert_eq!(result, Some(28865075793)) - } - - #[derive(Debug, Error)] - #[error("mock error")] - struct MockError; - - // Helper struct to count attempts and control failure/success behavior - struct Counter { - count: usize, - fail_until: usize, - } - - impl Counter { - fn new(fail_until: usize) -> Self { - Self { count: 0, fail_until } - } - - async fn retryable_fn(&mut self) -> Result<(), MockError> { - self.count += 1; - if self.count <= self.fail_until { - Err(MockError) - } else { - Ok(()) - } - } - } - - #[tokio::test] - async fn test_retry_success_without_retry() { - let counter = Arc::new(Mutex::new(Counter::new(0))); - - let result = retry_with_backoff(5, || { - let counter = Arc::clone(&counter); - async move { - let mut counter = counter.lock().await; - counter.retryable_fn().await - } - }) - .await; - - assert!(result.is_ok()); - assert_eq!(counter.lock().await.count, 1, "Should succeed on first attempt"); - } - - #[tokio::test] - async fn test_retry_until_success() { - let counter = Arc::new(Mutex::new(Counter::new(3))); // Fail 3 times, succeed on 4th - - let result = retry_with_backoff(5, || async { - let counter = Arc::clone(&counter); - let mut counter = counter.lock().await; - counter.retryable_fn().await - }) - .await; - - assert!(result.is_ok()); - assert_eq!(counter.lock().await.count, 4, "Should retry until success on 4th attempt"); - } - - #[tokio::test] - async fn test_max_retries_reached() { - let counter = Arc::new(Mutex::new(Counter::new(5))); // Fail 5 times, max retries = 3 - - let result = retry_with_backoff(3, || { - let counter = Arc::clone(&counter); - async move { - let mut counter = counter.lock().await; - counter.retryable_fn().await - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(counter.lock().await.count, 4, "Should stop after max retries are reached"); - } - - #[tokio::test] - async fn test_exponential_backoff_timing() { - let counter = Arc::new(Mutex::new(Counter::new(3))); // Fail 3 times, succeed on 4th - let start_time = Instant::now(); - - let result = retry_with_backoff(5, || { - let counter = Arc::clone(&counter); - async move { - let mut counter = counter.lock().await; - counter.retryable_fn().await - } - }) - .await; - - assert!(result.is_ok()); - let elapsed = start_time.elapsed(); - assert!( - elapsed >= Duration::from_millis(700), - "Total backoff duration should be at least 700ms" - ); - } -} diff --git a/bolt-sidecar/src/common/backoff.rs b/bolt-sidecar/src/common/backoff.rs new file mode 100644 index 000000000..190cfa2b2 --- /dev/null +++ b/bolt-sidecar/src/common/backoff.rs @@ -0,0 +1,128 @@ +use std::{future::Future, time::Duration}; + +use tokio_retry::{ + strategy::{jitter, ExponentialBackoff}, + Retry, +}; + +/// Retry a future with exponential backoff and jitter. +pub async fn retry_with_backoff(max_retries: usize, fut: impl Fn() -> F) -> Result +where + F: Future>, +{ + let backoff = ExponentialBackoff::from_millis(100) + .factor(2) + .max_delay(Duration::from_secs(1)) + .take(max_retries) + .map(jitter); + + Retry::spawn(backoff, fut).await +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use thiserror::Error; + use tokio::{ + sync::Mutex, + time::{Duration, Instant}, + }; + + use super::*; + + #[derive(Debug, Error)] + #[error("mock error")] + struct MockError; + + // Helper struct to count attempts and control failure/success behavior + struct Counter { + count: usize, + fail_until: usize, + } + + impl Counter { + fn new(fail_until: usize) -> Self { + Self { count: 0, fail_until } + } + + async fn retryable_fn(&mut self) -> Result<(), MockError> { + self.count += 1; + if self.count <= self.fail_until { + Err(MockError) + } else { + Ok(()) + } + } + } + + #[tokio::test] + async fn test_retry_success_without_retry() { + let counter = Arc::new(Mutex::new(Counter::new(0))); + + let result = retry_with_backoff(5, || { + let counter = Arc::clone(&counter); + async move { + let mut counter = counter.lock().await; + counter.retryable_fn().await + } + }) + .await; + + assert!(result.is_ok()); + assert_eq!(counter.lock().await.count, 1, "Should succeed on first attempt"); + } + + #[tokio::test] + async fn test_retry_until_success() { + let counter = Arc::new(Mutex::new(Counter::new(3))); // Fail 3 times, succeed on 4th + + let result = retry_with_backoff(5, || async { + let counter = Arc::clone(&counter); + let mut counter = counter.lock().await; + counter.retryable_fn().await + }) + .await; + + assert!(result.is_ok()); + assert_eq!(counter.lock().await.count, 4, "Should retry until success on 4th attempt"); + } + + #[tokio::test] + async fn test_max_retries_reached() { + let counter = Arc::new(Mutex::new(Counter::new(5))); // Fail 5 times, max retries = 3 + + let result = retry_with_backoff(3, || { + let counter = Arc::clone(&counter); + async move { + let mut counter = counter.lock().await; + counter.retryable_fn().await + } + }) + .await; + + assert!(result.is_err()); + assert_eq!(counter.lock().await.count, 4, "Should stop after max retries are reached"); + } + + #[tokio::test] + async fn test_exponential_backoff_timing() { + let counter = Arc::new(Mutex::new(Counter::new(3))); // Fail 3 times, succeed on 4th + let start_time = Instant::now(); + + let result = retry_with_backoff(5, || { + let counter = Arc::clone(&counter); + async move { + let mut counter = counter.lock().await; + counter.retryable_fn().await + } + }) + .await; + + assert!(result.is_ok()); + let elapsed = start_time.elapsed(); + assert!( + elapsed >= Duration::from_millis(700), + "Total backoff duration should be at least 700ms" + ); + } +} diff --git a/bolt-sidecar/src/common/mod.rs b/bolt-sidecar/src/common/mod.rs new file mode 100644 index 000000000..16421a8d8 --- /dev/null +++ b/bolt-sidecar/src/common/mod.rs @@ -0,0 +1,11 @@ +/// Utilities for retrying a future with backoff. +pub mod backoff; +/// A hash map-like bounded data structure with an additional scoring mechanism. +pub mod score_cache; +/// Secret key types wrappers for BLS, ECDSA and JWT. +pub mod secrets; +/// Utility functions for working with transactions. +pub mod transactions; + +/// The version of the Bolt sidecar binary. +pub const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/bolt-sidecar/src/common/score_cache.rs b/bolt-sidecar/src/common/score_cache.rs new file mode 100644 index 000000000..55c0f0e72 --- /dev/null +++ b/bolt-sidecar/src/common/score_cache.rs @@ -0,0 +1,240 @@ +use std::{ + borrow::Borrow, + collections::HashMap, + fmt::Debug, + hash::{BuildHasher, Hash, RandomState}, + ops::{Deref, DerefMut}, +}; + +/// [ScoreCache] provides a hash map-like data structure with an additional scoring mechanism. Each +/// entry in the cache is assigned a score, which is modified based on specific operations (GET, +/// INSERT, UPDATE). +/// The cache has a maximum length (max_len), and when this length is exceeded, +/// stale elements (entries with the lowest scores) are removed to make space for new entries. +/// +/// The module is particularly useful for scenarios where a priority-based +/// eviction policy is required. +pub struct ScoreCache< + const GET_SCORE: isize, + const INSERT_SCORE: isize, + const UPDATE_SCORE: isize, + K, + V, + S = RandomState, +> { + map: HashMap, + max_len: usize, +} + +// -------- TRAITS -------- + +impl Default + for ScoreCache +{ + fn default() -> Self { + Self::new() + } +} + +impl Deref + for ScoreCache +{ + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.map + } +} + +impl DerefMut + for ScoreCache +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.map + } +} + +impl< + const GET_SCORE: isize, + const INSERT_SCORE: isize, + const UPDATE_SCORE: isize, + K: Debug, + V: Debug, + S, + > Debug for ScoreCache +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScoreCache") + .field("map", &self.map) + .field("max_len", &self.max_len) + .finish() + } +} + +// -------- INIT IMPLEMENTATIONS -------- + +impl + ScoreCache +{ + /// Creates an empty `ScoreMap` without maximum length. + /// + /// See also [std::collections::HashMap::new]. + #[inline] + pub fn new() -> Self { + Self { map: HashMap::::new(), max_len: usize::MAX } + } + + /// Creates an empty `ScoreMap` with maximum length. + /// + /// See also [std::collections::HashMap::new]. + #[inline] + pub fn with_max_len(max_len: usize) -> Self { + Self { map: HashMap::::new(), max_len } + } + + /// Creates an empty `HashMap` with at least the specified capacity. + /// + /// See also [std::collections::HashMap::with_capacity]. + #[inline] + pub fn with_capacity(capacity: usize) -> Self { + Self { map: HashMap::::with_capacity(capacity), max_len: usize::MAX } + } + + /// Creates an empty `HashMap` with at least the specified capacity and maximum length. + #[inline] + pub fn with_capacity_and_len(capacity: usize, max_len: usize) -> Self { + Self { map: HashMap::::with_capacity(capacity), max_len } + } +} + +impl + ScoreCache +{ + /// See [std::collections::HashMap::with_hasher]. + #[inline] + pub fn with_hasher(hash_builder: S) -> Self { + Self { map: HashMap::with_hasher(hash_builder), max_len: usize::MAX } + } + + /// See [std::collections::HashMap::with_capacity_and_hasher]. + #[inline] + pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> Self { + Self { map: HashMap::with_capacity_and_hasher(capacity, hasher), max_len: usize::MAX } + } + + /// Creates a score map with the specified capacity, hasher, and length. + /// + /// See [std::collections::HashMap::with_capacity_and_hasher]. + #[inline] + pub fn with_capacity_and_hasher_and_max_len( + capacity: usize, + hasher: S, + max_len: usize, + ) -> Self { + Self { map: HashMap::with_capacity_and_hasher(capacity, hasher), max_len } + } +} + +// -------- METHODS -------- + +impl + ScoreCache +where + K: Eq + Hash, + S: BuildHasher, +{ + /// A wrapper over [std::collections::HashMap::get_mut] that bumps the score of the key. + /// + /// Requires mutable access to the cache to update the score. + #[inline] + pub fn get(&mut self, k: &Q) -> Option<&V> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.map.get_mut(k).map(|(v, score)| { + *score = score.saturating_add(GET_SCORE); + &*v + }) + } + + /// A wrapper over [std::collections::HashMap::get_mut] that bumps the score of the key. + /// + /// Requires mutable access to the cache to update the score. + #[inline] + pub fn get_mut(&mut self, k: &Q) -> Option<&mut V> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.map.get_mut(k).map(|(v, score)| { + *score = score.saturating_add(UPDATE_SCORE); + v + }) + } + + /// A wrapper over [std::collections::HashMap::insert] that bumps the score of the key. + /// + /// Adds a new key-value pair to the cache with the provided `INSERT_SCORE`, by first trying to + /// clear any stale element from the cache if necessary. + #[inline] + pub fn insert(&mut self, k: K, v: V) -> Option { + self.clear_stales(); + self.map.insert(k, (v, INSERT_SCORE)).map(|(v, _)| v) + } +} + +impl + ScoreCache +{ + // Clear the stale values from the cache if there is any. + #[inline] + fn clear_stales(&mut self) { + let mut i = 0; + while self.len() >= self.max_len { + self.map.retain(|_, (_, score)| *score > i); + i += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GET_SCORE: isize = 4; + const INSERT_SCORE: isize = 4; + const UPDATE_SCORE: isize = -1; + + fn default_score_cache() -> ScoreCache { + ScoreCache::with_max_len(2) + } + + #[test] + fn test_score_logic_2() { + let mut cache = default_score_cache(); + + cache.insert(1, "one".to_string()); + assert_eq!(cache.map.get(&1), Some(&("one".to_string(), GET_SCORE))); + + assert_eq!(cache.get(&1), Some(&"one".to_string())); + assert_eq!(cache.map.get(&1), Some(&("one".to_string(), GET_SCORE * 2))); + + let v = cache.get_mut(&1).unwrap(); + *v = "one".to_string(); + assert_eq!(cache.map.get(&1), Some(&("one".to_string(), GET_SCORE * 2 + UPDATE_SCORE))); + + // Insert a new value and update it to set its score to zero. + cache.insert(2, "two".to_string()); + for _ in 0..GET_SCORE { + let v = cache.get_mut(&2).unwrap(); + *v = "two".to_string(); + } + assert_eq!(cache.map.get(&2), Some(&("two".to_string(), 0))); + + // Insert a new value: "2" should be dropped. + cache.insert(3, "three".to_string()); + assert_eq!(cache.len(), 2); + assert_eq!(cache.map.get(&2), None); + } +} diff --git a/bolt-sidecar/src/common/secrets.rs b/bolt-sidecar/src/common/secrets.rs new file mode 100644 index 000000000..537f7d8c3 --- /dev/null +++ b/bolt-sidecar/src/common/secrets.rs @@ -0,0 +1,154 @@ +use std::{ + fmt::{self, Display}, + fs::read_to_string, + ops::Deref, + path::Path, +}; + +use alloy::{hex, signers::k256::ecdsa::SigningKey}; +use blst::min_pk::SecretKey; +use rand::{Rng, RngCore}; +use serde::{Deserialize, Deserializer}; + +/// A warpper for BLS secret key. +#[derive(Clone, Debug)] +pub struct BlsSecretKeyWrapper(pub SecretKey); + +impl BlsSecretKeyWrapper { + /// Generate a new random BLS secret key. + pub fn random() -> Self { + let mut rng = rand::thread_rng(); + let mut ikm = [0u8; 32]; + rng.fill_bytes(&mut ikm); + Self(SecretKey::key_gen(&ikm, &[]).unwrap()) + } +} + +impl<'de> Deserialize<'de> for BlsSecretKeyWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let sk = String::deserialize(deserializer)?; + Ok(Self::from(sk.as_str())) + } +} + +impl From<&str> for BlsSecretKeyWrapper { + fn from(sk: &str) -> Self { + let hex_sk = sk.strip_prefix("0x").unwrap_or(sk); + let sk = SecretKey::from_bytes(&hex::decode(hex_sk).expect("valid hex")).expect("valid sk"); + Self(sk) + } +} + +impl Deref for BlsSecretKeyWrapper { + type Target = SecretKey; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for BlsSecretKeyWrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode_prefixed(self.0.to_bytes())) + } +} + +/// A warpper for ECDSA secret key. +#[derive(Clone, Debug)] +pub struct EcdsaSecretKeyWrapper(pub SigningKey); + +impl EcdsaSecretKeyWrapper { + /// Generate a new random ECDSA secret key. + pub fn random() -> Self { + Self(SigningKey::random(&mut rand::thread_rng())) + } +} + +impl<'de> Deserialize<'de> for EcdsaSecretKeyWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let sk = String::deserialize(deserializer)?; + Ok(Self::from(sk.as_str())) + } +} + +impl From<&str> for EcdsaSecretKeyWrapper { + fn from(sk: &str) -> Self { + let hex_sk = sk.strip_prefix("0x").unwrap_or(sk); + let bytes = hex::decode(hex_sk).expect("valid hex"); + let sk = SigningKey::from_slice(&bytes).expect("valid sk"); + Self(sk) + } +} + +impl Display for EcdsaSecretKeyWrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode_prefixed(self.0.to_bytes())) + } +} + +impl Deref for EcdsaSecretKeyWrapper { + type Target = SigningKey; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// A warpper for JWT secret key. +#[derive(Debug, Clone)] +pub struct JwtSecretConfig(pub String); + +impl Default for JwtSecretConfig { + fn default() -> Self { + let random_bytes: [u8; 32] = rand::thread_rng().gen(); + let secret = hex::encode(random_bytes); + Self(secret) + } +} + +impl From<&str> for JwtSecretConfig { + fn from(jwt: &str) -> Self { + let jwt = if jwt.starts_with("0x") { + jwt.trim_start_matches("0x").to_string() + } else if Path::new(&jwt).exists() { + read_to_string(jwt) + .unwrap_or_else(|_| panic!("Failed reading JWT secret file: {:?}", jwt)) + .trim_start_matches("0x") + .to_string() + } else { + jwt.to_string() + }; + + assert!(jwt.len() == 64, "Engine JWT secret must be a 32 byte hex string"); + + Self(jwt) + } +} + +impl<'de> Deserialize<'de> for JwtSecretConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let jwt = String::deserialize(deserializer)?; + Ok(Self::from(jwt.as_str())) + } +} + +impl Deref for JwtSecretConfig { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for JwtSecretConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{}", self.0) + } +} diff --git a/bolt-sidecar/src/common/transactions.rs b/bolt-sidecar/src/common/transactions.rs new file mode 100644 index 000000000..210e9899c --- /dev/null +++ b/bolt-sidecar/src/common/transactions.rs @@ -0,0 +1,101 @@ +use alloy::primitives::U256; +use reth_primitives::PooledTransactionsElement; + +use crate::{ + primitives::{AccountState, TransactionExt}, + state::ValidationError, +}; + +/// Calculates the max_basefee `slot_diff` blocks in the future given a current basefee (in wei). +/// Returns None if an overflow would occur. +/// Cfr. https://github.com/flashbots/ethers-provider-flashbots-bundle/blob/7ddaf2c9d7662bef400151e0bfc89f5b13e72b4c/src/index.ts#L308 +/// +/// NOTE: this increase is correct also for the EIP-4844 blob base fee: +/// See https://eips.ethereum.org/EIPS/eip-4844#base-fee-per-blob-gas-update-rule +pub fn calculate_max_basefee(current: u128, block_diff: u64) -> Option { + // Define the multiplier and divisor for fixed-point arithmetic + let multiplier: u128 = 1125; // Represents 112.5% + let divisor: u128 = 1000; + let mut max_basefee = current; + + for _ in 0..block_diff { + // Check for potential overflow when multiplying + if max_basefee > u128::MAX / multiplier { + return None; // Overflow would occur + } + + // Perform the multiplication and division (and add 1 to round up) + max_basefee = max_basefee * multiplier / divisor + 1; + } + + Some(max_basefee) +} + +/// Calculates the max transaction cost (gas + value) in wei. +/// +/// - For EIP-1559 transactions: `max_fee_per_gas * gas_limit + tx_value`. +/// - For legacy transactions: `gas_price * gas_limit + tx_value`. +/// - For EIP-4844 blob transactions: `max_fee_per_gas * gas_limit + tx_value + max_blob_fee_per_gas +/// * blob_gas_used`. +pub fn max_transaction_cost(transaction: &PooledTransactionsElement) -> U256 { + let gas_limit = transaction.gas_limit() as u128; + + let mut fee_cap = transaction.max_fee_per_gas(); + fee_cap += transaction.max_priority_fee_per_gas().unwrap_or(0); + + if let Some(eip4844) = transaction.as_eip4844() { + fee_cap += eip4844.max_fee_per_blob_gas + eip4844.blob_gas() as u128; + } + + U256::from(gas_limit * fee_cap) + transaction.value() +} + +/// This function validates a transaction against an account state. It checks 2 things: +/// 1. The nonce of the transaction must be higher than the account's nonce, but not higher than +/// current + 1. +/// 2. The balance of the account must be higher than the transaction's max cost. +pub fn validate_transaction( + account_state: &AccountState, + transaction: &PooledTransactionsElement, +) -> 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( + account_state.transaction_count, + transaction.nonce(), + )); + } + + if transaction.nonce() > account_state.transaction_count { + return Err(ValidationError::NonceTooHigh( + account_state.transaction_count, + transaction.nonce(), + )); + } + + // Check if the balance is enough + if max_transaction_cost(transaction) > account_state.balance { + 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_max_basefee() { + let current = 10_000_000_000; // 10 gwei + let slot_diff = 9; // 9 full blocks in the future + + let result = calculate_max_basefee(current, slot_diff); + assert_eq!(result, Some(28865075793)) + } +} diff --git a/bolt-sidecar/src/config/constraint_signing.rs b/bolt-sidecar/src/config/constraint_signing.rs index f3712e9ab..db3981b51 100644 --- a/bolt-sidecar/src/config/constraint_signing.rs +++ b/bolt-sidecar/src/config/constraint_signing.rs @@ -5,7 +5,7 @@ use lighthouse_account_utils::ZeroizeString; use reqwest::Url; use serde::Deserialize; -use crate::common::{BlsSecretKeyWrapper, JwtSecretConfig}; +use crate::common::secrets::{BlsSecretKeyWrapper, JwtSecretConfig}; /// Command-line options for signing constraint messages #[derive(Args, Deserialize)] diff --git a/bolt-sidecar/src/config/limits.rs b/bolt-sidecar/src/config/limits.rs index 3d825be90..59a5b114e 100644 --- a/bolt-sidecar/src/config/limits.rs +++ b/bolt-sidecar/src/config/limits.rs @@ -11,6 +11,9 @@ pub const DEFAULT_MAX_COMMITTED_GAS: u64 = 10_000_000; /// Default min priority fee to accept for a commitment. pub const DEFAULT_MIN_PRIORITY_FEE: u128 = 1_000_000_000; // 1 Gwei +/// Default max account states size. +pub const DEFAULT_MAX_ACCOUNT_STATES_SIZE: u64 = 1_024; + /// Limits for the sidecar. #[cfg_attr(test, derive(PartialEq))] #[derive(Debug, Parser, Clone, Copy, serde::Serialize, serde::Deserialize)] @@ -36,6 +39,16 @@ pub struct LimitsOpts { default_value_t = LimitsOpts::default().min_priority_fee )] pub min_priority_fee: u128, + /// The maximum size in MiB of the [crate::state::ExecutionState] ScoreCache that holds account + /// states. Each [crate::primitives::AccountState] is 48 bytes, its score is [usize] bytes, and + /// its key is 20 bytes, so the default value of 1024 KiB = 1 MiB can hold around 15k account + /// states. + #[clap( + long, + env = "BOLT_SIDECAR_MAX_ACCOUNT_STATES_SIZE", + default_value_t = LimitsOpts::default().max_account_states_size, + )] + pub max_account_states_size: NonZero, } impl Default for LimitsOpts { @@ -46,6 +59,7 @@ impl Default for LimitsOpts { max_committed_gas_per_slot: NonZero::new(DEFAULT_MAX_COMMITTED_GAS) .expect("Valid non-zero"), min_priority_fee: DEFAULT_MIN_PRIORITY_FEE, + max_account_states_size: NonZero::new(1_024).expect("Valid non-zero"), } } } diff --git a/bolt-sidecar/src/config/mod.rs b/bolt-sidecar/src/config/mod.rs index c349dd577..806e9cead 100644 --- a/bolt-sidecar/src/config/mod.rs +++ b/bolt-sidecar/src/config/mod.rs @@ -23,7 +23,7 @@ pub mod limits; use limits::LimitsOpts; use tracing::debug; -use crate::common::{BlsSecretKeyWrapper, EcdsaSecretKeyWrapper, JwtSecretConfig}; +use crate::common::secrets::{BlsSecretKeyWrapper, EcdsaSecretKeyWrapper, JwtSecretConfig}; /// Default port for the JSON-RPC server exposed by the sidecar supporting the Commitments API. /// diff --git a/bolt-sidecar/src/driver.rs b/bolt-sidecar/src/driver.rs index 5839557e0..f233be8a6 100644 --- a/bolt-sidecar/src/driver.rs +++ b/bolt-sidecar/src/driver.rs @@ -23,7 +23,7 @@ use crate::{ builder::payload_fetcher::LocalPayloadFetcher, chain_io::BoltManager, client::ConstraintsClient, - common::retry_with_backoff, + common::backoff::retry_with_backoff, config::Opts, crypto::{SignableBLS, SignerECDSA}, primitives::{ diff --git a/bolt-sidecar/src/lib.rs b/bolt-sidecar/src/lib.rs index 9782a3494..534e615cd 100644 --- a/bolt-sidecar/src/lib.rs +++ b/bolt-sidecar/src/lib.rs @@ -13,7 +13,7 @@ mod client; pub mod telemetry; /// Common types and compatibility utilities -mod common; +pub mod common; /// Driver for the sidecar, which manages the main event loop pub mod driver; diff --git a/bolt-sidecar/src/primitives/mod.rs b/bolt-sidecar/src/primitives/mod.rs index 60c006d70..753700df9 100644 --- a/bolt-sidecar/src/primitives/mod.rs +++ b/bolt-sidecar/src/primitives/mod.rs @@ -40,6 +40,8 @@ pub use transaction::{deserialize_txs, serialize_txs, FullTransaction, Transacti pub type Slot = u64; /// Minimal account state needed for commitment validation. +/// +/// Each account state is 8 + 32 + 1 + 7 (padding) bytes = 48 bytes. #[derive(Debug, Clone, Copy, Default)] pub struct AccountState { /// The nonce of the account. This is the number of transactions sent from this account diff --git a/bolt-sidecar/src/signer/local.rs b/bolt-sidecar/src/signer/local.rs index e812b456c..78579ff38 100644 --- a/bolt-sidecar/src/signer/local.rs +++ b/bolt-sidecar/src/signer/local.rs @@ -111,7 +111,7 @@ impl LocalSigner { impl LocalSigner { /// Create a signer with a random BLS key configured for Mainnet for testing. pub fn random() -> Self { - use crate::common::BlsSecretKeyWrapper; + use crate::common::secrets::BlsSecretKeyWrapper; Self { key: BlsSecretKeyWrapper::random().0, chain: ChainConfig::mainnet() } } diff --git a/bolt-sidecar/src/state/account_state.rs b/bolt-sidecar/src/state/account_state.rs new file mode 100644 index 000000000..308e7ace4 --- /dev/null +++ b/bolt-sidecar/src/state/account_state.rs @@ -0,0 +1,44 @@ +use std::ops::{Deref, DerefMut}; + +use alloy::primitives::Address; + +use crate::{common::score_cache::ScoreCache, primitives::AccountState, telemetry::ApiMetrics}; + +const GET_SCORE: isize = 4; +const INSERT_SCORE: isize = 4; +const UPDATE_SCORE: isize = -1; + +/// A scored cache for account states. +/// +/// The cache is scored based on the number of times an account state is accessed. +/// In particular, there is a bonus when an account is read or inserted, because it means we've +/// received an inclusion preconfirmation requests. +/// +/// Moreover, updates incur a penalty. That is because after we insert an account, we must keep +/// track of its updates during new blocks. The goal of this cache is to keep to most active +/// accounts in it. +#[derive(Debug, Default)] +pub struct AccountStateCache( + pub ScoreCache, +); + +impl Deref for AccountStateCache { + type Target = ScoreCache; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for AccountStateCache { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl AccountStateCache { + /// Insert an account state into the cache, and update the metrics. + pub fn insert(&mut self, address: Address, account_state: AccountState) { + ApiMetrics::set_account_states(self.len()); + self.0.insert(address, account_state); + } +} diff --git a/bolt-sidecar/src/state/execution.rs b/bolt-sidecar/src/state/execution.rs index f139204d8..801f6a66a 100644 --- a/bolt-sidecar/src/state/execution.rs +++ b/bolt-sidecar/src/state/execution.rs @@ -7,17 +7,20 @@ use alloy::{ use reth_primitives::{revm_primitives::EnvKzgSettings, PooledTransactionsElement}; use std::{collections::HashMap, ops::Deref}; use thiserror::Error; -use tracing::{debug, trace, warn}; +use tracing::{debug, error, trace, warn}; use crate::{ builder::BlockTemplate, - common::{calculate_max_basefee, max_transaction_cost, validate_transaction}, + common::{ + score_cache::ScoreCache, + transactions::{calculate_max_basefee, max_transaction_cost, validate_transaction}, + }, config::limits::LimitsOpts, primitives::{AccountState, InclusionRequest, SignedConstraints, Slot}, telemetry::ApiMetrics, }; -use super::fetcher::StateFetcher; +use super::{account_state::AccountStateCache, fetcher::StateFetcher}; /// Possible commitment validation errors. /// @@ -109,12 +112,8 @@ impl ValidationError { Self::InsufficientBalance => "insufficient_balance", Self::Eip4844Limit => "eip4844_limit", Self::SlotTooLow(_) => "slot_too_low", - Self::MaxCommitmentsReachedForSlot(_, _) => { - "max_commitments_reached_for_slot" - } - Self::MaxCommittedGasReachedForSlot(_, _) => { - "max_committed_gas_reached_for_slot" - } + Self::MaxCommitmentsReachedForSlot(_, _) => "max_commitments_reached_for_slot", + Self::MaxCommittedGasReachedForSlot(_, _) => "max_committed_gas_reached_for_slot", Self::Signature(_) => "signature", Self::RecoverSigner => "recover_signer", Self::ChainIdMismatch => "chain_id_mismatch", @@ -146,10 +145,14 @@ pub struct ExecutionState { /// The cached account states. This should never be read directly. /// These only contain the canonical account states at the head block, /// not the intermediate states. - account_states: HashMap, + account_states: AccountStateCache, /// 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. + /// + /// INVARIANT: contains only entries for slots greater than or equal to the latest known beacon + /// chain head. + /// See [ExecutionState::remove_block_templates_until]. block_templates: HashMap, /// The chain ID of the chain (constant). chain_id: u64, @@ -192,6 +195,13 @@ impl ExecutionState { client.get_chain_id() )?; + // Calculate the number of account states that can be cached by diving the configured max + // size by the size of an account state and its key. + let num_accounts = limits + .max_account_states_size + .get() + .div_ceil(size_of::() + size_of::
()); + Ok(Self { basefee, blob_basefee, @@ -200,7 +210,7 @@ impl ExecutionState { limits, client, slot: 0, - account_states: HashMap::new(), + account_states: AccountStateCache(ScoreCache::with_max_len(num_accounts)), block_templates: HashMap::new(), // Load the default KZG settings kzg_settings: EnvKzgSettings::default(), @@ -350,7 +360,7 @@ impl ExecutionState { trace!(nonce_diff, %balance_diff, "Applying diffs to account state"); - let account_state = match self.account_state(sender).copied() { + 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 @@ -503,8 +513,13 @@ impl ExecutionState { self.block_number = update.block_number; self.basefee = update.min_basefee; - // `extend` will overwrite existing values. This is what we want. - self.account_states.extend(update.account_states); + for (address, state) in update.account_states { + let Some(prev_state) = self.account_states.get_mut(&address) else { + error!(%address, "Account state requested for update but not found in cache"); + continue; + }; + *prev_state = state + } self.refresh_templates(); } @@ -513,7 +528,7 @@ impl ExecutionState { /// transactions by checking the nonce and balance of the account after applying the state /// diffs. fn refresh_templates(&mut self) { - for (address, account_state) in &mut self.account_states { + for (address, (account_state, _)) in self.account_states.iter_mut() { trace!(%address, ?account_state, "Refreshing template..."); // Iterate over all block templates and apply the state diff for template in self.block_templates.values_mut() { @@ -532,11 +547,6 @@ impl ExecutionState { } } - /// Returns the cached account state for the given address - fn account_state(&self, address: &Address) -> Option<&AccountState> { - self.account_states.get(address) - } - /// 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) @@ -576,7 +586,10 @@ pub struct StateUpdate { #[cfg(test)] mod tests { - use crate::{builder::template::StateDiff, signer::local::LocalSigner}; + use crate::{ + builder::template::StateDiff, config::limits::DEFAULT_MAX_COMMITTED_GAS, + signer::local::LocalSigner, + }; use std::{num::NonZero, str::FromStr, time::Duration}; use alloy::{ @@ -805,12 +818,7 @@ mod tests { let anvil = launch_anvil(); let client = StateClient::new(anvil.endpoint_url()); - let limits = LimitsOpts { - max_commitments_per_slot: NonZero::new(10).unwrap(), - max_committed_gas_per_slot: NonZero::new(5_000_000).unwrap(), - min_priority_fee: 200000000, // 0.2 gwei - }; - + let limits = LimitsOpts::default(); let mut state = ExecutionState::new(client.clone(), limits).await?; let basefee = state.basefee(); @@ -845,9 +853,8 @@ mod tests { let client = StateClient::new(anvil.endpoint_url()); let limits = LimitsOpts { - max_commitments_per_slot: NonZero::new(10).unwrap(), max_committed_gas_per_slot: NonZero::new(5_000_000).unwrap(), - min_priority_fee: 2000000000, + ..Default::default() }; let mut state = ExecutionState::new(client.clone(), limits).await?; @@ -875,11 +882,7 @@ mod tests { let anvil = launch_anvil(); let client = StateClient::new(anvil.endpoint_url()); - let limits = LimitsOpts { - max_commitments_per_slot: NonZero::new(10).unwrap(), - max_committed_gas_per_slot: NonZero::new(5_000_000).unwrap(), - min_priority_fee: 2 * GWEI_TO_WEI as u128, - }; + let limits = LimitsOpts { min_priority_fee: 2 * GWEI_TO_WEI as u128, ..Default::default() }; let mut state = ExecutionState::new(client.clone(), limits).await?; @@ -917,11 +920,7 @@ mod tests { let anvil = launch_anvil(); let client = StateClient::new(anvil.endpoint_url()); - let limits = LimitsOpts { - max_commitments_per_slot: NonZero::new(10).unwrap(), - max_committed_gas_per_slot: NonZero::new(5_000_000).unwrap(), - min_priority_fee: 2 * GWEI_TO_WEI as u128, - }; + let limits = LimitsOpts { min_priority_fee: 2 * GWEI_TO_WEI as u128, ..Default::default() }; let mut state = ExecutionState::new(client.clone(), limits).await?; @@ -964,11 +963,7 @@ mod tests { let anvil = launch_anvil(); let client = StateClient::new(anvil.endpoint_url()); - let limits = LimitsOpts { - max_commitments_per_slot: NonZero::new(10).unwrap(), - max_committed_gas_per_slot: NonZero::new(5_000_000).unwrap(), - min_priority_fee: 2 * GWEI_TO_WEI as u128, - }; + let limits = LimitsOpts { min_priority_fee: 2 * GWEI_TO_WEI as u128, ..Default::default() }; let mut state = ExecutionState::new(client.clone(), limits).await?; @@ -1101,11 +1096,7 @@ mod tests { let anvil = launch_anvil(); let client = StateClient::new(anvil.endpoint_url()); - let limits: LimitsOpts = LimitsOpts { - max_commitments_per_slot: NonZero::new(10).unwrap(), - max_committed_gas_per_slot: NonZero::new(5_000_000).unwrap(), - min_priority_fee: 1000000000, - }; + let limits = LimitsOpts { min_priority_fee: 1000000000, ..Default::default() }; let mut state = ExecutionState::new(client.clone(), limits).await?; let sender = anvil.addresses().first().unwrap(); @@ -1115,7 +1106,8 @@ mod tests { let slot = client.get_head().await?; state.update_head(None, slot).await?; - let tx = default_test_transaction(*sender, None).with_gas_limit(4_999_999); + let tx = default_test_transaction(*sender, None) + .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?; @@ -1139,7 +1131,7 @@ mod tests { assert!(matches!( state.validate_request(&mut request).await, - Err(ValidationError::MaxCommittedGasReachedForSlot(_, 5_000_000)) + Err(ValidationError::MaxCommittedGasReachedForSlot(_, DEFAULT_MAX_COMMITTED_GAS)) )); Ok(()) diff --git a/bolt-sidecar/src/state/mod.rs b/bolt-sidecar/src/state/mod.rs index 5938ae6e3..c9870002b 100644 --- a/bolt-sidecar/src/state/mod.rs +++ b/bolt-sidecar/src/state/mod.rs @@ -23,6 +23,10 @@ pub use consensus::ConsensusState; pub mod head_tracker; pub use head_tracker::HeadTracker; +/// Module that defines the account state cache. +pub mod account_state; +pub use account_state::AccountStateCache; + /// The deadline for a which a commitment is considered valid. #[derive(Debug)] pub struct CommitmentDeadline { diff --git a/bolt-sidecar/src/telemetry/metrics.rs b/bolt-sidecar/src/telemetry/metrics.rs index f8026bb85..84b0105bd 100644 --- a/bolt-sidecar/src/telemetry/metrics.rs +++ b/bolt-sidecar/src/telemetry/metrics.rs @@ -28,6 +28,8 @@ const GROSS_TIP_REVENUE: &str = "bolt_sidecar_gross_tip_revenue"; // Gauges ------------------------------------------------------------------ /// Gauge for the latest slot number const LATEST_HEAD: &str = "bolt_sidecar_latest_head"; +/// Number of account states saved in cache. +const ACCOUNT_STATES: &str = "bolt_sidecar_account_states"; // Histograms -------------------------------------------------------------- /// Histogram for the total duration of HTTP requests in seconds. @@ -52,6 +54,7 @@ impl ApiMetrics { // Gauges describe_gauge!(LATEST_HEAD, "Latest slot number"); + describe_gauge!(ACCOUNT_STATES, "Number of account states saved in cache"); // Histograms describe_histogram!( @@ -119,6 +122,10 @@ impl ApiMetrics { gauge!(LATEST_HEAD).set(slot); } + pub fn set_account_states(count: usize) { + gauge!(ACCOUNT_STATES).set(count as f64); + } + /// Mixed ---------------------------------------------------------------- /// Observes the duration of an HTTP request by storing it in a histogram, diff --git a/bolt-sidecar/src/test_util.rs b/bolt-sidecar/src/test_util.rs index 45d76af69..3d7e3a25f 100644 --- a/bolt-sidecar/src/test_util.rs +++ b/bolt-sidecar/src/test_util.rs @@ -20,7 +20,7 @@ use secp256k1::Message; use tracing::warn; use crate::{ - common::{BlsSecretKeyWrapper, EcdsaSecretKeyWrapper, JwtSecretConfig}, + common::secrets::{BlsSecretKeyWrapper, EcdsaSecretKeyWrapper, JwtSecretConfig}, config::{ChainConfig, Opts}, crypto::{ecdsa::SignableECDSA, SignableBLS}, primitives::{