Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/lib/LibAuthoriserInvariants.sol
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;
Comment on lines +53 to +59

Copy link
Copy Markdown

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:

#!/bin/bash
rg -nP '\bSTOX_PROD_AUTHORISER_IMPL\b'

Repository: S01-Issuer/st0x.deploy

Length of output: 160


STOX_PROD_AUTHORISER_IMPL is declared but unused.

The constant STOX_PROD_AUTHORISER_IMPL documents the expectation that the live clone uses a specific implementation from rain-vats, yet it is never referenced. The assertAll() 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/LibAuthoriserInvariants.sol` around lines 53 - 59, The constant
STOX_PROD_AUTHORISER_IMPL in LibAuthoriserInvariants is currently unused, while
assertAll() only checks grant state. Update assertAll() (or the relevant
invariant helper it calls) to explicitly verify the live clone’s
implementation/backend matches STOX_PROD_AUTHORISER_IMPL, using the existing
STOX_PROD_AUTHORISER reference to locate the clone; otherwise remove the
constant if that invariant is not intended.


/// @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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

DEFAULT_ADMIN_ROLE is declared but never asserted — the documented invariant is not implemented.

The docstring states this pin exists "so the invariant flags any future grant of DEFAULT_ADMIN_ROLE as unexpected." However, DEFAULT_ADMIN_ROLE is never referenced in expectedGrants() nor in assertExpectedGrants/assertAll. More fundamentally, assertExpectedGrants is positive-only: it verifies that the listed pairs are held via hasRole, but it cannot detect an unexpected grant of DEFAULT_ADMIN_ROLE (or any other role) to an unknown address, since IAccessControl is not enumerable. As written, a rogue DEFAULT_ADMIN_ROLE grant would pass silently.

Either wire in an explicit assertion (e.g., assert known addresses do not hold DEFAULT_ADMIN_ROLE) or correct the docstring to scope the guarantee to positive presence only and drop the unused constant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/LibAuthoriserInvariants.sol` around lines 61 - 67, The invariant in
LibAuthoriserInvariants is only checking positive role membership, so the pinned
DEFAULT_ADMIN_ROLE constant is currently unused and cannot detect unexpected
grants. Update the invariant logic in expectedGrants(), assertExpectedGrants, or
assertAll to explicitly verify that known addresses do not hold
DEFAULT_ADMIN_ROLE, or else remove the constant and revise the docstring to
match the actual positive-only behavior. Use the DEFAULT_ADMIN_ROLE symbol and
the existing grant-checking flow in LibAuthoriserInvariants to locate the fix.


/// @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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unresolved TODO on GRANTEE_SERVICE_1C66 identity.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/LibAuthoriserInvariants.sol` around lines 79 - 81, Resolve the
unresolved identity TODO for GRANTEE_SERVICE_1C66 in LibAuthoriserInvariants by
verifying the referenced address and renaming the constant to a definitive,
descriptive identifier. Update or remove the placeholder comment once the
identity is confirmed, and ensure any usages of GRANTEE_SERVICE_1C66 reflect the
new name so the invariant source remains accurate.


/// @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);
}
}
13 changes: 8 additions & 5 deletions src/lib/LibInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
}
}
9 changes: 0 additions & 9 deletions src/lib/LibTokenInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions test/src/lib/LibAuthoriserInvariants.t.sol
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing error-path coverage for ExpectedGrantMissing.

The contract docstring states drift "surfaces as ExpectedGrantMissing here," but only the positive path (testAssertAllPasses) is exercised. The sibling LibTokenInvariants.t.sol mocks a single vault to assert the revert path; consider an analogous test here (e.g., vm.mockCall on hasRole for one pinned pair → expect ExpectedGrantMissing with the exact role/grantee) so the typed-error path is verified.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/lib/LibAuthoriserInvariants.t.sol` around lines 21 - 35, Add
negative-path coverage in LibAuthoriserInvariantsTest for the
ExpectedGrantMissing branch: alongside testAssertAllPasses, create an invariant
test that uses vm.mockCall against the authoriser’s hasRole for one pinned
role/grantee pair and asserts the revert with ExpectedGrantMissing including the
exact role and grantee values. Mirror the style used in
LibTokenInvariants.t.sol, and keep the test anchored on
LibAuthoriserInvariants.assertAll so the typed-error path is exercised
end-to-end.

7 changes: 4 additions & 3 deletions test/src/lib/LibTokenInvariants.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
Loading