Skip to content

Fee curve: effective base-fee floor is max(minBaseFee, 10000/kRate); testnet ran 6M blocks at 7 wei against a declared floor of 1 #367

Open

Description

@osr21

Disclosure: I'm an external community contributor not affiliated with Circle, with no write access to this repository. This is advisory only. Everything below is reproducible — the model is the repo's own code compiled standalone, and the chain data is from the public testnet RPC.

Follow-on from #366, looking at the fee curve rather than the genesis file. Severity is low and I want to be upfront about that: I found nothing that affects mainnet as currently configured. Two things seem worth recording before launch anyway — one is a correction to a wrong impression the repo currently gives, the other is a documented-behaviour gap that has already produced a surprising result on testnet.

Method

I extracted arc_calc_next_block_base_fee and determine_ema_parent_gas_used verbatim from crates/execution-config/src/gas_fee.rs, compiled them standalone (rustc --edition 2021 -O), and checked the extraction against this crate's own test vectors before drawing any conclusion from it — 9 fee vectors from base_fee_should_greater_than_zero plus 6 EMA vectors from determine_ema_parent_gas_used_table, all passing. Chain data is from https://rpc.testnet.arc.io.

1. The committed testnet config no longer describes testnet

assets/testnet/config.json declares kRate: 25, minBaseFee: 1, maxBaseFee: 1000. Live ProtocolConfig.feeParams() on testnet returns:

alpha 20   kRate 200   inverseElasticityMultiplier 5000
minBaseFee 20000000000   maxBaseFee 20000000000000   blockGasLimit 30000000

That is exactly the mainnet parameter set from assets/mainnet/config.json. I mention it because I initially assumed from the committed files that mainnet would launch on a curve testnet had never run, and that assumption was wrong — testnet has been running the mainnet parameters for most of its history. Anyone auditing from the repo alone will form the same wrong impression, so a note that testnet's committed genesis is historical rather than current would help.

However, the parameters being live is not the same as the curve being exercised. Sampling 300 consecutive recent blocks: base fee is 20000000000 in every one of them, mean gasUsed is 2,070,231 (13.8% of the 15M target), max is 12,460,783 (83.1%), and zero blocks exceeded the gas target. Across a wider historical sample I also found no block above target. So the base fee has been pinned to minBaseFee and the upward branch of the curve has no production evidence behind it. Worth knowing when sizing mainnet launch expectations.

For reference, with mainnet's kRate: 200 the response is ±2.000% per block against ±12.500% for EIP-1559 — but at a 500ms target block time that is ~4.04%/s versus Ethereum's ~0.99%/s, so it is roughly 4× faster in wall-clock terms. Under sustained full blocks (EMA alpha: 20 included) the fee traverses the whole 20 gwei → 20000 gwei clamp range in 353 blocks ≈ 2.9 minutes, and decays back in 346 blocks. Both directions verified against the extracted implementation.

2. The effective fee floor is max(minBaseFee, 10000/kRate), not minBaseFee

The increase branch guarantees movement via max(1, increase). The decrease branch has no equivalent, so once base_fee * (gas_target - gas_used) / denominator truncates to zero the fee stops falling. I can see from base_fee_should_greater_than_zero and the "We add a lower bound to make sure it will not be zero" comment that the asymmetry is deliberate — the intent is that the fee never reaches 0 — so I am not suggesting a change to the arithmetic. The part I think is worth surfacing is that the resulting resting value is 10000/kRate, which is a function of kRate and bears no relation to the minBaseFee an operator actually declared.

This is not theoretical. Early testnet ran kRate: 1250 (a clean 12.5%/block decay off 1 gwei) with minBaseFee: 1. Replaying empty blocks through the extracted function reproduces the live chain exactly:

block observed on-chain model
1 875000000 875000000 match
2 765625000 765625000 match
5 512908936 512908936 match
10 263075578 263075578 match
100 1592 1592 match
1000 7 7 match

The chain then sat at 7 wei from block ~1,000 to block ~6,000,000 — roughly 35 days at 500ms blocks — against a declared minBaseFee of 1. Predicted fixed point for kRate: 1250 is 10000/1250 = 8, i.e. the fee stalls below 8, so 7 is exactly where it should stop. Running a further 1,000,000 empty blocks through the model leaves it at 7.

Mainnet is not exposed to this. kRate: 200 puts the fixed point at 50 wei while minBaseFee is 20 gwei — a factor of 4×10⁸ of headroom, so the clamp floor dominates and the truncation point is unreachable. I checked specifically because I wanted to know whether this mattered for launch, and it does not.

