diff --git a/cmd/ethrex/cli.rs b/cmd/ethrex/cli.rs index 74a66aab1e4..c9634ba1a2b 100644 --- a/cmd/ethrex/cli.rs +++ b/cmd/ethrex/cli.rs @@ -10,7 +10,7 @@ use std::{ use clap::{ArgAction, Parser as ClapParser, Subcommand as ClapSubcommand}; use ethrex_blockchain::{ - BlockchainOptions, BlockchainType, L2Config, + BlockchainOptions, BlockchainType, DEFAULT_MIN_TIP_WEI, L2Config, error::{ChainError, InvalidBlockError}, }; use ethrex_common::types::{Block, DEFAULT_BUILDER_GAS_CEIL, Genesis, validate_block_body}; @@ -183,6 +183,15 @@ pub struct Options { env = "ETHREX_MEMPOOL_MAX_SIZE" )] pub mempool_max_size: usize, + #[arg( + help = "Minimum priority-fee cap (in wei) required for a transaction to be admitted into the mempool. Compared against the raw tip cap: `max_priority_fee_per_gas` for typed transactions, `gas_price` for legacy transactions (independent of current base fee, so admission stays stable as base fee oscillates). Set to 0 to disable the floor.", + long = "mempool.min-tip", + default_value_t = DEFAULT_MIN_TIP_WEI, + value_name = "MIN_TIP_WEI", + help_heading = "Node options", + env = "ETHREX_MEMPOOL_MIN_TIP" + )] + pub mempool_min_tip: u64, #[arg( long = "http.addr", default_value = "127.0.0.1", @@ -464,6 +473,7 @@ impl Default for Options { dev: Default::default(), force: false, mempool_max_size: Default::default(), + mempool_min_tip: DEFAULT_MIN_TIP_WEI, tx_broadcasting_time_interval: Default::default(), target_peers: Default::default(), lookup_interval: Default::default(), diff --git a/cmd/ethrex/initializers.rs b/cmd/ethrex/initializers.rs index 4f76b6eb5cd..04d7ed9c582 100644 --- a/cmd/ethrex/initializers.rs +++ b/cmd/ethrex/initializers.rs @@ -531,6 +531,7 @@ pub async fn init_l1( max_blobs_per_block: opts.max_blobs_per_block, precompute_witnesses: opts.precompute_witnesses, precompile_cache_enabled: !opts.no_precompile_cache, + min_tip_wei: opts.mempool_min_tip, }, ); diff --git a/cmd/ethrex/l2/initializers.rs b/cmd/ethrex/l2/initializers.rs index 668c1d9d55d..705ff059307 100644 --- a/cmd/ethrex/l2/initializers.rs +++ b/cmd/ethrex/l2/initializers.rs @@ -228,6 +228,7 @@ pub async fn init_l2( max_blobs_per_block: None, // L2 doesn't support blob transactions precompute_witnesses: opts.node_opts.precompute_witnesses, precompile_cache_enabled: true, + min_tip_wei: opts.node_opts.mempool_min_tip, }; let blockchain = init_blockchain(store.clone(), blockchain_opts.clone()); diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 8f3f301d8f6..0331eddbf98 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -211,6 +211,10 @@ pub struct Blockchain { merkle_pool: rayon::ThreadPool, } +/// Default min-tip floor (wei). Matches geth's mempool `PriceLimit = 1 wei`. +/// Effectively just rejects zero-tip transactions at admission. +pub const DEFAULT_MIN_TIP_WEI: u64 = 1; + /// Configuration options for the blockchain. #[derive(Debug, Clone)] pub struct BlockchainOptions { @@ -229,6 +233,10 @@ pub struct BlockchainOptions { /// warmer thread and the executor. Set to false (via `--no-precompile-cache`) to /// disable the cache for benchmarking purposes. pub precompile_cache_enabled: bool, + /// Minimum effective priority fee (in wei) required for a transaction to be + /// admitted into the mempool. Transactions below this floor are rejected at + /// admission. Set to 0 to disable the floor. + pub min_tip_wei: u64, } impl Default for BlockchainOptions { @@ -240,6 +248,7 @@ impl Default for BlockchainOptions { max_blobs_per_block: None, precompute_witnesses: false, precompile_cache_enabled: true, + min_tip_wei: DEFAULT_MIN_TIP_WEI, } } } @@ -342,13 +351,20 @@ impl Blockchain { } } + /// Test-permissive `Blockchain` constructor. Mirrors `BlockchainOptions::default` + /// but disables admission-policy gates (e.g. the min-tip floor) so that + /// unrelated tests don't need to set every mempool option explicitly. pub fn default_with_store(store: Store) -> Self { + let options = BlockchainOptions { + min_tip_wei: 0, + ..BlockchainOptions::default() + }; Self { storage: store, mempool: Mempool::new(MAX_MEMPOOL_SIZE_DEFAULT), is_synced: AtomicBool::new(false), payloads: Arc::new(TokioMutex::new(Vec::new())), - options: BlockchainOptions::default(), + options, merkle_pool: Self::build_merkle_pool(), } } @@ -2505,6 +2521,24 @@ impl Blockchain { return Err(MempoolError::TxTipAboveFeeCapError); } + // Admission-time minimum tip floor. Compares the raw tip cap + // (`max_priority_fee_per_gas` for typed txs, `gas_price` for legacy) + // against `min_tip_wei`, matching geth's `PriceLimit` check on + // `tx.GasTipCap()` and reth's check on `max_priority_fee_per_gas`. + // Using the raw tip cap keeps the admission decision independent of + // the current base fee, so a tx that paid the floor at admission + // doesn't get reclassified as under-floor when base fee oscillates. + // A floor of 0 disables the check. + if self.options.min_tip_wei > 0 { + let tip_cap = u64::try_from(tx.gas_tip_cap()).unwrap_or(u64::MAX); + if tip_cap < self.options.min_tip_wei { + return Err(MempoolError::TipBelowMinimum { + actual: tip_cap, + limit: self.options.min_tip_wei, + }); + } + } + // Check that the gas limit covers the gas needs for transaction metadata. if tx.gas_limit() < mempool::transaction_intrinsic_gas(tx, &header, &config)? { return Err(MempoolError::TxIntrinsicGasCostAboveLimitError); diff --git a/crates/blockchain/error.rs b/crates/blockchain/error.rs index 3bb0ea553fa..f84fba7cee7 100644 --- a/crates/blockchain/error.rs +++ b/crates/blockchain/error.rs @@ -83,6 +83,8 @@ pub enum MempoolError { TxMaxInitCodeSizeError, #[error("Transaction encoded size ({actual} bytes) exceeds the {limit}-byte limit")] TxSizeExceeded { actual: usize, limit: usize }, + #[error("Tip cap {actual} wei below the configured minimum of {limit} wei")] + TipBelowMinimum { actual: u64, limit: u64 }, #[error("Transaction gas limit exceeded")] TxGasLimitExceededError, #[error( diff --git a/crates/common/types/constants.rs b/crates/common/types/constants.rs index 3d5c78500d6..b583cf81a8b 100644 --- a/crates/common/types/constants.rs +++ b/crates/common/types/constants.rs @@ -12,10 +12,9 @@ pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01; // Defined in [EIP-4844](https: /// Minimum tip, obtained from geth's default miner config (https://github.com/ethereum/go-ethereum/blob/f750117ad19d623622cc4a46ea361a716ba7407e/miner/miner.go#L56) /// /// Scope: this constant is consumed only by the RPC gas-price estimators -/// (`eth_gasPrice`, `eth_maxPriorityFeePerGas`). It is NOT a mempool -/// admission gate — zero-tip transactions are currently admitted. -/// -/// TODO: This should be configurable along with the tip filter on https://github.com/lambdaclass/ethrex/issues/680 +/// (`eth_gasPrice`, `eth_maxPriorityFeePerGas`). The mempool admission +/// floor is a separate, lower default (see +/// `ethrex_blockchain::DEFAULT_MIN_TIP_WEI`). pub const MIN_GAS_TIP: u64 = 1000000; // Blob size related diff --git a/docs/CLI.md b/docs/CLI.md index 3456578a0f7..ee40fba6a88 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -91,6 +91,12 @@ Node options: [env: ETHREX_MEMPOOL_MAX_SIZE=] [default: 10000] + --mempool.min-tip + Minimum priority-fee cap (in wei) required for a transaction to be admitted into the mempool. Compared against the raw tip cap: `max_priority_fee_per_gas` for typed transactions, `gas_price` for legacy transactions (independent of current base fee, so admission stays stable as base fee oscillates). Set to 0 to disable the floor. + + [env: ETHREX_MEMPOOL_MIN_TIP=] + [default: 1] + --precompute-witnesses Once synced, computes execution witnesses upon receiving newPayload messages and stores them in local storage diff --git a/test/tests/blockchain/mempool_tests.rs b/test/tests/blockchain/mempool_tests.rs index 93f2b5ff388..51f735c662d 100644 --- a/test/tests/blockchain/mempool_tests.rs +++ b/test/tests/blockchain/mempool_tests.rs @@ -1,4 +1,5 @@ use ethrex_blockchain::Blockchain; +use ethrex_blockchain::BlockchainOptions; use ethrex_blockchain::constants::MAX_INITCODE_SIZE; use ethrex_blockchain::constants::{ TX_ACCESS_LIST_ADDRESS_GAS, TX_ACCESS_LIST_STORAGE_KEY_GAS, TX_CREATE_GAS_COST, @@ -548,3 +549,254 @@ fn blobs_bundle_insert_and_remove() { vec![None] ); } + +// --- min-tip floor admission tests ---------------------------------------- + +/// Builds a blockchain configured with `min_tip_wei` as the admission floor +/// (everything else permissive for tests). +fn blockchain_with_min_tip(store: Store, min_tip_wei: u64) -> Blockchain { + let mut bc = Blockchain::default_with_store(store); + let mut opts = bc.options.clone(); + opts.min_tip_wei = min_tip_wei; + bc.options = opts; + bc +} + +#[tokio::test] +async fn zero_tip_eip1559_rejected_under_default_floor() { + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + let blockchain = blockchain_with_min_tip(store, 1_000_000); + + let tx = EIP1559Transaction { + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000, + gas_limit: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }; + let tx = Transaction::EIP1559Transaction(tx); + + let res = blockchain + .validate_transaction(&tx, Address::random()) + .await; + assert!(matches!( + res, + Err(MempoolError::TipBelowMinimum { + actual: 0, + limit: 1_000_000, + }), + )); +} + +#[tokio::test] +async fn at_floor_eip1559_passes_tip_check() { + // Tip at the floor passes the min-tip check. The random sender has no + // funds, so the tx fails later with `NotEnoughBalance`; that's the only + // accepted post-tip-check outcome. Asserting the specific downstream + // error guards against a future refactor that accidentally skips the + // tip check (where a different error would still satisfy a `!matches!` + // negative assertion). + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + let blockchain = blockchain_with_min_tip(store, 1_000_000); + + let tx = EIP1559Transaction { + max_priority_fee_per_gas: 1_000_000, + max_fee_per_gas: 1_000_000, + gas_limit: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }; + let tx = Transaction::EIP1559Transaction(tx); + + let res = blockchain + .validate_transaction(&tx, Address::random()) + .await; + // The tip check itself must not fire; the downstream account-lookup + // (state root or balance) is what should fail in this minimal setup. + // Asserting on the concrete next-stage error keeps the test honest if + // a future refactor accidentally skips the tip check. + assert!( + matches!( + res, + Err(MempoolError::NotEnoughBalance) | Err(MempoolError::StoreError(_)) + ), + "expected the tip check to pass and an account-lookup error to fire next, got {res:?}", + ); +} + +#[tokio::test] +async fn floor_of_zero_admits_zero_tip() { + // Operators can disable the floor with --mempool.min-tip 0. Same + // structure as `at_floor_eip1559_passes_tip_check`: we want a specific + // downstream error (the balance check) to fire, NOT just "any error + // other than TipBelowMinimum". + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + let blockchain = blockchain_with_min_tip(store, 0); + + let tx = EIP1559Transaction { + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }; + let tx = Transaction::EIP1559Transaction(tx); + + let res = blockchain + .validate_transaction(&tx, Address::random()) + .await; + assert!( + matches!( + res, + Err(MempoolError::NotEnoughBalance) | Err(MempoolError::StoreError(_)) + ), + "expected the tip check to skip (floor=0) and an account-lookup error to fire next, got {res:?}", + ); +} + +#[tokio::test] +async fn legacy_gas_price_below_floor_rejected() { + // Legacy tx: `gas_tip_cap()` returns `gas_price` so the floor applies to + // the raw gas price for legacy txs (geth applies `PriceLimit` to + // `tx.GasTipCap()` which is `gas_price` for legacy). + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + let blockchain = blockchain_with_min_tip(store, 1_000_000); + + let tx = ethrex_common::types::LegacyTransaction { + gas_price: U256::from(999_999u64), // 1 wei below floor + gas: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }; + let tx = Transaction::LegacyTransaction(tx); + + let res = blockchain + .validate_transaction(&tx, Address::random()) + .await; + assert!(matches!( + res, + Err(MempoolError::TipBelowMinimum { + actual: 999_999, + limit: 1_000_000, + }), + )); +} + +#[tokio::test] +async fn options_field_is_used_in_validate_transaction() { + // Smoke test that BlockchainOptions::min_tip_wei is consulted (not + // accidentally ignored if the option-plumbing breaks). + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + + let mut bc = Blockchain::default_with_store(store); + bc.options = BlockchainOptions { + min_tip_wei: 5_000_000_000, // 5 gwei + ..BlockchainOptions::default() + }; + + let tx = EIP1559Transaction { + max_priority_fee_per_gas: 1_000_000_000, // 1 gwei (below 5 gwei) + max_fee_per_gas: 5_000_000_000, + gas_limit: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }; + let tx = Transaction::EIP1559Transaction(tx); + + let res = bc.validate_transaction(&tx, Address::random()).await; + assert!(matches!( + res, + Err(MempoolError::TipBelowMinimum { + actual: 1_000_000_000, + limit: 5_000_000_000, + }), + )); +} + +#[tokio::test] +async fn shipped_default_floor_rejects_zero_tip_admits_one() { + // Pin the actual shipped default (`DEFAULT_MIN_TIP_WEI = 1`, matching + // geth's `PriceLimit = 1 wei`). Without this test the default could + // silently regress to 0 (admit-everything) or to the old 1 Mwei value. + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + let blockchain = blockchain_with_min_tip(store, ethrex_blockchain::DEFAULT_MIN_TIP_WEI); + + // tip = 0 → rejected + let zero = Transaction::EIP1559Transaction(EIP1559Transaction { + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1, + gas_limit: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }); + assert!(matches!( + blockchain + .validate_transaction(&zero, Address::random()) + .await, + Err(MempoolError::TipBelowMinimum { + actual: 0, + limit: 1 + }), + )); + + // tip = 1 → passes the tip check; sender has no funds so the next + // failure is `NotEnoughBalance`. Assert that specifically rather than + // "not TipBelowMinimum" so the test catches accidental skip of the + // tip check in future refactors. + let one = Transaction::EIP1559Transaction(EIP1559Transaction { + max_priority_fee_per_gas: 1, + max_fee_per_gas: 1, + gas_limit: 50_000_000, + to: TxKind::Call(Address::from_low_u64_be(1)), + ..Default::default() + }); + let res = blockchain + .validate_transaction(&one, Address::random()) + .await; + // The tip check itself must not fire; the downstream account-lookup + // (state root or balance) is what should fail in this minimal setup. + // Asserting on the concrete next-stage error keeps the test honest if + // a future refactor accidentally skips the tip check. + assert!( + matches!( + res, + Err(MempoolError::NotEnoughBalance) | Err(MempoolError::StoreError(_)) + ), + "expected the tip check to pass and an account-lookup error to fire next, got {res:?}", + ); +} + +#[tokio::test] +async fn blob_tx_under_floor_rejected() { + // EIP-4844 path uses the same `gas_tip_cap()` accessor. Confirm an + // under-floor blob tx is also rejected so a regression in the per-type + // dispatch wouldn't slip through. + let (config, header) = build_basic_config_and_header(false, false); + let store = setup_storage(config, header).await.expect("Storage setup"); + let blockchain = blockchain_with_min_tip(store, 1_000_000); + + let tx = Transaction::EIP4844Transaction(EIP4844Transaction { + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000, + max_fee_per_blob_gas: 1.into(), + gas: 50_000_000, + to: Address::from_low_u64_be(1), + ..Default::default() + }); + + assert!(matches!( + blockchain + .validate_transaction(&tx, Address::random()) + .await, + Err(MempoolError::TipBelowMinimum { + actual: 0, + limit: 1_000_000, + }), + )); +} diff --git a/tooling/reorgs/src/simulator.rs b/tooling/reorgs/src/simulator.rs index 73145490c43..3e7e143af87 100644 --- a/tooling/reorgs/src/simulator.rs +++ b/tooling/reorgs/src/simulator.rs @@ -127,6 +127,7 @@ impl Simulator { format!("--network={}", self.genesis_path.display()), format!("--syncmode={:?}", opts.syncmode).to_lowercase(), "--force".to_string(), + "--mempool.min-tip=0".to_string(), ]) .stdin(Stdio::null()) .stdout(logs_file.try_clone().unwrap())