Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/blockchain/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ pub enum MempoolError {
InvalidTxSender(#[from] ethrex_crypto::CryptoError),
#[error("Attempted to replace a pooled transaction with an underpriced transaction")]
UnderpricedReplacement,
#[error(
"EIP-4844 replacement carries fewer blobs ({new_count}) than the in-pool tx ({old_count})"
)]
ReplacementShrinksBlobs { old_count: usize, new_count: usize },
}

#[derive(Debug)]
Expand Down
153 changes: 153 additions & 0 deletions crates/blockchain/mempool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,29 @@ impl Mempool {
let Some(tx_in_pool) = self.contains_sender_nonce(sender, nonce, tx.hash())? else {
return Ok(None);
};

// Reject EIP-4844 replacements that carry fewer blobs than the in-pool tx.
// A smaller sidecar costs the network less to gossip, so allowing a shrink
// would let an attacker cycle "replace N-blob tx with 1-blob tx" cheaply
// even when the fee bump is satisfied.
//
// TODO: When PR #6601 lands (type-discriminant check for RBF), this guard
// can be simplified — the discriminant check there already guarantees both
// sides are EIP-4844 before reaching this point, so the explicit match
// becomes redundant.
if let (Transaction::EIP4844Transaction(old_tx), Transaction::EIP4844Transaction(new_tx)) =
(tx_in_pool.transaction(), tx)
{
let old_count = old_tx.blob_versioned_hashes.len();
let new_count = new_tx.blob_versioned_hashes.len();
if new_count < old_count {
return Err(MempoolError::ReplacementShrinksBlobs {
old_count,
new_count,
});
}
}

let is_a_replacement_tx = {
// EIP-1559 values
let old_tx_max_fee_per_gas = tx_in_pool.max_fee_per_gas().unwrap_or_default();
Expand Down Expand Up @@ -574,3 +597,133 @@ pub fn transaction_intrinsic_gas(

Ok(gas)
}

#[cfg(test)]
mod tests {
use super::*;
use ethrex_common::types::{EIP4844Transaction, MempoolTransaction, Transaction};

/// Mempool capacity used by the test fixture. The exact value is not
/// significant for these tests; we just need room for two transactions.
const TEST_MEMPOOL_CAPACITY: usize = 16;

/// Multiplier used by tests that bump every fee field by 100%
/// (i.e. double the value of the in-pool transaction).
const DOUBLED_FEE_MULTIPLIER: u64 = 2;

fn make_blob_tx(
nonce: u64,
max_priority_fee_per_gas: u64,
max_fee_per_gas: u64,
max_fee_per_blob_gas: U256,
blob_count: usize,
) -> Transaction {
Transaction::EIP4844Transaction(EIP4844Transaction {
nonce,
max_priority_fee_per_gas,
max_fee_per_gas,
max_fee_per_blob_gas,
blob_versioned_hashes: (0..blob_count)
.map(|i| H256::from_low_u64_be(i as u64 + 1))
.collect(),
..Default::default()
})
}

fn insert_tx(mempool: &Mempool, sender: Address, tx: Transaction) {
let hash = tx.hash();
let mempool_tx = MempoolTransaction::new(tx, sender);
mempool
.add_transaction(hash, sender, mempool_tx)
.expect("failed to seed mempool");
}

#[test]
fn rejects_replacement_with_fewer_blobs_even_with_doubled_fees() {
let mempool = Mempool::new(TEST_MEMPOOL_CAPACITY);
let sender = Address::from_low_u64_be(1);
let nonce = 0u64;

let base_priority_fee = 1u64;
let base_max_fee = 10u64;
let base_blob_fee = U256::from(5u64);

let old = make_blob_tx(nonce, base_priority_fee, base_max_fee, base_blob_fee, 6);
insert_tx(&mempool, sender, old);

let new = make_blob_tx(
nonce,
base_priority_fee * DOUBLED_FEE_MULTIPLIER,
base_max_fee * DOUBLED_FEE_MULTIPLIER,
base_blob_fee * U256::from(DOUBLED_FEE_MULTIPLIER),
5,
);

match mempool.find_tx_to_replace(sender, nonce, &new) {
Err(MempoolError::ReplacementShrinksBlobs {
old_count,
new_count,
}) => {
assert_eq!(old_count, 6);
assert_eq!(new_count, 5);
}
other => panic!("expected ReplacementShrinksBlobs, got {other:?}"),
}
}

#[test]
fn accepts_replacement_with_same_blob_count_and_doubled_fees() {
let mempool = Mempool::new(TEST_MEMPOOL_CAPACITY);
let sender = Address::from_low_u64_be(2);
let nonce = 0u64;

let base_priority_fee = 1u64;
let base_max_fee = 10u64;
let base_blob_fee = U256::from(5u64);

let old = make_blob_tx(nonce, base_priority_fee, base_max_fee, base_blob_fee, 3);
let old_hash = old.hash();
insert_tx(&mempool, sender, old);

let new = make_blob_tx(
nonce,
base_priority_fee * DOUBLED_FEE_MULTIPLIER,
base_max_fee * DOUBLED_FEE_MULTIPLIER,
base_blob_fee * U256::from(DOUBLED_FEE_MULTIPLIER),
3,
);

let result = mempool
.find_tx_to_replace(sender, nonce, &new)
.expect("same-count replacement with doubled fees should be accepted");
assert_eq!(result, Some(old_hash));
}

#[test]
fn accepts_replacement_with_more_blobs_and_doubled_fees() {
let mempool = Mempool::new(TEST_MEMPOOL_CAPACITY);
let sender = Address::from_low_u64_be(3);
let nonce = 0u64;

let base_priority_fee = 1u64;
let base_max_fee = 10u64;
let base_blob_fee = U256::from(5u64);

let old = make_blob_tx(nonce, base_priority_fee, base_max_fee, base_blob_fee, 1);
let old_hash = old.hash();
insert_tx(&mempool, sender, old);

let new = make_blob_tx(
nonce,
base_priority_fee * DOUBLED_FEE_MULTIPLIER,
base_max_fee * DOUBLED_FEE_MULTIPLIER,
base_blob_fee * U256::from(DOUBLED_FEE_MULTIPLIER),
6,
);

let result = mempool
.find_tx_to_replace(sender, nonce, &new)
.expect("growing-blob replacement with doubled fees should be accepted");
assert_eq!(result, Some(old_hash));
}
}
Loading