Skip to content

feat(lib): LibTimelockInvariants + authoriser grant-map admin-holder parameterisation - #284

Merged
thedavidmeister merged 19 commits into
mainfrom
feat/20260729-timelock-invariants-lib
Aug 11, 2026
Merged

feat(lib): LibTimelockInvariants + authoriser grant-map admin-holder parameterisation#284
thedavidmeister merged 19 commits into
mainfrom
feat/20260729-timelock-invariants-lib

Conversation

@hardyjosh

@hardyjosh hardyjosh commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

New src/lib/LibTimelockInvariants.sol: constants and invariant assertions
for the ST0x governance timelock (unmodified pre-audited OZ
TimelockController from the version-locked 5.6.1 soldeer dep):

  • TIMELOCK_MIN_DELAY = 48 hours, role-hash mirrors pinned against the live
    OZ getters.
  • Per-chain address pins (Base + Ethereum), placeholders until the deploy
    broadcast executes and a pin PR hydrates them; timelockForChainId with a
    typed revert on unsupported chains (no cross-chain fallback).
  • timelockInitCode/expectedTimelockAddress: the exact creation code the
    deploy broadcasts (Safe as sole proposer+canceller+executor, admin = 0 so
    the timelock self-administers from birth and the deploy key is never
    granted anything) and its Zoltu CREATE2 address, derived in-source and
    validated against the real factory bytecode in tests.
  • assertTimelockState: codehash + minDelay pins, Safe role set, timelock
    self-administration, and negative checks (Safe must not hold root admin,
    no OZ open-role zero-address grants).
  • TIMELOCK_CANCELLER placeholder for a future dedicated canceller
    principal; asserted once hydrated.

LibAuthoriserInvariants: expectedGrants/assertExpectedGrants gain an
adminHolder parameterisation so the post-timelock-migration grant map (the
seven _ADMIN roles on the timelock, operational roles unchanged) is
expressible through the same single master map; existing overloads are
exact collapses with adminHolder = Safe.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT

Summary by CodeRabbit

  • New Features

    • Added governance timelock validation for deployment, code, delays, addresses, and role assignments.
    • Added support for assigning administrative grants to a dedicated admin holder.
    • Added chain-specific timelock address resolution with clear validation errors.
  • Bug Fixes

    • Prevented unintended root administrator access and retained administrative grants.
    • Ensured governance roles cannot remain open to unrestricted access.
  • Tests

    • Added comprehensive coverage for timelock configuration, derived addresses, supported chains, role assignments, and invalid states.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e63e02a0-5119-4854-babb-347c1308d927

📥 Commits

Reviewing files that changed from the base of the PR and between 98224d4 and 6da08a2.

📒 Files selected for processing (1)
  • test/src/lib/LibTimelockInvariants.t.sol
📝 Walkthrough

Walkthrough

The PR parameterizes authoriser grant invariants with a distinct admin holder. It also adds deterministic governance timelock derivation, state validation, and tests for valid and invalid configurations.

Changes

Authoriser grant invariants

Layer / File(s) Summary
Parameterized grant expectations
src/lib/LibAuthoriserInvariants.sol
Administrative grants use the supplied adminHolder. Assertions validate the parameterized grants and reject retained _ADMIN roles on the token-owner Safe.
Authoriser grant validation
test/src/lib/LibAuthoriserInvariants.t.sol, test/src/lib/LibAuthoriserInvariantsHarness.sol
Tests and harness calls cover overload equivalence, missing grants, retained grants, and successful validation with a distinct admin holder.

Governance timelock invariants

Layer / File(s) Summary
Timelock contracts and address derivation
src/lib/LibTimelockInvariants.sol
Defines typed errors, role and delay pins, chain selection, initialization code, deterministic addresses, and runtime codehash calculation.
Timelock state assertions
src/lib/LibTimelockInvariants.sol
Validates deployment, codehash, delay, required roles, default-admin ownership, open-role prevention, and dedicated canceller assignments.
Timelock invariant test coverage
test/src/lib/LibTimelockInvariants.t.sol, test/src/lib/LibTimelockInvariantsHarness.sol
Tests deterministic deployment, positive invariants, rejection paths, chain mapping, hydrated pins, and typed revert payloads.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Safe
  participant LibTimelockInvariants
  participant ZoltuFactory
  participant TimelockController
  participant AccessControl
  Safe->>LibTimelockInvariants: provide proposer/executor Safe
  LibTimelockInvariants->>ZoltuFactory: derive deterministic timelock address
  ZoltuFactory-->>TimelockController: deploy pinned initialization code
  LibTimelockInvariants->>TimelockController: read codehash and minimum delay
  LibTimelockInvariants->>AccessControl: validate required and forbidden roles
  AccessControl-->>LibTimelockInvariants: return role membership
