diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol new file mode 100644 index 00000000..141dfa9d --- /dev/null +++ b/src/lib/LibAuthoriserInvariants.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibSafeInvariants} from "./LibSafeInvariants.sol"; + +/// @notice A pinned `(role, grantee)` pair on the live authoriser. +struct RoleGrant { + bytes32 role; + address grantee; +} + +/// @notice An expected `(role, grantee)` pair is not held on the authoriser. +/// Surfaces the exact pair that breaks the role-grant invariant rather than +/// a generic mismatch. +/// @param authoriser The authoriser address inspected. +/// @param role The role that should be held. +/// @param grantee The grantee that should hold the role. +error ExpectedGrantMissing(address authoriser, bytes32 role, address grantee); + +/// @title LibAuthoriserInvariants +/// @notice Reusable invariants for the ST0x production authoriser on Base: +/// the pinned current-state authoriser address, the expected impl behind +/// it, the grantee constants, and the full `(role, grantee)` map enumerated +/// from `RoleGranted` / `RoleRevoked` events on-chain. Each assertion +/// either returns silently when the invariant holds against the live chain +/// state or reverts with a typed error that pinpoints the drift. +/// @dev Owns both the current-state pins and the assert functions. When the +/// authoriser-swap script lands and the live authoriser changes, the pins +/// here are updated (current → new clone address + new impl) so future +/// `assertAll` runs validate the new live state. The role-grant map +/// (`expectedGrants`) does not change pre / post swap because the swap +/// mirrors the same grants forward. +/// +/// Composed into `LibInvariants.assertAll` alongside `LibSafeInvariants` +/// and `LibTokenInvariants`; individually callable via `assertAll()` for +/// the focused authoriser drift detector. +/// +/// The V4 clone deploy target (the address the upcoming swap script +/// `setAuthorizer`s every receipt vault onto) does **not** live here — +/// that's a deploy artifact, pinned in `LibProdDeployV4`. This lib only +/// holds what the live authoriser **should** look like today. +library LibAuthoriserInvariants { + /// @notice The current live ST0x authoriser clone on Base. Every + /// production receipt vault's `authorizer()` returns this address. + /// Pinned as the current-state invariant; updated post-swap when the + /// receipt vaults are rewired onto a new authoriser clone, so future + /// runs of `assertAll` validate the new live state. + /// https://basescan.org/address/0x35f9fa9d80aaf2b0fb27f0ff015641b3408d7456 + address internal constant STOX_PROD_AUTHORISER = 0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456; + + /// @notice The implementation behind the live clone. A base + /// `OffchainAssetReceiptVaultAuthorizerV1` from rain-vats that predates + /// the corporate-action role-admin extension. Pinned as the + /// current-state invariant; updated alongside `STOX_PROD_AUTHORISER` + /// when the live clone changes. + /// https://basescan.org/address/0x2b4a510c3619d5e888095bfe9f95902d32da5556 + address internal constant STOX_PROD_AUTHORISER_IMPL = 0x2B4A510c3619d5E888095BFE9f95902D32dA5556; + + /// @notice The base role-admin hierarchy used by the live authoriser + /// sets `_ADMIN` as the admin of each action role rather than + /// `DEFAULT_ADMIN_ROLE`. Consequently no `DEFAULT_ADMIN_ROLE` grant was + /// emitted at init and no address holds it. Pinned as the explicit + /// expectation so the invariant flags any future grant of + /// `DEFAULT_ADMIN_ROLE` as unexpected. + bytes32 internal constant DEFAULT_ADMIN_ROLE = bytes32(0); + + /// @notice The ST0x token-owner Safe — holds every `_ADMIN` role on the + /// live authoriser (set at init) and was later granted DEPOSIT, WITHDRAW + /// and CERTIFY as a privileged operator. Identical to + /// `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE`; re-exported as a grantee + /// constant for call-site clarity. + address internal constant GRANTEE_TOKEN_OWNER_SAFE = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + + /// @notice External service EOA granted `DEPOSIT` (block 41797262), + /// `WITHDRAW` (block 41797281) and `CERTIFY` (block 41797297) shortly + /// after the first service was provisioned. EOA, active service signer. + /// @dev TODO: confirm identity and rename. + /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 + address internal constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; + + /// @notice The full `(role, grantee)` map in effect on the live + /// authoriser. Source of truth folded from `RoleGranted` / + /// `RoleRevoked` event scan on Base. The 11 entries split into: 5 + /// `_ADMIN` roles held by the token-owner Safe (set at init), 3 action + /// roles for the service EOA, 3 action roles the Safe later granted + /// itself for direct operational use. + /// @return grants The pinned `(role, grantee)` pairs. + function expectedGrants() internal pure returns (RoleGrant[] memory grants) { + grants = new RoleGrant[](11); + + // Init grants (block 41715184) — Safe receives every `_ADMIN` role. + grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + + // Service EOA provisioned at blocks 41797262, 41797281, 41797297. + grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); + grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); + grants[7] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); + + // Safe later granted itself the corresponding action roles (blocks + // 42704120, 42704140, 44076075) for direct operational use. + grants[8] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_TOKEN_OWNER_SAFE); + grants[9] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); + grants[10] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); + } + + /// @notice Assert every pinned `(role, grantee)` pair in + /// `expectedGrants()` is held on the supplied authoriser. Reverts with + /// `ExpectedGrantMissing` on the first pair that fails, surfacing the + /// exact role + grantee that broke the invariant. + /// @dev Parameterised on the authoriser address so the same assertion + /// can run against the live current clone (pre-swap) AND against a + /// freshly-deployed clone (the script's pre-flight on the swap target) + /// without duplicating the iteration. + /// @param authoriser The authoriser to validate. + function assertExpectedGrants(address authoriser) internal view { + IAccessControl acl = IAccessControl(authoriser); + RoleGrant[] memory grants = expectedGrants(); + 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 Full authoriser-side invariant bundle against the live + /// pinned `STOX_PROD_AUTHORISER`. Pre-flight at the start of every + /// migration script and prod-state fork test; if this passes silently + /// the live authoriser is in its current expected state. + /// @dev No-arg overload uses the lib's `STOX_PROD_AUTHORISER` constant. + /// Composed into `LibInvariants.assertAll`. + function assertAll() internal view { + assertExpectedGrants(STOX_PROD_AUTHORISER); + } +} diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol index d0c4cb72..cfcf73eb 100644 --- a/src/lib/LibInvariants.sol +++ b/src/lib/LibInvariants.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; +import {LibAuthoriserInvariants} from "./LibAuthoriserInvariants.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; import {LibTokenInvariants} from "./LibTokenInvariants.sol"; @@ -33,20 +34,22 @@ library LibInvariants { /// @param safe The Safe to validate against the pinned current truth. function assertAll(IGnosisSafe safe) internal view { LibSafeInvariants.assertAll(safe); - LibTokenInvariants.assertAll(address(safe), LibTokenInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER); + LibAuthoriserInvariants.assertAll(); } /// @notice Full-args bundle. Use when overriding the Safe-side /// threshold or owner set from `LibSafeInvariants`' current-truth pins — /// typically only when running a script that intentionally changes - /// one of those (post-state assertion). The token-side leg uses the - /// pinned defaults (vault ownership against the Safe, authoriser - /// against `LibTokenInvariants.STOX_PROD_AUTHORISER`). + /// one of those (post-state assertion). The token-side and authoriser- + /// side legs use the pinned current-truth defaults from + /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`. /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. /// @param expectedOwners The expected owner set in `getOwners()` order. function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) internal view { LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); - LibTokenInvariants.assertAll(address(safe), LibTokenInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER); + LibAuthoriserInvariants.assertAll(); } } diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index e38e550f..cdec7821 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -147,15 +147,6 @@ library LibTokenInvariants { /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 address internal constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); - /// @notice The single authoriser every production receipt vault is gated - /// by, as a token-side invariant. Pinned here as the expected value for - /// `assertUniformAuthoriser`; updated post-swap when the receipt vaults - /// are rewired onto a new authoriser clone. - /// @dev Read from `authorizer()` on the live vaults on Base. A vault - /// reporting any other authoriser is gated by a different RBAC contract - /// than the rest of the system and trips the invariant. - address internal constant STOX_PROD_AUTHORISER = address(0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456); - /// @notice Returns the 13 production receipt vault addresses on Base, in /// the order they were deployed. Provided so consumers (e.g. invariant /// assertions, migration scripts) can iterate without hardcoding the diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol new file mode 100644 index 00000000..6cb7b3d6 --- /dev/null +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -0,0 +1,35 @@ +// 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 {LibAuthoriserInvariants} from "../../../src/lib/LibAuthoriserInvariants.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; + +/// @title LibAuthoriserInvariantsTest +/// @notice Fork tests pinning the live ST0x authoriser's role-grant map +/// against the constants in `LibAuthoriserInvariants`. The positive case +/// runs the lib's no-arg `assertAll()`, which iterates `expectedGrants()` +/// and asserts every pair against the live authoriser pinned at +/// `STOX_PROD_AUTHORISER`. Any drift (a pin missing on-chain, or an +/// off-chain pin the lib doesn't know about) surfaces as +/// `ExpectedGrantMissing` here. +/// @dev Uses an unpinned Base head fork (same precedent as the other +/// prod-state drift detectors in this repo). Pinning would freeze the +/// invariant assertions against a stale snapshot and let new drift slip +/// through unnoticed. +contract LibAuthoriserInvariantsTest is Test { + /// @notice Selects the Base fork at chain head — deliberately unpinned. + /// Live drift detector; see contract-level rationale. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + } + + /// @notice The live authoriser pinned at + /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER` holds every + /// `expectedGrants()` pair. Passes against the live chain state. + function testAssertAllPasses() external { + selectBaseFork(); + LibAuthoriserInvariants.assertAll(); + } +} diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol index f361392e..b2cdc4d2 100644 --- a/test/src/lib/LibTokenInvariants.t.sol +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibAuthoriserInvariants} from "../../../src/lib/LibAuthoriserInvariants.sol"; import {LibTokenInvariants, IOwnable, ReceiptVaultOwnerMismatch} from "../../../src/lib/LibTokenInvariants.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {LibTokenInvariantsHarness} from "./LibTokenInvariantsHarness.sol"; @@ -15,7 +16,7 @@ import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// /// Both uniformity invariants currently hold on-chain (every vault is /// owned by `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE` and reports the pinned -/// `LibTokenInvariants.STOX_PROD_AUTHORISER`), so the positive +/// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`), so the positive /// cases pass against the live Base fork. The inverted ownership-drift /// case is also exercised here for full error-path coverage. /// @dev Uses an unpinned Base head fork (same precedent as the other @@ -43,11 +44,11 @@ contract LibTokenInvariantsTest is Test { } /// @notice Every production receipt vault reports - /// `LibTokenInvariants.STOX_PROD_AUTHORISER`. Passes against + /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`. Passes against /// the live chain state: vault authoriser is uniform. function testProdReceiptVaultsShareUniformAuthoriser() external { selectBaseFork(); - LibTokenInvariants.assertUniformAuthoriser(LibTokenInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertUniformAuthoriser(LibAuthoriserInvariants.STOX_PROD_AUTHORISER); } /// @notice Token-side ownership drift trips `ReceiptVaultOwnerMismatch`.