From be506734b3ed3775f62c7060ad4fdfde98b6d4d5 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:44:46 +0800 Subject: [PATCH 1/7] fix(l1): bump EIP-1559 fees on same-nonce poster retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track in-flight fees and floor re-estimates to the shared ≥10% replacement rule so flat markets cannot underprice replacements. --- sequencer/src/l1/eip1559.rs | 118 ++++++++++++++++++++++++++- sequencer/src/l1/submitter/poster.rs | 46 +++++++++-- sequencer/src/recovery/flusher.rs | 64 ++------------- 3 files changed, 163 insertions(+), 65 deletions(-) diff --git a/sequencer/src/l1/eip1559.rs b/sequencer/src/l1/eip1559.rs index 99ec7173..4dba81f5 100644 --- a/sequencer/src/l1/eip1559.rs +++ b/sequencer/src/l1/eip1559.rs @@ -1,7 +1,10 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 -//! Shared EIP-1559 fee estimate used by the poster and fee oracle. +//! Shared EIP-1559 fee estimate and same-nonce replacement bump. +//! +//! Used by the batch poster (submission + retry), the fee oracle (charge), and +//! the mempool flusher (no-op replacement). use alloy::consensus::BlockHeader; use alloy::providers::{DynProvider, Provider, utils}; @@ -15,6 +18,38 @@ pub struct Eip1559Fees { pub max_fee_per_gas: u128, } +/// Bump EIP-1559 fees for a same-nonce replacement under the ≥10% rule. +/// +/// `max_fee` gets ×1.1 (+1 for integer-rounding flat spots); priority doubles +/// (intentionally generous past the 10% threshold). The poster floors a +/// re-estimate against the last successful send at that wallet nonce; the +/// flusher bumps a fresh estimate so no-ops can compete with pending batch +/// txs. Eviction is operational acceleration, not a correctness precondition. +pub fn bumped_replacement_fees(base_max_fee: u128, base_priority_fee: u128) -> (u128, u128) { + let new_max_fee = base_max_fee.saturating_mul(11) / 10 + 1; + let new_priority_fee = base_priority_fee.saturating_mul(2).max(1); + (new_max_fee, new_priority_fee) +} + +/// Absolute estimate, raised to a replacement floor when `prior` is set. +/// +/// First send at a nonce uses `estimate` unchanged. A same-nonce resubmit +/// takes the per-component max of the fresh estimate and +/// [`bumped_replacement_fees`] of the last successful broadcast, so a flat +/// market cannot re-broadcast underpriced replacements. +pub fn fees_for_nonce(estimate: Eip1559Fees, prior: Option) -> Eip1559Fees { + let Some(prior) = prior else { + return estimate; + }; + let (bumped_max, bumped_prio) = + bumped_replacement_fees(prior.max_fee_per_gas, prior.max_priority_fee_per_gas); + Eip1559Fees { + base_fee_per_gas: estimate.base_fee_per_gas, + max_fee_per_gas: estimate.max_fee_per_gas.max(bumped_max), + max_priority_fee_per_gas: estimate.max_priority_fee_per_gas.max(bumped_prio), + } +} + /// Estimate fees with Alloy's default, MetaMask-style medium estimator. /// /// We intentionally pin the policy constants here: 10 historical blocks, the @@ -65,4 +100,85 @@ mod tests { assert_eq!(estimate.max_priority_fee_per_gas, 4); assert_eq!(estimate.max_fee_per_gas, 204); } + + #[test] + fn replacement_fee_bump_exceeds_ten_percent_for_max_fee() { + for base in [1_u128, 10, 100, 1_000, 1_000_000, 1_000_000_000_000] { + let (new_max, _) = bumped_replacement_fees(base, 0); + assert!( + new_max.saturating_mul(10) >= base.saturating_mul(11), + "max_fee bump violates ≥10% rule: base={base}, new={new_max}", + ); + } + } + + #[test] + fn replacement_fee_bump_doubles_priority_fee() { + for base in [1_u128, 10, 1_000, 1_000_000_000] { + let (_, new_prio) = bumped_replacement_fees(0, base); + assert_eq!(new_prio, base.saturating_mul(2)); + assert!( + new_prio.saturating_mul(10) >= base.saturating_mul(11), + "priority bump violates ≥10% rule: base={base}, new={new_prio}", + ); + } + } + + #[test] + fn replacement_fee_floor_is_positive_even_when_base_is_zero() { + let (new_max, new_prio) = bumped_replacement_fees(0, 0); + assert!(new_max >= 1); + assert!(new_prio >= 1); + } + + #[test] + fn replacement_fee_bump_saturates_at_u128_max() { + let (new_max, new_prio) = bumped_replacement_fees(u128::MAX, u128::MAX); + assert_eq!(new_max, u128::MAX / 10 + 1); + assert_eq!(new_prio, u128::MAX); + } + + #[test] + fn fees_for_nonce_passes_estimate_through_on_first_send() { + let estimate = Eip1559Fees { + base_fee_per_gas: 100, + max_priority_fee_per_gas: 2, + max_fee_per_gas: 202, + }; + assert_eq!(fees_for_nonce(estimate, None), estimate); + } + + #[test] + fn fees_for_nonce_floors_flat_estimate_to_replacement_bump() { + let prior = Eip1559Fees { + base_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + max_fee_per_gas: 1_000, + }; + // Flat market: estimate equals prior. Replacement must clear ≥10%. + let estimate = prior; + let fees = fees_for_nonce(estimate, Some(prior)); + let (bumped_max, bumped_prio) = + bumped_replacement_fees(prior.max_fee_per_gas, prior.max_priority_fee_per_gas); + assert_eq!(fees.max_fee_per_gas, bumped_max); + assert_eq!(fees.max_priority_fee_per_gas, bumped_prio); + assert!(fees.max_fee_per_gas > prior.max_fee_per_gas); + assert!(fees.max_priority_fee_per_gas > prior.max_priority_fee_per_gas); + } + + #[test] + fn fees_for_nonce_keeps_estimate_when_market_already_clears_bump() { + let prior = Eip1559Fees { + base_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + max_fee_per_gas: 1_000, + }; + let estimate = Eip1559Fees { + base_fee_per_gas: 500, + max_priority_fee_per_gas: 50, + max_fee_per_gas: 10_000, + }; + let fees = fees_for_nonce(estimate, Some(prior)); + assert_eq!(fees, estimate); + } } diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index ff451b60..ff4f3ad4 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -12,9 +12,11 @@ use sequencer_core::batch::Batch; use thiserror::Error; use tracing::{debug, info, warn}; -use crate::l1::eip1559::{Eip1559Fees, estimate_fees}; +use crate::l1::eip1559::{Eip1559Fees, estimate_fees, fees_for_nonce}; use crate::l1::partition::{decode_evm_advance_input, get_input_added_events_ordered}; use crate::l1::watermark::WalletNonceWatermarkSink; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; pub type TxHash = alloy_primitives::B256; @@ -68,11 +70,20 @@ pub trait BatchPoster: Send + Sync { pub struct EthereumBatchPoster { provider: DynProvider, config: BatchPosterConfig, + /// Fees of the last successful broadcast per wallet nonce still ≥ Latest. + /// Same-nonce retries floor a fresh estimate against + /// [`crate::l1::eip1559::bumped_replacement_fees`] of this record so a + /// flat market cannot re-broadcast underpriced replacements. + in_flight_fees: Arc>>, } impl EthereumBatchPoster { pub fn new(provider: DynProvider, config: BatchPosterConfig) -> Self { - Self { provider, config } + Self { + provider, + config, + in_flight_fees: Arc::new(Mutex::new(BTreeMap::new())), + } } /// Conservative upper-bound timeout for waiting on confirmations, derived @@ -123,11 +134,11 @@ impl EthereumBatchPoster { /// the time we start watching it. /// /// Timeouts return `Ok(())` rather than `Err` because the safe response is - /// "re-enter `submit_batches` on the next tick" — which re-estimates fees - /// (possibly replacing a pending transaction if the node accepts it) and - /// re-submits at the same wallet nonces. The - /// wallet-nonce ordering invariant above guarantees we cannot accidentally - /// skip work by returning early here. + /// "re-enter `submit_batches` on the next tick" — which re-estimates fees, + /// floors them to an explicit ≥10% replacement bump against any still + /// in-flight same-nonce submission, and re-submits at the same wallet + /// nonces. The wallet-nonce ordering invariant above guarantees we cannot + /// accidentally skip work by returning early here. async fn wait_for_confirmations(&self, tx_hashes: &[TxHash]) -> Result<(), BatchPosterError> { let timeout = self.confirmation_timeout(); for tx_hash in tx_hashes { @@ -203,11 +214,18 @@ impl BatchPoster for EthereumBatchPoster { }); } - let fees = estimate_fees(&self.provider) + let estimate = estimate_fees(&self.provider) .await .map_err(BatchPosterError::Provider)?; let mut next_nonce = self.latest_account_nonce().await?; + // Drop fee floors for nonces Latest has advanced past — those slots + // are resolved and must not floor a later send. + { + let mut in_flight = self.in_flight_fees.lock().expect("in_flight_fees lock"); + in_flight.retain(|&nonce, _| nonce >= next_nonce); + } + // Write-before-broadcast (R1a): durably cover every nonce this // tick will use before the first send. One raise to the highest // covers the whole consecutive range. @@ -219,11 +237,23 @@ impl BatchPoster for EthereumBatchPoster { let mut tx_hashes = Vec::with_capacity(payloads.len()); for payload in payloads { + let fees = { + let in_flight = self.in_flight_fees.lock().expect("in_flight_fees lock"); + fees_for_nonce(estimate, in_flight.get(&next_nonce).copied()) + }; let pending = self.send_batch_at_nonce(payload, next_nonce, &fees).await?; + // Record only after a successful broadcast — a failed send must + // not raise the replacement floor for the next tick. + self.in_flight_fees + .lock() + .expect("in_flight_fees lock") + .insert(next_nonce, fees); let tx_hash = *pending.tx_hash(); debug!( tx_nonce = next_nonce, %tx_hash, + max_fee_per_gas = fees.max_fee_per_gas, + max_priority_fee_per_gas = fees.max_priority_fee_per_gas, confirmation_depth = self.config.confirmation_depth, "sent batch submission tx to L1" ); diff --git a/sequencer/src/recovery/flusher.rs b/sequencer/src/recovery/flusher.rs index e7de31f3..e6d925ac 100644 --- a/sequencer/src/recovery/flusher.rs +++ b/sequencer/src/recovery/flusher.rs @@ -19,6 +19,7 @@ use std::time::Duration; use thiserror::Error; use tracing::{debug, error, info}; +use crate::l1::eip1559::bumped_replacement_fees; use crate::l1::watermark::{StorageWatermarkSink, WalletNonceWatermarkSink}; #[derive(Debug, Error)] @@ -50,20 +51,6 @@ fn derive_timeouts(seconds_per_block: u64) -> (Duration, Duration) { ) } -/// Bump current 1559 fee estimates so flush no-ops are competitive with -/// pending batch transactions at the same wallet nonces. -/// -/// Safety does not depend on the no-op winning. Either the original batch tx -/// or the no-op can consume the slot; `flush_and_wait` only returns once -/// `Pending <= Safe`. These bumped fees are an operational acceleration, not a -/// correctness precondition. The `+ 1` on `max_fee` avoids integer-rounding -/// flat spots, and the priority doubling is intentionally generous. -fn bumped_replacement_fees(base_max_fee: u128, base_priority_fee: u128) -> (u128, u128) { - let new_max_fee = base_max_fee.saturating_mul(11) / 10 + 1; - let new_priority_fee = base_priority_fee.saturating_mul(2).max(1); - (new_max_fee, new_priority_fee) -} - fn send_failures_error(failures: &[(u64, String)]) -> FlushError { const MAX_SAMPLES: usize = 3; @@ -258,6 +245,10 @@ impl MempoolFlusher { .await .map_err(|e| FlushError::Provider(e.to_string()))?; + // Bump the absolute estimate so no-ops can compete with pending batch + // txs at the same wallet nonces (shared ≥10% replacement rule). Safety + // does not depend on the no-op winning — `flush_and_wait` only returns + // once Pending ≤ Safe. let (bumped_max_fee, bumped_priority_fee) = bumped_replacement_fees(fees.max_fee_per_gas, fees.max_priority_fee_per_gas); @@ -369,40 +360,9 @@ mod tests { } // ── H5: replacement-fee bump keeps no-ops competitive ───────── - - #[test] - fn replacement_fee_bump_exceeds_ten_percent_for_max_fee() { - // `max_fee_per_gas` must strictly exceed base by ≥10% for any positive base. - for base in [1_u128, 10, 100, 1_000, 1_000_000, 1_000_000_000_000] { - let (new_max, _) = bumped_replacement_fees(base, 0); - assert!( - new_max.saturating_mul(10) >= base.saturating_mul(11), - "max_fee bump violates ≥10% rule: base={base}, new={new_max}", - ); - } - } - - #[test] - fn replacement_fee_bump_doubles_priority_fee() { - // `priority_fee` doubles (200%), easily clearing the 10% replacement threshold. - for base in [1_u128, 10, 1_000, 1_000_000_000] { - let (_, new_prio) = bumped_replacement_fees(0, base); - assert_eq!(new_prio, base.saturating_mul(2)); - assert!( - new_prio.saturating_mul(10) >= base.saturating_mul(11), - "priority bump violates ≥10% rule: base={base}, new={new_prio}", - ); - } - } - - #[test] - fn replacement_fee_floor_is_positive_even_when_base_is_zero() { - // If the estimator returns zero, bumped values are still positive so the - // tx is actually broadcast rather than rejected by the node. - let (new_max, new_prio) = bumped_replacement_fees(0, 0); - assert!(new_max >= 1); - assert!(new_prio >= 1); - } + // Rule itself lives in `l1::eip1559` (shared with the poster); the + // flusher's use site is the `bumped_replacement_fees(...)` call in + // `submit_noops`. #[test] fn send_failure_error_summarizes_failed_slots() { @@ -433,14 +393,6 @@ mod tests { assert!(matches!(err, FlushError::Provider(_))); } - #[test] - fn replacement_fee_bump_saturates_at_u128_max() { - // Overflow safety: astronomical base fees must not wrap around. - let (new_max, new_prio) = bumped_replacement_fees(u128::MAX, u128::MAX); - assert_eq!(new_max, u128::MAX / 10 + 1); - assert_eq!(new_prio, u128::MAX); - } - // ── H6: timeouts derive from seconds_per_block ──────────────── #[test] From 77a4a61042a84863fe43a0609981d29bac310003 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:03:48 +0800 Subject: [PATCH 2/7] test(l1): cover poster in-flight fee floor wiring Add mixed-component fees_for_nonce coverage, seeded replacement floor, Latest prune, and failed-send non-record assertions. --- sequencer/src/l1/eip1559.rs | 36 +++++ sequencer/src/l1/submitter/poster.rs | 197 +++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) diff --git a/sequencer/src/l1/eip1559.rs b/sequencer/src/l1/eip1559.rs index 4dba81f5..8a5244b6 100644 --- a/sequencer/src/l1/eip1559.rs +++ b/sequencer/src/l1/eip1559.rs @@ -181,4 +181,40 @@ mod tests { let fees = fees_for_nonce(estimate, Some(prior)); assert_eq!(fees, estimate); } + + #[test] + fn fees_for_nonce_clears_both_fields_when_estimate_is_mixed() { + // Market moved up on max_fee but not on priority (or the reverse): + // each component must still clear the ≥10% floor vs the in-flight tx. + let prior = Eip1559Fees { + base_fee_per_gas: 100, + max_priority_fee_per_gas: 100, + max_fee_per_gas: 1_000, + }; + let (bumped_max, bumped_prio) = + bumped_replacement_fees(prior.max_fee_per_gas, prior.max_priority_fee_per_gas); + + // High max_fee estimate, priority still below the replacement floor. + let estimate_high_max = Eip1559Fees { + base_fee_per_gas: 100, + max_priority_fee_per_gas: prior.max_priority_fee_per_gas + 1, // < 10% bump + max_fee_per_gas: bumped_max + 5_000, + }; + let fees = fees_for_nonce(estimate_high_max, Some(prior)); + assert_eq!(fees.max_fee_per_gas, estimate_high_max.max_fee_per_gas); + assert_eq!(fees.max_priority_fee_per_gas, bumped_prio); + + // High priority estimate, max_fee still below the replacement floor. + let estimate_high_prio = Eip1559Fees { + base_fee_per_gas: 100, + max_priority_fee_per_gas: bumped_prio + 50, + max_fee_per_gas: prior.max_fee_per_gas + 1, // < 10% bump + }; + let fees = fees_for_nonce(estimate_high_prio, Some(prior)); + assert_eq!(fees.max_fee_per_gas, bumped_max); + assert_eq!( + fees.max_priority_fee_per_gas, + estimate_high_prio.max_priority_fee_per_gas + ); + } } diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index ff4f3ad4..38ab26ff 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -16,6 +16,8 @@ use crate::l1::eip1559::{Eip1559Fees, estimate_fees, fees_for_nonce}; use crate::l1::partition::{decode_evm_advance_input, get_input_added_events_ordered}; use crate::l1::watermark::WalletNonceWatermarkSink; use std::collections::BTreeMap; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; pub type TxHash = alloy_primitives::B256; @@ -75,6 +77,10 @@ pub struct EthereumBatchPoster { /// [`crate::l1::eip1559::bumped_replacement_fees`] of this record so a /// flat market cannot re-broadcast underpriced replacements. in_flight_fees: Arc>>, + /// Test-only: next `send_batch_at_nonce` returns Err without broadcasting, + /// so callers can assert the in-flight map is not updated on send failure. + #[cfg(test)] + fail_next_send: Arc, } impl EthereumBatchPoster { @@ -83,9 +89,29 @@ impl EthereumBatchPoster { provider, config, in_flight_fees: Arc::new(Mutex::new(BTreeMap::new())), + #[cfg(test)] + fail_next_send: Arc::new(AtomicBool::new(false)), } } + #[cfg(test)] + pub(crate) fn in_flight_fees_for_test(&self) -> BTreeMap { + self.in_flight_fees + .lock() + .expect("in_flight_fees lock") + .clone() + } + + #[cfg(test)] + pub(crate) fn seed_in_flight_fees_for_test(&self, fees: BTreeMap) { + *self.in_flight_fees.lock().expect("in_flight_fees lock") = fees; + } + + #[cfg(test)] + pub(crate) fn fail_next_send_for_test(&self) { + self.fail_next_send.store(true, Ordering::SeqCst); + } + /// Conservative upper-bound timeout for waiting on confirmations, derived /// from the configured block time. Shorter block times on other chains just /// make the watch complete sooner. @@ -110,6 +136,14 @@ impl EthereumBatchPoster { nonce: u64, fees: &Eip1559Fees, ) -> Result, BatchPosterError> { + #[cfg(test)] + { + if self.fail_next_send.swap(false, Ordering::SeqCst) { + return Err(BatchPosterError::Provider( + "test-injected send failure".to_string(), + )); + } + } let input_box = InputBox::new(self.config.l1_submit_address, &self.provider); input_box .addInput(self.config.app_address, payload.into()) @@ -404,6 +438,7 @@ pub(crate) mod mock { #[cfg(test)] mod tests { + use std::collections::BTreeMap; use std::sync::Mutex; use std::time::Duration; @@ -609,4 +644,166 @@ mod tests { assert_eq!(derive_confirmation_timeout(2, 1), Duration::from_secs(6)); assert_eq!(derive_confirmation_timeout(5, 3), Duration::from_secs(36)); } + + fn poster_config(anvil: &alloy::node_bindings::AnvilInstance) -> BatchPosterConfig { + BatchPosterConfig { + l1_submit_address: alloy_primitives::Address::repeat_byte(0x11), + app_address: alloy_primitives::Address::repeat_byte(0x22), + batch_submitter_address: alloy_primitives::address!( + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + ), + start_block: 0, + // confirmation_depth 0 → watch timeout is 2 * seconds_per_block; + // keep it short so --no-mining ticks return promptly on timeout. + confirmation_depth: 0, + seconds_per_block: 1, + long_block_range_error_codes: vec![], + expected_chain_id: anvil.chain_id(), + } + } + + /// Same-nonce retry floors a flat re-estimate against the in-flight record + /// (≥10% on both fields). Seeds the prior floor explicitly so the assertion + /// does not depend on Anvil keeping a tx pending across ticks. + #[tokio::test] + async fn submit_batches_replacement_clears_ten_percent_bump() { + require_anvil(); + let anvil = Anvil::default().timeout(30_000).spawn(); + let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false) + .expect("signer provider"); + let poster = EthereumBatchPoster::new(provider.clone(), poster_config(&anvil)); + let sink = RecordingWatermarkSink::passing(); + + let base_nonce = provider + .get_transaction_count(submitter) + .await + .expect("base nonce"); + // Prior fees high enough that a fresh Anvil estimate will not clear the + // ≥10% floor on its own — the poster must bump against this record. + let prior = crate::l1::eip1559::Eip1559Fees { + base_fee_per_gas: 1, + max_priority_fee_per_gas: 50_000_000, // 0.05 gwei + max_fee_per_gas: 100_000_000_000, // 100 gwei + }; + poster.seed_in_flight_fees_for_test(BTreeMap::from([(base_nonce, prior)])); + + poster + .submit_batches(vec![vec![0u8; 4]], &sink) + .await + .expect("submit with in-flight floor"); + let sent = poster + .in_flight_fees_for_test() + .get(&base_nonce) + .copied() + .expect("successful send must record fees"); + + let (bumped_max, bumped_prio) = crate::l1::eip1559::bumped_replacement_fees( + prior.max_fee_per_gas, + prior.max_priority_fee_per_gas, + ); + assert!( + sent.max_fee_per_gas >= bumped_max, + "max_fee must clear replacement floor: sent={} floor={bumped_max}", + sent.max_fee_per_gas + ); + assert!( + sent.max_priority_fee_per_gas >= bumped_prio, + "priority must clear replacement floor: sent={} floor={bumped_prio}", + sent.max_priority_fee_per_gas + ); + } + + /// When Latest advances past a nonce, that nonce's fee floor is dropped so a + /// later tip send is not incorrectly floored by stale in-flight state. + #[tokio::test] + async fn submit_batches_prunes_in_flight_fees_past_latest() { + require_anvil(); + let anvil = Anvil::default().timeout(30_000).spawn(); // automine on + let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false) + .expect("signer provider"); + let poster = EthereumBatchPoster::new(provider.clone(), poster_config(&anvil)); + let sink = RecordingWatermarkSink::passing(); + + let base_nonce = provider + .get_transaction_count(submitter) + .await + .expect("base nonce"); + + poster + .submit_batches(vec![vec![0u8; 4]], &sink) + .await + .expect("first submit mines under automine"); + assert!( + poster.in_flight_fees_for_test().contains_key(&base_nonce), + "first send records fees for the mined nonce" + ); + + // Tip confirmed → Latest = base_nonce + 1. Re-seed a stale floor on the + // mined nonce (as if a previous tick left it) and confirm the next + // submit prunes it. + let stale = crate::l1::eip1559::Eip1559Fees { + base_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 1, + }; + poster.seed_in_flight_fees_for_test(BTreeMap::from([(base_nonce, stale)])); + + poster + .submit_batches(vec![vec![1u8; 4]], &sink) + .await + .expect("second submit"); + + let in_flight = poster.in_flight_fees_for_test(); + assert!( + !in_flight.contains_key(&base_nonce), + "mined nonce must be pruned once Latest advances: {in_flight:?}" + ); + let tip_nonce = base_nonce.saturating_add(1); + assert!( + in_flight.contains_key(&tip_nonce), + "current tip send must be recorded: {in_flight:?}" + ); + } + + /// A failed broadcast must not raise the replacement floor — otherwise a + /// blip would permanently overprice the next successful send, or worse, + /// record fees for a tx that never entered the mempool. + #[tokio::test] + async fn submit_batches_does_not_record_fees_when_send_fails() { + require_anvil(); + let anvil = Anvil::default().arg("--no-mining").timeout(30_000).spawn(); + let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false) + .expect("signer provider"); + let poster = EthereumBatchPoster::new(provider.clone(), poster_config(&anvil)); + let sink = RecordingWatermarkSink::passing(); + + let base_nonce = provider + .get_transaction_count(submitter) + .await + .expect("base nonce"); + let prior = crate::l1::eip1559::Eip1559Fees { + base_fee_per_gas: 42, + max_priority_fee_per_gas: 7, + max_fee_per_gas: 1_000, + }; + poster.seed_in_flight_fees_for_test(BTreeMap::from([(base_nonce, prior)])); + poster.fail_next_send_for_test(); + + let result = poster.submit_batches(vec![vec![0u8; 4]], &sink).await; + assert!( + matches!(result, Err(BatchPosterError::Provider(ref msg)) if msg.contains("test-injected")), + "injected send failure must surface, got {result:?}" + ); + assert_eq!( + poster.in_flight_fees_for_test(), + BTreeMap::from([(base_nonce, prior)]), + "failed send must leave the prior in-flight floor untouched" + ); + } } From e54879bd8127d86b845377b5b90cfc71486c8d8e Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:03:12 +0800 Subject: [PATCH 3/7] test(l1): unflake fee-oracle retain-across-transient loop A 200ms sleep raced the first spawn_blocking SQLite write on loaded CI, so the assertion still saw the default log_gas_price of 0. --- sequencer/src/l1/fee_oracle/worker.rs | 43 ++++++++++++++++++++------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/sequencer/src/l1/fee_oracle/worker.rs b/sequencer/src/l1/fee_oracle/worker.rs index 06c0322e..fe98823f 100644 --- a/sequencer/src/l1/fee_oracle/worker.rs +++ b/sequencer/src/l1/fee_oracle/worker.rs @@ -277,7 +277,8 @@ mod tests { use super::*; use crate::storage::test_helpers::temp_db; use alloy_primitives::U256; - use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; const TEST_MAX_AGE_MS: u64 = 60 * 60 * 1000; @@ -300,16 +301,15 @@ mod tests { } struct FailsAfterFirstGas { - calls: Mutex, + calls: Arc, ok: Eip1559Fees, } #[async_trait] impl GasFeeSource for FailsAfterFirstGas { async fn estimate_gas_fees(&self) -> Result { - let mut calls = self.calls.lock().expect("lock"); - *calls += 1; - if *calls == 1 { + let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if n == 1 { Ok(self.ok) } else { Err("rpc unavailable".into()) @@ -438,7 +438,7 @@ mod tests { &db.path, TEST_MAX_AGE_MS, Box::new(FailsAfterFirstGas { - calls: Mutex::new(0), + calls: Arc::new(AtomicUsize::new(0)), ok: sample_fees(), }), Box::new(StaticToken(sample_quote())), @@ -532,12 +532,13 @@ mod tests { initialize_db(&db.path); let expected_log = expected_log_price(); + let gas_calls = Arc::new(AtomicUsize::new(0)); let oracle = FeeOracle::new_with_sources( db.path.clone(), Duration::from_millis(40), TEST_MAX_AGE_MS, Box::new(FailsAfterFirstGas { - calls: Mutex::new(0), + calls: Arc::clone(&gas_calls), ok: sample_fees(), }), Box::new(StaticToken(sample_quote())), @@ -545,10 +546,30 @@ mod tests { let shutdown = ShutdownSignal::default(); let mut handle = oracle.start(shutdown.clone()); - tokio::select! { - biased; - result = &mut handle => panic!("fee oracle exited early: {result:?}"), - _ = tokio::time::sleep(Duration::from_millis(200)) => {} + // First refresh is a spawn_blocking SQLite write; a fixed sleep flakes + // when the blocking pool is busy. Wait until the price is persisted + // *and* a later tick has failed, so retain-on-transient is covered. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + tokio::select! { + biased; + result = &mut handle => panic!("fee oracle exited early: {result:?}"), + _ = tokio::time::sleep(Duration::from_millis(10)) => {} + } + let price = Storage::open_read_only(&db.path) + .unwrap() + .log_gas_price() + .unwrap(); + let calls = gas_calls.load(Ordering::SeqCst); + if price == expected_log && calls >= 2 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!( + "timed out waiting for retained fee-oracle price \ + (expected {expected_log}, last read {price}, gas_calls={calls})" + ); + } } let storage = Storage::open_read_only(&db.path).unwrap(); assert_eq!(storage.log_gas_price().unwrap(), expected_log); From ba0275082ad8337e71d8c31edf32c9621885980a Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:34:14 +0800 Subject: [PATCH 4/7] fix(l1): keep replacement bumps EIP-1559-valid across retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grow max_fee and priority by the same ×1.1+1 factor and clamp tip ≤ cap so poster retries cannot wedge on ErrTipAboveFeeCap. Only the blocking (Latest) nonce is re-escalated; suffix txs keep their original hash. --- sequencer/src/l1/eip1559.rs | 163 +++++++++++++++++++++++---- sequencer/src/l1/submitter/poster.rs | 132 ++++++++++++++++++---- sequencer/src/recovery/flusher.rs | 14 ++- 3 files changed, 261 insertions(+), 48 deletions(-) diff --git a/sequencer/src/l1/eip1559.rs b/sequencer/src/l1/eip1559.rs index 8a5244b6..147300b3 100644 --- a/sequencer/src/l1/eip1559.rs +++ b/sequencer/src/l1/eip1559.rs @@ -18,35 +18,62 @@ pub struct Eip1559Fees { pub max_fee_per_gas: u128, } +/// Bump one EIP-1559 component for a same-nonce replacement. +/// +/// ×1.1, plus 1 wei so integer division cannot stall on a flat spot and so +/// geth's strict-greater precheck still passes when `x` is tiny. Saturating +/// `x+1` keeps a `u128::MAX` fee from shrinking after `saturating_mul`. +fn bump_replacement_component(value: u128) -> u128 { + let bumped = value.saturating_mul(11) / 10 + 1; + bumped.max(value.saturating_add(1)) +} + /// Bump EIP-1559 fees for a same-nonce replacement under the ≥10% rule. /// -/// `max_fee` gets ×1.1 (+1 for integer-rounding flat spots); priority doubles -/// (intentionally generous past the 10% threshold). The poster floors a -/// re-estimate against the last successful send at that wallet nonce; the -/// flusher bumps a fresh estimate so no-ops can compete with pending batch -/// txs. Eviction is operational acceleration, not a correctness precondition. +/// Both `max_fee` and the priority tip grow by the same ×1.1 (+1) factor. +/// Asymmetric growth (tip ×2, cap ×1.1) compounds across poster retries until +/// `tip > max_fee` — an invalid EIP-1559 tx every node rejects +/// (`ErrTipAboveFeeCap`), and because a failed send does not update the +/// in-flight floor the poster then resubmits the identical invalid pair +/// forever. Equal growth preserves `tip ≤ max_fee` whenever the input did; +/// the clamp is defense-in-depth for a one-shot bump of a near-zero-base +/// estimate (the flusher) and for already-invalid inputs. +/// +/// The poster floors a re-estimate against the last successful send at that +/// wallet nonce; the flusher bumps a fresh estimate so no-ops can compete +/// with pending batch txs. Eviction is operational acceleration, not a +/// correctness precondition. pub fn bumped_replacement_fees(base_max_fee: u128, base_priority_fee: u128) -> (u128, u128) { - let new_max_fee = base_max_fee.saturating_mul(11) / 10 + 1; - let new_priority_fee = base_priority_fee.saturating_mul(2).max(1); + let tip = base_priority_fee.min(base_max_fee); + let new_max_fee = bump_replacement_component(base_max_fee); + let new_priority_fee = bump_replacement_component(tip).min(new_max_fee); (new_max_fee, new_priority_fee) } /// Absolute estimate, raised to a replacement floor when `prior` is set. /// -/// First send at a nonce uses `estimate` unchanged. A same-nonce resubmit -/// takes the per-component max of the fresh estimate and +/// First send at a nonce uses `estimate` (clamped so `tip ≤ max_fee`). A +/// same-nonce resubmit takes the per-component max of the fresh estimate and /// [`bumped_replacement_fees`] of the last successful broadcast, so a flat -/// market cannot re-broadcast underpriced replacements. +/// market cannot re-broadcast underpriced replacements. The tip is then +/// clamped to the fee cap: geth will not accept `maxPriorityFeePerGas > +/// maxFeePerGas`, and a rejected send must not become a sticky invalid pair. pub fn fees_for_nonce(estimate: Eip1559Fees, prior: Option) -> Eip1559Fees { - let Some(prior) = prior else { - return estimate; + let fees = match prior { + None => estimate, + Some(prior) => { + let (bumped_max, bumped_prio) = + bumped_replacement_fees(prior.max_fee_per_gas, prior.max_priority_fee_per_gas); + Eip1559Fees { + base_fee_per_gas: estimate.base_fee_per_gas, + max_fee_per_gas: estimate.max_fee_per_gas.max(bumped_max), + max_priority_fee_per_gas: estimate.max_priority_fee_per_gas.max(bumped_prio), + } + } }; - let (bumped_max, bumped_prio) = - bumped_replacement_fees(prior.max_fee_per_gas, prior.max_priority_fee_per_gas); Eip1559Fees { - base_fee_per_gas: estimate.base_fee_per_gas, - max_fee_per_gas: estimate.max_fee_per_gas.max(bumped_max), - max_priority_fee_per_gas: estimate.max_priority_fee_per_gas.max(bumped_prio), + max_priority_fee_per_gas: fees.max_priority_fee_per_gas.min(fees.max_fee_per_gas), + ..fees } } @@ -113,14 +140,33 @@ mod tests { } #[test] - fn replacement_fee_bump_doubles_priority_fee() { - for base in [1_u128, 10, 1_000, 1_000_000_000] { - let (_, new_prio) = bumped_replacement_fees(0, base); - assert_eq!(new_prio, base.saturating_mul(2)); + fn replacement_fee_bump_exceeds_ten_percent_for_priority_fee() { + for base in [1_u128, 10, 100, 1_000, 1_000_000, 1_000_000_000_000] { + // Cap high enough that the tip clamp does not bind. + let (_, new_prio) = bumped_replacement_fees(base.saturating_mul(4), base); assert!( new_prio.saturating_mul(10) >= base.saturating_mul(11), "priority bump violates ≥10% rule: base={base}, new={new_prio}", ); + assert!(new_prio > base); + } + } + + #[test] + fn replacement_fee_bump_keeps_tip_at_or_below_fee_cap() { + for (max_fee, tip) in [ + (0_u128, 0), + (0, 100), + (1, 1), + (1, 10), + (20_000_000_000, 1_000_000_000), + (u128::MAX, u128::MAX), + ] { + let (new_max, new_prio) = bumped_replacement_fees(max_fee, tip); + assert!( + new_prio <= new_max, + "bumped tip {new_prio} exceeds fee cap {new_max} (from max={max_fee} tip={tip})", + ); } } @@ -134,7 +180,7 @@ mod tests { #[test] fn replacement_fee_bump_saturates_at_u128_max() { let (new_max, new_prio) = bumped_replacement_fees(u128::MAX, u128::MAX); - assert_eq!(new_max, u128::MAX / 10 + 1); + assert_eq!(new_max, u128::MAX); assert_eq!(new_prio, u128::MAX); } @@ -217,4 +263,77 @@ mod tests { estimate_high_prio.max_priority_fee_per_gas ); } + + fn assert_eip1559_valid(fees: Eip1559Fees) { + assert!( + fees.max_priority_fee_per_gas <= fees.max_fee_per_gas, + "invalid EIP-1559 pair: tip {} > max_fee {}", + fees.max_priority_fee_per_gas, + fees.max_fee_per_gas, + ); + } + + fn assert_clears_replacement_floor(prior: Eip1559Fees, next: Eip1559Fees) { + assert!(next.max_fee_per_gas > prior.max_fee_per_gas); + assert!(next.max_priority_fee_per_gas > prior.max_priority_fee_per_gas); + assert!( + next.max_fee_per_gas.saturating_mul(10) >= prior.max_fee_per_gas.saturating_mul(11), + "max_fee lost the ≥10% floor: prior={} next={}", + prior.max_fee_per_gas, + next.max_fee_per_gas, + ); + assert!( + next.max_priority_fee_per_gas.saturating_mul(10) + >= prior.max_priority_fee_per_gas.saturating_mul(11), + "priority lost the ≥10% floor: prior={} next={}", + prior.max_priority_fee_per_gas, + next.max_priority_fee_per_gas, + ); + } + + #[test] + fn fees_for_nonce_stays_valid_across_repeated_flat_retries() { + // The poster records the *sent* pair, so a stuck nonce compounds the + // bump against its own output. Asymmetric ×2 tip / ×1.1 cap crossed + // `tip > max_fee` in ~7 rounds at 20 gwei base / 1 gwei tip — and on + // the first retry when cap ≈ tip. Equal growth must stay valid. + for start in [ + Eip1559Fees { + base_fee_per_gas: 20_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 41_000_000_000, + }, + Eip1559Fees { + base_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 1, + }, + Eip1559Fees { + base_fee_per_gas: 0, + max_priority_fee_per_gas: 1_000, + max_fee_per_gas: 1_000, + }, + ] { + let mut fees = start; + assert_eip1559_valid(fees); + for _ in 0..20 { + let next = fees_for_nonce(fees, Some(fees)); + assert_eip1559_valid(next); + assert_clears_replacement_floor(fees, next); + fees = next; + } + } + } + + #[test] + fn fees_for_nonce_clamps_tip_above_fee_cap_on_first_send() { + let estimate = Eip1559Fees { + base_fee_per_gas: 1, + max_priority_fee_per_gas: 500, + max_fee_per_gas: 100, + }; + let fees = fees_for_nonce(estimate, None); + assert_eq!(fees.max_fee_per_gas, 100); + assert_eq!(fees.max_priority_fee_per_gas, 100); + } } diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index 38ab26ff..1b68fe89 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -22,6 +22,16 @@ use std::sync::{Arc, Mutex}; pub type TxHash = alloy_primitives::B256; +/// Last successful broadcast at a wallet nonce: fees we actually sent, and +/// the hash the next tick can keep watching if this nonce is no longer the +/// blocking head (so we do not replace it). `tx_hash` is `None` only in tests +/// that seed a fee floor without a prior send. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InFlightTx { + fees: Eip1559Fees, + tx_hash: Option, +} + #[derive(Debug, Clone)] pub struct BatchPosterConfig { pub l1_submit_address: alloy_primitives::Address, @@ -72,11 +82,21 @@ pub trait BatchPoster: Send + Sync { pub struct EthereumBatchPoster { provider: DynProvider, config: BatchPosterConfig, - /// Fees of the last successful broadcast per wallet nonce still ≥ Latest. - /// Same-nonce retries floor a fresh estimate against - /// [`crate::l1::eip1559::bumped_replacement_fees`] of this record so a - /// flat market cannot re-broadcast underpriced replacements. - in_flight_fees: Arc>>, + /// Fees + tx hash of the last successful broadcast per wallet nonce still + /// ≥ Latest. + /// + /// Same-nonce retries of the **head** (Latest) nonce floor a fresh + /// estimate against [`crate::l1::eip1559::bumped_replacement_fees`] of + /// this record so a flat market cannot re-broadcast underpriced + /// replacements. Suffix nonces already in the map are left in the mempool: + /// only the head can be blocking, and re-escalating the whole unconfirmed + /// suffix compounds fees for txs that cannot mine until the head does. + /// + /// Process-local, so the floor is best-effort, not an invariant. A restart + /// (or a send whose response is lost after the node accepted) re-opens the + /// underpriced-retry window for a cycle. A future hardening is to also + /// raise the floor on a "replacement transaction underpriced" send error. + in_flight: Arc>>, /// Test-only: next `send_batch_at_nonce` returns Err without broadcasting, /// so callers can assert the in-flight map is not updated on send failure. #[cfg(test)] @@ -88,7 +108,7 @@ impl EthereumBatchPoster { Self { provider, config, - in_flight_fees: Arc::new(Mutex::new(BTreeMap::new())), + in_flight: Arc::new(Mutex::new(BTreeMap::new())), #[cfg(test)] fail_next_send: Arc::new(AtomicBool::new(false)), } @@ -96,15 +116,30 @@ impl EthereumBatchPoster { #[cfg(test)] pub(crate) fn in_flight_fees_for_test(&self) -> BTreeMap { - self.in_flight_fees + self.in_flight .lock() - .expect("in_flight_fees lock") - .clone() + .expect("in_flight lock") + .iter() + .map(|(&nonce, tx)| (nonce, tx.fees)) + .collect() } #[cfg(test)] pub(crate) fn seed_in_flight_fees_for_test(&self, fees: BTreeMap) { - *self.in_flight_fees.lock().expect("in_flight_fees lock") = fees; + let mut in_flight = self.in_flight.lock().expect("in_flight lock"); + in_flight.clear(); + for (nonce, fees) in fees { + in_flight.insert( + nonce, + InFlightTx { + fees, + // Tests that seed a floor then submit are replacing the + // head nonce; the hash is only used to skip suffix + // re-broadcast, which those tests do not exercise. + tx_hash: None, + }, + ); + } } #[cfg(test)] @@ -169,10 +204,11 @@ impl EthereumBatchPoster { /// /// Timeouts return `Ok(())` rather than `Err` because the safe response is /// "re-enter `submit_batches` on the next tick" — which re-estimates fees, - /// floors them to an explicit ≥10% replacement bump against any still - /// in-flight same-nonce submission, and re-submits at the same wallet - /// nonces. The wallet-nonce ordering invariant above guarantees we cannot - /// accidentally skip work by returning early here. + /// floors the **head** nonce to an explicit ≥10% replacement bump against + /// any still in-flight same-nonce submission, leaves already-broadcast + /// suffix txs in the mempool, and re-submits only what still needs a + /// replacement. The wallet-nonce ordering invariant above guarantees we + /// cannot accidentally skip work by returning early here. async fn wait_for_confirmations(&self, tx_hashes: &[TxHash]) -> Result<(), BatchPosterError> { let timeout = self.confirmation_timeout(); for tx_hash in tx_hashes { @@ -208,6 +244,15 @@ impl EthereumBatchPoster { } } +/// If this nonce is behind the blocking head and already in the mempool, keep +/// watching the original hash instead of replacing it. +fn suffix_watch_hash(head_nonce: u64, nonce: u64, existing: Option) -> Option { + if nonce == head_nonce { + return None; + } + existing.and_then(|tx| tx.tx_hash) +} + fn derive_confirmation_timeout( confirmation_depth: u64, seconds_per_block: u64, @@ -256,7 +301,7 @@ impl BatchPoster for EthereumBatchPoster { // Drop fee floors for nonces Latest has advanced past — those slots // are resolved and must not floor a later send. { - let mut in_flight = self.in_flight_fees.lock().expect("in_flight_fees lock"); + let mut in_flight = self.in_flight.lock().expect("in_flight lock"); in_flight.retain(|&nonce, _| nonce >= next_nonce); } @@ -269,20 +314,37 @@ impl BatchPoster for EthereumBatchPoster { .map_err(BatchPosterError::Provider)?; let mut tx_hashes = Vec::with_capacity(payloads.len()); + let head_nonce = next_nonce; for payload in payloads { - let fees = { - let in_flight = self.in_flight_fees.lock().expect("in_flight_fees lock"); - fees_for_nonce(estimate, in_flight.get(&next_nonce).copied()) + let existing = { + let in_flight = self.in_flight.lock().expect("in_flight lock"); + in_flight.get(&next_nonce).copied() }; + + if let Some(tx_hash) = suffix_watch_hash(head_nonce, next_nonce, existing) { + tx_hashes.push(tx_hash); + next_nonce = next_nonce.saturating_add(1); + continue; + } + + let prior = if next_nonce == head_nonce { + existing.map(|tx| tx.fees) + } else { + None + }; + let fees = fees_for_nonce(estimate, prior); let pending = self.send_batch_at_nonce(payload, next_nonce, &fees).await?; // Record only after a successful broadcast — a failed send must // not raise the replacement floor for the next tick. - self.in_flight_fees - .lock() - .expect("in_flight_fees lock") - .insert(next_nonce, fees); let tx_hash = *pending.tx_hash(); + self.in_flight.lock().expect("in_flight lock").insert( + next_nonce, + InFlightTx { + fees, + tx_hash: Some(tx_hash), + }, + ); debug!( tx_nonce = next_nonce, %tx_hash, @@ -443,8 +505,8 @@ mod tests { use std::time::Duration; use super::{ - BatchPoster, BatchPosterConfig, BatchPosterError, EthereumBatchPoster, - derive_confirmation_timeout, mock::MockBatchPoster, + BatchPoster, BatchPosterConfig, BatchPosterError, EthereumBatchPoster, InFlightTx, TxHash, + derive_confirmation_timeout, mock::MockBatchPoster, suffix_watch_hash, }; use crate::l1::watermark::WalletNonceWatermarkSink; use alloy::node_bindings::Anvil; @@ -645,6 +707,28 @@ mod tests { assert_eq!(derive_confirmation_timeout(5, 3), Duration::from_secs(36)); } + #[test] + fn suffix_watch_hash_skips_only_non_head_with_a_stored_hash() { + let hash = TxHash::repeat_byte(0xab); + let with_hash = InFlightTx { + fees: crate::l1::eip1559::Eip1559Fees { + base_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 2, + }, + tx_hash: Some(hash), + }; + let fees_only = InFlightTx { + fees: with_hash.fees, + tx_hash: None, + }; + + assert_eq!(suffix_watch_hash(10, 10, Some(with_hash)), None); + assert_eq!(suffix_watch_hash(10, 11, Some(with_hash)), Some(hash)); + assert_eq!(suffix_watch_hash(10, 11, Some(fees_only)), None); + assert_eq!(suffix_watch_hash(10, 11, None), None); + } + fn poster_config(anvil: &alloy::node_bindings::AnvilInstance) -> BatchPosterConfig { BatchPosterConfig { l1_submit_address: alloy_primitives::Address::repeat_byte(0x11), diff --git a/sequencer/src/recovery/flusher.rs b/sequencer/src/recovery/flusher.rs index e6d925ac..df7cd534 100644 --- a/sequencer/src/recovery/flusher.rs +++ b/sequencer/src/recovery/flusher.rs @@ -246,9 +246,18 @@ impl MempoolFlusher { .map_err(|e| FlushError::Provider(e.to_string()))?; // Bump the absolute estimate so no-ops can compete with pending batch - // txs at the same wallet nonces (shared ≥10% replacement rule). Safety + // txs at the same wallet nonces (shared ≥10% replacement rule). Both + // components grow equally; the helper also clamps tip ≤ cap so a + // near-zero-base estimate cannot produce ErrTipAboveFeeCap. Safety // does not depend on the no-op winning — `flush_and_wait` only returns // once Pending ≤ Safe. + // + // Residual gap: this is a one-shot bump of a *fresh* estimate, not of + // the pending tx's fees, so a replacement can still be underpriced + // when the two EIP-1559 components have moved independently. A + // rejected no-op hard-errors `flush_and_wait`; the orchestrator + // respawn retries. Tightening that needs the pending tx's fees (or a + // raise-on-underpriced-error loop), not a bigger one-shot multiplier. let (bumped_max_fee, bumped_priority_fee) = bumped_replacement_fees(fees.max_fee_per_gas, fees.max_priority_fee_per_gas); @@ -362,7 +371,8 @@ mod tests { // ── H5: replacement-fee bump keeps no-ops competitive ───────── // Rule itself lives in `l1::eip1559` (shared with the poster); the // flusher's use site is the `bumped_replacement_fees(...)` call in - // `submit_noops`. + // `submit_noops`. Equal ×1.1 growth plus the tip≤cap clamp are what + // keep a one-shot bump of a near-zero-base estimate valid. #[test] fn send_failure_error_summarizes_failed_slots() { From b09dbc1c513b8c3d947c497d5d3b2306e6572517 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:34:14 +0800 Subject: [PATCH 5/7] fix(l1): raise poster floor on underpriced replacement errors When a same-nonce send is rejected as replacement-underpriced, immediately raise the stored head-nonce floor from the attempted fees so the next tick self-corrects instead of repeating the same underpriced pair. --- sequencer/src/l1/submitter/poster.rs | 60 +++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index 1b68fe89..2ac4db10 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -94,8 +94,9 @@ pub struct EthereumBatchPoster { /// /// Process-local, so the floor is best-effort, not an invariant. A restart /// (or a send whose response is lost after the node accepted) re-opens the - /// underpriced-retry window for a cycle. A future hardening is to also - /// raise the floor on a "replacement transaction underpriced" send error. + /// underpriced-retry window for a cycle. A rejected "replacement transaction + /// underpriced" still raises the stored floor so the next tick self-corrects + /// without waiting for a confirmation timeout. in_flight: Arc>>, /// Test-only: next `send_batch_at_nonce` returns Err without broadcasting, /// so callers can assert the in-flight map is not updated on send failure. @@ -253,6 +254,11 @@ fn suffix_watch_hash(head_nonce: u64, nonce: u64, existing: Option) existing.and_then(|tx| tx.tx_hash) } +/// geth rejects same-nonce replacements below the ≥10% bump threshold. +fn is_replacement_underpriced(err: &str) -> bool { + err.contains("replacement transaction underpriced") +} + fn derive_confirmation_timeout( confirmation_depth: u64, seconds_per_block: u64, @@ -334,9 +340,28 @@ impl BatchPoster for EthereumBatchPoster { None }; let fees = fees_for_nonce(estimate, prior); - let pending = self.send_batch_at_nonce(payload, next_nonce, &fees).await?; + let pending = match self.send_batch_at_nonce(payload, next_nonce, &fees).await { + Ok(pending) => pending, + Err(BatchPosterError::Provider(ref msg)) if is_replacement_underpriced(msg) => { + // Node rejected the replacement fee — raise the floor from + // what we just tried so the next tick clears the threshold + // without waiting for a confirmation timeout. + let raised = fees_for_nonce(fees, Some(fees)); + self.in_flight.lock().expect("in_flight lock").insert( + next_nonce, + InFlightTx { + fees: raised, + tx_hash: existing.and_then(|tx| tx.tx_hash), + }, + ); + return Err(BatchPosterError::Provider(msg.clone())); + } + Err(err) => return Err(err), + }; // Record only after a successful broadcast — a failed send must - // not raise the replacement floor for the next tick. + // not raise the replacement floor for the next tick (except the + // underpriced path above, which self-corrects against a live pending + // tx the node already holds). let tx_hash = *pending.tx_hash(); self.in_flight.lock().expect("in_flight lock").insert( next_nonce, @@ -506,7 +531,8 @@ mod tests { use super::{ BatchPoster, BatchPosterConfig, BatchPosterError, EthereumBatchPoster, InFlightTx, TxHash, - derive_confirmation_timeout, mock::MockBatchPoster, suffix_watch_hash, + derive_confirmation_timeout, is_replacement_underpriced, mock::MockBatchPoster, + suffix_watch_hash, }; use crate::l1::watermark::WalletNonceWatermarkSink; use alloy::node_bindings::Anvil; @@ -729,6 +755,30 @@ mod tests { assert_eq!(suffix_watch_hash(10, 11, None), None); } + #[test] + fn is_replacement_underpriced_matches_geth_message() { + assert!(is_replacement_underpriced( + "server returned an error response: error code -32000: replacement transaction underpriced" + )); + assert!(!is_replacement_underpriced("nonce too low")); + assert!(!is_replacement_underpriced( + "max priority fee per gas higher than max fee per gas" + )); + } + + #[test] + fn underpriced_send_raises_floor_from_attempted_fees() { + let attempted = crate::l1::eip1559::Eip1559Fees { + base_fee_per_gas: 20_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 41_000_000_000, + }; + let raised = crate::l1::eip1559::fees_for_nonce(attempted, Some(attempted)); + assert!(raised.max_fee_per_gas > attempted.max_fee_per_gas); + assert!(raised.max_priority_fee_per_gas > attempted.max_priority_fee_per_gas); + assert!(raised.max_priority_fee_per_gas <= raised.max_fee_per_gas); + } + fn poster_config(anvil: &alloy::node_bindings::AnvilInstance) -> BatchPosterConfig { BatchPosterConfig { l1_submit_address: alloy_primitives::Address::repeat_byte(0x11), From b60f60830b5b1ecbeece78e376840aa73ddb57d4 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:10:01 +0800 Subject: [PATCH 6/7] fix(l1): estimate gas without pending nonce on poster replacements Anvil rejects eth_estimateGas with "nonce too low" when the same nonce is already pending, so replacements never reached eth_sendRawTransaction. --- sequencer/src/l1/submitter/poster.rs | 156 ++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 5 deletions(-) diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index 2ac4db10..9224c7c3 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -171,6 +171,8 @@ impl EthereumBatchPoster { payload: Vec, nonce: u64, fees: &Eip1559Fees, + // True when this nonce already has a pending broadcast we are replacing. + replace_pending: bool, ) -> Result, BatchPosterError> { #[cfg(test)] { @@ -181,12 +183,28 @@ impl EthereumBatchPoster { } } let input_box = InputBox::new(self.config.l1_submit_address, &self.provider); - input_box + let call = input_box .addInput(self.config.app_address, payload.into()) - .nonce(nonce) .max_fee_per_gas(fees.max_fee_per_gas) - .max_priority_fee_per_gas(fees.max_priority_fee_per_gas) - .send() + .max_priority_fee_per_gas(fees.max_priority_fee_per_gas); + + // Same-nonce replacement: estimate gas *without* the pending nonce. + // Anvil (and geth's pending simulation) apply mempool nonce policy to + // `eth_estimateGas` and reject with "nonce too low" when that nonce is + // already pending — so the filler's estimate-with-nonce never reaches + // `eth_sendRawTransaction`. Pin the gas limit first so the filler + // skips a second estimate that would include the nonce. + let call = if replace_pending { + let gas = call + .estimate_gas() + .await + .map_err(|err| BatchPosterError::Provider(err.to_string()))?; + call.gas(gas).nonce(nonce) + } else { + call.nonce(nonce) + }; + + call.send() .await .map_err(|err| BatchPosterError::Provider(err.to_string())) } @@ -340,7 +358,10 @@ impl BatchPoster for EthereumBatchPoster { None }; let fees = fees_for_nonce(estimate, prior); - let pending = match self.send_batch_at_nonce(payload, next_nonce, &fees).await { + let pending = match self + .send_batch_at_nonce(payload, next_nonce, &fees, prior.is_some()) + .await + { Ok(pending) => pending, Err(BatchPosterError::Provider(ref msg)) if is_replacement_underpriced(msg) => { // Node rejected the replacement fee — raise the floor from @@ -849,6 +870,131 @@ mod tests { ); } + /// Same-nonce replacement must reach `eth_sendRawTransaction` while the + /// original is still pending. Without pinning gas from a nonce-free + /// estimate, Anvil rejects `eth_estimateGas(..., nonce=N, block=pending)` + /// with "nonce too low" and the replacement never broadcasts. + #[tokio::test] + async fn submit_batches_replaces_pending_tx_after_confirmation_timeout() { + require_anvil(); + let anvil = Anvil::default().arg("--no-mining").timeout(30_000).spawn(); + let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let submitter = alloy_primitives::address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let provider = crate::l1::provider::create_signer_provider(&anvil.endpoint(), key, false) + .expect("signer provider"); + let poster = EthereumBatchPoster::new(provider.clone(), poster_config(&anvil)); + let sink = RecordingWatermarkSink::passing(); + + let base_nonce = provider + .get_transaction_count(submitter) + .await + .expect("base nonce"); + + let first_hashes = poster + .submit_batches(vec![vec![0u8; 4]], &sink) + .await + .expect("first submit parks a pending tx"); + assert_eq!(first_hashes.len(), 1); + let first_hash = first_hashes[0]; + + let pending_after_first = provider + .get_transaction_count(submitter) + .block_id(BlockNumberOrTag::Pending.into()) + .await + .expect("pending nonce"); + let latest_after_first = provider + .get_transaction_count(submitter) + .block_id(BlockNumberOrTag::Latest.into()) + .await + .expect("latest nonce"); + assert_eq!( + pending_after_first, + base_nonce + 1, + "first send must occupy the mempool slot" + ); + assert_eq!( + latest_after_first, base_nonce, + "mining is disabled; latest must not advance" + ); + + let prior_fees = poster + .in_flight_fees_for_test() + .get(&base_nonce) + .copied() + .expect("first send records in-flight fees"); + + // Confirmation watch timed out inside the first submit; the next tick + // must bump fees and broadcast a same-nonce replacement. + let second_hashes = poster + .submit_batches(vec![vec![0u8; 4]], &sink) + .await + .expect("replacement must clear gas estimation and broadcast"); + assert_eq!(second_hashes.len(), 1); + let replacement_hash = second_hashes[0]; + assert_ne!( + replacement_hash, first_hash, + "replacement must be a distinct tx hash" + ); + + let sent = poster + .in_flight_fees_for_test() + .get(&base_nonce) + .copied() + .expect("replacement records bumped fees"); + let (bumped_max, bumped_prio) = crate::l1::eip1559::bumped_replacement_fees( + prior_fees.max_fee_per_gas, + prior_fees.max_priority_fee_per_gas, + ); + assert!( + sent.max_fee_per_gas >= bumped_max, + "replacement max_fee must clear floor: sent={} floor={bumped_max}", + sent.max_fee_per_gas + ); + assert!( + sent.max_priority_fee_per_gas >= bumped_prio, + "replacement priority must clear floor: sent={} floor={bumped_prio}", + sent.max_priority_fee_per_gas + ); + + // Pending still one slot ahead of latest until we mine. + let pending_after_replace = provider + .get_transaction_count(submitter) + .block_id(BlockNumberOrTag::Pending.into()) + .await + .expect("pending after replace"); + assert_eq!( + pending_after_replace, + base_nonce + 1, + "replacement keeps a single pending nonce slot" + ); + + let _: serde_json::Value = provider + .raw_request("evm_mine".into(), ()) + .await + .expect("mine replacement"); + + let latest_after_mine = provider + .get_transaction_count(submitter) + .block_id(BlockNumberOrTag::Latest.into()) + .await + .expect("latest after mine"); + assert_eq!( + latest_after_mine, + base_nonce + 1, + "mined replacement must advance the account nonce" + ); + + let receipt = provider + .get_transaction_receipt(replacement_hash) + .await + .expect("receipt rpc") + .expect("replacement must have a receipt after mining"); + assert_eq!( + receipt.transaction_hash, replacement_hash, + "mined receipt must belong to the replacement tx" + ); + } + /// When Latest advances past a nonce, that nonce's fee floor is dropped so a /// later tip send is not incorrectly floored by stale in-flight state. #[tokio::test] From 9256c40e7ac8927efe7daa9f605aed6ffe3cf272 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:10:01 +0800 Subject: [PATCH 7/7] test(l1): harden poster same-nonce replacement E2E assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert the original pending tx is evicted and on-wire fees clear the ≥10% floor after mining resumes. --- sequencer/src/l1/submitter/poster.rs | 49 ++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index 9224c7c3..73259554 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -870,10 +870,15 @@ mod tests { ); } - /// Same-nonce replacement must reach `eth_sendRawTransaction` while the - /// original is still pending. Without pinning gas from a nonce-free - /// estimate, Anvil rejects `eth_estimateGas(..., nonce=N, block=pending)` - /// with "nonce too low" and the replacement never broadcasts. + /// Review E2E (jplgarcia): Anvil `--no-mining` → original poster tx → + /// confirmation timeout → real same-nonce replacement → resume mining → + /// assert receipt / nonce progression. + /// + /// Pins the gas-estimation hole that blocked the replacement path: with the + /// original still pending, Anvil rejects `eth_estimateGas(..., nonce=N, + /// block=pending)` as "nonce too low", so the filler never reaches + /// `eth_sendRawTransaction`. The poster must estimate without that nonce + /// and pin gas on the replacement. #[tokio::test] async fn submit_batches_replaces_pending_tx_after_confirmation_timeout() { require_anvil(); @@ -890,6 +895,7 @@ mod tests { .await .expect("base nonce"); + // 1) Original poster tx parks in the mempool (mining disabled). let first_hashes = poster .submit_batches(vec![vec![0u8; 4]], &sink) .await @@ -923,8 +929,8 @@ mod tests { .copied() .expect("first send records in-flight fees"); - // Confirmation watch timed out inside the first submit; the next tick - // must bump fees and broadcast a same-nonce replacement. + // 2) Confirmation watch timed out; next tick must bump fees and + // broadcast a same-nonce replacement (the gas-estimate fix). let second_hashes = poster .submit_batches(vec![vec![0u8; 4]], &sink) .await @@ -956,7 +962,7 @@ mod tests { sent.max_priority_fee_per_gas ); - // Pending still one slot ahead of latest until we mine. + // Still one pending slot until mining resumes. let pending_after_replace = provider .get_transaction_count(submitter) .block_id(BlockNumberOrTag::Pending.into()) @@ -968,6 +974,7 @@ mod tests { "replacement keeps a single pending nonce slot" ); + // 3) Resume mining and assert receipt / nonce progression. let _: serde_json::Value = provider .raw_request("evm_mine".into(), ()) .await @@ -993,6 +1000,34 @@ mod tests { receipt.transaction_hash, replacement_hash, "mined receipt must belong to the replacement tx" ); + assert!( + provider + .get_transaction_receipt(first_hash) + .await + .expect("original receipt rpc") + .is_none(), + "original pending tx must be evicted by the replacement" + ); + + // On-wire fees must clear the ≥10% floor (not just the in-memory record). + use alloy::consensus::Transaction as _; + let mined = provider + .get_transaction_by_hash(replacement_hash) + .await + .expect("get replacement tx") + .expect("replacement tx must be fetchable after mining"); + assert!( + mined.max_fee_per_gas() >= bumped_max, + "mined max_fee must clear floor: on_wire={} floor={bumped_max}", + mined.max_fee_per_gas() + ); + let on_wire_prio = mined + .max_priority_fee_per_gas() + .expect("replacement must be EIP-1559"); + assert!( + on_wire_prio >= bumped_prio, + "mined priority must clear floor: on_wire={on_wire_prio} floor={bumped_prio}" + ); } /// When Latest advances past a nonce, that nonce's fee floor is dropped so a