Loading

Possibly related issues

  • S01-Issuer/st0x.deploy#281: Enforces exclusive ownership of all seven _ADMIN roles and detects retained grants in assertExpectedGrants.

Possibly related PRs

Suggested reviewers: thedavidmeister

Poem

A rabbit checks each grant in line,
Timelock roles are pinned and fine.
Delays and codehashes match the chart,
Safe and admin roles each play their part.
Hop, hop—the tests now start!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies both main changes: LibTimelockInvariants and authoriser grant-map admin-holder parameterisation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/20260729-timelock-invariants-lib

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/LibAuthoriserInvariants.sol (1)

205-224: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Require the Safe to relinquish _ADMIN roles after migration.

The assertion only proves the timelock gained admin roles. It still passes if the Safe retains all seven roles, leaving a path to bypass timelock governance.

  • src/lib/LibAuthoriserInvariants.sol#L205-L224: when adminHolder != tokenOwnerSafe, revert if tokenOwnerSafe retains any of the seven _ADMIN roles.
  • test/src/lib/LibAuthoriserInvariants.t.sol#L132-L142: mock those Safe admin-role checks as false before asserting the migrated state passes, and add a failing case where one remains true.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/LibAuthoriserInvariants.sol` around lines 205 - 224, Require
assertExpectedGrants to verify that, when adminHolder differs from
tokenOwnerSafe, tokenOwnerSafe no longer holds any of the seven _ADMIN roles and
revert if any remain; preserve the existing behavior when both addresses are
identical. In test/src/lib/LibAuthoriserInvariants.t.sol:132-142, mock all Safe
admin-role checks as false for the passing migration case and add a failing case
where one admin role remains true.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/LibTimelockInvariants.sol`:
- Around line 282-294: Rename the internal library helpers `_assertHasRole` and
`_assertNotOpenRole` to unprefixed names, and update every call site in
LibTimelockInvariants to use the new names. Preserve their visibility, behavior,
and parameters.
- Line 3: Update the Solidity pragma in LibTimelockInvariants to use an exact
0.8.25 compiler version instead of allowing later 0.8.x releases, preserving the
requested deterministic compiler behavior.

---

Outside diff comments:
In `@src/lib/LibAuthoriserInvariants.sol`:
- Around line 205-224: Require assertExpectedGrants to verify that, when
adminHolder differs from tokenOwnerSafe, tokenOwnerSafe no longer holds any of
the seven _ADMIN roles and revert if any remain; preserve the existing behavior
when both addresses are identical. In
test/src/lib/LibAuthoriserInvariants.t.sol:132-142, mock all Safe admin-role
checks as false for the passing migration case and add a failing case where one
admin role remains true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 64a334d8-31a4-47d6-9ecb-53eb73990ead

📥 Commits

Reviewing files that changed from the base of the PR and between 5785920 and ec9653a.

📒 Files selected for processing (6)
  • src/lib/LibAuthoriserInvariants.sol
  • src/lib/LibTimelockInvariants.sol
  • test/src/lib/LibAuthoriserInvariants.t.sol
  • test/src/lib/LibAuthoriserInvariantsHarness.sol
  • test/src/lib/LibTimelockInvariants.t.sol
  • test/src/lib/LibTimelockInvariantsHarness.sol

Comment thread src/lib/LibTimelockInvariants.sol
Comment thread src/lib/LibTimelockInvariants.sol
@hardyjosh
hardyjosh changed the base branch from main to graphite-base/284 August 10, 2026 09:03
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-invariants-lib branch from ec9653a to 72742aa Compare August 10, 2026 09:04
@hardyjosh
hardyjosh changed the base branch from graphite-base/284 to ops/hyperevm-token-deploy August 10, 2026 09:04
@hardyjosh
hardyjosh changed the base branch from ops/hyperevm-token-deploy to graphite-base/284 August 10, 2026 09:18
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-invariants-lib branch from 72742aa to bdfaa30 Compare August 10, 2026 10:04
@hardyjosh
hardyjosh changed the base branch from graphite-base/284 to ops/hyperevm-token-deploy August 10, 2026 10:04
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-invariants-lib branch from bdfaa30 to f2df295 Compare August 10, 2026 10:11
@hardyjosh
hardyjosh changed the base branch from ops/hyperevm-token-deploy to graphite-base/284 August 10, 2026 10:18
Josh Hardy and others added 3 commits August 10, 2026 11:24
…parameterisation

New src/lib/LibTimelockInvariants.sol: constants and invariant assertions
for the ST0x governance timelock (unmodified pre-audited OZ
TimelockController from the version-locked 5.6.1 soldeer dep):

- TIMELOCK_MIN_DELAY = 48 hours, role-hash mirrors pinned against the live
  OZ getters.
- Per-chain address pins (Base + Ethereum), placeholders until the deploy
  broadcast executes and a pin PR hydrates them; timelockForChainId with a
  typed revert on unsupported chains (no cross-chain fallback).
- timelockInitCode/expectedTimelockAddress: the exact creation code the
  deploy broadcasts (Safe as sole proposer+canceller+executor, admin = 0 so
  the timelock self-administers from birth and the deploy key is never
  granted anything) and its Zoltu CREATE2 address, derived in-source and
  validated against the real factory bytecode in tests.
- assertTimelockState: codehash + minDelay pins, Safe role set, timelock
  self-administration, and negative checks (Safe must not hold root admin,
  no OZ open-role zero-address grants).
- TIMELOCK_CANCELLER placeholder for a future dedicated canceller
  principal; asserted once hydrated.

LibAuthoriserInvariants: expectedGrants/assertExpectedGrants gain an
adminHolder parameterisation so the post-timelock-migration grant map (the
seven _ADMIN roles on the timelock, operational roles unchanged) is
expressible through the same single master map; existing overloads are
exact collapses with adminHolder = Safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT
HyperEVM carries 29 live production tokens, so it is governed on the same
terms as Base and Ethereum rather than as a follow-up. Adds the per-chain
timelock pin placeholder and the timelockForChainId arm, plus the
derived-address assertion for it and an explicit test that all three
pinned chains resolve.

Note the HyperEVM Safe shares Ethereum's address, and the timelock
constructor embeds only that Safe, so the Zoltu-derived timelock address is
identical on those two chains. That is correct — same policy, same init
code, same CREATE2 address — and the pins stay separate constants because
the deploy is per-chain.
The admin-holder parameterisation was written against a 13-entry map; the
multichain stack underneath now carries the three additional service-signer
rows from #280, so the merged map is 16. The structure test still asserted
13 and failed the restacked branch.

Widens it to 16 and asserts the new rows explicitly: the additional signer's
three action roles are OPERATIONAL, so they must track the signer regardless
of who holds the _ADMIN slice — the property that keeps the timelock
migration from ever moving them.
Josh Hardy added 2 commits August 10, 2026 11:24
Deployed by 20260729-deploy-governance-timelock (Base, run 31378549555) at
0xA34b2968A8B480440B218B6768Ad700FC81379b3. Verified on-chain: 48h min
delay, timelock self-administers, Safe holds proposer/canceller/executor,
deploy key holds nothing. Equals expectedTimelockAddress(Base Safe), which
the structure test asserts.
…ress

The 0xA34b... deployment was broadcast when this branch still compiled at
optimizer_runs=5000. Commit 0a8c62c lowered it to 2000 (so the H01 fix fits
EIP-170), which changes TimelockController's creation bytecode and therefore
its CREATE2 address: current source derives 0xdb4b2187... instead.

Pinning an address the source can no longer reproduce is worse than not
pinning, so the pin goes back to address(0) until a deploy runs against
settled compiler settings. The 0xA34b... instance holds no power (nothing was
migrated to it) and is simply abandoned.

TimelockPinMismatch, added alongside the multi-chain deploy, is what caught
this.
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-invariants-lib branch from f2df295 to 3366565 Compare August 10, 2026 11:28
@thedavidmeister
thedavidmeister changed the base branch from graphite-base/284 to main August 10, 2026 16:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/LibAuthoriserInvariants.sol`:
- Around line 214-231: Reject legacy Safe _ADMIN grants in assertExpectedGrants
when adminHolder differs from tokenOwnerSafe by checking each of the seven
expected admin roles on tokenOwnerSafe and reverting if any remain; update
test/src/lib/LibAuthoriserInvariants.t.sol lines 125-153 to assert this
rejection, then mock those Safe role checks as false before the existing passing
post-migration assertion.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4bc60ec1-d9bd-47c2-bc56-16adcb0e2087

