diff --git a/crates/execution-config/src/gas_fee.rs b/crates/execution-config/src/gas_fee.rs index 37b944c..6884830 100644 --- a/crates/execution-config/src/gas_fee.rs +++ b/crates/execution-config/src/gas_fee.rs @@ -387,4 +387,54 @@ mod tests { ); } } + + /// Empty blocks stop lowering the fee once `base_fee * k_rate < 10_000` + /// (integer truncation on the decrease path). At production-scale gas + /// limits the resting value is `ceil(10000/k_rate) - 1`. + /// + /// `k_rate = 1250` / `iem = 5000` is the EIP-1559 equivalence point + /// (`10000/8`, `10000/2`) already exercised by `test_calc_next_block_base_fee` + /// — not a historical Arc testnet parameter set (see #367 / #372). + #[test] + fn empty_blocks_rest_at_truncation_floor() { + let k_rate = 1250; + let iem = 5000; // 50% target + let gas_limit = 30_000_000; + let resting = 7u64; // ceil(10000/1250) - 1 + assert_eq!( + arc_calc_next_block_base_fee(0, gas_limit, 8, k_rate, iem), + 7, + "one step above resting must fall" + ); + assert_eq!( + arc_calc_next_block_base_fee(0, gas_limit, resting, k_rate, iem), + resting, + "at resting value must stay put" + ); + assert_eq!( + arc_calc_next_block_base_fee(0, gas_limit, resting - 1, k_rate, iem), + resting - 1, + "below resting value must stay put" + ); + let mut base_fee = 1_000_000_000u64; // 1 gwei + for _ in 0..2_000 { + base_fee = arc_calc_next_block_base_fee(0, gas_limit, base_fee, k_rate, iem); + } + assert_eq!( + base_fee, resting, + "long empty-block run reaches resting value" + ); + + // Max ProtocolConfig kRate: decrease == base_fee, so one empty block + // reaches 0 (resting = ceil(10000/10000) - 1 = 0) before output clamps. + assert_eq!( + arc_calc_next_block_base_fee(0, gas_limit, 42, 10_000, iem), + 0, + "kRate=10000 empties the fee in one step" + ); + assert_eq!( + arc_calc_next_block_base_fee(0, gas_limit, 0, 10_000, iem), + 0 + ); + } } diff --git a/docs/adr/0004-base-fee-validation.md b/docs/adr/0004-base-fee-validation.md index 3be5202..57cec2a 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -162,6 +162,21 @@ assert decode(header.extra_data) == expected This replaces the current `parent.extra_data → child.base_fee` check with a post-execution invariant: the proposer's `extra_data` must match the deterministic output of execution. +### Effective base-fee floor vs `minBaseFee` + +`arc_calc_next_block_base_fee` guarantees a minimum **increase** of 1 when utilization is above target, but the **decrease** path truncates to zero with no equivalent floor. For `k_rate > 0`, an empty block stops lowering the fee once `base_fee * k_rate < 10_000`. Under sustained empty blocks at production-scale gas limits, the resting value is therefore: + +```text +resting_value = ceil(10000 / kRate) - 1 +effective_floor ≈ max(minBaseFee, resting_value) +``` + +`10000 / kRate` is the truncation *threshold*; the fee comes to rest one wei below it (e.g. `kRate = 1250` ⇒ threshold 8, rest 7; `kRate = 200` ⇒ threshold 50, rest 49; `kRate = 10000` ⇒ rest 0, so a single empty block reaches zero before the output clamp). Operators reading only `feeParams.minBaseFee` can miss this interaction. + +This is a **forward-looking governance risk**, not a past Arc-curve incident: since the Arc fee curve has been live on testnet (`kRate = 200` from block ~21,659,090), `minBaseFee` has stayed well above the resting value (49 wei). The 7 wei floor observed earlier on testnet came from plain EIP-1559 dynamics (`/8`), not from Arc `kRate` truncation — do not treat that history as evidence the Arc curve already gapped. A future `updateFeeParams` that lowers `minBaseFee` below `ceil(10000/kRate) - 1` (or raises that resting value above `minBaseFee`) would create the gap. See #367. + +Also note: `assets/testnet/config.json` / `assets/devnet/config.json` fee params (`kRate: 25`, `minBaseFee: 1`) are **historical genesis values** (predicted Arc resting value 399). Live testnet `ProtocolConfig.feeParams()` currently matches the mainnet-like set (`kRate: 200`). + ## Consequences ### Positive