@@ -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) ]
14281417fn 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) ]
15141503mod 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