📥 Commits

Reviewing files that changed from the base of the PR and between ec9653a and e4d9411.

📒 Files selected for processing (4)
  • src/lib/LibAuthoriserInvariants.sol
  • src/lib/LibTimelockInvariants.sol
  • test/src/lib/LibAuthoriserInvariants.t.sol
  • test/src/lib/LibTimelockInvariants.t.sol

Comment thread src/lib/LibAuthoriserInvariants.sol
…older

assertExpectedGrants(authoriser, safe, adminHolder) asserted the seven
_ADMIN roles onto the admin holder but accepted the Safe RETAINING its own
copies alongside — a state where the Safe can grant or revoke action roles
with zero delay, which is exactly the bypass the timelock migration exists
to close. The gap mattered because the post-execution flip PR repoints the
durable cron invariants at this overload while retiring the migration-window
suite's exclusive-holder check, leaving the dual-holder state unasserted.

Now, whenever the admin holder is distinct from the Safe, every map entry
assigned to the admin holder is also asserted NOT held by the Safe, with a
typed UnexpectedRetainedAdminGrant revert. The admin slice is identified
structurally (grantee == adminHolder) rather than by a hardcoded count. The
two-arg collapse (adminHolder == Safe) is unaffected.

Resolves the open CodeRabbit thread on this surface; the dual-holder state
the test previously blessed is now the rejection case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/LibAuthoriserInvariants.sol`:
- Around line 255-258: Update the retained-grant check in
LibAuthoriserInvariants around the grants loop to inspect only the explicit
seven *_ADMIN roles, rather than matching every grant whose grantee equals
adminHolder. Preserve the existing acl.hasRole validation and
UnexpectedRetainedAdminGrant behavior for those admin roles, while excluding
operational roles such as DEPOSIT, WITHDRAW, and CERTIFY.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d94d3e2e-4b24-431f-ae9c-5ad9aedd8363

📥 Commits

Reviewing files that changed from the base of the PR and between e4d9411 and f415edb.

📒 Files selected for processing (2)
  • src/lib/LibAuthoriserInvariants.sol
  • test/src/lib/LibAuthoriserInvariants.t.sol

Comment thread src/lib/LibAuthoriserInvariants.sol
thedavidmeister and others added 8 commits August 11, 2026 08:22
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ress

Matching map entries by grantee == adminHolder mis-slices when the admin
holder aliases another grantee (e.g. a service signer as adminHolder would
drag its operational DEPOSIT/WITHDRAW/CERTIFY entries into the check and
spuriously flag the Safe's legitimate copies). The admin slice is the map's
LEADING entries, a position the parameterisation test already pins, so the
check now walks grants[0..ADMIN_ROLE_COUNT) with the count as a lib
constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…parameterisation

New src/lib/LibTimelockInvariants.sol: constants and invariant assertions
for the ST0x governance timelock (unmodified pre-audited OZ
TimelockController from the version-locked 5.6.1 soldeer dep):

- TIMELOCK_MIN_DELAY = 48 hours, role-hash mirrors pinned against the live
  OZ getters.
- Per-chain address pins (Base + Ethereum), placeholders until the deploy
  broadcast executes and a pin PR hydrates them; timelockForChainId with a
  typed revert on unsupported chains (no cross-chain fallback).
- timelockInitCode/expectedTimelockAddress: the exact creation code the
  deploy broadcasts (Safe as sole proposer+canceller+executor, admin = 0 so
  the timelock self-administers from birth and the deploy key is never
  granted anything) and its Zoltu CREATE2 address, derived in-source and
  validated against the real factory bytecode in tests.
- assertTimelockState: codehash + minDelay pins, Safe role set, timelock
  self-administration, and negative checks (Safe must not hold root admin,
  no OZ open-role zero-address grants).
- TIMELOCK_CANCELLER placeholder for a future dedicated canceller
  principal; asserted once hydrated.

LibAuthoriserInvariants: expectedGrants/assertExpectedGrants gain an
adminHolder parameterisation so the post-timelock-migration grant map (the
seven _ADMIN roles on the timelock, operational roles unchanged) is
expressible through the same single master map; existing overloads are
exact collapses with adminHolder = Safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RquYKmoEVSfuVHy8kwn1yT
HyperEVM carries 29 live production tokens, so it is governed on the same
terms as Base and Ethereum rather than as a follow-up. Adds the per-chain
timelock pin placeholder and the timelockForChainId arm, plus the
derived-address assertion for it and an explicit test that all three
pinned chains resolve.

Note the HyperEVM Safe shares Ethereum's address, and the timelock
constructor embeds only that Safe, so the Zoltu-derived timelock address is
identical on those two chains. That is correct — same policy, same init
code, same CREATE2 address — and the pins stay separate constants because
the deploy is per-chain.
The admin-holder parameterisation was written against a 13-entry map; the
multichain stack underneath now carries the three additional service-signer
rows from #280, so the merged map is 16. The structure test still asserted
13 and failed the restacked branch.

Widens it to 16 and asserts the new rows explicitly: the additional signer's
three action roles are OPERATIONAL, so they must track the signer regardless
of who holds the _ADMIN slice — the property that keeps the timelock
migration from ever moving them.
Deployed by 20260729-deploy-governance-timelock (Base, run 31378549555) at
0xA34b2968A8B480440B218B6768Ad700FC81379b3. Verified on-chain: 48h min
delay, timelock self-administers, Safe holds proposer/canceller/executor,
deploy key holds nothing. Equals expectedTimelockAddress(Base Safe), which
the structure test asserts.
…ress

The 0xA34b... deployment was broadcast when this branch still compiled at
optimizer_runs=5000. Commit 0a8c62c lowered it to 2000 (so the H01 fix fits
EIP-170), which changes TimelockController's creation bytecode and therefore
its CREATE2 address: current source derives 0xdb4b2187... instead.

Pinning an address the source can no longer reproduce is worse than not
pinning, so the pin goes back to address(0) until a deploy runs against
settled compiler settings. The 0xA34b... instance holds no power (nothing was
migrated to it) and is simply abandoned.

TimelockPinMismatch, added alongside the multi-chain deploy, is what caught
this.
… chains

Deployed by 20260729-deploy-governance-timelock (run 31385281204), which
covered Base, Ethereum and HyperEVM in a single dispatch:

  Base      0xdb4b2187A685310E6b64170c97B80E90DD4a9B71
  Ethereum  0x290961EF70A86aB70B7201D46d29f2f357416b49
  HyperEVM  0x290961EF70A86aB70B7201D46d29f2f357416b49

Each was verified on-chain (48h min delay, timelock self-administers, Safe
holds proposer/canceller/executor, deploy key holds nothing) and equals
expectedTimelockAddress for its chain's Safe, which the structure test
asserts. Ethereum and HyperEVM share an address because they share a Safe
and the constructor embeds only that Safe.
@hardyjosh
hardyjosh force-pushed the feat/20260729-timelock-invariants-lib branch from 0f09c0a to 78aeb1d Compare August 11, 2026 09:56
thedavidmeister and others added 2 commits August 11, 2026 11:35
The runtime-codehash expectation was derived in-source
(keccak256(type(TimelockController).runtimeCode)), which describes whatever
the CURRENT compiler settings produce, not what production carries — as
soon as the settings change, the source drifts from prod. The
optimizer_runs 5000->2000 change demonstrated the failure mode: the
deployed Base instance became underivable from source and had to be
abandoned.

Now the timelock is described by constants, per the repo's pin convention:

- TIMELOCK_CREATION_CODE: the audited OZ TimelockController 5.6.1 creation
  bytecode, compiled once under the settled profile and embedded as a
  literal. The deploy broadcasts THESE bytes, so the CREATE2 address
  derivation and the deployed bytecode are immune to future
  compiler-settings changes.
- TIMELOCK_RUNTIME_CODEHASH: the keccak of the runtime those bytes deploy
  (no immutables, so constructor-independent), asserted by
  assertTimelockState in place of the removed derivation function.

testTimelockPinsMatchCompiledDependency ties both pins to the compiled
dependency while the profile remains at the pinned generation; on
divergence the pins stand — they describe prod, not source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s-lib' into fix-284

# Conflicts:
#	src/lib/LibAuthoriserInvariants.sol
#	src/lib/LibTimelockInvariants.sol
#	test/src/lib/LibAuthoriserInvariants.t.sol
#	test/src/lib/LibTimelockInvariants.t.sol

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
test/src/lib/LibAuthoriserInvariants.t.sol (3)

91-98: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Pin ADMIN_ROLE_COUNT in the test.

The source comment says the retained slice length is pinned, but the test only hardcodes 7. If ADMIN_ROLE_COUNT changes while the grant map remains unchanged, the test can pass while the production assertion checks the wrong slice. Add an explicit assertion for LibAuthoriserInvariants.ADMIN_ROLE_COUNT.

Proposed test fix
         RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock);
         assertEq(grants.length, 16);
+        assertEq(LibAuthoriserInvariants.ADMIN_ROLE_COUNT, 7);
         for (uint256 i = 0; i < 7; i++) {

This assessment uses the supplied ADMIN_ROLE_COUNT implementation and parameterization test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/lib/LibAuthoriserInvariants.t.sol` around lines 91 - 98, Add an
explicit assertion in testExpectedGrantsAdminHolderParameterisation that
LibAuthoriserInvariants.ADMIN_ROLE_COUNT equals 7, while retaining the existing
grant loop and length checks.

