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:
- Document the effective floor as
max(minBaseFee, 10000/kRate) next to the feeParams definition, so the interaction is discoverable.
- 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
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_feeanddetermine_ema_parent_gas_usedverbatim fromcrates/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 frombase_fee_should_greater_than_zeroplus 6 EMA vectors fromdetermine_ema_parent_gas_used_table, all passing. Chain data is fromhttps://rpc.testnet.arc.io.1. The committed testnet config no longer describes testnet
assets/testnet/config.jsondeclareskRate: 25,minBaseFee: 1,maxBaseFee: 1000. LiveProtocolConfig.feeParams()on testnet returns: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
20000000000in every one of them, meangasUsedis 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 tominBaseFeeand 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: 200the 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 (EMAalpha: 20included) 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), notminBaseFeeThe increase branch guarantees movement via
max(1, increase). The decrease branch has no equivalent, so oncebase_fee * (gas_target - gas_used) / denominatortruncates to zero the fee stops falling. I can see frombase_fee_should_greater_than_zeroand 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 is10000/kRate, which is a function ofkRateand bears no relation to theminBaseFeean operator actually declared.This is not theoretical. Early testnet ran
kRate: 1250(a clean 12.5%/block decay off 1 gwei) withminBaseFee: 1. Replaying empty blocks through the extracted function reproduces the live chain exactly: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
minBaseFeeof 1. Predicted fixed point forkRate: 1250is10000/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: 200puts the fixed point at 50 wei whileminBaseFeeis 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.
ProtocolConfigvalidatesalpha <= 100,kRate <= 10000,minBaseFee <= maxBaseFee,blockGasLimit > 0— nothing relatesminBaseFeetokRate. SinceProtocolConfig.controllercan updatefeeParamspost-genesis, a future change that lowersminBaseFeebelow10000/kRate, or lowerskRatesubstantially (atkRate: 1the 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:
max(minBaseFee, 10000/kRate)next to thefeeParamsdefinition, so the interaction is discoverable.requireinProtocolConfigthatminBaseFee * 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