Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 272 additions & 1 deletion sequencer/src/l1/eip1559.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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>) -> 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
Expand Down Expand Up @@ -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);
}
}
43 changes: 32 additions & 11 deletions sequencer/src/l1/fee_oracle/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -300,16 +301,15 @@ mod tests {
}

struct FailsAfterFirstGas {
calls: Mutex<usize>,
calls: Arc<AtomicUsize>,
ok: Eip1559Fees,
}

#[async_trait]
impl GasFeeSource for FailsAfterFirstGas {
async fn estimate_gas_fees(&self) -> Result<Eip1559Fees, String> {
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())
Expand Down Expand Up @@ -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())),
Expand Down Expand Up @@ -532,23 +532,44 @@ 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())),
);
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);
Expand Down
Loading