diff --git a/script/20260619-deploy-v4-authoriser-clone.s.sol b/script/20260619-deploy-v4-authoriser-clone.s.sol index 53f4cb88..3f12ce22 100644 --- a/script/20260619-deploy-v4-authoriser-clone.s.sol +++ b/script/20260619-deploy-v4-authoriser-clone.s.sol @@ -127,11 +127,11 @@ contract DeployV4AuthoriserClone is Script { /// on the freshly-cloned V4 authoriser, plus the two corporate-action /// admins the ST0x override adds — all transferred to the Safe by /// steps 3-4). Indices 7..12 are the operational grants (`DEPOSIT` / - /// `WITHDRAW` / `CERTIFY` × service + Safe) this script mirrors in. + /// `WITHDRAW` / `CERTIFY` × Safe + service) this script mirrors in. uint256 internal constant MIRROR_START_INDEX = 7; /// @notice The number of non-admin grants this script mirrors in. - uint256 internal constant MIRROR_COUNT = 9; + uint256 internal constant MIRROR_COUNT = 6; /// @notice The number of `_ADMIN` roles the base + ST0x-override /// `initialize` auto-grant to `initialAdmin` (five base + two diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index cdadea79..f18dc43b 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -698,14 +698,17 @@ contract MigrateGovernanceToTimelock is Script { /// the operation is pending-but-not-ready inside the 48h window, and /// after the delay the Safe executes it the same way, leaving the /// operation `Done`. The operation — re-granting `DEPOSIT` to the - /// service signer — is a no-op on live state, so the proof leaves no - /// residue beyond consumed Safe nonces (simulation-only anyway). + /// ACTIVE service signer, which already holds it on every chain — is a + /// no-op on live state, so the proof leaves no residue beyond consumed + /// Safe nonces (simulation-only anyway). The RETIRED signer is not + /// usable here: re-granting it anything would recreate a + /// `revokedGrants()` pair and trip the post-state's negative sweep. /// @param safe The chain's token-owner Safe. /// @param timelock The chain's governance timelock. /// @param authoriser The chain's authoriser clone. function _proveGovernanceLoop(IGnosisSafe safe, address timelock, address authoriser) internal { bytes memory adminAction = abi.encodeCall( - IAccessControl.grantRole, (keccak256("DEPOSIT"), LibAuthoriserInvariants.GRANTEE_SERVICE_1C66) + IAccessControl.grantRole, (keccak256("DEPOSIT"), LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C) ); TimelockController controller = TimelockController(payable(timelock)); bytes32 id = controller.hashOperation(authoriser, 0, adminAction, bytes32(0), LOOP_PROOF_SALT); diff --git a/script/20260810-revoke-fireblocks-service-signer.s.sol b/script/20260810-revoke-fireblocks-service-signer.s.sol index 910c991c..7b405c04 100644 --- a/script/20260810-revoke-fireblocks-service-signer.s.sol +++ b/script/20260810-revoke-fireblocks-service-signer.s.sol @@ -33,7 +33,16 @@ error UnsupportedChainForRevocation(uint256 chainId); error SafeMissingRoleAdmin(bytes32 adminRole); /// @title RevokeFireblocksServiceSigner -/// @notice **PENDING.** Authors the Safe bundle that revokes the RETIRED +/// @notice **EXECUTED — verified 2026-08-18 (Base, Ethereum and +/// HyperEVM).** Bundle MultiSend SafeTxHashes: +/// Base (nonce 694) +/// `0xc01cd00b63b4a646228a27781189b65fcb8e4dc2a9cf785002172aa4b74a0a76`, +/// Ethereum (nonce 3) +/// `0x04ca4b30c6de2413a5c1c13b1a41049eaadbb0479ede9d8b9ac7c80201d66378`, +/// HyperEVM (nonce 1) +/// `0xc621215611830c4692bb7e7870662f0f84ead4bed511aeddc30b0096a6d2b220`. +/// +/// Authors the Safe bundle that revokes the RETIRED /// Fireblocks-custodied service signer /// (`LibAuthoriserInvariants.GRANTEE_SERVICE_1C66`) from the ACTIVE chain's /// V4 authoriser: one `revokeRole` per canonical grant the signer still @@ -45,8 +54,7 @@ error SafeMissingRoleAdmin(bytes32 adminRole); /// the replacement signer. Dispatch via `Actions → run-script` with /// `script = 20260810-revoke-fireblocks-service-signer`, `sig = run()`, /// and the target `network`; one dispatch + Safe signing per chain -/// carrying a live authoriser — `base`, `ethereum` and `hyperevm`, all -/// three of which hold the signer's three canonical pairs today. +/// carrying a live authoriser — `base`, `ethereum` and `hyperevm`. /// /// SAFETY — the drift guard doubles as a rotation gate: pre-flight /// requires every NON-retired row of @@ -55,18 +63,14 @@ error SafeMissingRoleAdmin(bytes32 adminRole); /// signer is not fully provisioned therefore refuses to author the /// revoke, so the service can never be left without an active signer. /// -/// The canonical map still carries the retired signer's rows — it pins -/// what production IS, and the signer holds its grants until the Safes -/// sign. Executing this bundle on a chain turns that chain's `1C66` rows -/// red (`ExpectedGrantMissing`) in every live invariant — the cross-chain -/// parity authoriser leg, the multichain production-state bundle, the -/// per-chain prod pins — forcing the post-execution pin PR that removes -/// the rows from the map and pins their ABSENCE as the new negative -/// invariant. The prod pin for this script -/// (`20260810-revoke-fireblocks-service-signer.prod.t.sol`) works the -/// same way in the opposite direction: it asserts `run()` authors a -/// full three-pair bundle on each chain, so execution flips it to -/// `FireblocksSignerAlreadyRevoked` and it retires in the same pin PR. +/// The canonical map no longer carries the retired signer's rows: +/// `expectedGrants()` pins the post-rotation state, and `revokedGrants()` +/// pins the three revoked pairs, which every live invariant asserts +/// ABSENT via `assertExpectedGrants` (`RevokedGrantStillHeld` on a chain +/// that has not executed its bundle, or on any re-grant). Because the map +/// yields no retired pairs, re-dispatching this script reverts +/// `FireblocksSignerAlreadyRevoked` — the correct behaviour when +/// re-deriving an executed historical operation. /// /// SELF-SCOPING: only the pairs the signer still holds are authored /// (partial execution recovers by re-dispatch); a fully revoked chain diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 7a948b50..b19ed409 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -46,6 +46,15 @@ error AuthoriserImplCodehashMismatch(address authoriser, bytes32 expected, bytes /// @param holder The Safe retaining it. error UnexpectedRetainedAdminGrant(address authoriser, bytes32 role, address holder); +/// @notice A `(role, grantee)` pair pinned as REVOKED is still held on the +/// authoriser. The retired grantee must hold none of its former roles; a +/// held pair is either an unexecuted revocation bundle or a re-grant +/// outside the pinned map. +/// @param authoriser The authoriser inspected. +/// @param role The role that must not be held. +/// @param grantee The grantee that must not hold it. +error RevokedGrantStillHeld(address authoriser, bytes32 role, address grantee); + /// @title LibAuthoriserInvariants /// @notice Reusable invariants for the ST0x production authoriser on Base: /// the grantee constants and the single master `(role, grantee)` map every @@ -95,17 +104,16 @@ library LibAuthoriserInvariants { /// constant for call-site clarity. address internal constant GRANTEE_TOKEN_OWNER_SAFE = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; - /// @notice The original service EOA — Fireblocks-custodied — holding - /// `DEPOSIT`, `WITHDRAW` and `CERTIFY` on each chain's authoriser. + /// @notice The RETIRED Fireblocks-custodied service EOA. Holds NO role + /// on any chain's authoriser: its former action roles are pinned as + /// revoked (`revokedGrants()`) and asserted ABSENT by + /// `assertExpectedGrants`. /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 address internal constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; - /// @notice ADDITIONAL service EOA, holding the same three action roles - /// as `GRANTEE_SERVICE_1C66` — both signers are active side by side. - /// Provisioned on each live chain's authoriser by the - /// `20260723-provision-additional-service-signer` Safe bundle; the - /// ADDRESS is shared across chains while the grants are per-chain - /// state. + /// @notice The active service EOA, holding `DEPOSIT`, `WITHDRAW` and + /// `CERTIFY` on each chain's authoriser. The ADDRESS is shared across + /// chains while the grants are per-chain state. address internal constant GRANTEE_SERVICE_3D0C = 0x3d0CD66EFA66c05d86c3d4316B03eAE87ab9E8aE; /// @notice The full `(role, grantee)` map in effect on the Base @@ -149,7 +157,7 @@ library LibAuthoriserInvariants { pure returns (RoleGrant[] memory grants) { - grants = new RoleGrant[](16); + grants = new RoleGrant[](13); // Init grants (block 41715184 on Base) — the admin holder receives // every `_ADMIN` (the Safe at init; the governance timelock once the @@ -166,30 +174,39 @@ library LibAuthoriserInvariants { grants[5] = RoleGrant(keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), adminHolder); grants[6] = RoleGrant(keccak256("CANCEL_CORPORATE_ACTION_ADMIN"), adminHolder); - // Service EOA provisioned at blocks 41797262, 41797281, 41797297 (Base). - grants[7] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); - grants[8] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); - grants[9] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); + // Safe holds the action roles (Base blocks 42704120, 42704140, + // 44076075) for direct operational use. + grants[7] = RoleGrant(keccak256("DEPOSIT"), tokenOwnerSafe); + grants[8] = RoleGrant(keccak256("WITHDRAW"), tokenOwnerSafe); + grants[9] = RoleGrant(keccak256("CERTIFY"), tokenOwnerSafe); - // Safe holds the corresponding action roles (Base blocks 42704120, - // 42704140, 44076075) for direct operational use. - grants[10] = RoleGrant(keccak256("DEPOSIT"), tokenOwnerSafe); - grants[11] = RoleGrant(keccak256("WITHDRAW"), tokenOwnerSafe); - grants[12] = RoleGrant(keccak256("CERTIFY"), tokenOwnerSafe); + // The active service signer. + grants[10] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_3D0C); + grants[11] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_3D0C); + grants[12] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_3D0C); + } - // Additional service signer, provisioned by the 20260723 bundle - // per chain. - grants[13] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_3D0C); - grants[14] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_3D0C); - grants[15] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_3D0C); + /// @notice The `(role, grantee)` pairs pinned as REVOKED: the retired + /// Fireblocks service signer's former action roles, which no authoriser + /// may carry. The negative half of the canonical map — every consumer + /// of `expectedGrants` asserts these pairs ABSENT via + /// `assertExpectedGrants`. + /// @return revoked The pinned revoked `(role, grantee)` pairs. + function revokedGrants() internal pure returns (RoleGrant[] memory revoked) { + revoked = new RoleGrant[](3); + revoked[0] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); + revoked[1] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); + revoked[2] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); } /// @notice Assert every pinned `(role, grantee)` pair in - /// `expectedGrants()` is held on the supplied authoriser, and that no - /// pinned grantee holds `DEFAULT_ADMIN_ROLE`. Reverts with - /// `UnexpectedDefaultAdmin` if a pinned grantee holds the root admin - /// role, or `ExpectedGrantMissing` on the first missing pair, surfacing - /// the exact role + grantee that broke the invariant. + /// `expectedGrants()` is held on the supplied authoriser, that every + /// `revokedGrants()` pair is NOT held, and that no pinned grantee holds + /// `DEFAULT_ADMIN_ROLE`. Reverts with `UnexpectedDefaultAdmin` if a + /// pinned grantee holds the root admin role, `ExpectedGrantMissing` on + /// the first missing pair, or `RevokedGrantStillHeld` on the first + /// revoked pair still held, surfacing the exact role + grantee that + /// broke the invariant. /// @dev Parameterised on the authoriser address so the same assertion /// can run against the pinned production clone AND against a /// freshly-deployed clone (a script's pre-flight on a swap target) @@ -202,11 +219,12 @@ library LibAuthoriserInvariants { } /// @notice Assert every `(role, grantee)` pair from - /// `expectedGrants(tokenOwnerSafe)` is held on the supplied authoriser, and - /// that neither the Safe nor the service signer holds `DEFAULT_ADMIN_ROLE`. - /// Parameterised on the chain's token-owner Safe so the identical grant - /// STRUCTURE is asserted against each chain's authoriser with that chain's - /// Safe address (the service signer is shared). + /// `expectedGrants(tokenOwnerSafe)` is held on the supplied authoriser, + /// that every `revokedGrants()` pair is NOT held, and that no pinned + /// grantee holds `DEFAULT_ADMIN_ROLE`. Parameterised on the chain's + /// token-owner Safe so the identical grant STRUCTURE is asserted against + /// each chain's authoriser with that chain's Safe address (the service + /// signers are shared). /// @param authoriser The authoriser to validate. /// @param tokenOwnerSafe The chain's token-owner Safe filling the Safe /// grantee slots. @@ -230,9 +248,32 @@ library LibAuthoriserInvariants { /// @param adminHolder The holder of the seven `_ADMIN` roles. function assertExpectedGrants(address authoriser, address tokenOwnerSafe, address adminHolder) internal view { IAccessControl acl = IAccessControl(authoriser); - // No pinned grantee holds DEFAULT_ADMIN_ROLE: the hierarchy admins each - // action role by its own `_ADMIN`, so a root-admin holder would - // be an escalation path the pinned map does not sanction. + assertNoRootAdminHolders(acl, authoriser, tokenOwnerSafe, adminHolder); + RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe, adminHolder); + for (uint256 i = 0; i < grants.length; i++) { + if (!acl.hasRole(grants[i].role, grants[i].grantee)) { + revert ExpectedGrantMissing(authoriser, grants[i].role, grants[i].grantee); + } + } + assertExclusiveAdminHolding(acl, authoriser, grants, tokenOwnerSafe, adminHolder); + assertRevokedGrantsAbsent(acl, authoriser); + } + + /// @notice Assert no pinned principal — the Safe, the admin holder, the + /// service signers — holds `DEFAULT_ADMIN_ROLE`: the hierarchy admins + /// each action role by its own `_ADMIN`, so a root-admin holder + /// would be an escalation path the pinned map does not sanction. + /// @param acl The authoriser's access-control surface. + /// @param authoriser The authoriser under validation (surfaced in the + /// revert). + /// @param tokenOwnerSafe The chain's token-owner Safe. + /// @param adminHolder The holder of the seven `_ADMIN` roles. + function assertNoRootAdminHolders( + IAccessControl acl, + address authoriser, + address tokenOwnerSafe, + address adminHolder + ) internal view { if (acl.hasRole(DEFAULT_ADMIN_ROLE, tokenOwnerSafe)) { revert UnexpectedDefaultAdmin(authoriser, tokenOwnerSafe); } @@ -245,23 +286,49 @@ library LibAuthoriserInvariants { if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_3D0C)) { revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_3D0C); } - RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe, adminHolder); - for (uint256 i = 0; i < grants.length; i++) { - if (!acl.hasRole(grants[i].role, grants[i].grantee)) { - revert ExpectedGrantMissing(authoriser, grants[i].role, grants[i].grantee); + } + + /// @notice Assert exclusive `_ADMIN` holding: with a distinct admin + /// holder, a Safe that retains any admin entry can grant or revoke + /// action roles directly, bypassing the delay the admin holder exists + /// to impose. The slice is positional (the map's leading + /// `ADMIN_ROLE_COUNT` entries) rather than matched by grantee address, + /// which would mis-slice if the admin holder aliased another grantee. + /// No-op when the Safe IS the admin holder (the pre-migration state). + /// @param acl The authoriser's access-control surface. + /// @param authoriser The authoriser under validation (surfaced in the + /// revert). + /// @param grants The map from `expectedGrants(tokenOwnerSafe, + /// adminHolder)`, whose leading slice carries the `_ADMIN` roles. + /// @param tokenOwnerSafe The chain's token-owner Safe. + /// @param adminHolder The holder of the seven `_ADMIN` roles. + function assertExclusiveAdminHolding( + IAccessControl acl, + address authoriser, + RoleGrant[] memory grants, + address tokenOwnerSafe, + address adminHolder + ) internal view { + if (adminHolder == tokenOwnerSafe) { + return; + } + for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { + if (acl.hasRole(grants[i].role, tokenOwnerSafe)) { + revert UnexpectedRetainedAdminGrant(authoriser, grants[i].role, tokenOwnerSafe); } } - // Exclusive `_ADMIN` holding: with a distinct admin holder, a Safe - // that retains any admin entry can grant or revoke action roles - // directly, bypassing the delay the admin holder exists to impose. - // The slice is positional (the map's leading `ADMIN_ROLE_COUNT` - // entries) rather than matched by grantee address, which would - // mis-slice if the admin holder aliased another grantee. - if (adminHolder != tokenOwnerSafe) { - for (uint256 i = 0; i < ADMIN_ROLE_COUNT; i++) { - if (acl.hasRole(grants[i].role, tokenOwnerSafe)) { - revert UnexpectedRetainedAdminGrant(authoriser, grants[i].role, tokenOwnerSafe); - } + } + + /// @notice Assert every `revokedGrants()` pair is absent: the retired + /// service signer holds none of its former action roles. + /// @param acl The authoriser's access-control surface. + /// @param authoriser The authoriser under validation (surfaced in the + /// revert). + function assertRevokedGrantsAbsent(IAccessControl acl, address authoriser) internal view { + RoleGrant[] memory revoked = revokedGrants(); + for (uint256 i = 0; i < revoked.length; i++) { + if (acl.hasRole(revoked[i].role, revoked[i].grantee)) { + revert RevokedGrantStillHeld(authoriser, revoked[i].role, revoked[i].grantee); } } } diff --git a/test/script/20260619-deploy-v4-authoriser-clone.t.sol b/test/script/20260619-deploy-v4-authoriser-clone.t.sol index ebc9f3ab..b01cfc96 100644 --- a/test/script/20260619-deploy-v4-authoriser-clone.t.sol +++ b/test/script/20260619-deploy-v4-authoriser-clone.t.sol @@ -335,7 +335,7 @@ contract DeployV4AuthoriserCloneTest is Test { selectBaseFork(); RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); assertEq(harness.mirrorStartIndex(), 7, "MIRROR_START_INDEX drifted from the happy-path replica"); - assertEq(harness.mirrorCount(), 9, "MIRROR_COUNT drifted from the happy-path replica"); + assertEq(harness.mirrorCount(), 6, "MIRROR_COUNT drifted from the happy-path replica"); assertEq( harness.mirrorStartIndex() + harness.mirrorCount(), allGrants.length, diff --git a/test/script/20260723-provision-additional-service-signer.t.sol b/test/script/20260723-provision-additional-service-signer.t.sol index 6ea03248..89807e1b 100644 --- a/test/script/20260723-provision-additional-service-signer.t.sol +++ b/test/script/20260723-provision-additional-service-signer.t.sol @@ -183,7 +183,7 @@ contract ProvisionAdditionalServiceSignerTest is Test { function testRevertsWhenTheGrantMapHasDrifted() external { address authoriser = deployPreProvisionedAuthoriser(); vm.prank(SAFE); - IAccessControl(authoriser).revokeRole(DEPOSIT, LibAuthoriserInvariants.GRANTEE_SERVICE_1C66); + IAccessControl(authoriser).revokeRole(DEPOSIT, SAFE); ProvisionAdditionalServiceSignerHarness harness = new ProvisionAdditionalServiceSignerHarness(); vm.expectRevert(bytes("ProvisionAdditionalServiceSigner: authoriser grant map has drifted")); diff --git a/test/script/20260810-revoke-fireblocks-service-signer.prod.t.sol b/test/script/20260810-revoke-fireblocks-service-signer.prod.t.sol deleted file mode 100644 index 6c5ce6a4..00000000 --- a/test/script/20260810-revoke-fireblocks-service-signer.prod.t.sol +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; - -import {RevokeFireblocksServiceSigner} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; -import {RevokeFireblocksServiceSignerRemainderHarness} from "./RevokeFireblocksServiceSignerRemainderHarness.sol"; -import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; -import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; -import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; -import {LibSafeOps, SafeTx, TxBuilderArtifactMismatch} from "../../src/lib/LibSafeOps.sol"; -import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; - -/// @title RevokeFireblocksServiceSignerProdTest -/// @notice PROD coverage for the retired-signer revocation: what production -/// IS on each chain carrying a live V4 authoriser, read from a real fork -/// with no mocks anywhere. -/// -/// The revocation is PENDING — the retired Fireblocks signer still holds -/// all three canonical pairs on every chain — so each test asserts the -/// live pre-execution state and then drives `run()` end to end on the -/// fork: full pre-flight against the live Safe + authoriser, a three-tx -/// revoke bundle authored and round-tripped through the Tx Builder JSON, -/// the simulated post-state clean, and the n+1 reversal proven. Once the -/// Safes sign and the bundle executes on a chain, that chain's `run()` -/// reverts `FireblocksSignerAlreadyRevoked` and its test here goes red — -/// the forcing function for the post-execution pin PR, which removes the -/// retired rows from the canonical map, pins their absence, and retires -/// this file the way the executed provisioning retired its predecessor -/// fixtures. -/// -/// @dev No mocks. Each chain's state is whatever the chain says it is. -/// Selection logic — which pairs get authored for a given role state — is -/// covered without a fork in -/// `20260810-revoke-fireblocks-service-signer.t.sol`. -/// -/// The artifact path is chain-suffixed, so the three chains' tests cannot -/// clobber each other's file however forge schedules them. -contract RevokeFireblocksServiceSignerProdTest is Test { - /// @notice The signer this operation revokes. - address internal constant SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_1C66; - - /// @notice The artifact path `run()` writes for the ACTIVE chain — - /// mirrored from the script's `artifactPath()` so the round-trip below - /// reads the file the dispatch under test actually produced. - /// @return path The chain-suffixed artifact path. - function artifactPath() internal view returns (string memory path) { - path = string.concat("out/20260810-revoke-fireblocks-service-signer-", vm.toString(block.chainid), ".json"); - } - - /// @notice Assert the ACTIVE fork still carries the retired signer's - /// full grant set, then drive the script end to end against it: `run()` - /// authors the three-pair revoke bundle, writes the artifact, leaves - /// the simulated fork revoked, and proves the n+1 reversal. `run()` - /// reaches the authoring only after its full pre-flight passes, so this - /// also pins the Safe, the authoriser pin + codehash, and the rest of - /// the grant map on the live chain. - /// @param label Human chain name, surfaced in assertion messages. - /// @param authoriser The chain's pinned V4 authoriser clone. - /// @param safe The chain's token-owner Safe, filling the map's Safe - /// grantee slots. - function assertActiveForkAuthorsRevocation(string memory label, address authoriser, address safe) internal { - IAccessControl acl = IAccessControl(authoriser); - RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(safe); - uint256 pairs = 0; - for (uint256 i = 0; i < all.length; i++) { - if (all[i].grantee != SIGNER) continue; - pairs++; - assertTrue( - acl.hasRole(all[i].role, all[i].grantee), - string.concat(label, ": retired signer no longer holds a canonical pair - flip the map pin PR") - ); - } - assertEq(pairs, 3, "the canonical map carries the retired signer's three rows"); - - RevokeFireblocksServiceSigner script = new RevokeFireblocksServiceSigner(); - script.run(); - - // The simulated fork ends fully revoked. - for (uint256 i = 0; i < all.length; i++) { - if (all[i].grantee != SIGNER) continue; - assertFalse( - acl.hasRole(all[i].role, all[i].grantee), - string.concat(label, ": run() left the retired signer holding a canonical pair") - ); - } - - // ...and every OTHER canonical row survives — the replacement - // signer's operational grants, the Safe's action roles, every - // `_ADMIN`. Re-asserted here independently of the script's own - // post-state so this test fails even if those checks rot. - for (uint256 i = 0; i < all.length; i++) { - if (all[i].grantee == SIGNER) continue; - assertTrue( - acl.hasRole(all[i].role, all[i].grantee), - string.concat(label, ": run() disturbed a canonical row it must not touch") - ); - } - - // Round-trip the emitted artifact: scoped to this chain, three - // value-free revoke CALLs against the chain's authoriser, in - // canonical map order. - (uint256 chainId, address firstTarget, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson(artifactPath()); - assertEq(chainId, block.chainid, string.concat(label, ": artifact chain id")); - assertEq(firstTarget, authoriser, string.concat(label, ": artifact target")); - assertEq(txs.length, 3, string.concat(label, ": artifact bundle size")); - bytes32[3] memory roles = [keccak256("DEPOSIT"), keccak256("WITHDRAW"), keccak256("CERTIFY")]; - for (uint256 i = 0; i < txs.length; i++) { - assertEq(txs[i].to, authoriser, string.concat(label, ": artifact tx target")); - assertEq(txs[i].value, 0, string.concat(label, ": artifact tx value")); - assertEq( - txs[i].data, - abi.encodeCall(IAccessControl.revokeRole, (roles[i], SIGNER)), - string.concat(label, ": artifact tx calldata") - ); - } - } - - /// @notice Base still holds the retired signer's grants and authors the - /// full revoke bundle. - function testBaseAuthorsRevocation() external { - vm.createSelectFork(LibRainDeploy.BASE); - assertActiveForkAuthorsRevocation( - "Base", LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE - ); - } - - /// @notice Ethereum still holds the retired signer's grants and authors - /// the full revoke bundle. - function testEthereumAuthorsRevocation() external { - vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); - assertActiveForkAuthorsRevocation( - "Ethereum", - LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, - LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM - ); - } - - /// @notice HyperEVM still holds the retired signer's grants and authors - /// the full revoke bundle. - function testHyperEvmAuthorsRevocation() external { - vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); - assertActiveForkAuthorsRevocation( - "HyperEVM", - LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM, - LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM - ); - } - - /// @notice The signer-side `verify(string)` accepts a freshly authored - /// artifact against a fresh fork of the same live state, and rejects a - /// tampered copy with the exact mismatching field. Authoring simulates - /// the revocation onto its own fork, so verification runs on a NEW - /// fork — the signer's vantage point: artifact in hand, live chain - /// untouched. - function testVerifyAcceptsAuthoredArtifactAndRejectsTamper() external { - vm.createSelectFork(LibRainDeploy.BASE); - new RevokeFireblocksServiceSigner().run(); - string memory path = artifactPath(); - - // Fresh fork: authoring left the previous fork revoked; the live - // chain a signer verifies against still holds the grants. - vm.createSelectFork(LibRainDeploy.BASE); - RevokeFireblocksServiceSigner verifier = new RevokeFireblocksServiceSigner(); - verifier.verify(path); - - // Tamper one call's payload and re-emit: verify pinpoints the field. - (,, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson(path); - txs[0].data = abi.encodeCall(IAccessControl.revokeRole, (bytes32(uint256(1)), address(0xBAD))); - vm.writeFile( - "out/tampered-revocation.json", - LibSafeOps.emitTxBuilderJson(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, block.chainid, "tampered", txs) - ); - vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "data")); - verifier.verify("out/tampered-revocation.json"); - } - - /// @notice `verify` refuses a STALE artifact, not just a tampered one: - /// authored while all three pairs held, then a pair is revoked on the - /// live chain before signing — the re-derived bundle is now two txs and - /// the three-tx artifact must not be signed. This is the - /// authoring-to-signing drift window the entrypoint exists to close, - /// exercised as actual state drift rather than file mutation. - function testVerifyRejectsStaleArtifactAfterDrift() external { - vm.createSelectFork(LibRainDeploy.BASE); - new RevokeFireblocksServiceSigner().run(); - string memory path = artifactPath(); - - // Fresh fork = the live chain at signing time; drift one pair the - // way the bundle's own third leg would have. - vm.createSelectFork(LibRainDeploy.BASE); - vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); - IAccessControl(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE).revokeRole(keccak256("CERTIFY"), SIGNER); - - RevokeFireblocksServiceSigner verifier = new RevokeFireblocksServiceSigner(); - vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "txCount")); - verifier.verify(path); - } - - /// @notice A partial prior execution re-dispatches to exactly the - /// remainder, artifact included: with CERTIFY already revoked on the - /// live fork, `run()` authors and emits a two-tx bundle carrying the - /// DEPOSIT and WITHDRAW revokes only. The unit suite pins this - /// selection in memory; this pins the EMITTED artifact a signer would - /// import for the remainder. - function testBasePartialReDispatchEmitsRemainderArtifact() external { - vm.createSelectFork(LibRainDeploy.BASE); - address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; - address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; - - // The Safe holds CERTIFY_ADMIN, so it can revoke CERTIFY directly — - // the same call the bundle's third leg would have made. - vm.prank(safe); - IAccessControl(authoriser).revokeRole(keccak256("CERTIFY"), SIGNER); - - // The remainder harness writes to its own path: this test's bundle - // CONTENT differs from the full-bundle tests', and forge runs tests - // in parallel, so sharing the canonical path would race. - RevokeFireblocksServiceSignerRemainderHarness script = new RevokeFireblocksServiceSignerRemainderHarness(); - script.run(); - - (uint256 chainId, address firstTarget, SafeTx[] memory txs) = - LibSafeOps.parseTxBuilderJson(script.remainderArtifactPath()); - assertEq(chainId, block.chainid, "remainder artifact chain id"); - assertEq(firstTarget, authoriser, "remainder artifact target"); - assertEq(txs.length, 2, "remainder artifact bundle size"); - bytes32[2] memory roles = [keccak256("DEPOSIT"), keccak256("WITHDRAW")]; - for (uint256 i = 0; i < txs.length; i++) { - assertEq(txs[i].to, authoriser, "remainder artifact tx target"); - assertEq(txs[i].value, 0, "remainder artifact tx value"); - assertEq( - txs[i].data, - abi.encodeCall(IAccessControl.revokeRole, (roles[i], SIGNER)), - "remainder artifact tx calldata" - ); - } - } -} - diff --git a/test/script/20260810-revoke-fireblocks-service-signer.t.sol b/test/script/20260810-revoke-fireblocks-service-signer.t.sol deleted file mode 100644 index 7ee09eb0..00000000 --- a/test/script/20260810-revoke-fireblocks-service-signer.t.sol +++ /dev/null @@ -1,206 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; -import {Clones} from "@openzeppelin-contracts-5.6.1/proxy/Clones.sol"; -import {ICLONEABLE_V2_SUCCESS} from "rain-factory-0.1.1/src/interface/ICloneableV2.sol"; -import { - OffchainAssetReceiptVaultAuthorizerV1Config -} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; - -import {FireblocksSignerAlreadyRevoked} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; -import { - StoxOffchainAssetReceiptVaultAuthorizerV1 -} from "../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; -import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; -import {SafeTx} from "../../src/lib/LibSafeOps.sol"; -import {RevokeFireblocksServiceSignerHarness} from "./RevokeFireblocksServiceSignerHarness.sol"; - -/// @title RevokeFireblocksServiceSignerTest -/// @notice UNIT coverage for the retired-signer revocation SELECTION — -/// which canonical pairs the script authors for a given authoriser state, -/// and when it refuses to author at all. -/// -/// No fork and no mocks. Every fixture is a real EIP-1167 clone of the real -/// `StoxOffchainAssetReceiptVaultAuthorizerV1`, driven into the state under -/// test by real `grantRole`/`revokeRole` calls made by the address holding -/// the role's `_ADMIN`. The role state is therefore produced by the same -/// code path production uses, and no assertion here can be invalidated by -/// anything signed on-chain. -/// -/// The pairs come from `LibAuthoriserInvariants.expectedGrants()`, the -/// single current-state map every live invariant asserts, so the fixture -/// cannot drift from the map the script reads. -/// -/// Live per-chain state is asserted separately, unmocked, in -/// `20260810-revoke-fireblocks-service-signer.prod.t.sol`. -contract RevokeFireblocksServiceSignerTest is Test { - /// @notice The signer being revoked — the canonical pin the script - /// scopes itself to. - address internal constant SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_1C66; - - /// @notice Stand-in for a chain's token-owner Safe. The selection only - /// needs an address that holds the `_ADMIN` roles and fills the map's - /// Safe grantee slots; a locally cloned authoriser can be initialised - /// with any such address, so no Safe bytecode is involved. - address internal constant SAFE = address(uint160(uint256(keccak256("unit.token.owner.safe")))); - - bytes32 internal constant DEPOSIT = keccak256("DEPOSIT"); - bytes32 internal constant WITHDRAW = keccak256("WITHDRAW"); - bytes32 internal constant CERTIFY = keccak256("CERTIFY"); - - /// @notice A locally cloned V4 authoriser carrying EVERY canonical row, - /// the retired signer's three included — the pre-revocation state, i.e. - /// the state the script was written against. `initialize` auto-grants - /// `SAFE` the seven `_ADMIN` roles; the remaining rows are then granted - /// by `SAFE` under those admins. The loop is driven by - /// `expectedGrants()` itself, so a new canonical row lands in the - /// fixture without editing it. - /// @return authoriser The initialised clone. - function deployFullyGrantedAuthoriser() internal returns (address authoriser) { - StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); - authoriser = Clones.clone(address(impl)); - assertEq( - StoxOffchainAssetReceiptVaultAuthorizerV1(authoriser) - .initialize(abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: SAFE}))), - ICLONEABLE_V2_SUCCESS, - "authoriser clone failed to initialise" - ); - - IAccessControl acl = IAccessControl(authoriser); - RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(SAFE); - for (uint256 i = 0; i < all.length; i++) { - if (acl.hasRole(all[i].role, all[i].grantee)) continue; - vm.prank(SAFE); - acl.grantRole(all[i].role, all[i].grantee); - } - } - - /// @notice Really revoke `role` from the retired signer, as the Safe. - /// @param authoriser The clone to revoke on. - /// @param role The action role to revoke. - function revokeFromSigner(address authoriser, bytes32 role) internal { - vm.prank(SAFE); - IAccessControl(authoriser).revokeRole(role, SIGNER); - } - - /// @notice Assert the returned canonical pairs are exactly the retired - /// signer's three rows, in map order. Role, grantee AND order all - /// matter: order decides which pair drives the reversal walk in - /// `run()`, and the grantee is what makes these the retired signer's - /// rows rather than another grantee's. Invariant across every state, - /// because what the signer still holds changes the BUNDLE, never the - /// map. - /// @param retired The pairs returned alongside the bundle. - function assertCanonicalPairs(RoleGrant[] memory retired) internal pure { - assertEq(retired.length, 3, "the canonical map carries the retired signer's three rows"); - bytes32[3] memory roles = [DEPOSIT, WITHDRAW, CERTIFY]; - for (uint256 i = 0; i < roles.length; i++) { - assertEq(retired[i].role, roles[i], "canonical pair role"); - assertEq(retired[i].grantee, SIGNER, "canonical pair grantee"); - } - } - - /// @notice Assert `txn` is the `revokeRole(role, SIGNER)` call on - /// `authoriser` — a plain value-free CALL, the only shape the Safe Tx - /// Builder artifact can carry. - /// @param txn The authored transaction. - /// @param authoriser The expected destination. - /// @param role The expected role in the encoded call. - function assertRevokesRole(SafeTx memory txn, address authoriser, bytes32 role) internal pure { - assertEq(txn.to, authoriser, "revoke tx must target the authoriser"); - assertEq(txn.value, 0, "revoke tx must carry no value"); - assertEq(uint256(txn.operation), 0, "revoke tx must be a CALL"); - assertEq(txn.data, abi.encodeCall(IAccessControl.revokeRole, (role, SIGNER)), "revoke tx calldata"); - } - - /// @notice Everything still held: all three canonical pairs are - /// authored, in canonical map order. - function testAuthorsEveryPairWhenEveryPairIsHeld() external { - address authoriser = deployFullyGrantedAuthoriser(); - RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); - - (RoleGrant[] memory retired, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); - - assertCanonicalPairs(retired); - assertEq(txs.length, 3, "every pair is still held, so every pair is authored"); - assertRevokesRole(txs[0], authoriser, DEPOSIT); - assertRevokesRole(txs[1], authoriser, WITHDRAW); - assertRevokesRole(txs[2], authoriser, CERTIFY); - } - - /// @notice Partial execution recovers by re-dispatch: with CERTIFY - /// already revoked, only DEPOSIT and WITHDRAW are authored. - function testAuthorsOnlyTheHeldPairsWhenCertifyIsRevoked() external { - address authoriser = deployFullyGrantedAuthoriser(); - revokeFromSigner(authoriser, CERTIFY); - RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); - - (RoleGrant[] memory retired, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); - - assertCanonicalPairs(retired); - assertEq(txs.length, 2, "the revoked pair is not re-authored"); - assertRevokesRole(txs[0], authoriser, DEPOSIT); - assertRevokesRole(txs[1], authoriser, WITHDRAW); - } - - /// @notice The self-scoping tracks WHICH pair is held, not merely how - /// many: with the FIRST canonical pair already revoked the remaining - /// two are authored, so the bundle cannot be a prefix of the map. - function testAuthorsOnlyTheHeldPairsWhenDepositIsRevoked() external { - address authoriser = deployFullyGrantedAuthoriser(); - revokeFromSigner(authoriser, DEPOSIT); - RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); - - (RoleGrant[] memory retired, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); - - assertCanonicalPairs(retired); - assertEq(txs.length, 2, "the revoked pair is not re-authored"); - assertRevokesRole(txs[0], authoriser, WITHDRAW); - assertRevokesRole(txs[1], authoriser, CERTIFY); - } - - /// @notice A fully revoked authoriser refuses to author an empty - /// bundle — the post-execution state, reached here by really revoking - /// all three pairs. - function testRevertsWhenEveryPairIsAlreadyRevoked() external { - address authoriser = deployFullyGrantedAuthoriser(); - revokeFromSigner(authoriser, DEPOSIT); - revokeFromSigner(authoriser, WITHDRAW); - revokeFromSigner(authoriser, CERTIFY); - RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); - - vm.expectRevert(FireblocksSignerAlreadyRevoked.selector); - harness.callAuthorBundle(authoriser, SAFE); - } - - /// @notice The revoke is never authored on an authoriser whose - /// configuration has drifted: a missing NON-retired row aborts the - /// authoring before any pair is selected. The row removed here is one - /// of the REPLACEMENT signer's — this is the rotation gate: an - /// under-provisioned replacement blocks the revoke, so the service can - /// never be left without an active signer. - function testRevertsWhenTheReplacementSignerIsUnderProvisioned() external { - address authoriser = deployFullyGrantedAuthoriser(); - vm.prank(SAFE); - IAccessControl(authoriser).revokeRole(DEPOSIT, LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C); - RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); - - vm.expectRevert(bytes("RevokeFireblocksServiceSigner: authoriser grant map has drifted")); - harness.callAuthorBundle(authoriser, SAFE); - } - - /// @notice The drift guard is not specific to the replacement signer: - /// a missing Safe-grantee row aborts the authoring identically. - function testRevertsWhenTheGrantMapHasDrifted() external { - address authoriser = deployFullyGrantedAuthoriser(); - vm.prank(SAFE); - IAccessControl(authoriser).revokeRole(CERTIFY, SAFE); - RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); - - vm.expectRevert(bytes("RevokeFireblocksServiceSigner: authoriser grant map has drifted")); - harness.callAuthorBundle(authoriser, SAFE); - } -} diff --git a/test/script/RevokeFireblocksServiceSignerHarness.sol b/test/script/RevokeFireblocksServiceSignerHarness.sol deleted file mode 100644 index c216c26c..00000000 --- a/test/script/RevokeFireblocksServiceSignerHarness.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {RevokeFireblocksServiceSigner} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; -import {RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; -import {SafeTx} from "../../src/lib/LibSafeOps.sol"; - -/// @title RevokeFireblocksServiceSignerHarness -/// @notice Exposes the script's internal bundle authoring so the selection -/// logic can be driven directly against any authoriser, and so -/// `vm.expectRevert` can intercept the typed errors it raises (a -/// library-internal revert inlines, and `expectRevert` only sees reverts -/// from a lower call depth than the cheatcode itself). Its own file because -/// Rain convention is one contract per .sol and -/// `rainix-sol-single-contract` enforces it. Mirrors -/// `test/script/ProvisionAdditionalServiceSignerHarness.sol`. -contract RevokeFireblocksServiceSignerHarness is RevokeFireblocksServiceSigner { - /// @notice The script's `authorBundle()`, externally callable. - /// @param authoriser The authoriser whose live role state is read. - /// @param safeAddr The token-owner Safe filling the map's Safe slots. - /// @return retired The retired signer's canonical pairs, in map order. - /// @return txs One `revokeRole` tx per pair the signer still holds. - function callAuthorBundle(address authoriser, address safeAddr) - external - view - returns (RoleGrant[] memory retired, SafeTx[] memory txs) - { - return authorBundle(authoriser, safeAddr); - } -} diff --git a/test/script/RevokeFireblocksServiceSignerRemainderHarness.sol b/test/script/RevokeFireblocksServiceSignerRemainderHarness.sol deleted file mode 100644 index c9e30255..00000000 --- a/test/script/RevokeFireblocksServiceSignerRemainderHarness.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {RevokeFireblocksServiceSigner} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; - -/// @title RevokeFireblocksServiceSignerRemainderHarness -/// @notice The partial-re-dispatch fixture's script: identical to the -/// production script except the artifact lands on a dedicated path. The -/// partial test's bundle content DIFFERS from the full-bundle tests' on -/// the same chain, and forge runs tests in parallel, so writing the -/// canonical path would race with them. Its own file because Rain -/// convention is one contract per .sol. -contract RevokeFireblocksServiceSignerRemainderHarness is RevokeFireblocksServiceSigner { - /// @notice The dedicated remainder-artifact path, exposed so the test - /// parses exactly the file this fixture wrote. - /// @return path The remainder artifact path for the ACTIVE chain. - function remainderArtifactPath() public view returns (string memory path) { - path = string.concat("out/20260810-revoke-remainder-", vm.toString(block.chainid), ".json"); - } - - function artifactPath() internal view override returns (string memory path) { - path = remainderArtifactPath(); - } -} diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol index 7a7d02d1..4921ec52 100644 --- a/test/src/lib/LibAuthoriserInvariants.t.sol +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -10,7 +10,8 @@ import { ExpectedGrantMissing, UnexpectedDefaultAdmin, UnexpectedRetainedAdminGrant, - AuthoriserImplCodehashMismatch + AuthoriserImplCodehashMismatch, + RevokedGrantStillHeld } from "../../../src/lib/LibAuthoriserInvariants.sol"; import {LibProdDeployV4} from "../../../src/generated/LibProdDeployV4.sol"; import {LibAuthoriserInvariantsHarness} from "./LibAuthoriserInvariantsHarness.sol"; @@ -85,31 +86,28 @@ contract LibAuthoriserInvariantsTest is Test { /// @notice The admin-holder parameterisation: the seven `_ADMIN` entries /// track `adminHolder`, the six operational entries stay split between - /// the service signer and the Safe, and the narrower overloads are exact + /// the Safe and the service signer, and the narrower overloads are exact /// collapses of the widest one (so no consumer can drift from the single /// map). function testExpectedGrantsAdminHolderParameterisation() external pure { address safe = address(0x5AFE); address timelock = address(0x7135); RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(safe, timelock); - assertEq(grants.length, 16); + assertEq(grants.length, 13); for (uint256 i = 0; i < 7; i++) { assertEq(grants[i].grantee, timelock, "admin entries must track adminHolder"); } for (uint256 i = 7; i < 10; i++) { - assertEq(grants[i].grantee, LibAuthoriserInvariants.GRANTEE_SERVICE_1C66); - } - for (uint256 i = 10; i < 13; i++) { assertEq(grants[i].grantee, safe, "operational Safe entries must track the Safe"); } - // The additional service signer's three action roles are operational, - // not admin: they must track the signer regardless of who holds the + // The service signer's three action roles are operational, not + // admin: they must track the signer regardless of who holds the // `_ADMIN` slice, so the timelock migration never moves them. - for (uint256 i = 13; i < 16; i++) { + for (uint256 i = 10; i < 13; i++) { assertEq( grants[i].grantee, LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C, - "additional service signer entries must be independent of adminHolder" + "service signer entries must be independent of adminHolder" ); } @@ -164,6 +162,43 @@ contract LibAuthoriserInvariantsTest is Test { clone, abi.encodeWithSelector(IAccessControl.hasRole.selector, grants[i].role, safe), abi.encode(false) ); } + // The retired signer's live grants would still trip the revoked-pair + // sweep until its revocation bundle executes, so mock them absent — + // this test pins the admin-holder mechanics, not the rotation state. + RoleGrant[] memory revoked = LibAuthoriserInvariants.revokedGrants(); + for (uint256 i = 0; i < revoked.length; i++) { + vm.mockCall( + clone, + abi.encodeWithSelector(IAccessControl.hasRole.selector, revoked[i].role, revoked[i].grantee), + abi.encode(false) + ); + } harness.callAssertExpectedGrants(clone, safe, timelock); } + + /// @notice `assertExpectedGrants` reverts `RevokedGrantStillHeld` when a + /// `revokedGrants()` pair is held on the authoriser: the retired service + /// signer must hold none of its former action roles. Mocked so the pin + /// is deterministic regardless of the live chain state. + function testAssertExpectedGrantsRejectsRevokedGrantStillHeld() external { + selectBaseFork(); + address clone = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + vm.mockCall( + clone, + abi.encodeWithSelector( + IAccessControl.hasRole.selector, keccak256("DEPOSIT"), LibAuthoriserInvariants.GRANTEE_SERVICE_1C66 + ), + abi.encode(true) + ); + LibAuthoriserInvariantsHarness harness = new LibAuthoriserInvariantsHarness(); + vm.expectRevert( + abi.encodeWithSelector( + RevokedGrantStillHeld.selector, + clone, + keccak256("DEPOSIT"), + LibAuthoriserInvariants.GRANTEE_SERVICE_1C66 + ) + ); + harness.callAssertExpectedGrants(clone); + } }