diff --git a/book/cli/reth/node.md b/book/cli/reth/node.md index b11b97f2d6..f63981766a 100644 --- a/book/cli/reth/node.md +++ b/book/cli/reth/node.md @@ -362,6 +362,11 @@ RPC: [default: 50000000] + --rpc.txfeecap + Maximum eth transaction fee that can be sent via the RPC APIs (0 = no cap) + + [default: 1.0] + --rpc.max-simulate-blocks Maximum number of blocks for `eth_simulateV1` call diff --git a/crates/cli/util/src/lib.rs b/crates/cli/util/src/lib.rs index a5bc2452fe..a82c3ba57f 100644 --- a/crates/cli/util/src/lib.rs +++ b/crates/cli/util/src/lib.rs @@ -18,7 +18,7 @@ pub use load_secret_key::get_secret_key; pub mod parsers; pub use parsers::{ hash_or_num_value_parser, parse_duration_from_secs, parse_duration_from_secs_or_ms, - parse_socket_address, + parse_ether_value, parse_socket_address, }; #[cfg(all(unix, any(target_env = "gnu", target_os = "macos")))] diff --git a/crates/cli/util/src/parsers.rs b/crates/cli/util/src/parsers.rs index b4565333bd..ddb120452e 100644 --- a/crates/cli/util/src/parsers.rs +++ b/crates/cli/util/src/parsers.rs @@ -89,6 +89,23 @@ pub fn read_json_from_file(path: &str) -> Result reth_fs_util::read_json_file(Path::new(path)) } +/// Parses an ether value from a string. +/// +/// The amount in eth like "1.05" will be interpreted in wei (1.05 * 1e18). +/// Supports both decimal and integer inputs. +/// +/// # Examples +/// - "1.05" -> 1.05 ETH = 1.05 * 10^18 wei +/// - "2" -> 2 ETH = 2 * 10^18 wei +pub fn parse_ether_value(value: &str) -> eyre::Result { + let eth = value.parse::()?; + if eth.is_sign_negative() { + return Err(eyre::eyre!("Ether value cannot be negative")) + } + let wei = eth * 1e18; + Ok(wei as u128) +} + #[cfg(test)] mod tests { use super::*; @@ -131,4 +148,25 @@ mod tests { assert!(parse_duration_from_secs_or_ms("5ns").is_err()); } + + #[test] + fn parse_ether_values() { + // Test basic decimal value + let wei = parse_ether_value("1.05").unwrap(); + assert_eq!(wei, 1_050_000_000_000_000_000u128); + + // Test integer value + let wei = parse_ether_value("2").unwrap(); + assert_eq!(wei, 2_000_000_000_000_000_000u128); + + // Test zero + let wei = parse_ether_value("0").unwrap(); + assert_eq!(wei, 0); + + // Test negative value fails + assert!(parse_ether_value("-1").is_err()); + + // Test invalid input fails + assert!(parse_ether_value("abc").is_err()); + } } diff --git a/crates/ethereum/node/src/node.rs b/crates/ethereum/node/src/node.rs index 2f80bf9752..b3393812cd 100644 --- a/crates/ethereum/node/src/node.rs +++ b/crates/ethereum/node/src/node.rs @@ -363,6 +363,7 @@ where .with_head_timestamp(ctx.head().timestamp) .kzg_settings(ctx.kzg_settings()?) .with_local_transactions_config(pool_config.local_transactions_config.clone()) + .set_tx_fee_cap(ctx.config().rpc.rpc_tx_fee_cap) .with_additional_tasks(ctx.config().txpool.additional_validation_tasks) .build_with_tasks(ctx.task_executor().clone(), blob_store.clone()); diff --git a/crates/node/core/src/args/rpc_server.rs b/crates/node/core/src/args/rpc_server.rs index f83adea208..1835c4910a 100644 --- a/crates/node/core/src/args/rpc_server.rs +++ b/crates/node/core/src/args/rpc_server.rs @@ -14,6 +14,7 @@ use clap::{ Arg, Args, Command, }; use rand::Rng; +use reth_cli_util::parse_ether_value; use reth_rpc_server_types::{constants, RethRpcModule, RpcModuleSelection}; use crate::args::{ @@ -169,6 +170,16 @@ pub struct RpcServerArgs { )] pub rpc_gas_cap: u64, + /// Maximum eth transaction fee that can be sent via the RPC APIs (0 = no cap) + #[arg( + long = "rpc.txfeecap", + alias = "rpc-txfeecap", + value_name = "TX_FEE_CAP", + value_parser = parse_ether_value, + default_value = "1.0" + )] + pub rpc_tx_fee_cap: u128, + /// Maximum number of blocks for `eth_simulateV1` call. #[arg( long = "rpc.max-simulate-blocks", @@ -329,6 +340,7 @@ impl Default for RpcServerArgs { rpc_max_blocks_per_filter: constants::DEFAULT_MAX_BLOCKS_PER_FILTER.into(), rpc_max_logs_per_response: (constants::DEFAULT_MAX_LOGS_PER_RESPONSE as u64).into(), rpc_gas_cap: constants::gas_oracle::RPC_DEFAULT_GAS_CAP, + rpc_tx_fee_cap: constants::DEFAULT_TX_FEE_CAP_WEI, rpc_max_simulate_blocks: constants::DEFAULT_MAX_SIMULATE_BLOCKS, rpc_eth_proof_window: constants::DEFAULT_ETH_PROOF_WINDOW, gas_price_oracle: GasPriceOracleArgs::default(), @@ -422,4 +434,32 @@ mod tests { assert_eq!(args, default_args); } + + #[test] + fn test_rpc_tx_fee_cap_parse_integer() { + let args = CommandParser::::parse_from(["reth", "--rpc.txfeecap", "2"]).args; + let expected = 2_000_000_000_000_000_000u128; // 2 ETH in wei + assert_eq!(args.rpc_tx_fee_cap, expected); + } + + #[test] + fn test_rpc_tx_fee_cap_parse_decimal() { + let args = + CommandParser::::parse_from(["reth", "--rpc.txfeecap", "1.5"]).args; + let expected = 1_500_000_000_000_000_000u128; // 1.5 ETH in wei + assert_eq!(args.rpc_tx_fee_cap, expected); + } + + #[test] + fn test_rpc_tx_fee_cap_parse_zero() { + let args = CommandParser::::parse_from(["reth", "--rpc.txfeecap", "0"]).args; + assert_eq!(args.rpc_tx_fee_cap, 0); // 0 = no cap + } + + #[test] + fn test_rpc_tx_fee_cap_parse_none() { + let args = CommandParser::::parse_from(["reth"]).args; + let expected = 1_000_000_000_000_000_000u128; + assert_eq!(args.rpc_tx_fee_cap, expected); // 1 ETH default cap + } } diff --git a/crates/optimism/node/src/node.rs b/crates/optimism/node/src/node.rs index ee23a82e97..d9aa218ba8 100644 --- a/crates/optimism/node/src/node.rs +++ b/crates/optimism/node/src/node.rs @@ -560,6 +560,7 @@ where .no_eip4844() .with_head_timestamp(ctx.head().timestamp) .kzg_settings(ctx.kzg_settings()?) + .set_tx_fee_cap(ctx.config().rpc.rpc_tx_fee_cap) .with_additional_tasks( pool_config_overrides .additional_validation_tasks diff --git a/crates/rpc/rpc-eth-types/src/error/mod.rs b/crates/rpc/rpc-eth-types/src/error/mod.rs index 3d757f3cc2..9b7014f35d 100644 --- a/crates/rpc/rpc-eth-types/src/error/mod.rs +++ b/crates/rpc/rpc-eth-types/src/error/mod.rs @@ -685,6 +685,15 @@ pub enum RpcPoolError { /// When the transaction exceeds the block gas limit #[error("exceeds block gas limit")] ExceedsGasLimit, + /// Thrown when a new transaction is added to the pool, but then immediately discarded to + /// respect the tx fee exceeds the configured cap + #[error("tx fee ({max_tx_fee_wei} wei) exceeds the configured cap ({tx_fee_cap_wei} wei)")] + ExceedsFeeCap { + /// max fee in wei of new tx submitted to the pull (e.g. 0.11534 ETH) + max_tx_fee_wei: u128, + /// configured tx fee cap in wei (e.g. 1.0 ETH) + tx_fee_cap_wei: u128, + }, /// When a negative value is encountered #[error("negative value")] NegativeValue, @@ -750,6 +759,9 @@ impl From for RpcPoolError { match err { InvalidPoolTransactionError::Consensus(err) => Self::Invalid(err.into()), InvalidPoolTransactionError::ExceedsGasLimit(_, _) => Self::ExceedsGasLimit, + InvalidPoolTransactionError::ExceedsFeeCap { max_tx_fee_wei, tx_fee_cap_wei } => { + Self::ExceedsFeeCap { max_tx_fee_wei, tx_fee_cap_wei } + } InvalidPoolTransactionError::ExceedsMaxInitCodeSize(_, _) => { Self::ExceedsMaxInitCodeSize } diff --git a/crates/rpc/rpc-server-types/src/constants.rs b/crates/rpc/rpc-server-types/src/constants.rs index a30405d95d..46dac33ba1 100644 --- a/crates/rpc/rpc-server-types/src/constants.rs +++ b/crates/rpc/rpc-server-types/src/constants.rs @@ -54,6 +54,9 @@ pub const DEFAULT_MAX_SIMULATE_BLOCKS: u64 = 256; /// The default eth historical proof window. pub const DEFAULT_ETH_PROOF_WINDOW: u64 = 0; +/// The default eth tx fee cap is 1 ETH +pub const DEFAULT_TX_FEE_CAP_WEI: u128 = 1_000_000_000_000_000_000u128; + /// Maximum eth historical proof window. Equivalent to roughly 6 months of data on a 12 /// second block time, and a month on a 2 second block time. pub const MAX_ETH_PROOF_WINDOW: u64 = 28 * 24 * 60 * 60 / 2; diff --git a/crates/transaction-pool/src/error.rs b/crates/transaction-pool/src/error.rs index 8a0aac8bd2..6ef357d6b7 100644 --- a/crates/transaction-pool/src/error.rs +++ b/crates/transaction-pool/src/error.rs @@ -200,6 +200,15 @@ pub enum InvalidPoolTransactionError { #[error("transaction's gas limit {0} exceeds block's gas limit {1}")] ExceedsGasLimit(u64, u64), /// Thrown when a new transaction is added to the pool, but then immediately discarded to + /// respect the tx fee exceeds the configured cap + #[error("tx fee ({max_tx_fee_wei} wei) exceeds the configured cap ({tx_fee_cap_wei} wei)")] + ExceedsFeeCap { + /// max fee in wei of new tx submitted to the pull (e.g. 0.11534 ETH) + max_tx_fee_wei: u128, + /// configured tx fee cap in wei (e.g. 1.0 ETH) + tx_fee_cap_wei: u128, + }, + /// Thrown when a new transaction is added to the pool, but then immediately discarded to /// respect the `max_init_code_size`. #[error("transaction's input size {0} exceeds max_init_code_size {1}")] ExceedsMaxInitCodeSize(usize, usize), @@ -287,6 +296,7 @@ impl InvalidPoolTransactionError { } } Self::ExceedsGasLimit(_, _) => true, + Self::ExceedsFeeCap { max_tx_fee_wei: _, tx_fee_cap_wei: _ } => true, Self::ExceedsMaxInitCodeSize(_, _) => true, Self::OversizedData(_, _) => true, Self::Underpriced => { diff --git a/crates/transaction-pool/src/validate/eth.rs b/crates/transaction-pool/src/validate/eth.rs index 0781b6c72a..2d1d745f57 100644 --- a/crates/transaction-pool/src/validate/eth.rs +++ b/crates/transaction-pool/src/validate/eth.rs @@ -168,6 +168,8 @@ pub(crate) struct EthTransactionValidatorInner { eip7702: bool, /// The current max gas limit block_gas_limit: AtomicU64, + /// The current tx fee cap limit in wei locally submitted into the pool. + tx_fee_cap: Option, /// Minimum priority fee to enforce for acceptance into the pool. minimum_priority_fee: Option, /// Stores the setup and parameters needed for validating KZG proofs. @@ -297,9 +299,35 @@ where ) } + // determine whether the transaction should be treated as local + let is_local = self.local_transactions_config.is_local(origin, transaction.sender_ref()); + + // Ensure max possible transaction fee doesn't exceed configured transaction fee cap. + // Only for transactions locally submitted for acceptance into the pool. + if is_local { + match self.tx_fee_cap { + Some(0) | None => {} // Skip if cap is 0 or None + Some(tx_fee_cap_wei) => { + // max possible tx fee is (gas_price * gas_limit) + // (if EIP1559) max possible tx fee is (max_fee_per_gas * gas_limit) + let gas_price = transaction.max_fee_per_gas(); + let max_tx_fee_wei = gas_price.saturating_mul(transaction.gas_limit() as u128); + if max_tx_fee_wei > tx_fee_cap_wei { + return TransactionValidationOutcome::Invalid( + transaction, + InvalidPoolTransactionError::ExceedsFeeCap { + max_tx_fee_wei, + tx_fee_cap_wei, + }, + ); + } + } + } + } + // Drop non-local transactions with a fee lower than the configured fee for acceptance into // the pool. - if !self.local_transactions_config.is_local(origin, transaction.sender_ref()) && + if !is_local && transaction.is_dynamic_fee() && transaction.max_priority_fee_per_gas() < self.minimum_priority_fee { @@ -590,6 +618,8 @@ pub struct EthTransactionValidatorBuilder { eip7702: bool, /// The current max gas limit block_gas_limit: AtomicU64, + /// The current tx fee cap limit in wei locally submitted into the pool. + tx_fee_cap: Option, /// Minimum priority fee to enforce for acceptance into the pool. minimum_priority_fee: Option, /// Determines how many additional tasks to spawn @@ -623,7 +653,7 @@ impl EthTransactionValidatorBuilder { kzg_settings: EnvKzgSettings::Default, local_transactions_config: Default::default(), max_tx_input_bytes: DEFAULT_MAX_TX_INPUT_BYTES, - + tx_fee_cap: Some(1e18 as u128), // by default all transaction types are allowed eip2718: true, eip1559: true, @@ -770,6 +800,14 @@ impl EthTransactionValidatorBuilder { self } + /// Sets the block gas limit + /// + /// Transactions with a gas limit greater than this will be rejected. + pub fn set_tx_fee_cap(mut self, tx_fee_cap: u128) -> Self { + self.tx_fee_cap = Some(tx_fee_cap); + self + } + /// Builds a the [`EthTransactionValidator`] without spawning validator tasks. pub fn build(self, blob_store: S) -> EthTransactionValidator where @@ -785,6 +823,7 @@ impl EthTransactionValidatorBuilder { eip4844, eip7702, block_gas_limit, + tx_fee_cap, minimum_priority_fee, kzg_settings, local_transactions_config, @@ -813,6 +852,7 @@ impl EthTransactionValidatorBuilder { eip4844, eip7702, block_gas_limit, + tx_fee_cap, minimum_priority_fee, blob_store: Box::new(blob_store), kzg_settings, @@ -1035,4 +1075,77 @@ mod tests { let tx = pool.get(transaction.hash()); assert!(tx.is_none()); } + + #[tokio::test] + async fn invalid_on_fee_cap_exceeded() { + let transaction = get_transaction(); + let provider = MockEthProvider::default(); + provider.add_account( + transaction.sender(), + ExtendedAccount::new(transaction.nonce(), U256::MAX), + ); + + let blob_store = InMemoryBlobStore::default(); + let validator = EthTransactionValidatorBuilder::new(provider) + .set_tx_fee_cap(100) // 100 wei cap + .build(blob_store.clone()); + + let outcome = validator.validate_one(TransactionOrigin::Local, transaction.clone()); + assert!(outcome.is_invalid()); + + if let TransactionValidationOutcome::Invalid(_, err) = outcome { + assert!(matches!( + err, + InvalidPoolTransactionError::ExceedsFeeCap { max_tx_fee_wei, tx_fee_cap_wei } + if (max_tx_fee_wei > tx_fee_cap_wei) + )); + } + + let pool = + Pool::new(validator, CoinbaseTipOrdering::default(), blob_store, Default::default()); + let res = pool.add_transaction(TransactionOrigin::Local, transaction.clone()).await; + assert!(res.is_err()); + assert!(matches!( + res.unwrap_err().kind, + PoolErrorKind::InvalidTransaction(InvalidPoolTransactionError::ExceedsFeeCap { .. }) + )); + let tx = pool.get(transaction.hash()); + assert!(tx.is_none()); + } + + #[tokio::test] + async fn valid_on_zero_fee_cap() { + let transaction = get_transaction(); + let provider = MockEthProvider::default(); + provider.add_account( + transaction.sender(), + ExtendedAccount::new(transaction.nonce(), U256::MAX), + ); + + let blob_store = InMemoryBlobStore::default(); + let validator = EthTransactionValidatorBuilder::new(provider) + .set_tx_fee_cap(0) // no cap + .build(blob_store); + + let outcome = validator.validate_one(TransactionOrigin::Local, transaction); + assert!(outcome.is_valid()); + } + + #[tokio::test] + async fn valid_on_normal_fee_cap() { + let transaction = get_transaction(); + let provider = MockEthProvider::default(); + provider.add_account( + transaction.sender(), + ExtendedAccount::new(transaction.nonce(), U256::MAX), + ); + + let blob_store = InMemoryBlobStore::default(); + let validator = EthTransactionValidatorBuilder::new(provider) + .set_tx_fee_cap(2e18 as u128) // 2 ETH cap + .build(blob_store); + + let outcome = validator.validate_one(TransactionOrigin::Local, transaction); + assert!(outcome.is_valid()); + } }