Skip to content

fix(invariants): zero is a checked case, never a skipped one - #301

Closed
thedavidmeister wants to merge 1 commit into
mainfrom
fix/unconditional-pin-equality
Closed

fix(invariants): zero is a checked case, never a skipped one#301
thedavidmeister wants to merge 1 commit into
mainfrom
fix/unconditional-pin-equality

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

What

Ruling: avoiding the zero case skips the single most common value we need to check — address(0) is Solidity's default-everything reading (renounced ownership, unset storage, failed lookup), so every if (x != address(0)) guard around an assertion turns the most likely drift value into the one value never checked. Swept every not-zero guard on the invariant surfaces:

  • testPinsMatchDerivedAddressesOnceHydratedtestPinsMatchDerivedAddresses. The three per-chain pin equalities were guarded for the hydration window; all three deploys have executed, so the guards had inverted into a hole — zeroing a pin (exactly what the optimizer incident once did) made the test silently skip. The equalities are now unconditional; a future chain's placeholder phase gets its own guarded branch when its arm is added.
  • LibMigrationInvariant.assertMigration (address overload) rejects a zero actual unconditionally. Previously actual == post was accepted — so on any chain in the unhydrated window (post pin = 0), a vault or beacon owner renounced to address(0) read as "already migrated" and passed until the deadline. Zero now trips MigrationStateDrift on either side of the deadline: an unhydrated post means "post-state unreachable", never "post-state equals whatever reads as unset".

Guards that stay, because their zero case IS explicitly handled rather than skipped: TIMELOCK_CANCELLER (genuine placeholder; while zero the Safe's canceller grant is asserted unconditionally), the deploy script's pin cross-check (bootstrap step — zero is the pre-hydration state, handled with an explicit PIN OUTSTANDING log), and the migration script's TimelockNotPinned (refuses zero outright — the model pattern).

QA

  • Discriminating tests: testAddressZeroActualAlwaysRejected — fails on the base lib (verified by git checkout origin/main -- src/lib/LibMigrationInvariant.sol and re-running: base yields MigrationDeadlinePassed at the deadline leg instead of unconditional drift, and would silently accept the unhydrated-post leg); testPinsMatchDerivedAddresses — fails on any zeroed pin (verified by mutating the Base pin to address(0): 0x0 != 0xdb4b2187… assertion failure observed, then restored green).
  • Mutations applied: STOX_GOVERNANCE_TIMELOCKaddress(0) → killed by testPinsMatchDerivedAddresses; zero-rejection branch removed (lib reverted to origin/main) → killed by testAddressZeroActualAlwaysRejected. Both kills observed directly, both restorations re-verified green.
  • Oracle: pin equalities derive from CREATE2 math over the frozen TIMELOCK_CREATION_CODE and each chain's Safe — independent of the pinned literals under test; the primitive's expected reverts come from its stated acceptance semantics, with zero defined as the default/renounced reading rather than a sanctioned side.
  • Category check: the ruling is a property, not an instance — all four not-zero guard sites on the invariant surfaces were swept: two fixed (pin test, migration primitive), two kept with their zero case shown to be explicitly handled (canceller placeholder, deploy-script bootstrap guard).

🤖 Generated with Claude Code

Two surfaces treated address(0) as a case to route around instead of the
single most likely drift value to catch:

- testPinsMatchDerivedAddressesOnceHydrated guarded each pin equality with
  'if not zero' — hydration-lifecycle machinery that is now spent (all
  three deploys executed) and inverted into a hole: zeroing a pin made the
  test silently skip. Renamed testPinsMatchDerivedAddresses; the three
  equalities are now unconditional. A future chain's placeholder phase
  gets its own guarded branch when its arm is added; these three never go
  back.

- LibMigrationInvariant's address overload accepted actual == post, so on
  any chain in the unhydrated window (post pin == 0) an owner renounced to
  address(0) read as 'already migrated' and passed until the deadline. The
  overload now rejects a zero actual unconditionally, on either side of
  the deadline: for every address-valued surface it asserts (owners, role
  holders) zero is the default/renounced/unset reading, so it is its own
  checked case and an unhydrated post means 'post-state unreachable',
  never 'post-state equals whatever reads as unset'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 33 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: ffbd99a0-703a-4ac5-a004-236669e5301d

📥 Commits

Reviewing files that changed from the base of the PR and between bb14c7b and 2ddd794.

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

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.

@thedavidmeister

Copy link
Copy Markdown
Contributor Author

Folded into the stack instead: cherry-picked onto #285's branch, where this work line lives.

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.

1 participant