86-88: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the operational-grant count in the test documentation.

The map contains nine operational entries: three for GRANTEE_SERVICE_1C66, three for safe, and three for GRANTEE_SERVICE_3D0C. The comment says six entries and names only one service signer.

Proposed documentation fix
-    /// track `adminHolder`, the six operational entries stay split between
-    /// the service signer and the Safe, and the narrower overloads are exact
+    /// track `adminHolder`; the nine operational entries remain assigned to
+    /// the two service signers and the Safe. The narrower overloads are exact

This assessment uses the supplied grant map and test comments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/lib/LibAuthoriserInvariants.t.sol` around lines 86 - 88, Update the
documentation comment near the admin-holder parameterisation to state that there
are nine operational entries, split into three for GRANTEE_SERVICE_1C66, three
for safe, and three for GRANTEE_SERVICE_3D0C; leave the seven _ADMIN entries and
surrounding test behavior unchanged.

126-143: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Cover the adminHolder root-admin rejection.

The new branch at src/lib/LibAuthoriserInvariants.sol, Lines [241-243], checks DEFAULT_ADMIN_ROLE on adminHolder. The existing test only covers GRANTEE_TOKEN_OWNER_SAFE. Add a separate case that mocks hasRole(bytes32(0), timelock) to true and expects UnexpectedDefaultAdmin(clone, timelock).

This assessment uses the supplied assertion branch and test coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/lib/LibAuthoriserInvariants.t.sol` around lines 126 - 143, Add a
separate case in testAssertExpectedGrantsWithDistinctAdminHolder that mocks
hasRole(bytes32(0), timelock) as true for the adminHolder and expects the
assertion to revert with UnexpectedDefaultAdmin(clone, timelock), covering the
adminHolder branch in assertExpectedGrants.
src/lib/LibAuthoriserInvariants.sol (1)

