diff --git a/sequencer/src/l1/eip1559.rs b/sequencer/src/l1/eip1559.rs index 99ec717..147300b 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,65 @@ 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. +/// +/// 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 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` (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. 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 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), + } + } + }; + Eip1559Fees { + max_priority_fee_per_gas: fees.max_priority_fee_per_gas.min(fees.max_fee_per_gas), + ..fees + } +} + /// Estimate fees with Alloy's default, MetaMask-style medium estimator. /// /// We intentionally pin the policy constants here: 10 historical blocks, the @@ -65,4 +127,213 @@ 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_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})", + ); + } + } + + #[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); + 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); + } + + #[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 + ); + } + + 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/fee_oracle/worker.rs b/sequencer/src/l1/fee_oracle/worker.rs index 06c0322..fe98823 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); diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index ff451b6..2ac4db1 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -12,12 +12,26 @@ 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; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, Ordering}; +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, @@ -68,11 +82,70 @@ pub trait BatchPoster: Send + Sync { pub struct EthereumBatchPoster { provider: DynProvider, config: BatchPosterConfig, + /// 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 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. + #[cfg(test)] + fail_next_send: Arc, } impl EthereumBatchPoster { pub fn new(provider: DynProvider, config: BatchPosterConfig) -> Self { - Self { provider, config } + Self { + provider, + config, + in_flight: 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 + .lock() + .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) { + 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)] + 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 @@ -99,6 +172,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()) @@ -123,11 +204,12 @@ 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 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 { @@ -163,6 +245,20 @@ 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) +} + +/// 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, @@ -203,11 +299,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.lock().expect("in_flight 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. @@ -217,13 +320,61 @@ 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 pending = self.send_batch_at_nonce(payload, next_nonce, &fees).await?; + 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 = 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 (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, + InFlightTx { + fees, + tx_hash: Some(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" ); @@ -374,12 +525,14 @@ pub(crate) mod mock { #[cfg(test)] mod tests { + use std::collections::BTreeMap; use std::sync::Mutex; use std::time::Duration; use super::{ - BatchPoster, BatchPosterConfig, BatchPosterError, EthereumBatchPoster, - derive_confirmation_timeout, mock::MockBatchPoster, + BatchPoster, BatchPosterConfig, BatchPosterError, EthereumBatchPoster, InFlightTx, TxHash, + derive_confirmation_timeout, is_replacement_underpriced, mock::MockBatchPoster, + suffix_watch_hash, }; use crate::l1::watermark::WalletNonceWatermarkSink; use alloy::node_bindings::Anvil; @@ -579,4 +732,212 @@ mod tests { assert_eq!(derive_confirmation_timeout(2, 1), Duration::from_secs(6)); 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); + } + + #[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), + 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" + ); + } } diff --git a/sequencer/src/recovery/flusher.rs b/sequencer/src/recovery/flusher.rs index e7de31f..df7cd53 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,19 @@ 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). 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); @@ -369,40 +369,10 @@ 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`. 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() { @@ -433,14 +403,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]