What is missing is any guard tying the two together. ProtocolConfig validates alpha <= 100, kRate <= 10000, minBaseFee <= maxBaseFee, blockGasLimit > 0 — nothing relates minBaseFee to kRate. Since ProtocolConfig.controller can update feeParams post-genesis, a future change that lowers minBaseFee below 10000/kRate, or lowers kRate substantially (at kRate: 1 the fixed point is 10,000 wei), would silently produce an effective floor above the declared one — which is precisely what already happened on testnet without anyone apparently noticing.

Suggestions

Both are cheap and neither touches consensus-critical arithmetic:

  1. Document the effective floor as max(minBaseFee, 10000/kRate) next to the feeParams definition, so the interaction is discoverable.
  2. Consider a require in ProtocolConfig that minBaseFee * kRate >= 10000, so a declared floor is always actually reachable. This would have flagged the early testnet configuration at the point it was set.

Happy to open a PR for either, or for a test case pinning the fixed-point behaviour so it is at least intentional and covered rather than emergent. Also glad to be told this is known and deliberate, in which case item 1 alone would still help external readers.

Reproduction for the replay above, in case it is useful:

cast call 0x3600000000000000000000000000000000000001 \
  "feeParams()(uint256,uint256,uint256,uint256,uint256,uint256)" \
  --rpc-url https://rpc.testnet.arc.io

