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
71 changes: 49 additions & 22 deletions src/lib/LibAuthoriserInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -97,33 +97,47 @@ library LibAuthoriserInvariants {
/// 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.
/// @notice The full `(role, grantee)` map in effect on the live Base
/// authoriser. Source of truth folded from `RoleGranted` / `RoleRevoked`
/// event scan on Base. Delegates to the Safe-parametric overload with
/// Base's token-owner Safe.
/// @return grants The pinned `(role, grantee)` pairs for Base.
function expectedGrants() internal pure returns (RoleGrant[] memory grants) {
grants = expectedGrants(GRANTEE_TOKEN_OWNER_SAFE);
}

/// @notice The `(role, grantee)` map every ST0x authoriser carries,
/// parameterised on the chain's token-owner Safe. The STRUCTURE — 5
/// `_ADMIN` roles + 3 direct action roles held by the Safe, 3 action roles
/// held by the shared service signer — is identical on every chain; the
/// only per-chain input is the Safe ADDRESS (the service signer is shared),
/// because the Safe address is now a per-chain deploy artifact. The 11
/// entries split into: 5 `_ADMIN` roles held by the Safe (set at init), 3
/// action roles for the service EOA, 3 action roles the Safe holds for
/// direct operational use.
/// @param tokenOwnerSafe The chain's token-owner Safe filling the Safe
/// grantee slots.
/// @return grants The `(role, grantee)` pairs for that chain.
function expectedGrants(address tokenOwnerSafe) 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);
// Init grants (block 41715184 on Base) — Safe receives every `_ADMIN`.
grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), tokenOwnerSafe);
grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), tokenOwnerSafe);
grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), tokenOwnerSafe);
grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), tokenOwnerSafe);
grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), tokenOwnerSafe);

// Service EOA provisioned at blocks 41797262, 41797281, 41797297.
// Service EOA provisioned at blocks 41797262, 41797281, 41797297 (Base).
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);
// Safe holds the corresponding action roles (Base blocks 42704120,
// 42704140, 44076075) for direct operational use.
grants[8] = RoleGrant(keccak256("DEPOSIT"), tokenOwnerSafe);
grants[9] = RoleGrant(keccak256("WITHDRAW"), tokenOwnerSafe);
grants[10] = RoleGrant(keccak256("CERTIFY"), tokenOwnerSafe);
}

/// @notice Assert every pinned `(role, grantee)` pair in
Expand All @@ -140,17 +154,30 @@ library LibAuthoriserInvariants {
/// plain `AccessControl` cannot enumerate members).
/// @param authoriser The authoriser to validate.
function assertExpectedGrants(address authoriser) internal view {
assertExpectedGrants(authoriser, GRANTEE_TOKEN_OWNER_SAFE);
}

/// @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).
/// @param authoriser The authoriser to validate.
/// @param tokenOwnerSafe The chain's token-owner Safe filling the Safe
/// grantee slots.
function assertExpectedGrants(address authoriser, address tokenOwnerSafe) internal view {
IAccessControl acl = IAccessControl(authoriser);
// No pinned grantee holds DEFAULT_ADMIN_ROLE: the hierarchy admins each
// action role by its own `<ROLE>_ADMIN`, so a root-admin holder would
// be an escalation path the pinned map does not sanction.
if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_TOKEN_OWNER_SAFE)) {
revert UnexpectedDefaultAdmin(authoriser, GRANTEE_TOKEN_OWNER_SAFE);
if (acl.hasRole(DEFAULT_ADMIN_ROLE, tokenOwnerSafe)) {
revert UnexpectedDefaultAdmin(authoriser, tokenOwnerSafe);
}
if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_1C66)) {
revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_1C66);
}
RoleGrant[] memory grants = expectedGrants();
RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe);
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);
Expand Down
82 changes: 59 additions & 23 deletions src/lib/LibInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {IGnosisSafe} from "../interface/IGnosisSafe.sol";
import {LibAuthoriserInvariants} from "./LibAuthoriserInvariants.sol";
import {LibProdDeployV4} from "../generated/LibProdDeployV4.sol";
import {LibSafeInvariants} from "./LibSafeInvariants.sol";
import {LibTokenInvariants} from "./LibTokenInvariants.sol";
import {LibTokenInvariants, TokenInstance} from "./LibTokenInvariants.sol";

