diff --git a/src/lib/LibMigrationInvariant.sol b/src/lib/LibMigrationInvariant.sol index 554827d2..8d696a4a 100644 --- a/src/lib/LibMigrationInvariant.sol +++ b/src/lib/LibMigrationInvariant.sol @@ -73,6 +73,15 @@ library LibMigrationInvariant { /// @notice `address` overload. Casts each address to `bytes32` under the /// hood via `uint160`. + /// + /// The zero address is rejected as `actual` UNCONDITIONALLY — on either + /// side of the deadline, even when a migration side equals zero. Every + /// address-valued surface this lib asserts (owners, role holders) has + /// zero as its default/renounced/unset value, making it the single most + /// likely drift reading — so it is its own checked case, never an + /// accepted side. In particular an unhydrated `post` pin of zero means + /// "post-state unreachable", and a renounced-to-zero owner must trip + /// drift rather than read as "already migrated". /// @param label Human-readable identifier for the invariant surfaced in /// revert data. /// @param actual The value read from the live chain. @@ -83,6 +92,11 @@ library LibMigrationInvariant { internal view { + if (actual == address(0)) { + revert MigrationStateDrift( + label, bytes32(uint256(uint160(pre))), bytes32(uint256(uint160(post))), bytes32(0) + ); + } assertMigration( label, bytes32(uint256(uint160(actual))), diff --git a/src/lib/LibTimelockInvariants.sol b/src/lib/LibTimelockInvariants.sol index 7ad78622..edd442a2 100644 --- a/src/lib/LibTimelockInvariants.sol +++ b/src/lib/LibTimelockInvariants.sol @@ -117,7 +117,7 @@ library LibTimelockInvariants { /// @notice The ST0x governance timelock on **Base**. Equals /// `expectedTimelockAddress(STOX_TOKEN_OWNER_SAFE)`, which - /// `testPinsMatchDerivedAddressesOnceHydrated` asserts. + /// `testPinsMatchDerivedAddresses` asserts. /// https://basescan.org/address/0xdb4b2187a685310e6b64170c97b80e90dd4a9b71 address internal constant STOX_GOVERNANCE_TIMELOCK = address(0xdb4b2187A685310E6b64170c97B80E90DD4a9B71); diff --git a/test/src/lib/LibMigrationInvariant.t.sol b/test/src/lib/LibMigrationInvariant.t.sol index 4b5d8a51..fe538b27 100644 --- a/test/src/lib/LibMigrationInvariant.t.sol +++ b/test/src/lib/LibMigrationInvariant.t.sol @@ -164,4 +164,49 @@ contract LibMigrationInvariantTest is Test { ); harness.callAssertMigrationUint256(LABEL, 1, 1, 3, DEADLINE); } + + /// @notice The zero address is rejected as `actual` on BOTH sides of + /// the deadline, even when a migration side equals zero. Zero is + /// Solidity's default-everything value — renounced ownership, unset + /// storage, a failed lookup — the single most likely drift reading, so + /// it is its own checked case, never an accepted side. The load-bearing + /// branch is the third: with an UNHYDRATED `post` pin of zero, a + /// renounced-to-zero owner must trip drift rather than read as + /// "already migrated". + function testAddressZeroActualAlwaysRejected() external { + address pre = address(0xAAAA); + address post = address(0xBBBB); + + vm.warp(DEADLINE - 1); + vm.expectRevert( + abi.encodeWithSelector( + MigrationStateDrift.selector, + LABEL, + bytes32(uint256(uint160(pre))), + bytes32(uint256(uint160(post))), + bytes32(0) + ) + ); + harness.callAssertMigrationAddress(LABEL, address(0), pre, post, DEADLINE); + + vm.warp(DEADLINE); + vm.expectRevert( + abi.encodeWithSelector( + MigrationStateDrift.selector, + LABEL, + bytes32(uint256(uint160(pre))), + bytes32(uint256(uint160(post))), + bytes32(0) + ) + ); + harness.callAssertMigrationAddress(LABEL, address(0), pre, post, DEADLINE); + + vm.warp(DEADLINE - 1); + vm.expectRevert( + abi.encodeWithSelector( + MigrationStateDrift.selector, LABEL, bytes32(uint256(uint160(pre))), bytes32(0), bytes32(0) + ) + ); + harness.callAssertMigrationAddress(LABEL, address(0), pre, address(0), DEADLINE); + } } diff --git a/test/src/lib/LibTimelockInvariants.t.sol b/test/src/lib/LibTimelockInvariants.t.sol index 2fc20562..3697e366 100644 --- a/test/src/lib/LibTimelockInvariants.t.sol +++ b/test/src/lib/LibTimelockInvariants.t.sol @@ -240,30 +240,27 @@ contract LibTimelockInvariantsTest is Test { harness.callTimelockForChainId(31337); } - /// @notice The hydration contract for the per-chain pins: while a pin is - /// a placeholder it must be zero; once hydrated it must equal the - /// derived Zoltu address for that chain's Safe. Both directions are - /// asserted here so the post-deploy pin PR turns this from the - /// placeholder branch to the equality branch with no test change. - function testPinsMatchDerivedAddressesOnceHydrated() external pure { - address basePin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK; - if (basePin != address(0)) { - assertEq(basePin, LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE)); - } - address ethereumPin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM; - if (ethereumPin != address(0)) { - assertEq( - ethereumPin, - LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) - ); - } - address hyperevmPin = LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_HYPEREVM; - if (hyperevmPin != address(0)) { - assertEq( - hyperevmPin, - LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM) - ); - } + /// @notice Every per-chain pin equals the Zoltu address derived from the + /// frozen creation code and that chain's Safe — UNCONDITIONALLY. The + /// not-zero guards that let this test ride through the hydration window + /// are retired with it: all three deploys have executed and the pins are + /// deploy history, so a zeroed or drifted pin must fail loudly here + /// rather than silently skip. A future chain's placeholder phase gets + /// its own guarded branch when its arm is added; these three never go + /// back. + function testPinsMatchDerivedAddresses() external pure { + assertEq( + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE) + ); + assertEq( + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_ETHEREUM, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) + ); + assertEq( + LibTimelockInvariants.STOX_GOVERNANCE_TIMELOCK_HYPEREVM, + LibTimelockInvariants.expectedTimelockAddress(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM) + ); } /// @notice Every chain with a pinned token-owner Safe resolves through