feat: adding cli --rpc.txfeecap flag (#15654)

Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
This commit is contained in:
rrrengineer
2025-04-15 11:30:12 -07:00
committed by GitHub
parent a21769686c
commit 4850bd5ebc
10 changed files with 226 additions and 3 deletions

View File

@@ -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")))]

View File

@@ -89,6 +89,23 @@ pub fn read_json_from_file<T: serde::de::DeserializeOwned>(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<u128> {
let eth = value.parse::<f64>()?;
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());
}
}

View File

@@ -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());

View File

@@ -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::<RpcServerArgs>::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::<RpcServerArgs>::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::<RpcServerArgs>::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::<RpcServerArgs>::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
}
}

View File

@@ -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

View File

@@ -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<InvalidPoolTransactionError> 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
}

View File

@@ -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;

View File

@@ -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 => {

View File

@@ -168,6 +168,8 @@ pub(crate) struct EthTransactionValidatorInner<Client, T> {
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<u128>,
/// Minimum priority fee to enforce for acceptance into the pool.
minimum_priority_fee: Option<u128>,
/// 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<Client> {
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<u128>,
/// Minimum priority fee to enforce for acceptance into the pool.
minimum_priority_fee: Option<u128>,
/// Determines how many additional tasks to spawn
@@ -623,7 +653,7 @@ impl<Client> EthTransactionValidatorBuilder<Client> {
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<Client> EthTransactionValidatorBuilder<Client> {
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<Tx, S>(self, blob_store: S) -> EthTransactionValidator<Client, Tx>
where
@@ -785,6 +823,7 @@ impl<Client> EthTransactionValidatorBuilder<Client> {
eip4844,
eip7702,
block_gas_limit,
tx_fee_cap,
minimum_priority_fee,
kzg_settings,
local_transactions_config,
@@ -813,6 +852,7 @@ impl<Client> EthTransactionValidatorBuilder<Client> {
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());
}
}