/// @title LibInvariants
/// @notice Orchestrator that composes every per-facet `assertAll` into a
Expand All @@ -21,30 +21,28 @@ import {LibTokenInvariants} from "./LibTokenInvariants.sol";
/// subject and reachable standalone for scripts / fork tests that don't
/// need the full bundle.
library LibInvariants {
/// @notice Full production-state invariant bundle. Composes every
/// per-facet `assertAll`: Safe identity / config + token-side
/// @notice Full production-state invariant bundle for **Base**. Composes
/// every per-facet `assertAll`: Safe identity / config + token-side
/// owner/authoriser uniformity. Pre-flight at the start of every
/// migration script and prod-state fork test; if this passes silently
/// the live system is in its current expected state across every
/// pinned facet.
/// @dev The full-args overload is the right call site only when a
/// caller is *deliberately* asserting a state that diverges from the
/// pinned current truth (e.g. a migration script's post-state re-check
/// after it has simulated `changeThreshold`); the no-arg overload
/// fills in the `LibSafeInvariants`-pinned defaults.
///
/// The authoriser leg is migration-window gated for the V4 swap:
/// every vault's `authorizer()` may be the V3 authoriser
/// The authoriser leg is migration-window gated for the V4 swap: every
/// vault's `authorizer()` may be the V3 authoriser
/// (`LibAuthoriserInvariants.STOX_PROD_AUTHORISER`) or the V4 clone
/// (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE`) until
/// `LibProdDeployV4.V4_SWAP_DEADLINE`; only the V4 clone after. This
/// keeps the bundle green across the swap with no post-execution lib
/// repoint: before the swap the pre-state matches, after the swap the
/// post-state matches, and past the deadline an un-run swap red-lines
/// cron. `LibAuthoriserInvariants.assertAll()` continues to validate
/// the V3 clone's own impl pin + grant map — properties of that
/// contract which stay true after the swap (the swap does not revoke
/// anything on the old clone).
/// `LibProdDeployV4.V4_SWAP_DEADLINE`; only the V4 clone after. This keeps
/// the bundle green across the swap with no post-execution lib repoint:
/// before the swap the pre-state matches, after the swap the post-state
/// matches, and past the deadline an un-run swap red-lines cron.
/// `LibAuthoriserInvariants.assertAll()` continues to validate the V3
/// clone's own impl pin + grant map.
///
/// @dev The chain-agnostic generalisation used for other chains is
/// `assertProductionState`. Base keeps this dedicated overload because
/// the V4 swap window is a Base-only transitional concern — a bootstrap
/// chain deploys directly at V4 with a single authoriser and no window.
/// @param safe The Safe to validate against the pinned current truth.
function assertAll(IGnosisSafe safe) internal view {
LibSafeInvariants.assertAll(safe);
Expand All @@ -57,12 +55,50 @@ library LibInvariants {
LibAuthoriserInvariants.assertAll();
}

/// @notice Full-args bundle. Use when overriding the Safe-side
/// @notice Multichain full-production-state pre-flight — the
/// chain-agnostic generalisation of `assertAll(safe)`. Asserts, for the
/// ACTIVE chain (`block.chainid`): the Safe carries Base's shared policy
/// (`assertPolicyMatchesBase` — v1.4.1 identity, owner SET, threshold), the
/// token-side uniformity (every vault in `tokens` owned by that chain's
/// Safe and gated by the single `authoriser`), and the authoriser's role-
/// grant map for that chain's Safe. The Safe is resolved from the pinned
/// per-chain address via `LibSafeInvariants.safeForChainId(block.chainid)`,
/// so the deploy artifacts that differ per chain — the Safe address, the
/// token addresses, the authoriser clone address — are the only variation.
///
/// @dev The Safe POLICY (owner set, threshold, v1.4.1 identity) and the
/// service signer are SHARED across chains; only the ADDRESSES differ. The
/// Safe address is therefore a per-chain deploy artifact (not a principal):
/// resolved by chain id, and its policy asserted against the shared pins.
/// The owner check is order-INSENSITIVE (`assertPolicyMatchesBase`) because
/// a fresh per-chain Safe's `getOwners()` order is incidental. There is no
/// `ChainPrincipals` parameter — the per-chain inputs are the token
/// addresses and the authoriser clone address (whose impl codehash is
/// asserted equal across chains by the cross-chain parity pin); the Safe
/// address is read from the per-chain pin here.
///
/// Unlike Base's `assertAll(safe)` this asserts a SINGLE uniform
/// authoriser rather than the V4 swap-window pair: a bootstrap chain is
/// deployed directly at V4 with its vaults wired onto one clone from the
/// start, so there is no V3→V4 migration window to tolerate. The
/// authoriser CODEHASH is not asserted here (a deploy-artifact property
/// the clone-deploy script + cross-chain parity pin check); this bundle
/// asserts live ROLE state + ownership.
/// @param tokens The chain's production token table.
/// @param authoriser The chain's live authoriser the vaults point at.
function assertProductionState(TokenInstance[] memory tokens, address authoriser) internal view {
address safe = LibSafeInvariants.safeForChainId(block.chainid);
LibSafeInvariants.assertPolicyMatchesBase(IGnosisSafe(safe));
LibTokenInvariants.assertAll(tokens, safe, authoriser);
LibAuthoriserInvariants.assertExpectedGrants(authoriser, safe);
}

/// @notice Full-args Base 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 and authoriser-
/// side legs match the no-arg overload, including the V4 swap
/// migration window on the authoriser leg.
/// typically only when running a script that intentionally changes one of
/// those (post-state assertion). The token-side and authoriser-side legs
/// match the no-arg overload, including the V4 swap migration window on
/// the authoriser leg.
/// @param safe The Safe to validate.
/// @param expectedThreshold The expected signature threshold.
/// @param expectedOwners The expected owner set in `getOwners()` order.
Expand Down
Loading
Loading