diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 52a8900d..e680c36a 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -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 @@ -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 `_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); diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol index eda78b5c..0661bffa 100644 --- a/src/lib/LibInvariants.sol +++ b/src/lib/LibInvariants.sol @@ -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 @@ -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); @@ -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. diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index 7f1f2e0d..394b48de 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -6,6 +6,23 @@ import {IOwnable} from "../interface/IOwnable.sol"; import {IAuthorisable} from "../interface/IAuthorisable.sol"; import {LibMigrationInvariant} from "./LibMigrationInvariant.sol"; +/// @notice One production token's contract triple on a single chain, keyed +/// by the underlying ticker. The underlying symbol (e.g. "MSTR", not +/// "tMSTR" / "wtMSTR") is the chain-agnostic join key: it matches the +/// issuer-side asset identity, so cross-chain checks pair instances by +/// `underlying` and then compare the on-chain `name()` / `symbol()` / +/// `decimals()` read from each chain's contracts. +/// @param underlying The underlying ticker the token set wraps. +/// @param receipt The ERC-1155 receipt contract. +/// @param receiptVault The ERC-20 receipt vault (tStock). +/// @param wrappedTokenVault The ERC-4626 wrapped token vault (wtStock). +struct TokenInstance { + string underlying; + address receipt; + address receiptVault; + address wrappedTokenVault; +} + /// @notice A production receipt vault's `owner()` does not match the owner /// the uniform-ownership invariant expected every vault to share. Surfaces /// the exact vault address that breaks the invariant rather than a generic @@ -220,35 +237,51 @@ library LibTokenInvariants { /// https://basescan.org/address/0x8200c6d9AB9E02A25D7F2099244C476d99a085ef address internal constant ASML_WRAPPED_TOKEN_VAULT = address(0x8200c6d9AB9E02A25D7F2099244C476d99a085ef); + /// @notice Returns the 22 production token instance triples on Base, in + /// the order they were deployed. This is the structured source of truth + /// the flat `productionReceiptVaults()` accessor derives from; consumers + /// that need the receipt / wrapped-vault legs or the underlying join key + /// (cross-chain parity, per-token config checks) iterate this instead. + /// @return tokens The 22 production token instances on Base. + function productionTokensBase() internal pure returns (TokenInstance[] memory tokens) { + tokens = new TokenInstance[](22); + tokens[0] = TokenInstance("MSTR", MSTR_RECEIPT, MSTR_RECEIPT_VAULT, MSTR_WRAPPED_TOKEN_VAULT); + tokens[1] = TokenInstance("TSLA", TSLA_RECEIPT, TSLA_RECEIPT_VAULT, TSLA_WRAPPED_TOKEN_VAULT); + tokens[2] = TokenInstance("COIN", COIN_RECEIPT, COIN_RECEIPT_VAULT, COIN_WRAPPED_TOKEN_VAULT); + tokens[3] = TokenInstance("SPYM", SPYM_RECEIPT, SPYM_RECEIPT_VAULT, SPYM_WRAPPED_TOKEN_VAULT); + tokens[4] = TokenInstance("SIVR", SIVR_RECEIPT, SIVR_RECEIPT_VAULT, SIVR_WRAPPED_TOKEN_VAULT); + tokens[5] = TokenInstance("CRCL", CRCL_RECEIPT, CRCL_RECEIPT_VAULT, CRCL_WRAPPED_TOKEN_VAULT); + tokens[6] = TokenInstance("NVDA", NVDA_RECEIPT, NVDA_RECEIPT_VAULT, NVDA_WRAPPED_TOKEN_VAULT); + tokens[7] = TokenInstance("IAU", IAU_RECEIPT, IAU_RECEIPT_VAULT, IAU_WRAPPED_TOKEN_VAULT); + tokens[8] = TokenInstance("PPLT", PPLT_RECEIPT, PPLT_RECEIPT_VAULT, PPLT_WRAPPED_TOKEN_VAULT); + tokens[9] = TokenInstance("AMZN", AMZN_RECEIPT, AMZN_RECEIPT_VAULT, AMZN_WRAPPED_TOKEN_VAULT); + tokens[10] = TokenInstance("BMNR", BMNR_RECEIPT, BMNR_RECEIPT_VAULT, BMNR_WRAPPED_TOKEN_VAULT); + tokens[11] = TokenInstance("IBHG", IBHG_RECEIPT, IBHG_RECEIPT_VAULT, IBHG_WRAPPED_TOKEN_VAULT); + tokens[12] = TokenInstance("SGOV", SGOV_RECEIPT, SGOV_RECEIPT_VAULT, SGOV_WRAPPED_TOKEN_VAULT); + tokens[13] = TokenInstance("QQQM", QQQM_RECEIPT, QQQM_RECEIPT_VAULT, QQQM_WRAPPED_TOKEN_VAULT); + tokens[14] = TokenInstance("VWO", VWO_RECEIPT, VWO_RECEIPT_VAULT, VWO_WRAPPED_TOKEN_VAULT); + tokens[15] = TokenInstance("ARKK", ARKK_RECEIPT, ARKK_RECEIPT_VAULT, ARKK_WRAPPED_TOKEN_VAULT); + tokens[16] = TokenInstance("SPCX", SPCX_RECEIPT, SPCX_RECEIPT_VAULT, SPCX_WRAPPED_TOKEN_VAULT); + tokens[17] = TokenInstance("CEG", CEG_RECEIPT, CEG_RECEIPT_VAULT, CEG_WRAPPED_TOKEN_VAULT); + tokens[18] = TokenInstance("DRAM", DRAM_RECEIPT, DRAM_RECEIPT_VAULT, DRAM_WRAPPED_TOKEN_VAULT); + tokens[19] = TokenInstance("TSM", TSM_RECEIPT, TSM_RECEIPT_VAULT, TSM_WRAPPED_TOKEN_VAULT); + tokens[20] = TokenInstance("SKHY", SKHY_RECEIPT, SKHY_RECEIPT_VAULT, SKHY_WRAPPED_TOKEN_VAULT); + tokens[21] = TokenInstance("ASML", ASML_RECEIPT, ASML_RECEIPT_VAULT, ASML_WRAPPED_TOKEN_VAULT); + } + /// @notice Returns the 22 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 /// list inline. + /// @dev Derived from `productionTokensBase()` so the token table is the + /// single source of truth and the two accessors cannot drift. /// @return vaults The 22 production receipt vault addresses on Base. function productionReceiptVaults() internal pure returns (address[] memory vaults) { - vaults = new address[](22); - vaults[0] = MSTR_RECEIPT_VAULT; - vaults[1] = TSLA_RECEIPT_VAULT; - vaults[2] = COIN_RECEIPT_VAULT; - vaults[3] = SPYM_RECEIPT_VAULT; - vaults[4] = SIVR_RECEIPT_VAULT; - vaults[5] = CRCL_RECEIPT_VAULT; - vaults[6] = NVDA_RECEIPT_VAULT; - vaults[7] = IAU_RECEIPT_VAULT; - vaults[8] = PPLT_RECEIPT_VAULT; - vaults[9] = AMZN_RECEIPT_VAULT; - vaults[10] = BMNR_RECEIPT_VAULT; - vaults[11] = IBHG_RECEIPT_VAULT; - vaults[12] = SGOV_RECEIPT_VAULT; - vaults[13] = QQQM_RECEIPT_VAULT; - vaults[14] = VWO_RECEIPT_VAULT; - vaults[15] = ARKK_RECEIPT_VAULT; - vaults[16] = SPCX_RECEIPT_VAULT; - vaults[17] = CEG_RECEIPT_VAULT; - vaults[18] = DRAM_RECEIPT_VAULT; - vaults[19] = TSM_RECEIPT_VAULT; - vaults[20] = SKHY_RECEIPT_VAULT; - vaults[21] = ASML_RECEIPT_VAULT; + TokenInstance[] memory tokens = productionTokensBase(); + vaults = new address[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + vaults[i] = tokens[i].receiptVault; + } } /// @notice Assert that every production receipt vault reports the same @@ -264,11 +297,23 @@ library LibTokenInvariants { /// @param expectedOwner The address every production receipt vault is /// expected to report as `owner()`. function assertUniformOwnership(address expectedOwner) internal view { - address[] memory vaults = productionReceiptVaults(); - for (uint256 i = 0; i < vaults.length; i++) { - address actualOwner = IOwnable(vaults[i]).owner(); + assertUniformOwnership(productionTokensBase(), expectedOwner); + } + + /// @notice Chain-parametric `assertUniformOwnership`: assert every + /// receipt vault in the supplied token table reports `expectedOwner`. + /// The Base no-arg-table overload delegates here with + /// `productionTokensBase()`; a multichain caller passes another chain's + /// table so the same uniform-ownership invariant runs against every + /// chain with that chain's vaults and Safe. + /// @param tokens The token table whose receipt vaults are checked. + /// @param expectedOwner The address every receipt vault must report as + /// `owner()`. + function assertUniformOwnership(TokenInstance[] memory tokens, address expectedOwner) internal view { + for (uint256 i = 0; i < tokens.length; i++) { + address actualOwner = IOwnable(tokens[i].receiptVault).owner(); if (actualOwner != expectedOwner) { - revert ReceiptVaultOwnerMismatch(vaults[i], expectedOwner, actualOwner); + revert ReceiptVaultOwnerMismatch(tokens[i].receiptVault, expectedOwner, actualOwner); } } } @@ -284,11 +329,21 @@ library LibTokenInvariants { /// @param expected The authoriser address every production receipt vault /// is expected to share. function assertUniformAuthoriser(address expected) internal view { - address[] memory vaults = productionReceiptVaults(); - for (uint256 i = 0; i < vaults.length; i++) { - address actual = IAuthorisable(vaults[i]).authorizer(); + assertUniformAuthoriser(productionTokensBase(), expected); + } + + /// @notice Chain-parametric `assertUniformAuthoriser`: assert every + /// receipt vault in the supplied token table reports `expected` as its + /// authoriser. The Base overload delegates here with + /// `productionTokensBase()`; a multichain caller passes another chain's + /// table + that chain's authoriser clone. + /// @param tokens The token table whose receipt vaults are checked. + /// @param expected The authoriser every receipt vault must share. + function assertUniformAuthoriser(TokenInstance[] memory tokens, address expected) internal view { + for (uint256 i = 0; i < tokens.length; i++) { + address actual = IAuthorisable(tokens[i].receiptVault).authorizer(); if (actual != expected) { - revert ReceiptVaultAuthoriserMismatch(vaults[i], expected, actual); + revert ReceiptVaultAuthoriserMismatch(tokens[i].receiptVault, expected, actual); } } } @@ -334,7 +389,21 @@ library LibTokenInvariants { /// @param expectedAuthoriser The authoriser address every production /// receipt vault is expected to report as `authorizer()`. function assertAll(address safe, address expectedAuthoriser) internal view { - assertUniformOwnership(safe); - assertUniformAuthoriser(expectedAuthoriser); + assertAll(productionTokensBase(), safe, expectedAuthoriser); + } + + /// @notice Chain-parametric token-side bundle: every receipt vault in + /// the supplied table reports `safe` as `owner()` and + /// `expectedAuthoriser` as `authorizer()`. The Base overload delegates + /// here with `productionTokensBase()`; `LibInvariants.assertProductionState` + /// calls this with each chain's own table so the full-production-state + /// pre-flight works on every chain. + /// @param tokens The chain's token table. + /// @param safe The Safe every receipt vault must report as `owner()`. + /// @param expectedAuthoriser The authoriser every receipt vault must + /// report as `authorizer()`. + function assertAll(TokenInstance[] memory tokens, address safe, address expectedAuthoriser) internal view { + assertUniformOwnership(tokens, safe); + assertUniformAuthoriser(tokens, expectedAuthoriser); } } diff --git a/test/src/lib/LibInvariants.t.sol b/test/src/lib/LibInvariants.t.sol new file mode 100644 index 00000000..d0c42cf5 --- /dev/null +++ b/test/src/lib/LibInvariants.t.sol @@ -0,0 +1,37 @@ +// 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 {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; +import {LibInvariants} from "../../../src/lib/LibInvariants.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {LibTokenInvariants} from "../../../src/lib/LibTokenInvariants.sol"; +import {LibAuthoriserInvariants} from "../../../src/lib/LibAuthoriserInvariants.sol"; + +/// @title LibInvariantsTest +/// @notice Exercises the multichain production-state orchestrator. The Base +/// no-arg `assertAll(safe)` and the explicit `assertProductionState(...)` +/// entry point must produce the same result against live Base, proving the +/// multichain generalisation is a strict superset of the Base pre-flight (the +/// Ethereum call site is the same function with Ethereum's table + clone — +/// the token-owner Safe and grant map are shared across chains, so only those +/// deploy artifacts differ; asserted live in the cross-chain parity suite once +/// Ethereum is bootstrapped). +contract LibInvariantsTest is Test { + /// The explicit orchestrator wired with Base's deploy artifacts passes + /// against live Base, identically to the Base no-arg overload it backs. + function testAssertProductionStateBasePassesLive() external { + vm.createSelectFork(LibRainDeploy.BASE); + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + + // Both forms must return silently. `assertProductionState` asserts the + // shared token-owner Safe + shared grant map, so only the token table + // and the live authoriser are passed. + LibInvariants.assertAll(safe); + LibInvariants.assertProductionState( + LibTokenInvariants.productionTokensBase(), LibAuthoriserInvariants.STOX_PROD_AUTHORISER + ); + } +}