feat(l1): require percentage fee bump for mempool RBF (10%/100%)#6601
feat(l1): require percentage fee bump for mempool RBF (10%/100%)#6601ilitteri wants to merge 6 commits into
Conversation
Replaces the strict-`>` replacement check in `find_tx_to_replace` with a configurable per-fee-field percentage threshold, matching the universal defaults across geth (`PriceBump=10`, `blobpool.PriceBump=100`), reth (`default_price_bump=10`, `replace_blob_tx_price_bump=100`), nethermind (10% / 2× blob), erigon (`PriceBump=10`, `BlobPriceBump=100`) and besu (`DEFAULT_PRICE_BUMP=10`, `DEFAULT_BLOB_PRICE_BUMP=100`). The previous strict-`>` rule let a replacement bump fees by a single wei, enabling near-zero-cost replacement-spam that forced nodes to re-validate and re-gossip on every iteration. This is the Nethermind-#3094 attack class. Changes: - `BlockchainOptions` gains `price_bump_percent` (default 10) and `blob_price_bump_percent` (default 100), exposed through two new consts `DEFAULT_PRICE_BUMP_PERCENT` / `DEFAULT_BLOB_PRICE_BUMP_PERCENT`. - `Mempool::find_tx_to_replace` now takes the bump percents and applies the appropriate one per tx type: blob bump for EIP-4844 (covers all three fee fields including `max_fee_per_blob_gas`), standard bump for the 1559 fee pair, or for `gas_price` on legacy. Replaces the prior OR-of-everything logic with a per-type match. Saturating arithmetic, matching the existing style. - New `is_bumped_u64` / `is_bumped_u256` helpers backing the check. - New CLI flags `--mempool.price-bump` (env `ETHREX_MEMPOOL_PRICE_BUMP`) and `--mempool.blob-price-bump` (env `ETHREX_MEMPOOL_BLOB_PRICE_BUMP`), plumbed through both L1 and L2 initializers. - 13 unit tests in `mempool.rs` covering helper edge cases plus end-to-end scenarios: 1-wei bump rejected, full 10% bump accepted, asymmetric bump rejected, blob 50% rejected, blob 100% on three axes accepted, zero-fee case, saturating arithmetic.
🤖 Kimi Code ReviewThis PR correctly implements configurable RBF (Replace-By-Fee) price bump requirements for the mempool, matching the behavior of other EL clients (geth, reth, etc.). The implementation is solid with comprehensive test coverage. Minor suggestions:
mempool_price_bump: DEFAULT_PRICE_BUMP_PERCENT,
mempool_blob_price_bump: DEFAULT_BLOB_PRICE_BUMP_PERCENT,You'll need to import
This is likely intentional (removing blobs reduces network load), but verify this matches your L2 requirements. If you want to enforce the stricter bump whenever a blob is being replaced (regardless of replacement type), check Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
Lines of code reportTotal lines added: Detailed view |
🤖 Claude Code ReviewNow I have everything I need. Here is my review: Review: feat(l1): require percentage fee bump for mempool RBF (10%/100%)SummaryThis PR correctly addresses the RBF spam attack (Nethermind-#3094 class) by replacing the strict There is one concrete security regression introduced by the refactor, plus a few smaller issues. Critical: EIP-2930 transactions bypass the fee bump checkFile:
When an EIP-2930 replacement falls into the is_bumped_u64(0, 0, 10) // threshold = 0 * 110/100 = 0; 0 >= 0 → true
is_bumped_u64(0, 0, 10) // sameResult: any EIP-2930 transaction unconditionally passes the replacement check, regardless of how much (or how little) the The fix is to treat EIP-2930 like legacy, since both use a single Transaction::LegacyTransaction(_) | Transaction::EIP2930Transaction(_) => {
is_bumped_u256(tx_in_pool.gas_price(), tx.gas_price(), bump)
}EIP-2930 is rarely used in practice, but the bypass is complete and deterministic. Medium: Incorrect test comment for saturation caseFile: // Multiplier saturates at u64::MAX; threshold stays bounded.
assert!(is_bumped_u64(u64::MAX, u64::MAX, 100));The comment is wrong. The multiplier is The assertion also deserves scrutiny: with a 100% bump requirement and A better test name and comment would be: Low: Bump rate determined by new tx type, not pool tx typeFile: let bump = if matches!(tx, Transaction::EIP4844Transaction(_)) {
blob_price_bump_percent
} else {
price_bump_percent
};If there is an EIP-4844 tx in the pool and the replacement is a non-blob tx (EIP-1559), only the 10% bump applies, and Positive notes
Required change before mergeAdd an explicit Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewFindings
The CLI/initializer plumbing into I couldn’t run the Rust tests here because toolchain/dependency resolution is blocked in this sandbox. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
There was a problem hiding this comment.
Pull request overview
This PR hardens mempool Replace-By-Fee (RBF) admission rules by requiring percentage-based fee bumps (default 10% for non-blob transactions and 100% for EIP-4844 blob transactions), and exposes these thresholds via new CLI options and BlockchainOptions.
Changes:
- Add configurable
price_bump_percentandblob_price_bump_percenttoBlockchainOptions, with defaults and CLI plumbing for both L1 and L2. - Update
Mempool::find_tx_to_replaceto enforce per-tx-type fee-field bump rules and introduceis_bumped_u64/is_bumped_u256helpers. - Add unit tests for bump helpers and basic replacement scenarios.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/blockchain/mempool.rs | Implements percentage-based RBF checks, adds bump helpers, and adds unit tests. |
| crates/blockchain/blockchain.rs | Adds new mempool bump configuration to BlockchainOptions and wires it into tx validation. |
| cmd/ethrex/l2/initializers.rs | Plumbs CLI bump options into L2 blockchain initialization. |
| cmd/ethrex/initializers.rs | Plumbs CLI bump options into L1 blockchain initialization. |
| cmd/ethrex/cli.rs | Adds CLI flags/env vars for configuring mempool RBF bump thresholds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let bumped_fee = is_bumped_u64( | ||
| tx_in_pool.max_fee_per_gas().unwrap_or_default(), | ||
| tx.max_fee_per_gas().unwrap_or_default(), | ||
| bump, | ||
| ); |
| /// using saturating arithmetic. A `bump_percent` of 0 collapses to | ||
| /// `new >= existing`. | ||
| fn is_bumped_u64(existing: u64, new: u64, bump_percent: u64) -> bool { | ||
| let multiplier = 100u64.saturating_add(bump_percent); | ||
| let threshold = existing.saturating_mul(multiplier) / 100; | ||
| new >= threshold | ||
| } | ||
|
|
| fn is_bumped_u256(existing: U256, new: U256, bump_percent: u64) -> bool { | ||
| let multiplier = U256::from(100u64.saturating_add(bump_percent)); | ||
| let threshold = existing.saturating_mul(multiplier) / U256::from(100u64); | ||
| new >= threshold |
Greptile SummaryThis PR replaces the simple strict-greater-than fee comparison for mempool RBF with a percentage-bump check: 10% for non-blob transactions and 100% for EIP-4844 blob transactions, matching the defaults used by geth, reth, nethermind, erigon, and besu. The change adds
Confidence Score: 3/5The blob bump protection can be bypassed by replacing a blob tx with a non-blob tx at 10%; should be fixed before merging. The blob-to-non-blob cross-type replacement path uses the wrong bump rate, meaning the 100% blob protection added in this PR can be circumvented with a well-chosen replacement sequence. The rest of the change — CLI wiring, struct fields, helper functions, and non-blob RBF logic — is correct and well-tested. crates/blockchain/mempool.rs — specifically the bump selector in find_tx_to_replace and the is_bumped_u64 saturation behavior
|
| Filename | Overview |
|---|---|
| crates/blockchain/mempool.rs | Core RBF logic rewritten with percentage bump helpers; bump-type selector only checks the new tx's type, allowing blob txs to be replaced cheaply via a cross-type non-blob replacement. |
| crates/blockchain/blockchain.rs | Adds price_bump_percent and blob_price_bump_percent fields to BlockchainOptions and plumbs them into find_tx_to_replace; straightforward and correct. |
| cmd/ethrex/cli.rs | Adds two new CLI flags with correct defaults and env-var bindings. |
| cmd/ethrex/initializers.rs | Plumbs new CLI options into BlockchainOptions for L1; straightforward. |
| cmd/ethrex/l2/initializers.rs | Plumbs new CLI options into BlockchainOptions for L2; straightforward. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[New TX submitted] --> B{contains_sender_nonce?}
B -- No --> C[Return None]
B -- Yes --> D{New TX type?}
D -- EIP-4844 --> E[bump = 100%]
D -- Other --> F[bump = 10%]
E --> G[Check max_fee_per_gas, max_priority_fee_per_gas, max_fee_per_blob_gas]
G --> J{All bumped?}
J -- Yes --> K[Replacement admitted]
J -- No --> L[UnderpricedReplacement]
F -- Legacy --> M[Check gas_price]
F -- 1559/Other --> N[Check max_fee_per_gas and max_priority_fee_per_gas]
M --> Q{Passes?}
N --> P{Both pass?}
Q -- Yes --> K
Q -- No --> L
P -- Yes --> K
P -- No --> L
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
crates/blockchain/mempool.rs:463-470
**Blob bump rate bypassed by cross-type replacement**
The `bump` selector checks only the *new* transaction's type. If a blob (`EIP-4844`) tx is already in the pool and a non-blob (`EIP-1559`) tx is submitted as a replacement at the same `(sender, nonce)`, the code picks `price_bump_percent` (10%) instead of `blob_price_bump_percent` (100%). That is the exact spam vector the 100% threshold was added to block: an attacker can keep cycling a blob tx out of the pool with cheap 10%-bumped non-blob replacements, forcing repeated re-validation and re-gossip at near-zero cost. The selection should use the blob bump if *either* the in-pool tx or the incoming tx is EIP-4844.
### Issue 2 of 2
crates/blockchain/mempool.rs:531-535
**Saturating multiply silently lowers the required threshold**
When `existing` is large (close to `u64::MAX`) and `bump_percent` is 100, `existing.saturating_mul(200)` saturates to `u64::MAX`, and dividing by 100 yields `≈ u64::MAX / 100`, which is *far below* the mathematically correct threshold of `2 × existing`. The test `is_bumped_u64_saturating_does_not_panic` locks in this incorrect behavior as expected. Fee values this large are impossible on mainnet, but using `u128` intermediate arithmetic would be more correct and clearer.
Reviews (1): Last reviewed commit: "feat(l1): require percentage fee bump fo..." | Re-trigger Greptile
| return Ok(None); | ||
| }; | ||
| 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(); | ||
| let old_tx_max_priority_fee_per_gas = tx_in_pool.max_priority_fee().unwrap_or_default(); | ||
| let new_tx_max_fee_per_gas = tx.max_fee_per_gas().unwrap_or_default(); | ||
| let new_tx_max_priority_fee_per_gas = tx.max_priority_fee().unwrap_or_default(); | ||
|
|
||
| // Legacy tx values | ||
| let old_tx_gas_price = tx_in_pool.gas_price(); | ||
| let new_tx_gas_price = tx.gas_price(); | ||
|
|
||
| // EIP-4844 values | ||
| let old_tx_max_fee_per_blob = tx_in_pool.max_fee_per_blob_gas(); | ||
| let new_tx_max_fee_per_blob = tx.max_fee_per_blob_gas(); | ||
|
|
||
| let eip4844_higher_fees = if let (Some(old_blob_fee), Some(new_blob_fee)) = | ||
| (old_tx_max_fee_per_blob, new_tx_max_fee_per_blob) | ||
| { | ||
| new_blob_fee > old_blob_fee | ||
| } else { | ||
| true // We are marking it as always true if the tx is not eip-4844 | ||
| }; | ||
|
|
||
| let eip1559_higher_fees = new_tx_max_fee_per_gas > old_tx_max_fee_per_gas | ||
| && new_tx_max_priority_fee_per_gas > old_tx_max_priority_fee_per_gas; | ||
| let legacy_higher_fees = new_tx_gas_price > old_tx_gas_price; | ||
| // Blob replacements use a stricter bump (default 100%) because blob | ||
| // sidecars are expensive to re-propagate; all other tx types use the | ||
| // base bump (default 10%). | ||
| let bump = if matches!(tx, Transaction::EIP4844Transaction(_)) { | ||
| blob_price_bump_percent |
There was a problem hiding this comment.
Blob bump rate bypassed by cross-type replacement
The bump selector checks only the new transaction's type. If a blob (EIP-4844) tx is already in the pool and a non-blob (EIP-1559) tx is submitted as a replacement at the same (sender, nonce), the code picks price_bump_percent (10%) instead of blob_price_bump_percent (100%). That is the exact spam vector the 100% threshold was added to block: an attacker can keep cycling a blob tx out of the pool with cheap 10%-bumped non-blob replacements, forcing repeated re-validation and re-gossip at near-zero cost. The selection should use the blob bump if either the in-pool tx or the incoming tx is EIP-4844.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/mempool.rs
Line: 463-470
Comment:
**Blob bump rate bypassed by cross-type replacement**
The `bump` selector checks only the *new* transaction's type. If a blob (`EIP-4844`) tx is already in the pool and a non-blob (`EIP-1559`) tx is submitted as a replacement at the same `(sender, nonce)`, the code picks `price_bump_percent` (10%) instead of `blob_price_bump_percent` (100%). That is the exact spam vector the 100% threshold was added to block: an attacker can keep cycling a blob tx out of the pool with cheap 10%-bumped non-blob replacements, forcing repeated re-validation and re-gossip at near-zero cost. The selection should use the blob bump if *either* the in-pool tx or the incoming tx is EIP-4844.
How can I resolve this? If you propose a fix, please make it concise.| fn is_bumped_u64(existing: u64, new: u64, bump_percent: u64) -> bool { | ||
| let multiplier = 100u64.saturating_add(bump_percent); | ||
| let threshold = existing.saturating_mul(multiplier) / 100; | ||
| new >= threshold | ||
| } |
There was a problem hiding this comment.
Saturating multiply silently lowers the required threshold
When existing is large (close to u64::MAX) and bump_percent is 100, existing.saturating_mul(200) saturates to u64::MAX, and dividing by 100 yields ≈ u64::MAX / 100, which is far below the mathematically correct threshold of 2 × existing. The test is_bumped_u64_saturating_does_not_panic locks in this incorrect behavior as expected. Fee values this large are impossible on mainnet, but using u128 intermediate arithmetic would be more correct and clearer.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/mempool.rs
Line: 531-535
Comment:
**Saturating multiply silently lowers the required threshold**
When `existing` is large (close to `u64::MAX`) and `bump_percent` is 100, `existing.saturating_mul(200)` saturates to `u64::MAX`, and dividing by 100 yields `≈ u64::MAX / 100`, which is *far below* the mathematically correct threshold of `2 × existing`. The test `is_bumped_u64_saturating_does_not_panic` locks in this incorrect behavior as expected. Fee values this large are impossible on mainnet, but using `u128` intermediate arithmetic would be more correct and clearer.
How can I resolve this? If you propose a fix, please make it concise.…rithmetic, named constants
Review-driven changes for the RBF replacement path:
1. Reject type-change replacements
A blob transaction in the pool must not be displaced by a non-blob
transaction at the same (sender, nonce) — the blob's sidecar is
expensive to re-propagate. The inverse is also rejected for
symmetry. Peer clients keep blob/non-blob in separate sub-pools and
get this rejection for free; ethrex's single pool needs an explicit
`std::mem::discriminant` check. Returns the existing
`MempoolError::UnderpricedReplacement`.
2. Checked-mul arithmetic in `is_bumped_u64` / `is_bumped_u256`
The previous saturating-mul implementation silently *admitted* an
under-priced replacement when `existing * (100 + bump)` exceeded the
integer width. Switched to u128 / U256::checked_mul with overflow →
reject. The test that documented the wrong semantic
(`is_bumped_u64_saturating_does_not_panic`) is renamed and re-asserts
the correct rejection at huge `existing` values.
3. Reference named constants in cli.rs
`default_value_t` and the `Options::default` impl for
`mempool_price_bump` / `mempool_blob_price_bump` now reference
`DEFAULT_PRICE_BUMP_PERCENT` / `DEFAULT_BLOB_PRICE_BUMP_PERCENT`
instead of magic literals 10 / 100. Help text and docs/CLI.md drop
peer-client name-drops.
4. Tests
Added three end-to-end tests:
- `blob_cannot_be_replaced_by_non_blob`
- `non_blob_cannot_be_replaced_by_blob`
- `legacy_replacement_requires_10_percent_bump` (closes the
previously-untested legacy path)
Plus the renamed overflow test.
5. Comment fix
Clarified the `_ =>` arm comment: the PrivilegedL2 variant inside
this arm is unreachable in practice (early-returned in
validate_transaction), but the other variants (2930, 1559, 7702,
FeeToken) do hit it. Previous comment said "this branch" was
unreachable, which was wrong.
| // its expensive sidecar) or vice versa. ethrex has a single pool, | ||
| // so the same guarantee has to be enforced here. | ||
| if std::mem::discriminant(tx) != std::mem::discriminant(tx_in_pool.transaction()) { | ||
| return Err(MempoolError::UnderpricedReplacement); |
There was a problem hiding this comment.
We should return a new error variant here
MegaRedHand
left a comment
There was a problem hiding this comment.
LGTM. Left a comment
…F rejection @MegaRedHand: "We should return a new error variant here" — the type-change rejection in `find_tx_to_replace` was reusing `UnderpricedReplacement`, which conflates two distinct failure modes: "new tx is the same type but didn't bump enough" vs. "new tx is a different type than the in-pool tx (e.g. non-blob trying to replace blob)". Splitting them lets RPC clients distinguish "retry with higher fees" from "retry with the same type" without re-parsing error text. Cross-client audit also flagged this overloading: peer clients separate "underpriced", "replacement-underpriced", "type-mismatch" / "blob- replace-tx" diagnostics. ethrex now matches with two variants (`UnderpricedReplacement` for the fee-bump case, `ReplacementTypeMismatch` for the discriminant-mismatch case). The three discriminant-mismatch tests in `mempool.rs` are updated to expect the new variant.
Phase 2 cross-check found that the original PR guard only checks the blob-count when BOTH old and new are EIP-4844. A non-blob tx replacing an in-pool blob tx is treated as a degenerate shrink to 0 blobs and rejected with the same error. Removes this PR's dependency on PR #6601's type-discriminant check landing first to close the cross-type spam vector. The TODO is updated to note that the cross-type arm becomes unreachable once #6601 merges. New test: rejects_non_blob_replacement_of_blob_tx.
|
| EIP | Bucket | Count |
|---|---|---|
| EIP-7702 | set_code_txs |
24 |
| EIP-7702 | set_code_txs_2 |
15 |
| EIP-7702 | gas |
1 |
| EIP-8037 | state_gas_set_code |
17 |
| EIP-8037 | state_gas_pricing |
1 |
| EIP-8037 | state_gas_sstore |
1 |
| EIP-7928 | block_access_lists_eip7702 |
8 |
| EIP-7928 | block_access_lists |
1 |
| EIP-7778 | gas_accounting |
3 |
| EIP-7708 | transfer_logs |
1 |
| EIP-7976 | refunds |
1 |
| EIP-1344 | chainid (Amsterdam fork-transition fixture) |
1 |
| Total | 74 |
Re-enable once we either:
- (a) bump fixtures to a snobal-devnet-7 release that locks in the new
accounting; or - (b) revert the bal-devnet-7-prep subtraction for bal-devnet-6
compatibility.
Full test list (74)
EIP-7702 — for_amsterdam/prague/eip7702_set_code_tx/set_code_txs/
delegation_clearingdelegation_clearing_and_setdelegation_clearing_failing_txdelegation_clearing_tx_toeoa_tx_after_set_codeext_code_on_chain_delegating_set_codeext_code_on_self_delegating_set_codeext_code_on_self_set_codeext_code_on_set_codemany_delegationsnonce_overflow_after_first_authorizationnonce_validityreset_codeself_code_on_set_codeself_sponsored_set_codeset_code_multiple_valid_authorization_tuples_same_signer_increasing_nonceset_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce_self_sponsoredset_code_to_logset_code_to_non_empty_storage_non_zero_nonceset_code_to_self_destructset_code_to_self_destructing_account_deployed_in_same_txset_code_to_sstoreset_code_to_sstore_then_sloadset_code_to_system_contract
EIP-7702 — for_amsterdam/prague/eip7702_set_code_tx/set_code_txs_2/
call_pointer_to_created_from_create_after_oog_call_againcall_to_precompile_in_pointer_contextcontract_storage_to_pointer_with_storagedelegation_replacement_call_previous_contractdouble_authpointer_measurementspointer_normalpointer_reentrypointer_resets_an_empty_code_account_with_storagepointer_revertspointer_to_pointerpointer_to_precompilepointer_to_staticpointer_to_static_reentrystatic_to_pointer
EIP-7702 — for_amsterdam/prague/eip7702_set_code_tx/gas/
account_warming
EIP-8037 — for_amsterdam/amsterdam/eip8037_state_creation_gas_cost_increase/state_gas_set_code/
auth_refund_block_gas_accountingauth_refund_bypasses_one_fifth_capauth_with_calldata_and_access_listauth_with_multiple_sstoresauthorization_exact_state_gas_boundaryauthorization_to_precompile_addressauthorization_with_sstoreduplicate_signer_authorizationsexisting_account_auth_header_gas_used_uses_worst_caseexisting_account_refundexisting_account_refund_enables_sstoreexisting_auth_with_reverted_execution_preserves_intrinsicmany_authorizations_state_gasmixed_auths_header_gas_used_uses_worst_casemixed_new_and_existing_authsmixed_valid_and_invalid_authsmulti_tx_block_auth_refund_and_sstore
EIP-8037 — state_gas_pricing/
auth_state_gas_scales_with_cpsb
EIP-8037 — state_gas_sstore/
sstore_state_gas_all_tx_types
EIP-7928 — for_amsterdam/amsterdam/eip7928_block_level_access_lists/block_access_lists_eip7702/
bal_7702_delegation_clearbal_7702_delegation_createbal_7702_delegation_updatebal_7702_double_auth_resetbal_7702_double_auth_swapbal_7702_null_address_delegation_no_code_changebal_selfdestruct_to_7702_delegationbal_withdrawal_to_7702_delegation
EIP-7928 — block_access_lists/
bal_all_transaction_types
EIP-7778 — for_amsterdam/amsterdam/eip7778_block_gas_accounting_without_refunds/gas_accounting/
multiple_refund_types_in_one_txsimple_gas_accountingvarying_calldata_costs
EIP-7708 — for_amsterdam/amsterdam/eip7708_eth_transfer_logs/transfer_logs/
transfer_with_all_tx_types
EIP-7976 — for_amsterdam/amsterdam/eip7976_increase_calldata_floor_cost/refunds/
gas_refunds_from_data_floor
EIP-1344 — for_amsterdam/istanbul/eip1344_chainid/chainid/
chainid(Amsterdam fork-transition fixture)
Motivation
The previous strict-
>replacement rule lets a replacement bump fees by a single wei, enabling near-zero-cost replacement-spam that forces nodes to re-validate and re-gossip on every iteration (the Nethermind-#3094 attack class). Every major Ethereum execution client requires a percentage bump on each applicable fee field: 10% for non-blob, 100% for blob.Description
BlockchainOptionsgainsprice_bump_percent(default 10) andblob_price_bump_percent(default 100), exposed through new constantsDEFAULT_PRICE_BUMP_PERCENTandDEFAULT_BLOB_PRICE_BUMP_PERCENT.Mempool::find_tx_to_replacenow takes the bump percents and applies the appropriate one per tx type:max_fee_per_blob_gas)gas_priceon legacyTransactionvariant (e.g. legacy → 1559, or blob → non-blob) is rejected viastd::mem::discriminant. Cross-type replacement skews accounting (blob vs non-blob slot reservation) and matches geth'stx.Type() == old.Type()guard.is_bumped_u64/is_bumped_u256helpers usingchecked_mulu128 arithmetic so a hugeexistingvalue rejects under the floor instead of silently saturating tou64::MAX/U256::MAX.--mempool.price-bump(envETHREX_MEMPOOL_PRICE_BUMP) and--mempool.blob-price-bump(envETHREX_MEMPOOL_BLOB_PRICE_BUMP), plumbed through both L1 and L2 initializers.mempool.rscovering helper edge cases plus end-to-end scenarios: 1-wei bump rejected, full 10% bump accepted, asymmetric bump rejected, blob 50% rejected, blob 100% on three axes accepted, zero-fee case, overflow rejected, blob↔non-blob and legacy-bump cross-type rejection.