216-217: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pass the governance timelock to post-migration callers. If these paths run after migration, LibInvariants.assertProductionState and StoxCrossChainParity must use the three-argument overload. The current two-argument calls set adminHolder to the Safe and then reject the migrated state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/LibAuthoriserInvariants.sol` around lines 216 - 217, Update
post-migration callers in LibInvariants.assertProductionState and
StoxCrossChainParity to use the three-argument assertExpectedGrants overload,
passing the governance timelock as the adminHolder instead of the Safe while
preserving the existing authoriser and Safe arguments.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/lib/LibAuthoriserInvariants.sol`:
- Around line 216-217: Update post-migration callers in
LibInvariants.assertProductionState and StoxCrossChainParity to use the
three-argument assertExpectedGrants overload, passing the governance timelock as
the adminHolder instead of the Safe while preserving the existing authoriser and
Safe arguments.

In `@test/src/lib/LibAuthoriserInvariants.t.sol`:
- Around line 91-98: Add an explicit assertion in
testExpectedGrantsAdminHolderParameterisation that
LibAuthoriserInvariants.ADMIN_ROLE_COUNT equals 7, while retaining the existing
grant loop and length checks.
- Around line 86-88: Update the documentation comment near the admin-holder
parameterisation to state that there are nine operational entries, split into
three for GRANTEE_SERVICE_1C66, three for safe, and three for
GRANTEE_SERVICE_3D0C; leave the seven _ADMIN entries and surrounding test
behavior unchanged.
- Around line 126-143: Add a separate case in
testAssertExpectedGrantsWithDistinctAdminHolder that mocks hasRole(bytes32(0),
timelock) as true for the adminHolder and expects the assertion to revert with
UnexpectedDefaultAdmin(clone, timelock), covering the adminHolder branch in
assertExpectedGrants.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a73be80d-5823-4a52-8192-67755524d2d4

