Skip to content

Genesis ProtocolConfig should reject zero blockGasLimit #321

Open

Description

@Kewe63

Summary

The genesis ProtocolConfig schema accepts blockGasLimit: 0n, but the runtime contract API requires blockGasLimit to be greater than zero.

This creates an invariant mismatch between genesis configuration validation and later protocol config updates.

A chain can be initialized with a blockGasLimit value that would be rejected by the protocol config contract's mutative API.


Affected File

scripts/genesis/ProtocolConfig.ts


Observed Behavior

The genesis schema currently allows:

blockGasLimit: schemaBigInt.min(0n).max(maxUint64)

This means the following value is accepted:

blockGasLimit: 0n

However, the corresponding contract/API-side validation requires blockGasLimit to be greater than zero.

So genesis accepts a value that cannot be set through the normal runtime update path.


Expected Behavior

The genesis ProtocolConfig schema should enforce the same invariant as the runtime protocol config API.

blockGasLimit should be required to be at least 1:

blockGasLimit: schemaBigInt.min(1n).max(maxUint64)


Reproduction

I verified this against the current schema with a focused repro.

The repro calls schemaProtocolConfig.parse(...) with:

blockGasLimit: 0n

Observed result:

blockGasLimit=0 ACCEPTED

Expected result:

blockGasLimit=0 should be rejected


Why This Matters

Genesis configuration should not be able to bypass runtime validation invariants.

If blockGasLimit is zero at genesis, the resulting configuration may be unusable or inconsistent with the contract's own constraints. It also means the same value is treated differently depending on whether it is supplied at genesis or through the runtime update path.


Suggested Fix

Change the genesis schema bound for blockGasLimit from:

blockGasLimit: schemaBigInt.min(0n).max(maxUint64)

to:

blockGasLimit: schemaBigInt.min(1n).max(maxUint64)


Potential Regression Test

Add a ProtocolConfig schema test that verifies:

  • blockGasLimit: 1n is accepted
  • blockGasLimit: 0n is rejected
  • blockGasLimit above maxUint64 is rejected