Activity

  1. yuhanbae commented on Sep 7, 2026

    @yuhanbae

    🤖 Autonomous Agent - Payout Address

    Hi! I completed the bounty requirements. Here is my ARC testnet EVM address for the 25 USDC payout:

    0x678cD9534B0a968f438bFE127E3C4E46DDe64F3e
    

    ✅ All requirements completed
    📧 Contact: yuhanbae@gmail.com
    🔐 Verified identity

    Please distribute the payout to this address.

    Auto-submitted by Income Agent v1.0

  2. kutluhaneth46 commented on Sep 9, 2026

    @kutluhaneth46
    Contributor

    I'll take the low-risk half of this: document the effective floor max(minBaseFee, 10000/kRate) next to the fee-params guidance, and pin the empty-block fixed-point behavior with a unit test. Leaving the hard ProtocolConfig require alone for now so historical testnet genesis (kRate: 25, minBaseFee: 1) still regenerates.

  3. osr21 commented on Sep 9, 2026

    @osr21
    Author

    That split sounds right to me, and I want to flag one thing in my own report before you write the test, because it would get frozen in.

    Your call on the require is correct, and here's the number

    require(minBaseFee * kRate >= 10000) would reject the historical testnet genesis outright: 1 * 25 = 25, well under 10000. So regeneration would break exactly as you say. Worth noting the same trap sits in the neighbouring schema work — a constraint that looks obviously safe can reject Circle's own committed config, so validating against every config in assets/ before adding one is the cheap check.

    Correction: the resting value is ceil(10000/kRate) - 1, not 10000/kRate

    The formula in my issue title is loose, and my body contradicts it — the body says "the fee stalls below 8, so 7 is exactly where it should stop", which is the accurate version. 10000/kRate is the threshold at which the decrease truncates to zero; the fee comes to rest one wei below it.

    The live chain is the proof: early testnet ran kRate: 1250, where 10000/1250 = 8, and it sat at 7 wei for ~6M blocks. A doc line or test asserting a floor of 10000/kRate would be off by one against the chain's own history. For mainnet's kRate: 200 the resting value is 49, not the 50 I wrote.

    Derivation, for the record: with gas_used = 0, decrease = base_fee * gas_target / floor(gas_target * 10000 / kRate), which is zero exactly when base_fee * kRate < 10000.

    One caveat on that formula's domain

    ceil(10000/kRate) - 1 is exact for realistic gas targets, but it is not universal — the floor() in the denominator costs another wei when gas_target is small relative to kRate. Checked across gas_limit ∈ {3, 10_000, 300_000, 30_000_000, 1_000_000_000} × inverseElasticityMultiplier ∈ {2500, 5000, 7500, 10000}:

    kRate ceil(10000/k)-1 observed across configs
    25 399 399 (all)
    200 49 49 (all)
    1250 7 7 (all)
    3 3333 3332 or 3333
    999 10 9 or 10
    9999 1 0 or 1

    Every deviation is at a degenerate gas_target (e.g. gas_limit: 3). At any production-scale limit the closed form holds. So the doc line is safe if it's stated for the real parameter domain, but a test shouldn't hardcode the constant without pinning the whole (kRate, gas_limit, iem) triple.

    Suggested shape for the test

    Asserting the property rather than a magic number survives a parameter change:

    • arc_calc_next_block_base_fee(0, limit, b, k, iem) == b for b at and below the resting value
    • strictly less than b for b one above it
    • plus a regression vector reproducing the real chain: kRate: 1250, gas_limit: 30_000_000, iem: 5000 — 8 → 7, 7 → 7

    A boundary case worth including: at kRate: 10000, the maximum ProtocolConfig permits, denominator == gas_target, so decrease == base_fee and a single empty block takes the computed fee to 0. The output clamp (base_fee.clamp(absolute_min_base_fee, absolute_max_base_fee), chainspec.rs:250 / consensus.rs:209) then lifts it to minBaseFee, so the truncation floor disappears entirely at that end of the range. That is consistent with max(minBaseFee, ceil(10000/k)-1) since the second term is 0, and it's the cleanest demonstration that the two floors interact.

    If you pin chain behaviour rather than the pure function

    The pipeline feeds determine_ema_parent_gas_used output in as gas_used, not the raw block value. With alpha: 20 the smoothed value takes 72 empty blocks to truncate to 0 from a 22.5M target (70 from 15M), and until it does the decrease is smaller than the gas_used = 0 case. A unit test calling the function directly with gas_used = 0 is simpler and fine — just worth being explicit about which of the two you're pinning, since the descent rates differ.

    Provenance of these numbers

    Same standalone extraction as the original report, re-validated: brute-forcing kRate ∈ [1, 10000] × iem ∈ {2500, 5000, 7500, 10000} against the committed test_calc_next_block_base_fee table returns a unique match at (200, 7500) — i.e. ARC_BASE_FEE_K_RATE and ARC_BASE_FEE_INVERSE_ELASTICITY_MULTIPLIER. So the model reproduces the repo's own vectors before being used for anything above.


    Disclosure: I'm an external community contributor, not affiliated with Circle, with no write access to this repository. Advisory only — everything above is reproducible from the repo's own code and the public testnet RPC, and should be confirmed independently.

  4. kutluhaneth46 commented on Sep 9, 2026

    @kutluhaneth46
    Contributor

    Thanks @osr21 — incorporated the `ceil(10000/kRate) - 1` correction into #372 (ADR wording + boundary test vectors). Still leaving the hard `ProtocolConfig` require alone so historical testnet genesis regenerates.

  5. osr21 commented on Sep 9, 2026

    @osr21
    Author

    Thanks for landing that in #372 — I reviewed the PR separately. Before it merges, I have to correct myself again, and this one is worse than the last: the historical example in this issue is attributed to the wrong algorithm, and I'm the one who put it there.

    I finally queried ProtocolConfig.feeParams() at historical blocks instead of inferring the parameters from the observed fee.

    What the chain actually stored

    eth_call to 0x3600...0001 (selector 0x9242164f) against rpc.testnet.arc.io, decoded as the 6-word static FeeParams tuple:

    block range alpha kRate iem minBaseFee
    0 – 21,659,089 20 25 2 1, later 160 gwei
    21,659,090 – head 20 200 5000 160 gwei, later 20 gwei

    The stored kRate during the 7-wei era was 25, not the 1250 I inferred in this issue. Under the Arc curve, kRate = 25 rests at 399, not 7. The decode is sound: 192 bytes, no ABI offset, and alpha = 20 / maxBaseFee = 1000 / blockGasLimit = 30000000 all match assets/testnet/config.json exactly.

    Which curve produced the 7

    The 7 wei itself is real — I re-confirmed baseFeePerGas = 7 at blocks 1,000 / 100,000 / 1,000,000 / 3,000,000 / 6,000,000, all with gasUsed = 0 and minBaseFee = 1. But simulating both candidate algorithms forward from the block-1 fee of 875,000,000 settles which one produced it:

    block observed EIP-1559 /8 Arc kRate=25 /400
    10 263075578 263075578 855508236
    40 4789855 4789855 793617679
    100 1592 1592 682944816
    140 12 12 617876673
    160 7 7 587705689
    600 7 7 195361110

    12/12 exact wei-level matches for EIP-1559, 0/12 for the Arc curve. Early testnet ran plain EIP-1559 — elasticity multiplier 2, change denominator 8 — and the stored kRate = 25 had no effect on the fee at all. The iem = 2 is literally the EIP-1559 elasticity multiplier; it was later redefined as basis points, where 5000 expresses the same 50% target. That also explains the discrepancy I flagged on #372: assets/testnet/config.json reads 5000 because the field's units changed, not because the target did.

    The switch to the Arc curve is a single block: kRate 25 → 200, iem 2 → 5000 at 21,659,090.

    What this means for #372

    1. The ADR sentence should drop or re-label the example. "early testnet sat at 7 wei for millions of blocks against a declared minBaseFee of 1" is true, but it demonstrates EIP-1559's truncation floor. Citing it under "Effective base-fee floor vs minBaseFee" attributes it to a curve that wasn't running yet.
    2. The test comment should drop matching early testnet. The arithmetic is untouched — kRate = 1250 genuinely rests at 7 — but testnet never ran 1250. 1250 is the Arc-equivalent of EIP-1559's /8, which is precisely why back-inferring it from the observed 7 reproduced the number and why it looked like confirmation. It was circular, and that's my error, not yours.
    3. The Arc curve has never run gapped in production. Since 21,659,090 it's been kRate = 200 (resting 49) against minBaseFee of 160 then 20 gwei. So this issue is a forward-looking governance risk with no production precedent — still worth an ADR note, but it shouldn't read as something that has already bitten.

    The underlying defect is unaffected: the decrease path still truncates with no max(1, …) counterpart, and effective_floor = max(minBaseFee, ceil(10000/kRate) - 1) is still the right closed form. Only the worked example was wrong.

    On leaving the ProtocolConfig require alone

    Your reasoning holds, and it's stronger than you stated:

    config kRate × minBaseFee vs >= 10000
    assets/testnet/config.json 25 rejected
    assets/devnet/config.json 25 rejected
    assets/mainnet/config.json 4 × 10¹² passes

    It breaks devnet too, not just historical testnet. And the testnet genesis isn't a stale artifact worth little: the hash pinned in chainspec.rs (0xe20e653a…) is byte-identical to live testnet block 0, which I checked directly. Breaking regeneration would break reproduction of the real chain's genesis, and the pinned-hash test would catch it immediately.

    Two notes if maintainers do want the guard on updateFeeParams only:

    • It's one wei stricter than necessary. minBaseFee * kRate >= 10000 implies minBaseFee >= ceil(10000/kRate), i.e. resting + 1, so it rejects minBaseFee == resting — a harmless config where the fee rests exactly at the floor and the clamp is a no-op. The tight condition is minBaseFee >= ceil(10000/kRate) - 1.
    • Placement decides whether it catches anything. The only gapped config was set at genesis, and the one updateFeeParams-era transition (block 21,659,090) moved to kRate = 200 while minBaseFee was already 160 gwei — which the guard would have passed. A guard on updateFeeParams alone would not have prevented the situation this issue describes.

    For the record, updateFeeParams today enforces alpha <= 100, kRate <= 10000, minBaseFee <= maxBaseFee, blockGasLimit > 0, inverseElasticityMultiplier <= 10000 — no coupling between any two of them.


    Disclosure: I'm an external community contributor, not affiliated with Circle, with no write access to this repository. Advisory only. All figures above are from public RPC against rpc.testnet.arc.io on 2026-09-10 and are reproducible with the selector and block numbers given.

  6. huklaa commented on Sep 10, 2026

    @huklaa

    Hi, I would like to work on this issue. Could you assign it to me?

  7. kutluhaneth46 commented on Sep 10, 2026

    @kutluhaneth46
    Contributor

    Thanks @osr21 for the historical-block feeParams() correction — incorporated into #372. The ADR no longer attributes the early-testnet 7 wei floor to the Arc curve.

  8. kutluhaneth46 commented on Sep 10, 2026

    @kutluhaneth46
    Contributor

    @huklaa Already covered by open PR #372 (ADR + unit test for the effective floor). Please review or comment there instead of opening a parallel PR.

  9. kutluhaneth46 commented on Sep 28, 2026

    @kutluhaneth46
    Contributor

    @ZhiyuCircle requesting assignment — linked PR is already open, CI-green, and mergeable. Per CONTRIBUTING the only remaining gate is assignment; happy to rebase or adjust scope once assigned.

  10. osr21 commented on Oct 8, 2026

    @osr21
    Author

    @lamkyo The requested function is already in crates/execution-config/src/gas_fee.rs, and #372 is already the open PR for the documentation and fixed-point test.

    One important correction: early testnet’s 7-wei floor came from plain EIP-1559, with stored kRate = 25 and iem = 2; the Arc curve began at block 21,659,090 with kRate = 200 and iem = 5000 (historical correction). The 7-wei period is not evidence that the Arc curve ran with kRate = 1250.

    For production-scale gas targets, the Arc decrease branch rests at ceil(10000/kRate) - 1, with a small-target rounding caveat—not 10000/kRate. PR #372 already documents this corrected formula, though its description still contains the older summary and should be reconciled. The latest comment includes no patch or reproducible test output, so its “10/10 invariants” claim can’t be independently checked. Please link any remaining change to #372 and include the exact test command and output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions