-
Notifications
You must be signed in to change notification settings - Fork 1
feat(safe): LibAuthoriserInvariants — pin live authoriser state + role-grant map #201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b53a327
dd3ca93
2dfe631
a2861dd
b3086fe
d683550
4d61eaf
fe02e87
e4a93dc
ec68206
8d9b4a7
fda6221
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<ROLE>_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); | ||
|
Comment on lines
+61
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
The docstring states this pin exists "so the invariant flags any future grant of Either wire in an explicit assertion (e.g., assert known addresses do not hold 🤖 Prompt for AI Agents |
||
|
|
||
| /// @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; | ||
|
Comment on lines
+79
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value Unresolved TODO on The grantee identity is unconfirmed and the constant name is a placeholder. Worth resolving before this becomes a pinned source of truth. Want me to open a tracking issue to confirm the EOA identity and rename the constant? 🤖 Prompt for AI Agents |
||
|
|
||
| /// @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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } | ||
|
Comment on lines
+21
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Missing error-path coverage for The contract docstring states drift "surfaces as 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor
🧩 Analysis chain
🏁 Script executed:
Repository: S01-Issuer/st0x.deploy
Length of output: 160
STOX_PROD_AUTHORISER_IMPLis declared but unused.The constant
STOX_PROD_AUTHORISER_IMPLdocuments the expectation that the live clone uses a specific implementation from rain-vats, yet it is never referenced. TheassertAll()function currently verifies only grants, not the clone's implementation codehash. Add an explicit assertion to validate the implementation backend matches this constant, or remove the unused constant.🤖 Prompt for AI Agents