📥 Commits

Reviewing files that changed from the base of the PR and between f415edb and 98224d4.

📒 Files selected for processing (4)
  • src/lib/LibAuthoriserInvariants.sol
  • src/lib/LibTimelockInvariants.sol
  • test/src/lib/LibAuthoriserInvariants.t.sol
  • test/src/lib/LibTimelockInvariants.t.sol

thedavidmeister and others added 2 commits August 11, 2026 11:45
The zero-address (open-role) rejection was tested only for EXECUTOR_ROLE;
mutants deleting the proposer, canceller, or DEFAULT_ADMIN open-role checks
survived the suite. The strengthened test grants address(0) each of the
four roles in turn through the self-administration path, asserts the typed
revert per role, revokes, and proves the closed state passes again after
each revoke — so each rejection is attributable to the zero-grant alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror of the open-role walk on the positive checks: the missing-role
rejection was tested only for the Safe's proposer role, so mutants deleting
the canceller, executor, or self-administration _assertHasRole lines
survived. The strengthened test revokes each required grant in turn —
proposer, canceller, executor off the Safe, re-granting and re-asserting
the restored state after each — and finishes with the timelock's own root
admin, which goes last and unrestored: once self-administration is revoked
no principal can re-grant it, which is exactly why the invariant pins it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thedavidmeister

