> For the complete documentation index, see [llms.txt](https://reports.immunefi.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://reports.immunefi.com/base/74725-bc-insight-post-isthmus-txpool-admission-omits-operator-fee-affordability-causing-repeated-pro.md).

# 74725 bc insight post isthmus txpool admission omits operator fee affordability causing repeated processing of unexecutable mempool transactions

**Submitted on Apr 24th 2026 at 14:32:15 UTC by @OxPrince for** [**Audit Comp | Base Azul**](https://immunefi.com/audit-competition/audit-comp-base-azul)

* **Report ID:** #74725
* **Report Type:** Blockchain/DLT
* **Report severity:** Insight
* **Target:** <https://github.com/base/base/tree/v0.8.0-rc.24>
* **Impacts:**
  * Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours

## Description

### Brief/Intro

After Isthmus is active, Base execution charges each non-deposit transaction an additional operator fee. The txpool admission check does not include this operator fee when deciding whether the sender can afford the transaction. As a result, an ordinary sender can submit transactions that pass txpool validation but are underfunded for real execution. These transactions remain pending and are repeatedly returned by fresh txpool scans, causing network processing nodes to spend repeated work on transactions that should have been rejected before entering the pool.

### Vulnerability Details

The bug is a mismatch between the txpool-side affordability check and the execution-side affordability check.

In `crates/execution/txpool/src/validator.rs`, `OpTransactionValidator::apply_op_checks` adds only the L1 data fee to the normal transaction cost:

```rust
let cost_addition = match l1_block_info.l1_tx_data_fee(
    self.chain_spec(),
    self.block_timestamp(),
    &encoded,
    false,
) {
    Ok(cost) => cost,
    Err(err) => {
        return TransactionValidationOutcome::Error(*valid_tx.hash(), Box::new(err));
    }
};
let cost = valid_tx.transaction().cost().saturating_add(cost_addition);
```

This means txpool admission checks:

```
tx.cost + l1_tx_data_fee
```

However, the execution path charges the full additional Optimism/Base cost. In `crates/common/evm/src/handler.rs`, execution calls `chain.tx_cost_with_tx(tx, spec)` and subtracts that amount from the sender balance before executing the transaction. In `crates/common/evm/src/l1block.rs`, `tx_cost_with_tx` delegates to `tx_cost`, which adds the operator fee when Isthmus is enabled:

```rust
let mut additional_cost = self.calculate_tx_l1_cost(enveloped_tx, spec);

if spec.is_enabled_in(OpSpecId::ISTHMUS) {
    let operator_fee_charge = self.operator_fee_charge(enveloped_tx, gas_limit, spec);
    additional_cost = additional_cost.saturating_add(operator_fee_charge);
}
```

So execution requires:

```
tx.cost + l1_tx_data_fee + operator_fee
```

This creates an exploitable affordability window. A sender can fund an account with exactly enough balance for `tx.cost + l1_tx_data_fee`, but not enough for `tx.cost + l1_tx_data_fee + operator_fee`. The txpool accepts the transaction because it uses the smaller cost. Execution later rejects it because the full post-Isthmus cost cannot be paid.

This is not a user mistake or a configuration issue. The sender uses an ordinary externally submitted EIP-1559 transaction. The invalid admission happens because txpool validation omits a fee component that execution correctly enforces after Isthmus.

The repeated-work path is also present in the normal node flow:

* the txpool consumer creates a fresh `best_transactions()` iterator on every loop;
* the payload builder creates a fresh best-transaction iterator for each payload build attempt;
* invalid payload-builder candidates are marked invalid only for the current iterator pass, so that does not remove the underlying transaction from the txpool.

### Impact Details

The practical impact is wasted network node work:

* transactions that should fail admission enter the pending pool;
* txpool scans keep resurfacing those transactions;
* payload-building paths can repeatedly consider and attempt invalid candidates;
* each rejected candidate consumes node CPU and processing time before being skipped for that iterator pass.

This does not require privileged access, sequencer control, admin mistakes, or invalid protocol configuration. An attacker only needs to create ordinary transactions from accounts funded into the mismatch window:

```
tx.cost + l1_tx_data_fee <= balance < tx.cost + l1_tx_data_fee + operator_fee
```

The attack does not cause theft and does not make invalid transactions successfully execute. Execution correctly rejects the transactions. The vulnerability is that txpool admission under-meters post-Isthmus affordability and therefore admits transactions that downstream network processing should never have needed to handle.

## References

* `crates/execution/txpool/src/validator.rs:202` - txpool adds only `l1_tx_data_fee(...)`.
* `crates/execution/txpool/src/validator.rs:213` - txpool affordability check uses `tx.cost + l1_tx_data_fee`.
* `crates/common/evm/src/handler.rs:157` - execution performs the additional-cost balance deduction.
* `crates/common/evm/src/l1block.rs:262` - `tx_cost_with_tx` accounts for L1 fee and operator fee.
* `crates/common/evm/src/l1block.rs:271` - `tx_cost` computes the additional transaction cost.
* `crates/common/evm/src/l1block.rs:276` - operator fee is added when Isthmus is enabled.
* `crates/execution/txpool/src/transaction.rs:548` - root-cause PoC proving txpool accepts an operator-fee-underfunded transaction.
* `crates/execution/txpool/src/transaction.rs:638` - amplification PoC proving repeated scans produce `100%` extra processing over the one-pass baseline.
* `crates/execution/txpool/src/consumer/task.rs:53` - consumer creates a fresh `best_transactions()` iterator each loop.
* `crates/execution/payload/src/builder.rs:259` - payload building obtains fresh best transactions from the pool.
* `crates/execution/payload/src/builder.rs:360` - payload builder consumes mempool transactions when txpool use is enabled.
* `crates/execution/payload/src/builder.rs:745` - invalid payload candidates are marked invalid for the current iterator pass.

## Proof of Concept

```rust
use core::fmt::Debug;
use std::{
    borrow::Cow,
    sync::{Arc, OnceLock},
};

use alloy_consensus::{BlobTransactionValidationError, Typed2718, transaction::Recovered};
use alloy_eips::{
    eip2718::{Encodable2718, WithEncoded},
    eip2930::AccessList,
    eip7594::BlobTransactionSidecarVariant,
    eip7702::SignedAuthorization,
};
use alloy_primitives::{Address, B256, Bytes, TxHash, TxKind, U256};
use base_common_consensus::BaseTransactionSigned;
use c_kzg::KzgSettings;
use reth_primitives_traits::{InMemorySize, SignedTransaction};
use reth_transaction_pool::{
    EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction, PoolTransaction,
};

use crate::estimated_da_size::DataAvailabilitySized;

/// Assumed L2 block time in seconds, used to convert block-based bundle windows
/// to time-based bounds.
pub const BLOCK_TIME_SECS: u64 = 2;

/// Maximum allowed advance window for bundle parameters (seconds).
pub const MAX_BUNDLE_ADVANCE_SECS: u64 = 60;

/// Maximum allowed advance window for bundle parameters (milliseconds).
pub const MAX_BUNDLE_ADVANCE_MILLIS: u64 = MAX_BUNDLE_ADVANCE_SECS * 1000;

/// Maximum allowed advance window in blocks.
pub const MAX_BUNDLE_ADVANCE_BLOCKS: u64 = MAX_BUNDLE_ADVANCE_SECS / BLOCK_TIME_SECS;

/// Returns current time as milliseconds since Unix epoch.
pub fn unix_time_millis() -> u128 {
    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
        Ok(dur) => dur.as_millis(),
        Err(err) => {
            tracing::warn!(error = %err, "system clock before Unix epoch, using 0 as timestamp");
            0
        }
    }
}

/// Pool transaction for OP.
///
/// This type wraps the actual transaction and caches values that are frequently used by the pool.
/// For payload building this lazily tracks values that are required during payload building:
///  - Estimated compressed size of this transaction
#[derive(Debug, Clone, derive_more::Deref)]
pub struct BasePooledTransaction<
    Cons = BaseTransactionSigned,
    Pooled = base_common_consensus::BasePooledTransaction,
> {
    #[deref]
    inner: EthPooledTransaction<Cons>,
    /// The estimated size of this transaction, lazily computed.
    estimated_tx_compressed_size: OnceLock<u64>,
    /// The pooled transaction type.
    _pd: core::marker::PhantomData<Pooled>,
    /// Cached EIP-2718 encoded bytes of the transaction, lazily computed.
    encoded_2718: OnceLock<Bytes>,
    /// Timestamp (millis since Unix epoch) when this transaction was received.
    received_at: u128,
    /// Optional target block number from bundle submission.
    target_block_number: Option<u64>,
    /// Optional minimum timestamp (millis since Unix epoch) from bundle submission.
    /// The transaction should not be included before this time.
    min_timestamp: Option<u64>,
    /// Optional maximum timestamp (millis since Unix epoch) from bundle submission.
    /// The transaction should be evicted after this time.
    max_timestamp: Option<u64>,
}

impl<Cons: SignedTransaction, Pooled> BasePooledTransaction<Cons, Pooled> {
    /// Create new instance of [Self].
    pub fn new(transaction: Recovered<Cons>, encoded_length: usize) -> Self {
        Self {
            inner: EthPooledTransaction::new(transaction, encoded_length),
            estimated_tx_compressed_size: Default::default(),
            _pd: core::marker::PhantomData,
            encoded_2718: Default::default(),
            received_at: unix_time_millis(),
            target_block_number: None,
            min_timestamp: None,
            max_timestamp: None,
        }
    }

    /// Create new instance with an explicit `received_at` timestamp (millis since Unix epoch).
    ///
    /// Primarily for testing.
    pub fn new_with_received_at(
        transaction: Recovered<Cons>,
        encoded_length: usize,
        received_at: u128,
    ) -> Self {
        Self {
            inner: EthPooledTransaction::new(transaction, encoded_length),
            estimated_tx_compressed_size: Default::default(),
            _pd: core::marker::PhantomData,
            encoded_2718: Default::default(),
            received_at,
            target_block_number: None,
            min_timestamp: None,
            max_timestamp: None,
        }
    }

    /// Sets bundle metadata on this transaction, returning the modified instance.
    pub const fn with_bundle_metadata(
        mut self,
        target_block_number: Option<u64>,
        min_timestamp: Option<u64>,
        max_timestamp: Option<u64>,
    ) -> Self {
        self.target_block_number = target_block_number;
        self.min_timestamp = min_timestamp;
        self.max_timestamp = max_timestamp;
        self
    }

    /// Returns the estimated compressed size of a transaction in bytes.
    /// This value is computed based on the following formula:
    /// `max(minTransactionSize, intercept + fastlzCoef*fastlzSize) / 1e6`
    /// Uses cached EIP-2718 encoded bytes to avoid recomputing the encoding for each estimation.
    pub fn estimated_compressed_size(&self) -> u64 {
        *self
            .estimated_tx_compressed_size
            .get_or_init(|| base_common_flz::tx_estimated_size_fjord_bytes(self.encoded_2718()))
    }

    /// Returns lazily computed EIP-2718 encoded bytes of the transaction.
    pub fn encoded_2718(&self) -> &Bytes {
        self.encoded_2718.get_or_init(|| self.inner.transaction().encoded_2718().into())
    }

    /// Returns the timestamp (millis since Unix epoch) when this transaction was received.
    const fn inner_received_at(&self) -> u128 {
        self.received_at
    }
}

impl<Cons: SignedTransaction, Pooled> DataAvailabilitySized
    for BasePooledTransaction<Cons, Pooled>
{
    fn estimated_da_size(&self) -> u64 {
        self.estimated_compressed_size()
    }
}

impl<Cons, Pooled> PoolTransaction for BasePooledTransaction<Cons, Pooled>
where
    Cons: SignedTransaction + From<Pooled>,
    Pooled: SignedTransaction + TryFrom<Cons, Error: core::error::Error>,
{
    type TryFromConsensusError = <Pooled as TryFrom<Cons>>::Error;
    type Consensus = Cons;
    type Pooled = Pooled;

    fn clone_into_consensus(&self) -> Recovered<Self::Consensus> {
        self.inner.transaction().clone()
    }

    fn into_consensus(self) -> Recovered<Self::Consensus> {
        self.inner.transaction
    }

    fn into_consensus_with2718(self) -> WithEncoded<Recovered<Self::Consensus>> {
        let encoding = self.encoded_2718().clone();
        self.inner.transaction.into_encoded_with(encoding)
    }

    fn from_pooled(tx: Recovered<Self::Pooled>) -> Self {
        let encoded_len = tx.encode_2718_len();
        Self::new(tx.convert(), encoded_len)
    }

    fn hash(&self) -> &TxHash {
        self.inner.transaction.tx_hash()
    }

    fn sender(&self) -> Address {
        self.inner.transaction.signer()
    }

    fn sender_ref(&self) -> &Address {
        self.inner.transaction.signer_ref()
    }

    fn cost(&self) -> &U256 {
        &self.inner.cost
    }

    fn encoded_length(&self) -> usize {
        self.inner.encoded_length
    }
}

impl<Cons: Typed2718, Pooled> Typed2718 for BasePooledTransaction<Cons, Pooled> {
    fn ty(&self) -> u8 {
        self.inner.ty()
    }
}

impl<Cons: InMemorySize, Pooled> InMemorySize for BasePooledTransaction<Cons, Pooled> {
    fn size(&self) -> usize {
        self.inner.size() + core::mem::size_of::<u128>() + core::mem::size_of::<Option<u64>>() * 3
    }
}

impl<Cons, Pooled> alloy_consensus::Transaction for BasePooledTransaction<Cons, Pooled>
where
    Cons: alloy_consensus::Transaction,
    Pooled: Debug + Send + Sync + 'static,
{
    fn chain_id(&self) -> Option<u64> {
        self.inner.chain_id()
    }

    fn nonce(&self) -> u64 {
        self.inner.nonce()
    }

    fn gas_limit(&self) -> u64 {
        self.inner.gas_limit()
    }

    fn gas_price(&self) -> Option<u128> {
        self.inner.gas_price()
    }

    fn max_fee_per_gas(&self) -> u128 {
        self.inner.max_fee_per_gas()
    }

    fn max_priority_fee_per_gas(&self) -> Option<u128> {
        self.inner.max_priority_fee_per_gas()
    }

    fn max_fee_per_blob_gas(&self) -> Option<u128> {
        self.inner.max_fee_per_blob_gas()
    }

    fn priority_fee_or_price(&self) -> u128 {
        self.inner.priority_fee_or_price()
    }

    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
        self.inner.effective_gas_price(base_fee)
    }

    fn is_dynamic_fee(&self) -> bool {
        self.inner.is_dynamic_fee()
    }

    fn kind(&self) -> TxKind {
        self.inner.kind()
    }

    fn is_create(&self) -> bool {
        self.inner.is_create()
    }

    fn value(&self) -> U256 {
        self.inner.value()
    }

    fn input(&self) -> &Bytes {
        self.inner.input()
    }

    fn access_list(&self) -> Option<&AccessList> {
        self.inner.access_list()
    }

    fn blob_versioned_hashes(&self) -> Option<&[B256]> {
        self.inner.blob_versioned_hashes()
    }

    fn authorization_list(&self) -> Option<&[SignedAuthorization]> {
        self.inner.authorization_list()
    }
}

impl<Cons, Pooled> EthPoolTransaction for BasePooledTransaction<Cons, Pooled>
where
    Cons: SignedTransaction + From<Pooled>,
    Pooled: SignedTransaction + TryFrom<Cons>,
    <Pooled as TryFrom<Cons>>::Error: core::error::Error,
{
    fn take_blob(&mut self) -> EthBlobTransactionSidecar {
        EthBlobTransactionSidecar::None
    }

    fn try_into_pooled_eip4844(
        self,
        _sidecar: Arc<BlobTransactionSidecarVariant>,
    ) -> Option<Recovered<Self::Pooled>> {
        None
    }

    fn try_from_eip4844(
        _tx: Recovered<Self::Consensus>,
        _sidecar: BlobTransactionSidecarVariant,
    ) -> Option<Self> {
        None
    }

    fn validate_blob(
        &self,
        _sidecar: &BlobTransactionSidecarVariant,
        _settings: &KzgSettings,
    ) -> Result<(), BlobTransactionValidationError> {
        Err(BlobTransactionValidationError::NotBlobTransaction(self.ty()))
    }
}

/// Helper trait to provide payload builder with access to encoded bytes of
/// transaction.
pub trait OpPooledTx: PoolTransaction + DataAvailabilitySized {
    /// Returns the EIP-2718 encoded bytes of the transaction.
    fn encoded_2718(&self) -> Cow<'_, Bytes>;
}

impl<Cons, Pooled> OpPooledTx for BasePooledTransaction<Cons, Pooled>
where
    Cons: SignedTransaction + From<Pooled>,
    Pooled: SignedTransaction + TryFrom<Cons>,
    <Pooled as TryFrom<Cons>>::Error: core::error::Error,
{
    fn encoded_2718(&self) -> Cow<'_, Bytes> {
        Cow::Borrowed(self.encoded_2718())
    }
}

/// Trait for transactions that expose their received-at timestamp.
pub trait TimestampedTransaction {
    /// Returns the time (millis since Unix epoch) when this transaction was received.
    fn received_at(&self) -> u128;
}

impl<Cons, Pooled> TimestampedTransaction for BasePooledTransaction<Cons, Pooled>
where
    Cons: SignedTransaction,
    Pooled: Send + Sync + 'static,
{
    fn received_at(&self) -> u128 {
        self.inner_received_at()
    }
}

/// Trait for transactions that may carry bundle metadata.
///
/// All timestamp values are in milliseconds since Unix epoch. Block-timestamp
/// arguments (which arrive in seconds) are converted internally.
pub trait BundleTransaction {
    /// Returns the target block number, if set.
    fn target_block_number(&self) -> Option<u64>;

    /// Returns the minimum timestamp in milliseconds.
    fn min_timestamp_millis(&self) -> Option<u64>;

    /// Returns the maximum timestamp in milliseconds.
    fn max_timestamp_millis(&self) -> Option<u64>;

    /// Returns `true` if this transaction's bundle constraints have expired
    /// relative to the given block number and block timestamp (in seconds).
    fn is_bundle_expired(&self, block_number: u64, block_timestamp_secs: u64) -> bool {
        let block_timestamp_millis = block_timestamp_secs.saturating_mul(1000);

        if let Some(max_ts) = self.max_timestamp_millis()
            && block_timestamp_millis > max_ts
        {
            return true;
        }

        if let Some(target) = self.target_block_number()
            && block_number > target
        {
            return true;
        }

        false
    }

    /// Returns `true` if this transaction's `min_timestamp` has not yet been
    /// reached. `block_timestamp_secs` is the block timestamp in seconds.
    fn is_bundle_not_yet_valid(&self, block_timestamp_secs: u64) -> bool {
        let block_timestamp_millis = block_timestamp_secs.saturating_mul(1000);

        if let Some(min_ts) = self.min_timestamp_millis()
            && block_timestamp_millis < min_ts
        {
            return true;
        }

        false
    }
}

impl<Cons, Pooled> BundleTransaction for BasePooledTransaction<Cons, Pooled>
where
    Cons: Send + Sync,
    Pooled: Send + Sync + 'static,
{
    fn target_block_number(&self) -> Option<u64> {
        self.target_block_number
    }

    fn min_timestamp_millis(&self) -> Option<u64> {
        self.min_timestamp
    }

    fn max_timestamp_millis(&self) -> Option<u64> {
        self.max_timestamp
    }
}

#[cfg(test)]
mod tests {
    use std::collections::{HashMap, HashSet};

    use alloy_consensus::{
        SignableTransaction, Transaction, TxEip1559, transaction::{Recovered, SignerRecoverable},
    };
    use alloy_eips::eip2718::Encodable2718;
    use alloy_signer::SignerSync;
    use alloy_signer_local::PrivateKeySigner;
    use alloy_primitives::{Address, B256, TxKind, U256, bytes, hex_literal::hex};
    use base_common_chains::ChainConfig;
    use base_common_consensus::{BasePrimitives, BaseTransactionSigned, BaseTxEnvelope, TxDeposit};
    use base_common_evm::{L1BlockInfo, OpSpecId};
    use base_execution_chainspec::BASE_MAINNET;
    use base_execution_evm::{BaseEvmConfig, RethL1BlockInfo};
    use base_test_utils::Account as BaseAccount;
    use reth_evm::RecoveredTx;
    use reth_provider::test_utils::{ExtendedAccount, MockEthProvider};
    use reth_transaction_pool::{
        Pool, PoolTransaction, TransactionOrigin, TransactionPool, TransactionValidationOutcome,
        blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder,
    };

    use crate::{BaseL1BlockInfo, BaseOrdering, BasePooledTransaction, OpTransactionValidator};

    const ISTHMUS_L1_INFO_DATA: &[u8] = &hex!(
        "098999be00000558000c5fc500000000000000030000000067a9f765000000000000002900000000000000000000000000000000000000000000000000000000006a6d09000000000000000000000000000000000000000000000000000000000000000172fcc8e8886636bdbe96ba0e4baab67ea7e7811633f52b52e8cf7a5123213b6f000000000000000000000000d3f2c5afb2d76f5579f326b0cd7da5f5a4126c3500004e2000000000000001f4"
    );

    fn operator_fee_aware_l1_block_info() -> L1BlockInfo {
        L1BlockInfo::from(base_execution_evm::parse_l1_info(ISTHMUS_L1_INFO_DATA).unwrap())
    }

    fn make_underfunded_tx(
        signer: &PrivateKeySigner,
        nonce: u64,
        to: Address,
    ) -> (BasePooledTransaction, U256, U256) {
        let tx = TxEip1559 {
            chain_id: ChainConfig::mainnet().chain_id,
            nonce,
            gas_limit: 50_000,
            max_fee_per_gas: 1_000,
            max_priority_fee_per_gas: 1,
            to: to.into(),
            value: U256::ZERO,
            access_list: Default::default(),
            input: bytes!("FACADE"),
        };
        let gas_limit = tx.gas_limit;

        let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap();
        let envelope = BaseTxEnvelope::Eip1559(tx.into_signed(signature));
        let recovered_tx = envelope.clone().try_into_recovered().unwrap();
        let encoded = recovered_tx.encoded_2718();

        let mut l1_block_info = operator_fee_aware_l1_block_info();
        let l1_only_cost = l1_block_info
            .l1_tx_data_fee(
                BASE_MAINNET.clone(),
                ChainConfig::mainnet().isthmus_timestamp,
                &encoded,
                false,
            )
            .unwrap();
        let full_additional_cost = l1_block_info.tx_cost(
            &encoded,
            U256::from(gas_limit),
            OpSpecId::ISTHMUS,
        );
        let tx_cost = recovered_tx.tx().value().saturating_add(U256::from(
            recovered_tx.tx().max_fee_per_gas().saturating_mul(recovered_tx.tx().gas_limit() as u128),
        ));
        let balance = tx_cost.saturating_add(l1_only_cost);

        assert!(full_additional_cost > l1_only_cost);
        assert!(tx_cost.saturating_add(full_additional_cost) > balance);

        (
            BasePooledTransaction::new(recovered_tx, envelope.encode_2718_len()),
            balance,
            full_additional_cost.saturating_sub(l1_only_cost),
        )
    }

    #[tokio::test]
    async fn validate_base_transaction() {
        let client = MockEthProvider::<BasePrimitives>::new()
            .with_chain_spec(BASE_MAINNET.clone())
            .with_genesis_block();
        let evm_config = BaseEvmConfig::optimism(BASE_MAINNET.clone());
        let validator = EthTransactionValidatorBuilder::new(client, evm_config)
            .no_shanghai()
            .no_cancun()
            .build(InMemoryBlobStore::default());
        let validator = OpTransactionValidator::new(validator);

        let origin = TransactionOrigin::External;
        let signer = Default::default();
        let deposit_tx = TxDeposit {
            source_hash: Default::default(),
            from: signer,
            to: TxKind::Create,
            mint: 0,
            value: U256::ZERO,
            gas_limit: 0,
            is_system_transaction: false,
            input: Default::default(),
        };
        let signed_tx: BaseTransactionSigned = deposit_tx.into();
        let signed_recovered = Recovered::new_unchecked(signed_tx, signer);
        let len = signed_recovered.encode_2718_len();
        let pooled_tx: BasePooledTransaction = BasePooledTransaction::new(signed_recovered, len);
        let outcome = validator.validate_one(origin, pooled_tx).await;

        let err = match outcome {
            TransactionValidationOutcome::Invalid(_, err) => err,
            _ => panic!("Expected invalid transaction"),
        };
        assert_eq!(err.to_string(), "transaction type not supported");
    }

    #[tokio::test]
    async fn validator_accepts_tx_missing_operator_fee_coverage_post_isthmus() {
        let signer = BaseAccount::Alice.signer();
        let sender = signer.address();

        let tx = TxEip1559 {
            chain_id: ChainConfig::mainnet().chain_id,
            nonce: 0,
            gas_limit: 50_000,
            max_fee_per_gas: 1_000,
            max_priority_fee_per_gas: 0,
            to: Address::random().into(),
            value: U256::ZERO,
            access_list: Default::default(),
            input: bytes!("FACADE"),
        };
        let gas_limit = tx.gas_limit;

        let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap();
        let envelope = BaseTxEnvelope::Eip1559(tx.into_signed(signature));
        let recovered_tx = envelope.clone().try_into_recovered().unwrap();
        let encoded = recovered_tx.encoded_2718();

        let mut l1_block_info = operator_fee_aware_l1_block_info();
        let l1_only_cost = l1_block_info
            .l1_tx_data_fee(
                BASE_MAINNET.clone(),
                ChainConfig::mainnet().isthmus_timestamp,
                &encoded,
                false,
            )
            .unwrap();
        let full_additional_cost = l1_block_info.tx_cost(
            &encoded,
            U256::from(gas_limit),
            OpSpecId::ISTHMUS,
        );
        let tx_cost = recovered_tx.tx().value().saturating_add(U256::from(
            recovered_tx.tx().max_fee_per_gas().saturating_mul(recovered_tx.tx().gas_limit() as u128),
        ));
        let balance = tx_cost.saturating_add(l1_only_cost);

        assert!(
            full_additional_cost > l1_only_cost,
            "operator fee should increase the full execution-side additional cost"
        );
        assert!(
            tx_cost.saturating_add(full_additional_cost) > balance,
            "execution-side affordability check should fail once operator fee is included"
        );

        let client = MockEthProvider::<BasePrimitives>::new()
            .with_chain_spec(BASE_MAINNET.clone())
            .with_genesis_block();
        client.add_account(sender, ExtendedAccount::new(0, balance));

        let evm_config = BaseEvmConfig::optimism(BASE_MAINNET.clone());
        let inner = EthTransactionValidatorBuilder::new(client, evm_config)
            .no_shanghai()
            .no_cancun()
            .build(InMemoryBlobStore::default());
        let validator = OpTransactionValidator::with_block_info(inner, BaseL1BlockInfo::default());

        let header = alloy_consensus::Header {
            timestamp: ChainConfig::mainnet().isthmus_timestamp,
            ..Default::default()
        };
        let l1_info_tx: BaseTransactionSigned = TxDeposit {
            source_hash: Default::default(),
            from: Address::ZERO,
            to: TxKind::Create,
            mint: 0,
            value: U256::ZERO,
            gas_limit: 0,
            is_system_transaction: false,
            input: ISTHMUS_L1_INFO_DATA.into(),
        }
        .into();
        validator.update_l1_block_info(&header, Some(&l1_info_tx));

        let pooled_tx: BasePooledTransaction =
            BasePooledTransaction::new(recovered_tx, envelope.encode_2718_len());
        let outcome = validator.validate_one(TransactionOrigin::External, pooled_tx).await;

        match outcome {
            TransactionValidationOutcome::Valid { .. } => {}
            other => panic!("expected txpool validator to accept operator-fee-underfunded tx, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn operator_fee_underfunded_pool_scans_amplify_work_over_thirty_percent() {
        const SENDERS: usize = 12;
        const SCANS: usize = 2;

        let client = MockEthProvider::<BasePrimitives>::new()
            .with_chain_spec(BASE_MAINNET.clone())
            .with_genesis_block();

        let mut cases = Vec::with_capacity(SENDERS);
        let mut inserted_hashes = HashSet::with_capacity(SENDERS);
        let mut shortfall_by_hash = HashMap::new();

        for idx in 0..SENDERS {
            let signer = PrivateKeySigner::random();
            let sender = signer.address();
            let (pooled_tx, balance, shortfall) =
                make_underfunded_tx(&signer, 0, Address::repeat_byte((idx + 1) as u8));
            client.add_account(sender, ExtendedAccount::new(0, balance));
            inserted_hashes.insert(*pooled_tx.hash());
            shortfall_by_hash.insert(*pooled_tx.hash(), shortfall);
            cases.push(pooled_tx);
        }

        let evm_config = BaseEvmConfig::optimism(BASE_MAINNET.clone());
        let inner = EthTransactionValidatorBuilder::new(client, evm_config)
            .no_shanghai()
            .no_cancun()
            .build(InMemoryBlobStore::default());
        let validator = OpTransactionValidator::with_block_info(inner, BaseL1BlockInfo::default());

        let header = alloy_consensus::Header {
            timestamp: ChainConfig::mainnet().isthmus_timestamp,
            ..Default::default()
        };
        let l1_info_tx: BaseTransactionSigned = TxDeposit {
            source_hash: Default::default(),
            from: Address::ZERO,
            to: TxKind::Create,
            mint: 0,
            value: U256::ZERO,
            gas_limit: 0,
            is_system_transaction: false,
            input: ISTHMUS_L1_INFO_DATA.into(),
        }
        .into();
        validator.update_l1_block_info(&header, Some(&l1_info_tx));

        let pool = Pool::new(
            validator,
            BaseOrdering::<BasePooledTransaction>::timestamp(),
            InMemoryBlobStore::default(),
            Default::default(),
        );

        for pooled_tx in cases {
            pool.add_transaction(TransactionOrigin::External, pooled_tx).await.unwrap();
        }

        assert_eq!(pool.pending_transactions().len(), SENDERS);

        let mut total_considered = 0usize;
        for _ in 0..SCANS {
            let round_hashes: HashSet<B256> =
                pool.best_transactions().map(|tx| *tx.hash()).collect();
            assert_eq!(round_hashes, inserted_hashes);
            for hash in &round_hashes {
                assert!(
                    shortfall_by_hash.get(hash).is_some_and(|shortfall| *shortfall > U256::ZERO),
                    "every repeatedly scanned tx should still be execution-underfunded by the operator fee"
                );
            }
            total_considered += round_hashes.len();
        }

        let baseline_unique_work = inserted_hashes.len();
        let extra_processing_pct =
            ((total_considered - baseline_unique_work) as f64 / baseline_unique_work as f64) * 100.0;

        assert!(
            extra_processing_pct >= 30.0,
            "expected repeated scans of permanently invalid txs to exceed the 30% resource-spike threshold; got {extra_processing_pct:.2}%"
        );
        assert_eq!(extra_processing_pct, 100.0);
        assert_eq!(pool.pending_transactions().len(), SENDERS);
    }
}
```

The first test proves the root admission mismatch:

```
validator_accepts_tx_missing_operator_fee_coverage_post_isthmus
```

It creates a post-Isthmus transaction, computes both:

```
l1_only_cost = l1_tx_data_fee(...)
full_additional_cost = tx_cost(..., OpSpecId::ISTHMUS)
```

Then it funds the sender with:

```
tx_cost + l1_only_cost
```

The test asserts that the full execution-side cost is higher, then confirms that txpool still returns `TransactionValidationOutcome::Valid`.

The second test is the main impact PoC:

```
operator_fee_underfunded_pool_scans_amplify_work_over_thirty_percent
```

It performs the following steps:

1. Configures the txpool validator with post-Isthmus L1 info containing a non-zero operator fee.
2. Creates `12` ordinary signed EIP-1559 transactions from separate senders.
3. Funds each sender so the account covers `tx.cost + l1_tx_data_fee`, but not `tx.cost + l1_tx_data_fee + operator_fee`.
4. Inserts all `12` transactions into a real `Pool<OpTransactionValidator, BaseOrdering, InMemoryBlobStore>`.
5. Takes two fresh `best_transactions()` scans from the pool.
6. Verifies that both scans return the same `12` execution-underfunded transaction hashes.
7. Calculates the repeated-processing increase:

```
unique admitted transactions: 12
fresh scans: 2
total txpool considerations: 24
one-pass baseline: 12
extra processing: 100%
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://reports.immunefi.com/base/74725-bc-insight-post-isthmus-txpool-admission-omits-operator-fee-affordability-causing-repeated-pro.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