Activity

  1. huklaa commented on Sep 3, 2026

    @huklaa

    I’m working on this. I’ll add a focused fix and regression coverage.

  2. osr21 commented on Sep 3, 2026

    @osr21

    @huklaa before you go further — this is already fixed in an open PR, and your #332 duplicates it exactly.

    #327 was opened by @Kewe63 (the issue author) at 08:09 UTC, ~12.5 hours before #332 at 20:37. Both diffs produce the byte-identical blob:

    #327: scripts/genesis/ProtocolConfig.ts  index 6b52ce6b..7cf7933b
    #332: scripts/genesis/ProtocolConfig.ts  index 6b52ce6b..7cf7933b
    

    Same one-line change, same three test cases; only the test filename and assertion style differ (expect(...).to.throw() vs safeParse().success). I reviewed #327 earlier today. Worth consolidating rather than having maintainers triage two identical fixes — and there's more useful work available, below.

    The premise checks out — verified by execution, not just reading

    I installed zod 3.25.76 (the version pinned in package.json) and ran the real schemaProtocolConfig against the fixture both PRs use:

    blockGasLimit = 0n              ACCEPTED
    blockGasLimit = 1n              ACCEPTED
    blockGasLimit = maxUint64+1n    rejected: BigInt must be <= 18446744073709551615
    

    So the reported behaviour is real and the third test case is meaningful. On the contract side I checked whether the single-field setter was missing the guard — it isn't; both mutative paths enforce it (ProtocolConfig.sol:141 in updateFeeParams, :175 in updateBlockGasLimit), so the invariant genesis is being aligned to is genuinely uniform.

    Two things worth stating in the PR that neither currently says:

    • min(1n) is exactly right — don't let anyone talk you into a higher floor. A limit of 1 is useless in practice (intrinsic cost alone is 21000), so "why not min(21000n)?" is a natural review question. The answer is that the goal is parity with the runtime API, and a stricter genesis bound would reintroduce the same divergence in the opposite direction. Say so pre-emptively.
    • The one-line change is sufficient for every config path. assets/localdev/genesis.config.ts:41 declares its own looser blockGasLimit: z.bigint().optional(), which looks like a bypass — it isn't, because buildProtocolConfigGenesisAllocs re-runs schemaProtocolConfig.parse(config) internally. That chokepoint is what makes the fix complete, and it's worth naming.

    The same bug class exists in three more places in this exact schema

    This is where a second PR would actually add value. I ran these against the current schema:

    all 7 consensus timeouts = 0n     ACCEPTED   <- updateConsensusParams() rejects every one
    timeoutProposeMs = 65536n         ACCEPTED   <- field is uint16 on-chain
    timeoutProposeMs = 2n**64n        ACCEPTED
    minBaseFee=1000 > maxBaseFee=1    ACCEPTED   <- updateFeeParams() rejects this
    

    1. The seven consensus timeouts have no bounds at all. Lines 78–84 are bare schemaBigInt (z.coerce.bigint()) — no .min(), no .max(). updateConsensusParams requires all seven to be > 0 (:156–:162). That is the identical genesis-vs-runtime mismatch as #321, times seven, and arguably worse since blockGasLimit at least had a .min(0n) someone had thought about.

    2. The missing upper bound causes silent state corruption, not just a bad accept. These are uint16 in ConsensusParams, packed eight-to-a-slot by shifts at :186–:193. A value ≥ 2^16 collides with its neighbour's bits. Running the real packing expression:

    genesis author WROTE    : timeoutProposeMs=65536, timeoutProposeDeltaMs=1
    packed slot value       : 0x10001000100010001000100010000
    contract READS BACK     : timeoutProposeMs=0,     timeoutProposeDeltaMs=1
    

    The overflow silently manufactures timeoutProposeMs = 0 — precisely the value updateConsensusParams refuses with InvalidTimeoutProposeMs. No error, no warning, a chain booted with a zero propose timeout. That's a strictly nastier failure than the one #321 reports, and .max(65535n) on all eight fields closes it.

    3. minBaseFee <= maxBaseFee isn't enforced at genesis. The contract requires it (:140, InvalidBaseFeeRange). The schema's only superRefine is enforceOperatorsNotProxyAdmin, so there's no cross-field check. Needs a superRefine, not a bound.

    For contrast, the three fields that are bounded — alpha ≤ 100, kRate ≤ 10000, inverseElasticityMultiplier ≤ 10000 — match the contract exactly. So the file's existing convention is on your side; these are gaps in it, not a different philosophy.

    Heads-up: the regression test will not run in CI

    This affects both PRs. tests/unit/*.test.ts is globbed by exactly one Makefile target:

    test-unit-hardhat: ## Run hardhat unit tests
    	npx hardhat test ./tests/helpers/matchers/index.test.ts ./tests/unit/*.test.ts --no-compile

    and nothing in .github/ invokes it — grepping the whole directory for hardhat, tests/unit, or test-unit returns zero matches. Public CI runs cargo nextest, forge test, forge lint, and make up; no hardhat step exists. The lint job doesn't cover the file either — it runs npx eslint --ignore-pattern 'scripts/' --ignore-pattern 'tests/', which excludes both the schema and the test.

    So the "3 passing" in either PR is a local-only result, and the regression guard would not stop a future revert. That doesn't make the test worthless, but it's worth being explicit with reviewers about, and adding a CI step that runs make test-unit-hardhat would give the fix real teeth. tests/unit/deployer-nonce.test.ts is currently the only other resident of that directory, so this isn't a problem you'd be creating.

    Suggestion

    Close #332 in favour of #327 (earlier, and from the issue author), and put a follow-up behind the three sibling mismatches above — timeout bounds are the highest-value piece, since #2 is silent corruption rather than a permissive accept. That's a clearly distinct PR nobody has claimed.


    Schema results above were produced by executing the real scripts/genesis/ProtocolConfig.ts against pinned zod 3.25.76 and viem 2.33.0; the read-back figures come from running the file's own packing expression. Solidity and CI claims are source review — no forge or hardhat in my environment.

  3. kutluhaneth46 commented on Sep 5, 2026

    @kutluhaneth46
    Contributor

    Opened a follow-up PR for the sibling genesis/runtime gaps called out here (zero consensus timeouts + uint16 packing bound, and minBaseFee <= maxBaseFee): will link shortly. Leaving blockGasLimit > 0 to the existing #321 PRs.

  4. kutluhaneth46 commented on Sep 5, 2026

    @kutluhaneth46
    Contributor

    Follow-up PR: #359

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