Copy link
Copy Markdown
Contributor

Reviewed 6da08a2: ship — timelock invariants lib with frozen bytecode pins matching the three live deployments, authoriser grant-map admin-holder parameterisation with exclusive-holding enforcement, and per-role walks covering every missing-role and open-role branch; all checks green, all review threads resolved.

@thedavidmeister

Copy link
Copy Markdown
Contributor

Reviewed 6da08a2: ship — timelock invariants lib with frozen bytecode pins matching the three live deployments, authoriser grant-map admin-holder parameterisation with exclusive-holding enforcement, and per-role walks covering every missing-role and open-role branch; all checks green, all review threads resolved.
Rulings-conformance: checked against every ruling stated for this work and the repo CLAUDE.md. (1) "timelock code needs to be a constant" — obeyed: TIMELOCK_CREATION_CODE and TIMELOCK_RUNTIME_CODEHASH are pinned literals, both type(...) derivations are removed, the deploy broadcasts the frozen bytes, and testPinsMatchDerivedAddressesOnceHydrated proves the three live deployments are exactly the pinned generation. (2) duped constants must have tests matching upstream — obeyed: testRoleMirrorsMatchLiveGetters pins every role-hash mirror against the timelock's own constant-returning getters (the complete set in OZ 5.6.1), testTimelockPinsMatchCompiledDependency pins the bytecode literals against the version-locked dep. (3) address(0) must not fall under OZ open roles, with test coverage on every role including root admin — obeyed: constructor admin=0 grants nothing (guarded in OZ source), and testAssertRejectsEveryOpenRole walks all four roles. (4) "same walk for the missing role checks" — obeyed: testAssertRejectsEveryMissingRole walks proposer/canceller/executor with restore, root admin last and unrestored. (5) CLAUDE.md compiler/pragma/naming rulings — obeyed: ^0.8.25 for the library, =0.8.25 for tests, underscore private helpers per LibSafeOps precedent, pins generated under the single default profile.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants