Skip to content

Commit 00de880

Browse files
committed
fix(l1): address RBF review feedback on find_tx_to_replace
- Narrow the cross-type rejection to blob ↔ non-blob only (review by ElFantasma): type changes WITHIN the regular pool (legacy ↔ 2930 ↔ 1559 ↔ 7702) are allowed again, matching geth/reth/nethermind and the pre-PR behavior. The discriminant-based gate rejected all type changes, stranding users on legacy → 1559 replacements. - Compare fee cap and tip uniformly via gas_fee_cap()/gas_tip_cap() (review by Copilot): a legacy pooled tx's per-field maxes are None, which previously let an underpriced typed replacement slip through the 0-default comparison. The legacy gas price now acts as both fee cap and tip (geth's GasFeeCap/GasTipCap translation). - The per-variant match + catch-all is gone, so a future tx variant can't silently inherit the wrong fee comparison (review by ElFantasma). - Pin the round-down floor semantics on the bump helpers (nit). New tests: legacy→1559 allowed when bumped, underpriced typed replacement against a legacy pooled tx rejected, and 1559→legacy bump rules. Existing blob↔non-blob rejection tests keep passing. The blob bump-rate selection (greptile P1) is moot under the category gate, which forces both sides to share blob-ness before the bump is chosen. Also merges the branch's RBF test module into main's updated test module (signatures for hash/add_transaction).
1 parent 16c6223 commit 00de880

1 file changed

Lines changed: 125 additions & 62 deletions

File tree

crates/blockchain/mempool.rs

Lines changed: 125 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,69 +1286,52 @@ impl Mempool {
12861286
return Ok(None);
12871287
};
12881288

