From fc30eb50c6879262206145b6def6d79b9514edd4 Mon Sep 17 00:00:00 2001 From: KutluhanETH Date: Wed, 9 Sep 2026 13:46:05 +0000 Subject: [PATCH 1/4] docs(fee): document effective base-fee floor vs minBaseFee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the empty-block truncation floor (kRate=1250 → 7 wei) with a unit test and record the max(minBaseFee, 10000/kRate) interaction in ADR-0004. Leaves ProtocolConfig requires alone so historical testnet genesis still regenerates. Addresses #367. --- crates/execution-config/src/gas_fee.rs | 23 +++++++++++++++++++++++ docs/adr/0004-base-fee-validation.md | 13 +++++++++++++ 2 files changed, 36 insertions(+) diff --git a/crates/execution-config/src/gas_fee.rs b/crates/execution-config/src/gas_fee.rs index 37b944c8..811a2142 100644 --- a/crates/execution-config/src/gas_fee.rs +++ b/crates/execution-config/src/gas_fee.rs @@ -387,4 +387,27 @@ mod tests { ); } } + + /// Empty blocks stop lowering the fee once `base_fee * k_rate < 10_000` + /// (integer truncation on the decrease path). For early-testnet-like + /// `k_rate = 1250`, that resting value is 7 — matching the on-chain + /// observation in #367. + #[test] + fn empty_blocks_rest_at_truncation_floor() { + let k_rate = 1250; + let iem = 5000; // 50% target + let gas_limit = 30_000_000; + 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, 7, "expected truncation floor for k_rate={k_rate}"); + // One more empty block must not move it. + assert_eq!( + arc_calc_next_block_base_fee(0, gas_limit, base_fee, k_rate, iem), + 7 + ); + // Truncation point intuition: floor stops below 10000/k_rate (= 8). + assert_eq!(10_000 / k_rate, 8); + } } diff --git a/docs/adr/0004-base-fee-validation.md b/docs/adr/0004-base-fee-validation.md index 3be52023..4818292e 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -132,6 +132,7 @@ next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, if fee_params is not None: next_base_fee = clamp(next_base_fee, fee_params.minBaseFee, fee_params.maxBaseFee) + # 4. Apply chainspec absolute bounds next_base_fee = clamp(next_base_fee, config.absolute_min_base_fee, config.absolute_max_base_fee) @@ -162,6 +163,18 @@ 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`, once `base_fee * k_rate < 10_000` an empty block no longer lowers the fee. The practical resting floor under sustained empty blocks is therefore: + +```text +effective_floor ≈ max(minBaseFee, 10000 / kRate) +``` + +Operators reading only `feeParams.minBaseFee` can miss this interaction. It does not affect mainnet as currently configured (`minBaseFee = 20 gwei`, `kRate = 200` ⇒ truncation point 50 wei), but it did pin early testnet near 7 wei for an extended period against a declared `minBaseFee` of 1. A future governance update that lowers `minBaseFee` below `10000/kRate` (or lowers `kRate` enough to raise that truncation point above `minBaseFee`) would recreate the gap. See #367. + +Also note: the fee parameters in `assets/testnet/config.json` are **historical genesis values**. Live testnet `ProtocolConfig.feeParams()` currently matches the mainnet parameter set. + ## Consequences ### Positive From e89278fc3482bb36b437e299ae729706074a4970 Mon Sep 17 00:00:00 2001 From: KutluhanETH Date: Wed, 9 Sep 2026 20:49:21 +0000 Subject: [PATCH 2/4] docs(fee): correct resting floor to ceil(10000/kRate)-1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten ADR-0004 wording and the unit test per review on #367: the truncation threshold is 10000/kRate, but the fee rests one wei below it. Pin the early-testnet (8→7, 7→7) boundary vectors explicitly. --- crates/execution-config/src/gas_fee.rs | 31 +++++++++++++++++--------- docs/adr/0004-base-fee-validation.md | 7 +++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/crates/execution-config/src/gas_fee.rs b/crates/execution-config/src/gas_fee.rs index 811a2142..bd9b32bc 100644 --- a/crates/execution-config/src/gas_fee.rs +++ b/crates/execution-config/src/gas_fee.rs @@ -389,25 +389,34 @@ mod tests { } /// Empty blocks stop lowering the fee once `base_fee * k_rate < 10_000` - /// (integer truncation on the decrease path). For early-testnet-like - /// `k_rate = 1250`, that resting value is 7 — matching the on-chain - /// observation in #367. + /// (integer truncation on the decrease path). At production-scale gas + /// limits the resting value is `ceil(10000/k_rate) - 1` — matching early + /// testnet (`k_rate = 1250` → 7 wei) from #367. #[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, 7, "expected truncation floor for k_rate={k_rate}"); - // One more empty block must not move it. - assert_eq!( - arc_calc_next_block_base_fee(0, gas_limit, base_fee, k_rate, iem), - 7 - ); - // Truncation point intuition: floor stops below 10000/k_rate (= 8). - assert_eq!(10_000 / k_rate, 8); + assert_eq!(base_fee, resting, "long empty-block run reaches resting value"); } } diff --git a/docs/adr/0004-base-fee-validation.md b/docs/adr/0004-base-fee-validation.md index 4818292e..aed43b0a 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -165,13 +165,14 @@ This replaces the current `parent.extra_data → child.base_fee` check with a po ### 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`, once `base_fee * k_rate < 10_000` an empty block no longer lowers the fee. The practical resting floor under sustained empty blocks is therefore: +`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 -effective_floor ≈ max(minBaseFee, 10000 / kRate) +resting_value = ceil(10000 / kRate) - 1 +effective_floor ≈ max(minBaseFee, resting_value) ``` -Operators reading only `feeParams.minBaseFee` can miss this interaction. It does not affect mainnet as currently configured (`minBaseFee = 20 gwei`, `kRate = 200` ⇒ truncation point 50 wei), but it did pin early testnet near 7 wei for an extended period against a declared `minBaseFee` of 1. A future governance update that lowers `minBaseFee` below `10000/kRate` (or lowers `kRate` enough to raise that truncation point above `minBaseFee`) would recreate the gap. See #367. +`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). Operators reading only `feeParams.minBaseFee` can miss this interaction. It does not affect mainnet as currently configured (`minBaseFee = 20 gwei`, `kRate = 200`), but early testnet sat at 7 wei for millions of blocks against a declared `minBaseFee` of 1. A future governance update that lowers `minBaseFee` below that resting value (or lowers `kRate` enough to raise it above `minBaseFee`) would recreate the gap. See #367. Also note: the fee parameters in `assets/testnet/config.json` are **historical genesis values**. Live testnet `ProtocolConfig.feeParams()` currently matches the mainnet parameter set. From d9630c8fa98af45a8dc2f20fd07afcb313b3f3e6 Mon Sep 17 00:00:00 2001 From: KutluhanETH Date: Wed, 9 Sep 2026 22:26:41 +0000 Subject: [PATCH 3/4] style(execution-config): rustfmt empty-block fee floor test Fix cargo fmt --check failure on the assert_eq! line length. --- crates/execution-config/src/gas_fee.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/execution-config/src/gas_fee.rs b/crates/execution-config/src/gas_fee.rs index bd9b32bc..df488282 100644 --- a/crates/execution-config/src/gas_fee.rs +++ b/crates/execution-config/src/gas_fee.rs @@ -417,6 +417,9 @@ mod tests { 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"); + assert_eq!( + base_fee, resting, + "long empty-block run reaches resting value" + ); } } From 1dfac2e021295fb2d67169fc6e30ee67c47e91b9 Mon Sep 17 00:00:00 2001 From: KutluhanETH Date: Thu, 10 Sep 2026 11:53:27 +0000 Subject: [PATCH 4/4] docs(fee): correct ADR historical example and add kRate=10000 case Drop the mistaken Arc-curve attribution for early-testnet's 7 wei floor (that era was EIP-1559), clarify committed vs live fee params, note the EIP-1559-equivalence of the 1250/5000 test vector, and cover the ProtocolConfig max kRate=10000 empty-block-to-zero boundary. --- crates/execution-config/src/gas_fee.rs | 19 +++++++++++++++++-- docs/adr/0004-base-fee-validation.md | 7 ++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/execution-config/src/gas_fee.rs b/crates/execution-config/src/gas_fee.rs index df488282..68848309 100644 --- a/crates/execution-config/src/gas_fee.rs +++ b/crates/execution-config/src/gas_fee.rs @@ -390,8 +390,11 @@ 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` — matching early - /// testnet (`k_rate = 1250` → 7 wei) from #367. + /// 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; @@ -421,5 +424,17 @@ mod tests { 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 aed43b0a..57cec2ad 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -132,7 +132,6 @@ next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, if fee_params is not None: next_base_fee = clamp(next_base_fee, fee_params.minBaseFee, fee_params.maxBaseFee) - # 4. Apply chainspec absolute bounds next_base_fee = clamp(next_base_fee, config.absolute_min_base_fee, config.absolute_max_base_fee) @@ -172,9 +171,11 @@ 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). Operators reading only `feeParams.minBaseFee` can miss this interaction. It does not affect mainnet as currently configured (`minBaseFee = 20 gwei`, `kRate = 200`), but early testnet sat at 7 wei for millions of blocks against a declared `minBaseFee` of 1. A future governance update that lowers `minBaseFee` below that resting value (or lowers `kRate` enough to raise it above `minBaseFee`) would recreate the gap. See #367. +`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: the fee parameters in `assets/testnet/config.json` are **historical genesis values**. Live testnet `ProtocolConfig.feeParams()` currently matches the mainnet parameter set. +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