1289-
// Reject type-change replacements. Peer clients keep blob and
1290-
// non-blob transactions in separate sub-pools precisely so a
1291-
// cheap-to-replicate non-blob tx can't displace a blob tx (with
1292-
// its expensive sidecar) or vice versa. ethrex has a single pool,
1293-
// so the same guarantee has to be enforced here.
1294-
if std::mem::discriminant(tx) != std::mem::discriminant(tx_in_pool.transaction()) {
1289+
// Reject blob ↔ non-blob category changes. Peer clients keep blob
1290+
// and non-blob transactions in separate sub-pools precisely so a
1291+
// cheap-to-replicate non-blob tx can't displace a blob tx (with its
1292+
// expensive sidecar) or vice versa. ethrex has a single pool, so the
1293+
// same guarantee has to be enforced here. Type changes WITHIN the
1294+
// regular pool (legacy ↔ 2930 ↔ 1559 ↔ 7702) are allowed, matching
1295+
// geth/reth/nethermind.
1296+
let new_is_blob = matches!(tx, Transaction::EIP4844Transaction(_));
1297+
let old_is_blob = matches!(
1298+
tx_in_pool.transaction(),
1299+
Transaction::EIP4844Transaction(_)
1300+
);
1301+
if new_is_blob != old_is_blob {
12951302
return Err(MempoolError::ReplacementTypeMismatch);
12961303
}
12971304

12981305
// Blob replacements use a stricter bump (default 100%) because blob
12991306
// sidecars are expensive to re-propagate; all other tx types use the
1300-
// base bump (default 10%).
1301-
let bump = if matches!(tx, Transaction::EIP4844Transaction(_)) {
1307+
// base bump (default 10%). The category gate above makes the two
1308+
// sides share blob-ness, so keying on the new tx is sufficient.
1309+
let bump = if new_is_blob {
13021310
blob_price_bump_percent
13031311
} else {
13041312
price_bump_percent
13051313
};
13061314

1307-
// The new tx must bump every applicable fee field on its own tx type
1308-
// by at least `bump` percent compared to the in-pool tx at the same
1309-
// (sender, nonce). Each peer EL client enforces this independently per
1310-
// field.
1311-
let is_a_replacement_tx = match tx {
1312-
Transaction::LegacyTransaction(_) => {
1313-
is_bumped_u256(tx_in_pool.gas_price(), tx.gas_price(), bump)
1314-
}
1315-
Transaction::EIP4844Transaction(_) => {
1316-
let bumped_fee = is_bumped_u64(
1317-
tx_in_pool.max_fee_per_gas().unwrap_or_default(),
1318-
tx.max_fee_per_gas().unwrap_or_default(),
1319-
bump,
1320-
);
1321-
let bumped_tip = is_bumped_u64(
1322-
tx_in_pool.max_priority_fee().unwrap_or_default(),
1323-
tx.max_priority_fee().unwrap_or_default(),
1324-
bump,
1325-
);
1326-
let bumped_blob = is_bumped_u256(
1327-
tx_in_pool.max_fee_per_blob_gas().unwrap_or_default(),
1328-
tx.max_fee_per_blob_gas().unwrap_or_default(),
1329-
bump,
1330-
);
1331-
bumped_fee && bumped_tip && bumped_blob
1332-
}
1333-
// EIP-2930 / EIP-1559 / EIP-7702 / FeeToken / Privileged: 1559-style
1334-
// pair of fee fields. (PrivilegedL2 transactions short-circuit
1335-
// before `validate_transaction` ever reaches `find_tx_to_replace`,
1336-
// so the Privileged variant of this arm is unreachable in practice;
1337-
// the other variants do hit it.)
1338-
_ => {
1339-
let bumped_fee = is_bumped_u64(
1340-
tx_in_pool.max_fee_per_gas().unwrap_or_default(),
1341-
tx.max_fee_per_gas().unwrap_or_default(),
1342-
bump,
1343-
);
1344-
let bumped_tip = is_bumped_u64(
1345-
tx_in_pool.max_priority_fee().unwrap_or_default(),
1346-
tx.max_priority_fee().unwrap_or_default(),
1347-
bump,
1348-
);
1349-
bumped_fee && bumped_tip
1350-
}
1315+
// The new tx must bump every applicable fee dimension by at least
1316+
// `bump` percent. Fee cap and tip are compared uniformly across
1317+
// types: a legacy tx's gas price acts as both its fee cap and its
1318+
// tip (geth's `GasFeeCap`/`GasTipCap` translation), so a legacy ↔
1319+
// typed replacement is compared on the same two dimensions and a
1320+
// typed replacement can't slip in underpriced against a legacy
1321+
// pooled tx (whose per-field maxes are `None`).
1322+
let bumped_fee_cap = is_bumped_u256(tx_in_pool.gas_fee_cap(), tx.gas_fee_cap(), bump);
1323+
let bumped_tip = is_bumped_u256(tx_in_pool.gas_tip_cap(), tx.gas_tip_cap(), bump);
1324+
// Blob fee dimension (both sides are blob txs here, per the gate).
1325+
let bumped_blob = if new_is_blob {
1326+
is_bumped_u256(
1327+
tx_in_pool.max_fee_per_blob_gas().unwrap_or_default(),
1328+
tx.max_fee_per_blob_gas().unwrap_or_default(),
1329+
bump,
1330+
)
1331+
} else {
1332+
true
13511333
};
1334+
let is_a_replacement_tx = bumped_fee_cap && bumped_tip && bumped_blob;
13521335

13531336
if !is_a_replacement_tx {
13541337
return Err(MempoolError::UnderpricedReplacement);
@@ -1425,6 +1408,12 @@ impl Mempool {
14251408
/// threshold computation is treated as "reject" rather than silently
14261409
/// admitting an under-priced replacement. A `bump_percent` of 0 collapses to
14271410
/// `new >= existing`.
1411+
///
1412+
/// Threshold rounds down (toward zero), so `new == floor(existing * (100 +
1413+
/// bump) / 100)` is admitted — matches geth's and reth's replacement rules.
1414+
/// Currently only exercised by unit tests: production compares fees via the
1415+
/// U256 accessors (`gas_fee_cap`/`gas_tip_cap`).
1416+
#[cfg(test)]
14281417
fn is_bumped_u64(existing: u64, new: u64, bump_percent: u64) -> bool {
14291418
let multiplier = 100u128 + bump_percent as u128;
14301419
let Some(threshold) = (existing as u128).checked_mul(multiplier).map(|v| v / 100) else {
@@ -1513,7 +1502,7 @@ pub fn transaction_intrinsic_gas(
15131502
#[cfg(test)]
15141503
mod tests {
15151504
use super::*;
1516-
use ethrex_common::types::{EIP1559Transaction, Transaction, TxKind};
1505+
use ethrex_common::types::{EIP1559Transaction, EIP4844Transaction, Transaction, TxKind};
15171506
use ethrex_common::{Address, Bytes};
15181507

15191508
fn dummy_mempool_tx(sender: Address, nonce: u64) -> MempoolTransaction {
@@ -1940,19 +1929,13 @@ mod tests {
19401929
"duplicate announced hash must be reserved once"
19411930
);
19421931
}
1943-
}
19441932

1945-
#[cfg(test)]
1946-
mod tests {
1947-
use super::*;
1948-
use ethrex_common::types::{EIP1559Transaction, EIP4844Transaction};
1949-
1950-
// --- helpers --------------------------------------------------------
1933+
// --- RBF price-bump helpers --------------------------------------------
19511934

19521935
fn add_to_pool(pool: &Mempool, sender: Address, tx: Transaction) -> H256 {
19531936
let mtx = MempoolTransaction::new(tx, sender);
1954-
let hash = mtx.hash();
1955-
pool.add_transaction(hash, sender, mtx).unwrap();
1937+
let hash = mtx.hash(&NativeCrypto);
1938+
pool.add_transaction(hash, sender, mtx, None, None).unwrap();
19561939
hash
19571940
}
19581941

@@ -1975,6 +1958,14 @@ mod tests {
19751958
})
19761959
}
19771960

1961+
fn legacy(nonce: u64, gas_price: u64) -> Transaction {
1962+
Transaction::LegacyTransaction(ethrex_common::types::LegacyTransaction {
1963+
nonce,
1964+
gas_price: U256::from(gas_price),
1965+
..Default::default()
1966+
})
1967+
}
1968+
19781969
// --- is_bumped_u64 -------------------------------------------------
19791970

19801971
#[test]
@@ -2146,6 +2137,78 @@ mod tests {
21462137
assert!(matches!(err, MempoolError::ReplacementTypeMismatch));
21472138
}
21482139

2140+
// --- cross-type replacements within the regular pool ------------------
2141+
2142+
#[test]
2143+
fn legacy_to_1559_replacement_allowed_when_bumped() {
2144+
// Type changes WITHIN the regular pool are allowed — geth/reth/
2145+
// nethermind all accept legacy → 1559 with a sufficient bump. The
2146+
// legacy gas price acts as both fee cap and tip (geth's
2147+
// GasFeeCap/GasTipCap translation).
2148+
let pool = Mempool::new(64);
2149+
let sender = Address::from_low_u64_be(5);
2150+
add_to_pool(&pool, sender, legacy(0, 1_000));
2151+
2152+
// +10% on the translated dimensions → accepted.
2153+
let ok = eip1559(0, 1_100, 1_100);
2154+
let found = pool
2155+
.find_tx_to_replace(sender, 0, &ok, 10, 100)
2156+
.unwrap()
2157+
.expect("legacy → 1559 replacement at 10% bump should be admitted");
2158+
assert!(!found.is_zero());
2159+
2160+
// Fee cap bumped but tip below the translated floor → rejected.
2161+
let bad = eip1559(0, 1_100, 1_099);
2162+
assert!(matches!(
2163+
pool.find_tx_to_replace(sender, 0, &bad, 10, 100)
2164+
.unwrap_err(),
2165+
MempoolError::UnderpricedReplacement
2166+
));
2167+
}
2168+
2169+
#[test]
2170+
fn typed_replacement_against_legacy_pool_tx_uses_translated_dimensions() {
2171+
// Regression (review): previously the pooled legacy tx's per-field
2172+
// maxes were `None` → 0, so an underpriced typed replacement was
2173+
// accepted. The gas_fee_cap/gas_tip_cap translation closes the hole.
2174+
let pool = Mempool::new(64);
2175+
let sender = Address::from_low_u64_be(6);
2176+
add_to_pool(&pool, sender, legacy(0, 1_000));
2177+
2178+
let underpriced = eip1559(0, 1_050, 1_050); // +5% < 10%
2179+
assert!(matches!(
2180+
pool.find_tx_to_replace(sender, 0, &underpriced, 10, 100)
2181+
.unwrap_err(),
2182+
MempoolError::UnderpricedReplacement
2183+
));
2184+
}
2185+
2186+
#[test]
2187+
fn legacy_to_legacy_and_1559_to_legacy_bump_rules() {
2188+
// 1559 → legacy: the pooled typed tx's fee cap/tip are compared
2189+
// against the legacy gas price (translated to both dimensions).
2190+
let pool = Mempool::new(64);
2191+
let sender = Address::from_low_u64_be(7);
2192+
add_to_pool(&pool, sender, eip1559(0, 1_000, 100));
2193+
2194+
// gas_price 1_100 = +10% over the pooled fee cap (1_000) and well
2195+
// above the tip floor (110) → accepted.
2196+
let ok = legacy(0, 1_100);
2197+
assert!(
2198+
pool.find_tx_to_replace(sender, 0, &ok, 10, 100)
2199+
.unwrap()
2200+
.is_some()
2201+
);
2202+
2203+
// gas_price 1_050 clears the tip floor but not the fee-cap bump → rejected.
2204+
let bad = legacy(0, 1_050);
2205+
assert!(matches!(
2206+
pool.find_tx_to_replace(sender, 0, &bad, 10, 100)
2207+
.unwrap_err(),
2208+
MempoolError::UnderpricedReplacement
2209+
));
2210+
}
2211+
21492212
// --- legacy path ---------------------------------------------------
21502213

21512214
#[test]

0 commit comments

Comments
 (0)