diff --git a/script/MigrateMultisigThreshold.s.sol b/script/MigrateMultisigThreshold.s.sol index e12cd3d5..46f43e02 100644 --- a/script/MigrateMultisigThreshold.s.sol +++ b/script/MigrateMultisigThreshold.s.sol @@ -6,8 +6,8 @@ import {Script} from "forge-std-1.16.1/src/Script.sol"; import {console2} from "forge-std-1.16.1/src/console2.sol"; import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; -import {LibProdSafes} from "../src/lib/LibProdSafes.sol"; import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibInvariants} from "../src/lib/LibInvariants.sol"; import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; /// @notice A previously emitted Tx Builder JSON artifact (parsed via @@ -28,7 +28,7 @@ error VerifyExpectedSingleTx(uint256 actualCount); /// @title MigrateMultisigThreshold /// @notice Forge script that authors the ST0x token-owner Safe's /// multisig threshold migration (1-of-6 -> 3-of-6 against the post-rotation roster). Performs an -/// exhaustive on-chain pre-flight via `LibSafeInvariants.assertAll` +/// exhaustive on-chain pre-flight via `LibInvariants.assertAll` /// (proxy codehash, singleton + bytecode, version, modules, guard, /// fallback handler, uniform vault ownership, expected owner set, /// expected threshold), simulates the post-state, emits a Safe Tx @@ -44,7 +44,7 @@ error VerifyExpectedSingleTx(uint256 actualCount); /// artifact wasn't tampered with between authoring and signing. /// /// Pre-flight uses the no-arg `assertAll(safe)` overload, which defaults -/// the expected threshold and owner set to the `LibProdSafes`-pinned +/// the expected threshold and owner set to the `LibSafeInvariants`-pinned /// current truth. Post-state uses the full-args overload to override /// the threshold with the deliberately-changed `TARGET_THRESHOLD` /// while keeping the owner set pinned. @@ -73,12 +73,12 @@ contract MigrateMultisigThreshold is Script { /// verification in production and we explicitly simulate via /// `vm.prank`. function run() external { - IGnosisSafe safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); // Pre-flight: every immutable invariant plus the pinned 6-owner // roster plus the pinned current threshold. Defaults from - // `LibProdSafes` (no-arg overload). Reverts with the relevant + // `LibSafeInvariants` (no-arg overload). Reverts with the relevant // typed error from the underlying library on first mismatch. - LibSafeInvariants.assertAll(safe); + LibInvariants.assertAll(safe); // Build the single-tx bundle: a self-call to `changeThreshold(3)`. SafeTx memory txn = SafeTx({ @@ -104,7 +104,7 @@ contract MigrateMultisigThreshold is Script { // implementation that secretly mutates the owner roster, modules, // or fallback handler as a side effect. LibSafeOps.simulateSelfCall(safe, txn.data); - LibSafeInvariants.assertAll(safe, TARGET_THRESHOLD, LibProdSafes.expectedOwners()); + LibInvariants.assertAll(safe, TARGET_THRESHOLD, LibSafeInvariants.expectedOwners()); // Emit the Tx Builder JSON artifact and write it under `out/`. SafeTx[] memory txs = new SafeTx[](1); @@ -133,7 +133,7 @@ contract MigrateMultisigThreshold is Script { // forward migration (`changeThreshold(3)`) rather than the // reversal. The reversal exists only as a fork-local simulation; // signers never see it. - LibSafeOps.simulateNPlus1Reversal(safe, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, TARGET_THRESHOLD); + LibSafeOps.simulateNPlus1Reversal(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, TARGET_THRESHOLD); console2.log("n+1 reversibility check passed: threshold reverted to", safe.getThreshold()); } @@ -143,11 +143,11 @@ contract MigrateMultisigThreshold is Script { /// integrity before signing. /// @param jsonPath Filesystem path to the Tx Builder JSON to verify. function verify(string calldata jsonPath) external view { - IGnosisSafe safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); // Same pre-flight bundle as `run()`. If the live state has drifted // since the artifact was authored, the typed error bubbles before // we even open the file. - LibSafeInvariants.assertAll(safe); + LibInvariants.assertAll(safe); (uint256 parsedChainId, address parsedSafe, SafeTx[] memory parsedTxs) = LibSafeOps.parseTxBuilderJson(jsonPath); diff --git a/src/interface/IAuthorisable.sol b/src/interface/IAuthorisable.sol new file mode 100644 index 00000000..988e3e40 --- /dev/null +++ b/src/interface/IAuthorisable.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title IAuthorisable +/// @notice Minimal authoriser-getter surface exposed by ST0x receipt vaults. +/// Returns `address` rather than reusing the upstream `IAuthorizableV1` so +/// the token-invariant checks carry a narrow surface and not the upstream's +/// richer return type. +interface IAuthorisable { + /// @notice The authoriser contract gating restricted vault operations. + /// @return The authoriser address. + function authorizer() external view returns (address); +} diff --git a/src/interface/IOwnable.sol b/src/interface/IOwnable.sol new file mode 100644 index 00000000..58dbaa06 --- /dev/null +++ b/src/interface/IOwnable.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title IOwnable +/// @notice Minimal `Ownable`-like surface used by ST0x receipt vaults. +/// Every production receipt vault exposes `owner()`; the token-invariant +/// checks only need the getter, not the transfer/renounce mutators. This +/// narrow surface avoids depending on a richer token-side interface that +/// could drift. +interface IOwnable { + /// @notice The current owner of the contract. + /// @return The owner address. + function owner() external view returns (address); +} diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol new file mode 100644 index 00000000..d0c4cb72 --- /dev/null +++ b/src/lib/LibInvariants.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; +import {LibSafeInvariants} from "./LibSafeInvariants.sol"; +import {LibTokenInvariants} from "./LibTokenInvariants.sol"; + +/// @title LibInvariants +/// @notice Orchestrator that composes every per-facet `assertAll` into a +/// single bundle. Each facet lib (`LibSafeInvariants`, `LibTokenInvariants`, +/// any future `LibInvariants`) owns its own `assertAll`; this lib +/// chains them so a consumer asserting the full production state has a +/// single call site without any facet lib having to know about other +/// facets. +/// @dev Lives separately from `LibSafeInvariants` so the file name doesn't +/// lie about scope: cross-facet composition belongs in a cross-facet lib, +/// not inside a Safe-named lib. Per-facet libs stay focused on their +/// 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 + /// 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. + /// @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); + } + + /// @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`). + /// @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); + } +} diff --git a/src/lib/LibProdSafes.sol b/src/lib/LibProdSafes.sol deleted file mode 100644 index aca1999d..00000000 --- a/src/lib/LibProdSafes.sol +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -/// @title LibProdSafes -/// @notice Production Safe constants for the ST0x token-owner multisig on -/// Base. Pinned addresses, codehashes, slot values, and the expected owner -/// set (6 owners post-rotation) are derived from live on-chain state and -/// the canonical Safe deployment manifest, then re-asserted from fork -/// tests so that drift between this file and reality trips CI rather than -/// slipping into a migration script. -/// @dev Scope: this file pins the *post-rotation* roster (6 ST0x -/// governance signers) and the threshold migration script targets 3-of-6 -/// against that roster. The owner rotation itself is done manually via -/// the Safe UI under the current 1-of-N threshold (no rotation script -/// needed — at threshold 1 a single signer can execute each -/// `addOwnerWithThreshold` / `removeOwner` call via the Safe UI). Each -/// pinned address was verified off-chain via a send-back ceremony before -/// being committed here; the per-signer verification tracker is held -/// privately. Until the manual roster swap finishes executing on-chain -/// (i.e. all 6 listed addresses are present and the previous owners are -/// removed), every script and fork test that asserts against -/// `expectedOwners()` deliberately fails — the red CI is the explicit -/// forcing function gating the threshold-raise script. -/// @dev Sources: -/// - Safe v1.4.1 L2 singleton & proxy: github.com/safe-global/safe-deployments -/// under `src/assets/v1.4.1/safe_l2.json` (chainId 8453 entry). Both the -/// singleton address and the proxy bytecode are deterministic across the -/// Safe L2 deployment, so the proxy codehash below is also constant. -/// - ST0x Safe address & live state read on Base on 2026-05-20 via -/// `cast call`. The owner set, threshold, and storage-slot pins below -/// match the post-removal state. `StoxProdV2.t.sol::testProdDeployBaseV2` -/// exercises these against an unpinned head fork (via -/// `LibSafeInvariants.assertAll`) so the next CI run catches any further -/// drift; see that test for why `LibTestProd.PROD_TEST_BLOCK_NUMBER_BASE` -/// is not reused. -library LibProdSafes { - /// @notice Safe v1.4.1 L2 singleton (master copy) address on Base. - /// Verified by reading proxy storage slot `0x0` of - /// `STOX_TOKEN_OWNER_SAFE` and matching against the - /// `safe-deployments` manifest. - address constant SAFE_V1_4_1_L2_SINGLETON = 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762; - - /// @notice Runtime codehash of a Safe v1.4.1 proxy on Base. Equal to - /// `extcodehash(STOX_TOKEN_OWNER_SAFE)` and to every other v1.4.1 L2 - /// proxy pointing at `SAFE_V1_4_1_L2_SINGLETON`. Pinning this codehash - /// guards against the Safe address being replaced by an EOA-controlled - /// contract or a fake proxy pointing at a malicious singleton. - bytes32 constant SAFE_V1_4_1_L2_PROXY_CODEHASH = 0xb89c1b3bdf2cf8827818646bce9a8f6e372885f8c55e5c07acbd307cb133b000; - - /// @notice Expected `VERSION()` string from a Safe v1.4.1 singleton. - string constant SAFE_V1_4_1_VERSION = "1.4.1"; - - /// @notice Runtime codehash of the Safe v1.4.1 L2 singleton bytecode at - /// `SAFE_V1_4_1_L2_SINGLETON`. Pinning this guards against an attacker - /// who replaces the bytecode at the singleton address (e.g. via - /// `SELFDESTRUCT` + re-create) while preserving the proxy codehash. - /// Without this pin, every implementation-backed accessor on the Safe - /// (`VERSION()`, `getOwners()`, `getThreshold()`, etc.) is mediated by - /// untrusted code at the singleton address. Asserting this codehash - /// before any of those reads closes that gap. - /// @dev Computed via `keccak256(eth_getCode(SAFE_V1_4_1_L2_SINGLETON))` - /// on Base on 2026-05-20. - bytes32 constant SAFE_V1_4_1_L2_SINGLETON_CODEHASH = - 0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff; - - /// @notice CompatibilityFallbackHandler v1.4.1 address on Base. Verified - /// against the live Safe's fallback handler storage slot. Pinned so a - /// swapped-in malicious handler that shadows view selectors via - /// fallback can be detected by `LibSafeInvariants.assertImmutableInvariants`. - /// @dev Source: github.com/safe-global/safe-deployments - /// `src/assets/v1.4.1/compatibility_fallback_handler.json` (chainId - /// 8453 entry). Cross-checked on Base on 2026-05-20. - address constant SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER = 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99; - - /// @notice The Safe that owns every ST0x receipt vault on Base. Subject - /// of the threshold migration (1 -> 3, against the post-rotation - /// 6-owner roster). - /// https://basescan.org/address/0xe70d821f3462A074E63b42D0aac6523faAe1D611 - address constant STOX_TOKEN_OWNER_SAFE = 0xe70d821f3462a074e63b42d0AaC6523faAe1d611; - - /// @notice The current expected threshold for `STOX_TOKEN_OWNER_SAFE`. - /// Updated by the threshold-migration PR family once live execution - /// lands: scripts and the post-migration pin both treat this constant - /// as the canonical current truth, so the value bumps from `1` to `3` - /// in the same PR that records the live post-execution state. - uint256 constant STOX_TOKEN_OWNER_SAFE_THRESHOLD = 1; - - /// @notice Owner #1 of `STOX_TOKEN_OWNER_SAFE`. - /// @dev Order matches `getOwners()` (Safe-internal linked-list order) - /// against the post-rotation roster: `getOwners()` returns owners - /// newest-first, so the last signer to be added via - /// `addOwnerWithThreshold` appears at slot 0. The owner rotation - /// itself is done manually via the Safe UI under the current 1-of-N - /// threshold (no rotation script); only the threshold raise is - /// scripted. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_1 = 0x4746095B1Ea1A84446d34448f44e74D3d51f92F2; - - /// @notice Owner #2 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_2 = 0xceC2cb8B8EE4000FFA3F8a7f8E0Fa0A3E3DAb72d; - - /// @notice Owner #3 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_3 = 0x8D5901d8aE48101B59400235ad8614A2e0510466; - - /// @notice Owner #4 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_4 = 0xC1C89b7f5448F447d59f920456A9610f6b2544bC; - - /// @notice Owner #5 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_5 = 0xAB92b327c97A6E7461cBd76E2a789E5e106FF87e; - - /// @notice Owner #6 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_6 = 0x5CCd3cE683b66ff271DDB8915fF528b8fcFa23c2; - - /// @notice Returns the expected owner set for `STOX_TOKEN_OWNER_SAFE` in - /// the exact order returned by `getOwners()` against an unpinned Base - /// head fork (the live-state pin lives in - /// `StoxProdV2.t.sol::testProdDeployBaseV2`, which selects head rather - /// than pinning to a historical block so the next CI run catches any - /// further drift). Provided as a helper because Solidity 0.8 cannot - /// express a file-scope `constant address[]` and declaring the array - /// as `immutable` is contract-scoped only. - /// @dev Six entries post-rotation. The roster uses a mixed-vendor - /// hardware-wallet policy: the 3-of-6 threshold combined with the - /// vendor mix enforces that no single-vendor subset can reach quorum - /// on its own, so a single-vendor compromise cannot sign a tx without - /// recruiting a different-vendor signer. - /// @return The six owners of the ST0x token-owner Safe in - /// `getOwners()` order. - function expectedOwners() internal pure returns (address[] memory) { - address[] memory owners = new address[](6); - owners[0] = STOX_TOKEN_OWNER_SAFE_OWNER_1; - owners[1] = STOX_TOKEN_OWNER_SAFE_OWNER_2; - owners[2] = STOX_TOKEN_OWNER_SAFE_OWNER_3; - owners[3] = STOX_TOKEN_OWNER_SAFE_OWNER_4; - owners[4] = STOX_TOKEN_OWNER_SAFE_OWNER_5; - owners[5] = STOX_TOKEN_OWNER_SAFE_OWNER_6; - return owners; - } -} diff --git a/src/lib/LibProdTokensBase.sol b/src/lib/LibProdTokensBase.sol deleted file mode 100644 index 17f14189..00000000 --- a/src/lib/LibProdTokensBase.sol +++ /dev/null @@ -1,241 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -/// @title LibProdTokensBase -/// @notice Production token instance addresses on Base. These are beacon proxy -/// instances created via the V1 deployer, not implementation contracts. -/// Each token set consists of a receipt (ERC-1155), receipt vault (ERC-20), -/// and wrapped token vault (ERC-4626). -library LibProdTokensBase { - // ========================================================================= - // tMSTR / wtMSTR — MicroStrategy Incorporated ST0x - // Deployed via V1 OffchainAssetReceiptVaultBeaconSetDeployer + V1 StoxWrappedTokenVaultBeaconSetDeployer - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tMSTR. - /// https://basescan.org/address/0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC - address constant MSTR_RECEIPT = address(0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC); - - /// @dev Receipt vault (ERC-20, "tMSTR") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE - address constant MSTR_RECEIPT_VAULT = address(0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE); - - /// @dev Wrapped token vault (ERC-4626, "wtMSTR") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2 - address constant MSTR_WRAPPED_TOKEN_VAULT = address(0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2); - - // ========================================================================= - // tTSLA / wtTSLA — Tesla Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tTSLA. - /// https://basescan.org/address/0x660923230fAA859622711a5fC80f532dd588b125 - address constant TSLA_RECEIPT = address(0x660923230fAA859622711a5fC80f532dd588b125); - - /// @dev Receipt vault (ERC-20, "tTSLA") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0 - address constant TSLA_RECEIPT_VAULT = address(0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0); - - /// @dev Wrapped token vault (ERC-4626, "wtTSLA") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D - address constant TSLA_WRAPPED_TOKEN_VAULT = address(0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D); - - // ========================================================================= - // tCOIN / wtCOIN — Coinbase Global Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tCOIN. - /// https://basescan.org/address/0xBA1B8836A5510815e96103F067715b7CCC7c2E0E - address constant COIN_RECEIPT = address(0xBA1B8836A5510815e96103F067715b7CCC7c2E0E); - - /// @dev Receipt vault (ERC-20, "tCOIN") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x626757e6F50675D17fcAd312E82f989aE7A23d38 - address constant COIN_RECEIPT_VAULT = address(0x626757e6F50675D17fcAd312E82f989aE7A23d38); - - /// @dev Wrapped token vault (ERC-4626, "wtCOIN") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x5cDa0E1CA4ce2af96315f7F8963C85399c172204 - address constant COIN_WRAPPED_TOKEN_VAULT = address(0x5cDa0E1CA4ce2af96315f7F8963C85399c172204); - - // ========================================================================= - // tSPYM / wtSPYM — State Street SPDR Portfolio S&P 500 ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tSPYM. - /// https://basescan.org/address/0x957056dD6e2E594742E36675e8AA5A567163E5bd - address constant SPYM_RECEIPT = address(0x957056dD6e2E594742E36675e8AA5A567163E5bd); - - /// @dev Receipt vault (ERC-20, "tSPYM") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb - address constant SPYM_RECEIPT_VAULT = address(0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb); - - /// @dev Wrapped token vault (ERC-4626, "wtSPYM") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x31C2C14134e6E3B7ef9478297F199331133Fc2d8 - address constant SPYM_WRAPPED_TOKEN_VAULT = address(0x31C2C14134e6E3B7ef9478297F199331133Fc2d8); - - // ========================================================================= - // tSIVR / wtSIVR — abrdn Physical Silver Shares ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tSIVR. - /// https://basescan.org/address/0x053F52109a3439b4F292056D2DceC0486B544e82 - address constant SIVR_RECEIPT = address(0x053F52109a3439b4F292056D2DceC0486B544e82); - - /// @dev Receipt vault (ERC-20, "tSIVR") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8 - address constant SIVR_RECEIPT_VAULT = address(0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8); - - /// @dev Wrapped token vault (ERC-4626, "wtSIVR") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F - address constant SIVR_WRAPPED_TOKEN_VAULT = address(0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F); - - // ========================================================================= - // tCRCL / wtCRCL — Circle Internet Group Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tCRCL. - /// https://basescan.org/address/0xd508B97975fBE04E62bFf18959549b046bD8FA78 - address constant CRCL_RECEIPT = address(0xd508B97975fBE04E62bFf18959549b046bD8FA78); - - /// @dev Receipt vault (ERC-20, "tCRCL") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52 - address constant CRCL_RECEIPT_VAULT = address(0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52); - - /// @dev Wrapped token vault (ERC-4626, "wtCRCL") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf - address constant CRCL_WRAPPED_TOKEN_VAULT = address(0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf); - - // ========================================================================= - // tNVDA / wtNVDA — NVIDIA Corporation ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tNVDA. - /// https://basescan.org/address/0x8Dd4c6f08E446075879310AFae8167CC4DE2f805 - address constant NVDA_RECEIPT = address(0x8Dd4c6f08E446075879310AFae8167CC4DE2f805); - - /// @dev Receipt vault (ERC-20, "tNVDA") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x7271A3C91Bb6070eD09333B84a815949D4f16d14 - address constant NVDA_RECEIPT_VAULT = address(0x7271A3C91Bb6070eD09333B84a815949D4f16d14); - - /// @dev Wrapped token vault (ERC-4626, "wtNVDA") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7 - address constant NVDA_WRAPPED_TOKEN_VAULT = address(0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7); - - // ========================================================================= - // tIAU / wtIAU — iShares Gold Trust ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tIAU. - /// https://basescan.org/address/0x9E128159ff53Ce113df52D760C032DD65DDb0E64 - address constant IAU_RECEIPT = address(0x9E128159ff53Ce113df52D760C032DD65DDb0E64); - - /// @dev Receipt vault (ERC-20, "tIAU") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF - address constant IAU_RECEIPT_VAULT = address(0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF); - - /// @dev Wrapped token vault (ERC-4626, "wtIAU") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8 - address constant IAU_WRAPPED_TOKEN_VAULT = address(0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8); - - // ========================================================================= - // tPPLT / wtPPLT — abrdn Physical Platinum Shares ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tPPLT. - /// https://basescan.org/address/0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6 - address constant PPLT_RECEIPT = address(0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6); - - /// @dev Receipt vault (ERC-20, "tPPLT") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x1f17523b147CcC2A2328c0F014f6d49c479ea063 - address constant PPLT_RECEIPT_VAULT = address(0x1f17523b147CcC2A2328c0F014f6d49c479ea063); - - /// @dev Wrapped token vault (ERC-4626, "wtPPLT") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x82f5BAEE1076334357a34A19E04f7c282D51cE47 - address constant PPLT_WRAPPED_TOKEN_VAULT = address(0x82f5BAEE1076334357a34A19E04f7c282D51cE47); - - // ========================================================================= - // tAMZN / wtAMZN — Amazon.com Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tAMZN. - /// https://basescan.org/address/0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721 - address constant AMZN_RECEIPT = address(0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721); - - /// @dev Receipt vault (ERC-20, "tAMZN") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd - address constant AMZN_RECEIPT_VAULT = address(0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd); - - /// @dev Wrapped token vault (ERC-4626, "wtAMZN") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x997baE3EC193a249596d3708C3fAB7C501Bb8a53 - address constant AMZN_WRAPPED_TOKEN_VAULT = address(0x997baE3EC193a249596d3708C3fAB7C501Bb8a53); - - // ========================================================================= - // tBMNR / wtBMNR — Bitmine Immersion Technologies, Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tBMNR. - /// https://basescan.org/address/0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc - address constant BMNR_RECEIPT = address(0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc); - - /// @dev Receipt vault (ERC-20, "tBMNR") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0xfBde45dF60249203b12148452fC77C3B5F811eB2 - address constant BMNR_RECEIPT_VAULT = address(0xfBde45dF60249203b12148452fC77C3B5F811eB2); - - /// @dev Wrapped token vault (ERC-4626, "wtBMNR") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x2512EC661f0bA089c275EA105E31bAD6FcFcf319 - address constant BMNR_WRAPPED_TOKEN_VAULT = address(0x2512EC661f0bA089c275EA105E31bAD6FcFcf319); - - // ========================================================================= - // tIBHG / wtIBHG — iShares iBonds 2027 Term High Yield and Income ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tIBHG. - /// https://basescan.org/address/0xE603De6450555cEf32be7e666eEd70fddDa13e1e - address constant IBHG_RECEIPT = address(0xE603De6450555cEf32be7e666eEd70fddDa13e1e); - - /// @dev Receipt vault (ERC-20, "tIBHG") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF - address constant IBHG_RECEIPT_VAULT = address(0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF); - - /// @dev Wrapped token vault (ERC-4626, "wtIBHG") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xf73894603e92d6f91b1f156e98cca38fd1f78dbf - address constant IBHG_WRAPPED_TOKEN_VAULT = address(0xF73894603e92D6f91B1f156e98Cca38Fd1F78dBf); - - // ========================================================================= - // tSGOV / wtSGOV — iShares 0-3 Month Treasury Bond ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tSGOV. - /// https://basescan.org/address/0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111 - address constant SGOV_RECEIPT = address(0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111); - - /// @dev Receipt vault (ERC-20, "tSGOV") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612 - address constant SGOV_RECEIPT_VAULT = address(0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612); - - /// @dev Wrapped token vault (ERC-4626, "wtSGOV") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 - address constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); - - /// @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 - /// list inline. - /// @return vaults The 13 production receipt vault addresses on Base. - function productionReceiptVaults() internal pure returns (address[] memory vaults) { - vaults = new address[](13); - 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; - } -} diff --git a/src/lib/LibSafeInvariants.sol b/src/lib/LibSafeInvariants.sol index 27770d7f..1890effe 100644 --- a/src/lib/LibSafeInvariants.sol +++ b/src/lib/LibSafeInvariants.sol @@ -3,19 +3,6 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; -import {LibProdSafes} from "./LibProdSafes.sol"; -import {LibProdTokensBase} from "./LibProdTokensBase.sol"; - -/// @notice Minimal `Ownable`-like surface used by ST0x receipt vaults. -/// Every production receipt vault exposes `owner()`; this library only -/// needs the getter, not the transfer/renounce mutators. Declared inline -/// here so the Safe invariant bundle owns its only external surface -/// rather than depending on a token-side interface that could drift. -interface IOwnable { - /// @notice The current owner of the contract. - /// @return The owner address. - function owner() external view returns (address); -} /// @notice The runtime codehash at the Safe's address does not match the /// pinned Safe v1.4.1 L2 proxy codehash. Signals either that the address has @@ -23,7 +10,7 @@ interface IOwnable { /// different bytecode. /// @param safe The Safe address whose codehash was checked. /// @param expected The pinned codehash that was expected -/// (`LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH`). +/// (`SAFE_V1_4_1_L2_PROXY_CODEHASH`). /// @param actual The codehash returned by `extcodehash(safe)`. error SafeProxyCodehashMismatch(address safe, bytes32 expected, bytes32 actual); @@ -33,12 +20,12 @@ error SafeProxyCodehashMismatch(address safe, bytes32 expected, bytes32 actual); /// different singleton. /// @param safe The Safe proxy address that was inspected. /// @param expected The pinned singleton address -/// (`LibProdSafes.SAFE_V1_4_1_L2_SINGLETON`). +/// (`SAFE_V1_4_1_L2_SINGLETON`). /// @param actual The singleton address read from slot `0x0` of the proxy. error SafeSingletonMismatch(address safe, address expected, address actual); /// @notice The Safe singleton's runtime bytecode codehash does not match the -/// pinned `LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH`. Pinning the +/// pinned `SAFE_V1_4_1_L2_SINGLETON_CODEHASH`. Pinning the /// singleton address alone trusts the bytecode at that address; a swap (e.g. /// `SELFDESTRUCT` + recreate, or a delegatecall-time substitution on a /// forked test environment) could preserve the address while replacing the @@ -48,7 +35,7 @@ error SafeSingletonMismatch(address safe, address expected, address actual); /// @param safe The Safe proxy address that was inspected. /// @param singleton The singleton address read from slot `0x0` of the proxy. /// @param expected The pinned singleton codehash -/// (`LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH`). +/// (`SAFE_V1_4_1_L2_SINGLETON_CODEHASH`). /// @param actual The codehash observed at `singleton`. error SafeSingletonBytecodeMismatch(address safe, address singleton, bytes32 expected, bytes32 actual); @@ -81,21 +68,11 @@ error SafeUnexpectedGuard(address safe, address guard); /// used for introspection) so the pin is enforced as an invariant. /// @param safe The Safe address whose fallback handler slot was read. /// @param expected The pinned fallback handler address -/// (`LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER`). +/// (`SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER`). /// @param actual The fallback handler address read from the well-known /// fallback handler slot. error SafeFallbackHandlerMismatch(address safe, address expected, address actual); -/// @notice A production receipt vault's `owner()` does not match the Safe -/// the immutable-invariants leg expected to own every vault. Surfaces the -/// exact vault address that breaks the uniform-ownership invariant rather -/// than a generic mismatch. -/// @param vault The receipt vault whose owner was read. -/// @param expected The Safe address every vault is expected to report as -/// `owner()`. -/// @param actual The owner address returned by `vault.owner()`. -error ReceiptVaultOwnerMismatch(address vault, address expected, address actual); - /// @notice The Safe's `getOwners()` array length does not match the /// caller-supplied `expected` array length. /// @param safe The Safe address whose owner set was queried. @@ -127,13 +104,12 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// or reverts with a typed error that pinpoints the drift. /// @dev The library splits checks into two categories: /// -/// - **Immutable invariants** (`assertImmutableInvariants`) — properties -/// that always hold against this Safe regardless of any pending or past -/// migration: proxy codehash, singleton pointer + bytecode, version, -/// modules empty, guard zero, fallback handler pinned, and uniform -/// `owner()` across every production receipt vault. The same set is -/// evaluated pre-migration and post-migration; nothing here is -/// parameterised on operational intent. +/// - **Immutable invariants** (`assertImmutableInvariants`) — pure Safe +/// identity and configuration properties that always hold against this +/// Safe regardless of any pending or past migration: proxy codehash, +/// singleton pointer + bytecode, version, modules empty, guard zero, and +/// fallback handler pinned. The same set is evaluated pre-migration and +/// post-migration; nothing here is parameterised on operational intent. /// /// - **Parameterised state assertions** (`assertOwnerSet`, `assertThreshold`) /// — properties whose expected value is supplied by the caller because @@ -141,14 +117,14 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// threshold migration). Wrong values here are caller intent, not Safe /// drift, so the comparison target is an argument. /// -/// The `assertAll` overloads bundle the immutable invariants and the two -/// parameterised checks into a single call site. The pattern mirrors -/// `StoxProdV2Test::checkAllV2OnChain`: a full-args helper that takes -/// every expected value, and a no-arg default that fills in the -/// current-truth pins from `LibProdSafes`. Scripts default to the no-arg -/// version for pre-flight; only the migration script with deliberate -/// state changes uses the full-args overload for its post-state -/// assertion. +/// The `assertAll` overloads bundle the Safe-side immutable invariants +/// and the two parameterised checks into a single call site. The pattern +/// mirrors `StoxProdV2Test::checkAllV2OnChain`: a full-args helper that +/// takes every expected value, and a no-arg default that fills in the +/// current-truth pins from `LibSafeInvariants`. Token-side invariants are +/// composed alongside these by `LibInvariants.assertAll` for callers +/// asserting the full production state; this lib is Safe-only by design +/// so the file name doesn't mislead. /// /// Centralising the assertions here keeps drift detection consistent /// across the threshold migration script, its tests, the post-migration @@ -162,6 +138,96 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// slot are explicit constants in `GuardManager`/`FallbackManager` chosen so /// they cannot collide with the owner/module/threshold linked-list slots. library LibSafeInvariants { + // ========================================================================= + // Safe v1.4.1 deployment manifest constants. Universal to every v1.4.1 L2 + // Safe; sourced from `safe-deployments` for chainId 8453 and cross-checked + // against the live ST0x production Safe. + // ========================================================================= + + /// @notice Safe v1.4.1 L2 singleton (master copy) address on Base. + /// Verified by reading proxy storage slot `0x0` of + /// `STOX_TOKEN_OWNER_SAFE` and matching against the + /// `safe-deployments` manifest. + address internal constant SAFE_V1_4_1_L2_SINGLETON = 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762; + + /// @notice Runtime codehash of a Safe v1.4.1 proxy on Base. Equal to + /// `extcodehash(STOX_TOKEN_OWNER_SAFE)` and to every other v1.4.1 L2 + /// proxy pointing at `SAFE_V1_4_1_L2_SINGLETON`. Pinning this codehash + /// guards against the Safe address being replaced by an EOA-controlled + /// contract or a fake proxy pointing at a malicious singleton. + bytes32 internal constant SAFE_V1_4_1_L2_PROXY_CODEHASH = + 0xb89c1b3bdf2cf8827818646bce9a8f6e372885f8c55e5c07acbd307cb133b000; + + /// @notice Expected `VERSION()` string from a Safe v1.4.1 singleton. + string internal constant SAFE_V1_4_1_VERSION = "1.4.1"; + + /// @notice Runtime codehash of the Safe v1.4.1 L2 singleton bytecode at + /// `SAFE_V1_4_1_L2_SINGLETON`. Pinning this guards against an attacker + /// who replaces the bytecode at the singleton address (e.g. via + /// `SELFDESTRUCT` + re-create) while preserving the proxy codehash. + /// Without this pin, every implementation-backed accessor on the Safe + /// (`VERSION()`, `getOwners()`, `getThreshold()`, etc.) is mediated by + /// untrusted code at the singleton address. Asserting this codehash + /// before any of those reads closes that gap. + /// @dev Computed via `keccak256(eth_getCode(SAFE_V1_4_1_L2_SINGLETON))` + /// on Base on 2026-05-20. + bytes32 internal constant SAFE_V1_4_1_L2_SINGLETON_CODEHASH = + 0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff; + + /// @notice CompatibilityFallbackHandler v1.4.1 address on Base. Verified + /// against the live Safe's fallback handler storage slot. Pinned so a + /// swapped-in malicious handler that shadows view selectors via + /// fallback can be detected by `assertImmutableInvariants`. + /// @dev Source: github.com/safe-global/safe-deployments + /// `src/assets/v1.4.1/compatibility_fallback_handler.json` (chainId + /// 8453 entry). Cross-checked on Base on 2026-05-20. + address internal constant SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER = 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99; + + // ========================================================================= + // ST0x token-owner Safe pins. Current-state invariants for the specific + // Safe at `STOX_TOKEN_OWNER_SAFE`; updated when the live state changes + // (e.g. the threshold migration bumps `STOX_TOKEN_OWNER_SAFE_THRESHOLD` + // from `1` to `3` in the same PR that records the post-execution state). + // ========================================================================= + + /// @notice The Safe that owns every ST0x receipt vault on Base. Subject + /// of the threshold migration (1 -> 3, against the post-rotation + /// 6-owner roster). + /// https://basescan.org/address/0xe70d821f3462A074E63b42D0aac6523faAe1D611 + address internal constant STOX_TOKEN_OWNER_SAFE = 0xe70d821f3462a074e63b42d0AaC6523faAe1d611; + + /// @notice The current expected threshold for `STOX_TOKEN_OWNER_SAFE`: + /// 3-of-6 against the post-rotation owner roster. Scripts and the + /// prod-state invariant pin treat this as the canonical current truth + /// for the Safe's threshold. + uint256 internal constant STOX_TOKEN_OWNER_SAFE_THRESHOLD = 3; + + /// @notice Owner #1 of `STOX_TOKEN_OWNER_SAFE`. Order matches + /// `getOwners()` (Safe-internal linked-list order) against the + /// post-rotation roster: `getOwners()` returns owners newest-first, + /// so the last signer to be added via `addOwnerWithThreshold` appears + /// at slot 0. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_1 = 0x4746095B1Ea1A84446d34448f44e74D3d51f92F2; + + /// @notice Owner #2 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_2 = 0xceC2cb8B8EE4000FFA3F8a7f8E0Fa0A3E3DAb72d; + + /// @notice Owner #3 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_3 = 0x8D5901d8aE48101B59400235ad8614A2e0510466; + + /// @notice Owner #4 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_4 = 0xC1C89b7f5448F447d59f920456A9610f6b2544bC; + + /// @notice Owner #5 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_5 = 0xAB92b327c97A6E7461cBd76E2a789E5e106FF87e; + + /// @notice Owner #6 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_6 = 0x5CCd3cE683b66ff271DDB8915fF528b8fcFa23c2; + + // ========================================================================= + // Storage layout constants for paginated / direct slot reads. + // ========================================================================= + /// @notice Storage slot at which Safe v1.4.1 stores the transaction /// guard address. Equal to /// `keccak256("guard_manager.guard.address")`. A non-zero value here @@ -192,33 +258,26 @@ library LibSafeInvariants { /// @notice Assert every immutable invariant of the Safe at `safe`: /// pinned proxy codehash, pinned singleton pointer, pinned singleton - /// bytecode, pinned version, no modules, no guard, pinned fallback - /// handler, and uniform `owner()` across every production ST0x receipt - /// vault (as enumerated by - /// `LibProdTokensBase.productionReceiptVaults`). Reverts with a typed - /// error on first failure; returns silently otherwise. - /// @dev "Immutable" here means properties that should hold against the - /// production Safe at any point in time, regardless of pending or - /// past operational migrations. The same set is asserted pre-migration - /// and post-migration; nothing in this call is parameterised on - /// caller intent. - /// - /// Token-side uniform ownership is included as an immutable Safe - /// invariant because the threshold migration (and any future Safe - /// migration on this deployment) does not independently transfer - /// vault ownership: drift in the vault ownership set against the Safe - /// at migration time is therefore an invariant break to surface here, - /// not a separate pre-flight concern of every consumer. + /// bytecode, pinned version, no modules, no guard, and pinned fallback + /// handler. Reverts with a typed error on first failure; returns + /// silently otherwise. + /// @dev "Immutable" here means pure Safe identity and configuration + /// properties that should hold against the production Safe at any + /// point in time, regardless of pending or past operational + /// migrations. The same set is asserted pre-migration and + /// post-migration; nothing in this call is parameterised on caller + /// intent. Token-side uniformity (vault owner/authoriser) is a + /// separate concern composed into `assertAll` via `LibTokenInvariants` + /// rather than here, because it is a property of the token deployment + /// rather than of the Safe. /// /// The check ordering is deliberate. Codehash first (cheapest, and /// catches an EOA at the address or a fake proxy). Singleton slot next /// (catches a swap of the implementation pointer). Singleton bytecode /// third (catches a swap behind the singleton address). VERSION() /// fourth (catches an unexpected implementation that happens to have - /// the same bytecode hash). Modules/guard/fallback handler next, after - /// the proxy has been proven to be the singleton we expect. Uniform - /// vault ownership last, because it is the most expensive (13 external - /// calls) and only meaningful once the Safe itself has been validated. + /// the same bytecode hash). Modules/guard/fallback handler last, after + /// the proxy has been proven to be the singleton we expect. /// @param safe The Safe to assert immutable invariants on. function assertImmutableInvariants(IGnosisSafe safe) internal view { address safeAddr = address(safe); @@ -227,16 +286,16 @@ library LibSafeInvariants { assembly ("memory-safe") { actualCodehash := extcodehash(safeAddr) } - if (actualCodehash != LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH) { - revert SafeProxyCodehashMismatch(safeAddr, LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH, actualCodehash); + if (actualCodehash != SAFE_V1_4_1_L2_PROXY_CODEHASH) { + revert SafeProxyCodehashMismatch(safeAddr, SAFE_V1_4_1_L2_PROXY_CODEHASH, actualCodehash); } // Slot 0 of a Safe proxy holds the singleton (master copy) address. // Read it raw via `getStorageAt` rather than going through any // accessor so a malicious fallback can't shadow the result. address actualSingleton = readSafeStorageAddress(safe, 0); - if (actualSingleton != LibProdSafes.SAFE_V1_4_1_L2_SINGLETON) { - revert SafeSingletonMismatch(safeAddr, LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, actualSingleton); + if (actualSingleton != SAFE_V1_4_1_L2_SINGLETON) { + revert SafeSingletonMismatch(safeAddr, SAFE_V1_4_1_L2_SINGLETON, actualSingleton); } // Address pin alone trusts whatever code lives at the singleton @@ -249,15 +308,15 @@ library LibSafeInvariants { assembly ("memory-safe") { actualSingletonCodehash := extcodehash(actualSingleton) } - if (actualSingletonCodehash != LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH) { + if (actualSingletonCodehash != SAFE_V1_4_1_L2_SINGLETON_CODEHASH) { revert SafeSingletonBytecodeMismatch( - safeAddr, actualSingleton, LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH, actualSingletonCodehash + safeAddr, actualSingleton, SAFE_V1_4_1_L2_SINGLETON_CODEHASH, actualSingletonCodehash ); } string memory actualVersion = safe.VERSION(); - if (keccak256(bytes(actualVersion)) != keccak256(bytes(LibProdSafes.SAFE_V1_4_1_VERSION))) { - revert SafeVersionMismatch(safeAddr, LibProdSafes.SAFE_V1_4_1_VERSION, actualVersion); + if (keccak256(bytes(actualVersion)) != keccak256(bytes(SAFE_V1_4_1_VERSION))) { + revert SafeVersionMismatch(safeAddr, SAFE_V1_4_1_VERSION, actualVersion); } // Page size 10 is sufficient: any non-zero module count trips the @@ -277,24 +336,11 @@ library LibSafeInvariants { } address actualFallbackHandler = readSafeStorageAddress(safe, uint256(SAFE_FALLBACK_HANDLER_STORAGE_SLOT)); - if (actualFallbackHandler != LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER) { + if (actualFallbackHandler != SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER) { revert SafeFallbackHandlerMismatch( - safeAddr, LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, actualFallbackHandler + safeAddr, SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, actualFallbackHandler ); } - - // Token-side uniform ownership: every production receipt vault - // reports `owner() == safe`. Iterates the vault list emitted by - // `LibProdTokensBase.productionReceiptVaults` and reverts with - // `ReceiptVaultOwnerMismatch` on the first drift, surfacing the - // offending vault. - address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); - for (uint256 i = 0; i < vaults.length; i++) { - address actualOwner = IOwnable(vaults[i]).owner(); - if (actualOwner != safeAddr) { - revert ReceiptVaultOwnerMismatch(vaults[i], safeAddr, actualOwner); - } - } } /// @notice Reads a single 32-byte storage slot from a Safe via @@ -343,32 +389,32 @@ library LibSafeInvariants { } } - /// @notice Full-args invariant bundle. Use when you want to override - /// the expected threshold or owner set from the `LibProdSafes` + /// @notice Full-args Safe-side invariant bundle. Use when you want to + /// override the expected threshold or owner set from the `LibSafeInvariants` /// current-truth pins — typically only when running a script that /// intentionally changes one of those (post-state assertion). - /// @dev Mirrors the `StoxProdV2Test::checkAllV2OnChain` pattern: a + /// @dev Composes the Safe-side invariants only: immutable Safe + /// identity/config, owner set, and threshold. Token-side uniformity + /// invariants are composed in `LibInvariants.assertAll` so the + /// full-production-state bundle still exists, but they don't live + /// here — this lib is purely Safe-side. + /// + /// Mirrors the `StoxProdV2Test::checkAllV2OnChain` pattern: a /// full-args helper alongside a no-arg overload. Migration scripts /// call the no-arg overload pre-execution to assert the pinned /// current truth, then call this overload post-execution with the /// deliberately-changed expectation. - /// - /// Implementation is intentionally a thin wrapper rather than a - /// separate body: keeping each underlying check addressable in - /// isolation lets fork tests exercise individual drift surfaces, and - /// keeping the bundle alongside them means migration code never has - /// to remember which of the three pieces to run. /// @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 { + /// @param expectedOwnerSet The expected owner set in `getOwners()` order. + function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwnerSet) internal view { assertImmutableInvariants(safe); - assertOwnerSet(safe, expectedOwners); + assertOwnerSet(safe, expectedOwnerSet); assertThreshold(safe, expectedThreshold); } - /// @notice No-arg invariant bundle that fills in the - /// `LibProdSafes`-pinned current-truth defaults: the threshold from + /// @notice No-arg Safe-side invariant bundle that fills in the + /// `LibSafeInvariants`-pinned current-truth defaults: the threshold from /// `STOX_TOKEN_OWNER_SAFE_THRESHOLD` and the owner set from /// `expectedOwners()`. Pre-flight at the start of every script and /// fork test that runs against the production Safe; if this passes @@ -379,6 +425,27 @@ library LibSafeInvariants { /// re-check after it has simulated `changeThreshold`). /// @param safe The Safe to validate against the pinned current truth. function assertAll(IGnosisSafe safe) internal view { - assertAll(safe, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, LibProdSafes.expectedOwners()); + assertAll(safe, STOX_TOKEN_OWNER_SAFE_THRESHOLD, expectedOwners()); + } + + /// @notice Returns the expected owner set for `STOX_TOKEN_OWNER_SAFE` in + /// the exact order returned by `getOwners()` against an unpinned Base + /// head fork (the live-state pin lives in + /// `StoxProdV2.t.sol::testProdDeployBaseV2`, which selects head rather + /// than pinning to a historical block so the next CI run catches any + /// further drift). Provided as a helper because Solidity 0.8 cannot + /// express a file-scope `constant address[]` and declaring the array + /// as `immutable` is contract-scoped only. + /// @return The six owners of the ST0x token-owner Safe in + /// `getOwners()` order. + function expectedOwners() internal pure returns (address[] memory) { + address[] memory owners = new address[](6); + owners[0] = STOX_TOKEN_OWNER_SAFE_OWNER_1; + owners[1] = STOX_TOKEN_OWNER_SAFE_OWNER_2; + owners[2] = STOX_TOKEN_OWNER_SAFE_OWNER_3; + owners[3] = STOX_TOKEN_OWNER_SAFE_OWNER_4; + owners[4] = STOX_TOKEN_OWNER_SAFE_OWNER_5; + owners[5] = STOX_TOKEN_OWNER_SAFE_OWNER_6; + return owners; } } diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol new file mode 100644 index 00000000..e38e550f --- /dev/null +++ b/src/lib/LibTokenInvariants.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IOwnable} from "../interface/IOwnable.sol"; +import {IAuthorisable} from "../interface/IAuthorisable.sol"; + +/// @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 +/// mismatch. +/// @param vault The receipt vault whose owner was read. +/// @param expected The address every vault is expected to report as +/// `owner()`. +/// @param actual The owner address returned by `vault.owner()`. +error ReceiptVaultOwnerMismatch(address vault, address expected, address actual); + +/// @notice A production receipt vault's `authorizer()` does not match the +/// authoriser every vault is expected to share. Surfaces the exact vault +/// that breaks the uniform-authoriser invariant. +/// @param vault The receipt vault whose authoriser was read. +/// @param expected The authoriser address every vault is expected to share. +/// @param actual The authoriser address returned by `vault.authorizer()`. +error ReceiptVaultAuthoriserMismatch(address vault, address expected, address actual); + +/// @title LibTokenInvariants +/// @notice Reusable token-side uniformity invariants for the ST0x +/// production receipt vaults on Base. Each assertion iterates the vault +/// list emitted by `productionReceiptVaults` and either +/// returns silently when the invariant holds against the live chain state +/// or reverts with a typed error that pinpoints the offending vault. +/// @dev These are token-side prod invariants: a receipt vault's owner and +/// authoriser uniformity is a property of the token deployment, not of the +/// Safe multisig. `LibInvariants.assertAll` composes this lib's `assertAll` +/// alongside `LibSafeInvariants.assertAll` so consumers asserting the full +/// production state get both. Individual asserts are also callable +/// standalone for focused drift detection. +library LibTokenInvariants { + // ========================================================================= + // Production token instance addresses on Base. Each token set is a + // beacon proxy triple deployed via V1 OffchainAssetReceiptVaultBeaconSetDeployer + // + V1 StoxWrappedTokenVaultBeaconSetDeployer: a receipt (ERC-1155), a + // receipt vault (ERC-20), and a wrapped token vault (ERC-4626). + // ========================================================================= + + // ---- tMSTR / wtMSTR — MicroStrategy Incorporated ST0x ---- + /// https://basescan.org/address/0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC + address internal constant MSTR_RECEIPT = address(0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC); + /// https://basescan.org/address/0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE + address internal constant MSTR_RECEIPT_VAULT = address(0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE); + /// https://basescan.org/address/0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2 + address internal constant MSTR_WRAPPED_TOKEN_VAULT = address(0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2); + + // ---- tTSLA / wtTSLA — Tesla Inc ST0x ---- + /// https://basescan.org/address/0x660923230fAA859622711a5fC80f532dd588b125 + address internal constant TSLA_RECEIPT = address(0x660923230fAA859622711a5fC80f532dd588b125); + /// https://basescan.org/address/0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0 + address internal constant TSLA_RECEIPT_VAULT = address(0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0); + /// https://basescan.org/address/0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D + address internal constant TSLA_WRAPPED_TOKEN_VAULT = address(0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D); + + // ---- tCOIN / wtCOIN — Coinbase Global Inc ST0x ---- + /// https://basescan.org/address/0xBA1B8836A5510815e96103F067715b7CCC7c2E0E + address internal constant COIN_RECEIPT = address(0xBA1B8836A5510815e96103F067715b7CCC7c2E0E); + /// https://basescan.org/address/0x626757e6F50675D17fcAd312E82f989aE7A23d38 + address internal constant COIN_RECEIPT_VAULT = address(0x626757e6F50675D17fcAd312E82f989aE7A23d38); + /// https://basescan.org/address/0x5cDa0E1CA4ce2af96315f7F8963C85399c172204 + address internal constant COIN_WRAPPED_TOKEN_VAULT = address(0x5cDa0E1CA4ce2af96315f7F8963C85399c172204); + + // ---- tSPYM / wtSPYM — State Street SPDR Portfolio S&P 500 ETF ST0x ---- + /// https://basescan.org/address/0x957056dD6e2E594742E36675e8AA5A567163E5bd + address internal constant SPYM_RECEIPT = address(0x957056dD6e2E594742E36675e8AA5A567163E5bd); + /// https://basescan.org/address/0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb + address internal constant SPYM_RECEIPT_VAULT = address(0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb); + /// https://basescan.org/address/0x31C2C14134e6E3B7ef9478297F199331133Fc2d8 + address internal constant SPYM_WRAPPED_TOKEN_VAULT = address(0x31C2C14134e6E3B7ef9478297F199331133Fc2d8); + + // ---- tSIVR / wtSIVR — abrdn Physical Silver Shares ETF ST0x ---- + /// https://basescan.org/address/0x053F52109a3439b4F292056D2DceC0486B544e82 + address internal constant SIVR_RECEIPT = address(0x053F52109a3439b4F292056D2DceC0486B544e82); + /// https://basescan.org/address/0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8 + address internal constant SIVR_RECEIPT_VAULT = address(0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8); + /// https://basescan.org/address/0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F + address internal constant SIVR_WRAPPED_TOKEN_VAULT = address(0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F); + + // ---- tCRCL / wtCRCL — Circle Internet Group Inc ST0x ---- + /// https://basescan.org/address/0xd508B97975fBE04E62bFf18959549b046bD8FA78 + address internal constant CRCL_RECEIPT = address(0xd508B97975fBE04E62bFf18959549b046bD8FA78); + /// https://basescan.org/address/0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52 + address internal constant CRCL_RECEIPT_VAULT = address(0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52); + /// https://basescan.org/address/0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf + address internal constant CRCL_WRAPPED_TOKEN_VAULT = address(0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf); + + // ---- tNVDA / wtNVDA — NVIDIA Corporation ST0x ---- + /// https://basescan.org/address/0x8Dd4c6f08E446075879310AFae8167CC4DE2f805 + address internal constant NVDA_RECEIPT = address(0x8Dd4c6f08E446075879310AFae8167CC4DE2f805); + /// https://basescan.org/address/0x7271A3C91Bb6070eD09333B84a815949D4f16d14 + address internal constant NVDA_RECEIPT_VAULT = address(0x7271A3C91Bb6070eD09333B84a815949D4f16d14); + /// https://basescan.org/address/0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7 + address internal constant NVDA_WRAPPED_TOKEN_VAULT = address(0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7); + + // ---- tIAU / wtIAU — iShares Gold Trust ST0x ---- + /// https://basescan.org/address/0x9E128159ff53Ce113df52D760C032DD65DDb0E64 + address internal constant IAU_RECEIPT = address(0x9E128159ff53Ce113df52D760C032DD65DDb0E64); + /// https://basescan.org/address/0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF + address internal constant IAU_RECEIPT_VAULT = address(0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF); + /// https://basescan.org/address/0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8 + address internal constant IAU_WRAPPED_TOKEN_VAULT = address(0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8); + + // ---- tPPLT / wtPPLT — abrdn Physical Platinum Shares ETF ST0x ---- + /// https://basescan.org/address/0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6 + address internal constant PPLT_RECEIPT = address(0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6); + /// https://basescan.org/address/0x1f17523b147CcC2A2328c0F014f6d49c479ea063 + address internal constant PPLT_RECEIPT_VAULT = address(0x1f17523b147CcC2A2328c0F014f6d49c479ea063); + /// https://basescan.org/address/0x82f5BAEE1076334357a34A19E04f7c282D51cE47 + address internal constant PPLT_WRAPPED_TOKEN_VAULT = address(0x82f5BAEE1076334357a34A19E04f7c282D51cE47); + + // ---- tAMZN / wtAMZN — Amazon.com Inc ST0x ---- + /// https://basescan.org/address/0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721 + address internal constant AMZN_RECEIPT = address(0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721); + /// https://basescan.org/address/0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd + address internal constant AMZN_RECEIPT_VAULT = address(0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd); + /// https://basescan.org/address/0x997baE3EC193a249596d3708C3fAB7C501Bb8a53 + address internal constant AMZN_WRAPPED_TOKEN_VAULT = address(0x997baE3EC193a249596d3708C3fAB7C501Bb8a53); + + // ---- tBMNR / wtBMNR — Bitmine Immersion Technologies, Inc ST0x ---- + /// https://basescan.org/address/0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc + address internal constant BMNR_RECEIPT = address(0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc); + /// https://basescan.org/address/0xfBde45dF60249203b12148452fC77C3B5F811eB2 + address internal constant BMNR_RECEIPT_VAULT = address(0xfBde45dF60249203b12148452fC77C3B5F811eB2); + /// https://basescan.org/address/0x2512EC661f0bA089c275EA105E31bAD6FcFcf319 + address internal constant BMNR_WRAPPED_TOKEN_VAULT = address(0x2512EC661f0bA089c275EA105E31bAD6FcFcf319); + + // ---- tIBHG / wtIBHG — iShares iBonds 2027 Term High Yield and Income ETF ST0x ---- + /// https://basescan.org/address/0xE603De6450555cEf32be7e666eEd70fddDa13e1e + address internal constant IBHG_RECEIPT = address(0xE603De6450555cEf32be7e666eEd70fddDa13e1e); + /// https://basescan.org/address/0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF + address internal constant IBHG_RECEIPT_VAULT = address(0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF); + /// https://basescan.org/address/0xf73894603e92d6f91b1f156e98cca38fd1f78dbf + address internal constant IBHG_WRAPPED_TOKEN_VAULT = address(0xF73894603e92D6f91B1f156e98Cca38Fd1F78dBf); + + // ---- tSGOV / wtSGOV — iShares 0-3 Month Treasury Bond ETF ST0x ---- + /// https://basescan.org/address/0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111 + address internal constant SGOV_RECEIPT = address(0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111); + /// https://basescan.org/address/0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612 + address internal constant SGOV_RECEIPT_VAULT = address(0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612); + /// 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 + /// list inline. + /// @return vaults The 13 production receipt vault addresses on Base. + function productionReceiptVaults() internal pure returns (address[] memory vaults) { + vaults = new address[](13); + 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; + } + + /// @notice Assert that every production receipt vault reports the same + /// `owner()`. Iterates `productionReceiptVaults` and + /// reverts with `ReceiptVaultOwnerMismatch` on the first vault whose + /// `owner()` diverges from `expectedOwner`, surfacing the offending + /// vault. + /// @dev A divergent owner means a token is controlled by a different + /// account than the rest of the system — the class of inconsistency + /// this invariant exists to prevent. Composed into `assertAll` (with + /// the Safe as the expected owner) and through there into + /// `LibInvariants.assertAll`; also callable standalone. + /// @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(); + if (actualOwner != expectedOwner) { + revert ReceiptVaultOwnerMismatch(vaults[i], expectedOwner, actualOwner); + } + } + } + + /// @notice Assert that every production receipt vault reports the same + /// authoriser. Iterates `productionReceiptVaults` and + /// reverts with `ReceiptVaultAuthoriserMismatch` on the first vault whose + /// `authorizer()` diverges from `expected`, surfacing the offending vault. + /// @dev A divergent authoriser means a token is gated by a different RBAC + /// contract than the rest of the system — the class of inconsistency this + /// invariant exists to prevent. Composed into `assertAll` and through + /// there into `LibInvariants.assertAll`; also callable standalone. + /// @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(); + if (actual != expected) { + revert ReceiptVaultAuthoriserMismatch(vaults[i], expected, actual); + } + } + } + + /// @notice Full token-side invariant bundle: every production receipt + /// vault reports the supplied Safe as its `owner()` AND the supplied + /// authoriser as its `authorizer()`. Pre-flight / post-state hook for + /// any script touching the production receipt vault set; consumers + /// asserting the full production state (Safe + token + authoriser) + /// compose this alongside `LibSafeInvariants.assertAll` and + /// `LibAuthoriserInvariants.assertAll` via `LibInvariants.assertAll`. + /// @dev Both legs run last in the composed bundle because each is + /// `O(13)` external calls and only meaningful once the Safe itself + /// has been validated. The authoriser is parameterised rather than + /// hardcoded so this lib stays free of cross-facet dependencies; the + /// orchestrator supplies the pinned address. + /// @param safe The Safe address every production receipt vault is + /// expected to report as `owner()`. + /// @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); + } +} diff --git a/test/script/MigrateMultisigThresholdTest.t.sol b/test/script/MigrateMultisigThresholdTest.t.sol index 99d5e38a..5732fccd 100644 --- a/test/script/MigrateMultisigThresholdTest.t.sol +++ b/test/script/MigrateMultisigThresholdTest.t.sol @@ -9,15 +9,11 @@ import { VerifyExpectedSingleTx } from "../../script/MigrateMultisigThreshold.s.sol"; import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; -import {LibProdSafes} from "../../src/lib/LibProdSafes.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; -import { - LibSafeInvariants, - IOwnable, - SafeThresholdMismatch, - ReceiptVaultOwnerMismatch -} from "../../src/lib/LibSafeInvariants.sol"; -import {LibProdTokensBase} from "../../src/lib/LibProdTokensBase.sol"; +import {LibSafeInvariants, SafeThresholdMismatch} from "../../src/lib/LibSafeInvariants.sol"; +import {IOwnable, ReceiptVaultOwnerMismatch} from "../../src/lib/LibTokenInvariants.sol"; +import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// @title MigrateMultisigThresholdTest @@ -39,7 +35,7 @@ contract MigrateMultisigThresholdTest is Test { function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); script = new MigrateMultisigThreshold(); - safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); } /// @notice `run()` dry-run completes against the live pre-state, @@ -73,7 +69,7 @@ contract MigrateMultisigThresholdTest is Test { // of an internal revert. assertEq( safe.getThreshold(), - LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, "n+1 reversibility check rolled threshold back to pre-migration value" ); } @@ -95,17 +91,18 @@ contract MigrateMultisigThresholdTest is Test { script.verify(artifactPath); } - /// @notice Inverted: the pre-flight rejects a drifted threshold. If - /// the Safe's threshold already moved off `1` (e.g. someone else ran - /// a migration first), `run()` must trip the threshold invariant - /// rather than emitting a stale artifact. - function testRunRejectsAlreadyMigratedThreshold() external { + /// @notice Inverted: the pre-flight rejects a drifted threshold. If the + /// Safe's threshold has moved off the pinned current value (3), `run()` + /// must trip the threshold invariant rather than emitting a stale + /// artifact. + function testRunRejectsThresholdDrift() external { selectBaseFork(); - // Mock the Safe's threshold to `3` to simulate post-migration - // pre-state; the pre-flight should reject this. - vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getThreshold.selector), abi.encode(uint256(3))); + // Mock the Safe's threshold away from the pinned current value (3) + // to simulate drift; the pre-flight rejects before producing an + // artifact. + vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getThreshold.selector), abi.encode(uint256(1))); - vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(1), uint256(3))); + vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(3), uint256(1))); script.run(); } @@ -117,10 +114,10 @@ contract MigrateMultisigThresholdTest is Test { function testRunRejectsVaultOwnershipDrift() external { selectBaseFork(); address rogueOwner = address(0xBADC0DE); - // Victim address sourced from `LibProdTokensBase` — the canonical + // Victim address sourced from `LibTokenInvariants` — the canonical // list of production receipt vaults. Any vault from the list // would do; MSTR is the first entry. - address victim = LibProdTokensBase.MSTR_RECEIPT_VAULT; + address victim = LibTokenInvariants.MSTR_RECEIPT_VAULT; vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, address(safe), rogueOwner)); diff --git a/test/src/concrete/deploy/StoxProdV2.t.sol b/test/src/concrete/deploy/StoxProdV2.t.sol index 4dab587e..a0f59ffd 100644 --- a/test/src/concrete/deploy/StoxProdV2.t.sol +++ b/test/src/concrete/deploy/StoxProdV2.t.sol @@ -5,8 +5,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibProdDeployV2} from "../../../../src/lib/LibProdDeployV2.sol"; import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; -import {LibProdSafes} from "../../../../src/lib/LibProdSafes.sol"; import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; import {IGnosisSafe} from "../../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; @@ -122,12 +122,13 @@ contract StoxProdV2Test is Test { } /// Per-Safe invariant bundle for the ST0x token-owner Safe on Base. - /// Calls `LibSafeInvariants.assertAll` against the production Safe - /// address pinned in `LibProdSafes`. The Safe is Base-only (no Safe + /// Calls `LibInvariants.assertAll` against the production Safe + /// address pinned in `LibSafeInvariants` — composes the Safe-side and + /// token-side invariants in one call. The Safe is Base-only (no Safe /// on Arbitrum / Base Sepolia / Flare / Polygon for ST0x ops), so /// this helper is only invoked from `testProdDeployBaseV2`. function checkAllSafeBase() internal view { - LibSafeInvariants.assertAll(IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE)); + LibInvariants.assertAll(IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE)); } /// All V2 contracts MUST be deployed on Arbitrum. diff --git a/test/src/lib/LibSafeInvariants.t.sol b/test/src/lib/LibSafeInvariants.t.sol index d834ff29..db2909be 100644 --- a/test/src/lib/LibSafeInvariants.t.sol +++ b/test/src/lib/LibSafeInvariants.t.sol @@ -5,13 +5,9 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {LibSafeInvariantsHarness} from "./LibSafeInvariantsHarness.sol"; -import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; -import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import { - IOwnable, - ReceiptVaultOwnerMismatch, SafeProxyCodehashMismatch, SafeSingletonMismatch, SafeSingletonBytecodeMismatch, @@ -50,24 +46,10 @@ contract LibSafeInvariantsTest is Test { /// unpinned. Live drift detector; see contract-level rationale. function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); harness = new LibSafeInvariantsHarness(); } - /// @notice Token-side ownership drift bubbles - /// `ReceiptVaultOwnerMismatch` through `assertImmutableInvariants`. - /// Confirms the token-ownership leg is exercised by the immutable - /// bundle. The victim vault address comes from `LibProdTokensBase` - /// (the source of truth for production receipt vaults). - function testInvertedImmutableInvariantsTokenOwnershipDrift() external { - selectBaseFork(); - address rogueOwner = address(0xBADC0DE); - address victim = LibProdTokensBase.MSTR_RECEIPT_VAULT; - vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); - vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, address(safe), rogueOwner)); - harness.callAssertImmutableInvariants(safe); - } - /// @notice Drift in the proxy runtime codehash trips /// `SafeProxyCodehashMismatch`. Simulated by overwriting the proxy /// bytecode with a single `INVALID` opcode; `extcodehash` then returns @@ -85,7 +67,7 @@ contract LibSafeInvariantsTest is Test { abi.encodeWithSelector( SafeProxyCodehashMismatch.selector, safeAddr, - LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH, + LibSafeInvariants.SAFE_V1_4_1_L2_PROXY_CODEHASH, mutatedCodehash ) ); @@ -105,7 +87,7 @@ contract LibSafeInvariantsTest is Test { ); vm.expectRevert( abi.encodeWithSelector( - SafeSingletonMismatch.selector, address(safe), LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, impostor + SafeSingletonMismatch.selector, address(safe), LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON, impostor ) ); harness.callAssertImmutableInvariants(safe); @@ -125,19 +107,19 @@ contract LibSafeInvariantsTest is Test { function testInvertedSingletonBytecodeMismatch() external { selectBaseFork(); bytes memory bogusCode = hex"60016000526001601ff3"; - vm.etch(LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, bogusCode); + vm.etch(LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON, bogusCode); vm.mockCall( address(safe), abi.encodeWithSelector(IGnosisSafe.getStorageAt.selector, uint256(0), uint256(1)), - abi.encode(abi.encodePacked(bytes32(uint256(uint160(LibProdSafes.SAFE_V1_4_1_L2_SINGLETON))))) + abi.encode(abi.encodePacked(bytes32(uint256(uint160(LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON))))) ); - bytes32 expected = LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH; + bytes32 expected = LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON_CODEHASH; bytes32 actual = keccak256(bogusCode); vm.expectRevert( abi.encodeWithSelector( SafeSingletonBytecodeMismatch.selector, address(safe), - LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, + LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON, expected, actual ) @@ -153,7 +135,9 @@ contract LibSafeInvariantsTest is Test { string memory bogus = "9.9.9"; vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.VERSION.selector), abi.encode(bogus)); vm.expectRevert( - abi.encodeWithSelector(SafeVersionMismatch.selector, address(safe), LibProdSafes.SAFE_V1_4_1_VERSION, bogus) + abi.encodeWithSelector( + SafeVersionMismatch.selector, address(safe), LibSafeInvariants.SAFE_V1_4_1_VERSION, bogus + ) ); harness.callAssertImmutableInvariants(safe); } @@ -213,7 +197,7 @@ contract LibSafeInvariantsTest is Test { abi.encodeWithSelector( SafeFallbackHandlerMismatch.selector, address(safe), - LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, + LibSafeInvariants.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, impostor ) ); @@ -225,9 +209,9 @@ contract LibSafeInvariantsTest is Test { function testInvertedOwnerCountMismatch() external { selectBaseFork(); address[] memory truncated = new address[](3); - truncated[0] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_1; - truncated[1] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_2; - truncated[2] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_3; + truncated[0] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_1; + truncated[1] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_2; + truncated[2] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_3; vm.expectRevert(abi.encodeWithSelector(SafeOwnerCountMismatch.selector, address(safe), uint256(3), uint256(6))); harness.callAssertOwnerSet(safe, truncated); } @@ -237,7 +221,7 @@ contract LibSafeInvariantsTest is Test { /// index 1. function testInvertedOwnerMismatch() external { selectBaseFork(); - address[] memory swapped = LibProdSafes.expectedOwners(); + address[] memory swapped = LibSafeInvariants.expectedOwners(); address impostor = address(0xC0FFEE); swapped[1] = impostor; vm.expectRevert( @@ -246,18 +230,18 @@ contract LibSafeInvariantsTest is Test { address(safe), uint256(1), impostor, - LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_2 + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_2 ) ); harness.callAssertOwnerSet(safe, swapped); } /// @notice Threshold drift trips `SafeThresholdMismatch`. Caller asks - /// for `3` against a live threshold of `1`. + /// for `1` against a live threshold of `3`. function testInvertedThresholdMismatch() external { selectBaseFork(); - vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(3), uint256(1))); - harness.callAssertThreshold(safe, 3); + vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(1), uint256(3))); + harness.callAssertThreshold(safe, 1); } /// @notice `assertAll(safe)` (no-arg overload) trips @@ -265,13 +249,16 @@ contract LibSafeInvariantsTest is Test { /// pinned current truth. Mocks `getThreshold()` to `5` and asserts the /// bundle surfaces the threshold error rather than passing silently. /// This is the load-bearing test for the no-arg overload's defaulting - /// to `LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD`. + /// to `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD`. function testInvertedAssertAllDefaultsThresholdDrift() external { selectBaseFork(); vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getThreshold.selector), abi.encode(uint256(5))); vm.expectRevert( abi.encodeWithSelector( - SafeThresholdMismatch.selector, address(safe), LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, uint256(5) + SafeThresholdMismatch.selector, + address(safe), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, + uint256(5) ) ); harness.callAssertAllDefaults(safe); @@ -281,11 +268,11 @@ contract LibSafeInvariantsTest is Test { /// trips `SafeThresholdMismatch` when the caller's supplied threshold /// diverges from the live Safe — covering the migration script's /// post-state call site. Caller asks for `4` against a live threshold - /// of `1`; the bundle reports the mismatch with the caller's `4` as + /// of `3`; the bundle reports the mismatch with the caller's `4` as /// the expected value, not the pinned constant. function testInvertedAssertAllFullArgsThresholdMismatch() external { selectBaseFork(); - vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(4), uint256(1))); - harness.callAssertAll(safe, 4, LibProdSafes.expectedOwners()); + vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(4), uint256(3))); + harness.callAssertAll(safe, 4, LibSafeInvariants.expectedOwners()); } } diff --git a/test/src/lib/LibSafeOps.t.sol b/test/src/lib/LibSafeOps.t.sol index 604af265..b83803b3 100644 --- a/test/src/lib/LibSafeOps.t.sol +++ b/test/src/lib/LibSafeOps.t.sol @@ -4,7 +4,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibSafeOps, SafeTx, TxBuilderJsonNoTransactions} from "../../../src/lib/LibSafeOps.sol"; -import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import {CallerRecorder} from "./CallerRecorder.sol"; @@ -22,12 +22,12 @@ contract LibSafeOpsTest is Test { IGnosisSafe internal safe; /// @notice Selects the Base fork at chain head — deliberately unpinned. - /// Mirrors `LibProdSafes.t.sol::selectBaseFork` and + /// Mirrors `LibSafeInvariants.t.sol::selectBaseFork` and /// `StoxProdV2.t.sol::testProdDeployBaseV2`: any drift in the live Safe /// surfaces immediately on the next CI run. function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); } /// @notice Build a single-tx bundle that changes the Safe's threshold @@ -63,7 +63,7 @@ contract LibSafeOpsTest is Test { selectBaseFork(); uint256 nonceBefore = safe.nonce(); uint256 thresholdBefore = safe.getThreshold(); - assertEq(thresholdBefore, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + assertEq(thresholdBefore, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); SafeTx memory txn = _buildThresholdTx(); LibSafeOps.simulateSelfCall(safe, txn.data); @@ -175,7 +175,7 @@ contract LibSafeOpsTest is Test { function testSimulateNPlus1ReversalRoundTrip() external { selectBaseFork(); uint256 oldThreshold = safe.getThreshold(); - assertEq(oldThreshold, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, "pre-state threshold pin"); + assertEq(oldThreshold, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, "pre-state threshold pin"); // Simulate the forward state change the migration script makes. // After this prank-call the Safe is in the "post-migration" state @@ -202,8 +202,8 @@ contract LibSafeOpsTest is Test { // for `newThreshold = 3`. The require in `simulateNPlus1Reversal` // should fire before any prank/approve hit the Safe. address[] memory shortRoster = new address[](2); - shortRoster[0] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_1; - shortRoster[1] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_2; + shortRoster[0] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_1; + shortRoster[1] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_2; vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getOwners.selector), abi.encode(shortRoster)); NPlus1Harness harness = new NPlus1Harness(); diff --git a/test/src/lib/LibProdTokensBase.t.sol b/test/src/lib/LibTokenInvariants.addresses.t.sol similarity index 92% rename from test/src/lib/LibProdTokensBase.t.sol rename to test/src/lib/LibTokenInvariants.addresses.t.sol index 611bbe7e..4480be7b 100644 --- a/test/src/lib/LibProdTokensBase.t.sol +++ b/test/src/lib/LibTokenInvariants.addresses.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; +import {LibTokenInvariants} from "../../../src/lib/LibTokenInvariants.sol"; import {LibProdDeployV1} from "../../../src/lib/LibProdDeployV1.sol"; import {LibTestProd} from "../../lib/LibTestProd.sol"; import {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; @@ -25,9 +25,9 @@ import {IExtrospectV1} from "rain-extrospection-0.1.1/src/interface/IExtrospectV import {EXTROSPECT_ZOLTU_ADDRESS_V1} from "rain-extrospection-0.1.1/src/concrete/Extrospect.sol"; import {IBeacon} from "rain-extrospection-0.1.1/src/interface/IBeacon.sol"; -/// @title LibProdTokensBaseTest +/// @title LibTokenInvariantsAddressesTest /// @notice Fork tests verifying production token instances on Base. -contract LibProdTokensBaseTest is Test { +contract LibTokenInvariantsAddressesTest is Test { /// Read the EIP-1967 beacon address from a proxy contract. function beaconOf(address proxy) internal view returns (address) { return address(uint160(uint256(vm.load(proxy, ERC1967_BEACON_SLOT)))); @@ -478,7 +478,7 @@ contract LibProdTokensBaseTest is Test { // All wrapped vault proxies share a single beacon. Read it from // any wrapped proxy (MSTR is arbitrary) and assert the constant. assertEq( - beaconOf(LibProdTokensBase.MSTR_WRAPPED_TOKEN_VAULT), + beaconOf(LibTokenInvariants.MSTR_WRAPPED_TOKEN_VAULT), LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1, "wrapped vault beacon read from MSTR proxy slot drifted" ); @@ -522,9 +522,9 @@ contract LibProdTokensBaseTest is Test { function testMstrTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.MSTR_RECEIPT, - LibProdTokensBase.MSTR_RECEIPT_VAULT, - LibProdTokensBase.MSTR_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.MSTR_RECEIPT, + LibTokenInvariants.MSTR_RECEIPT_VAULT, + LibTokenInvariants.MSTR_WRAPPED_TOKEN_VAULT, "tMSTR", "wtMSTR" ); @@ -533,9 +533,9 @@ contract LibProdTokensBaseTest is Test { function testTslaTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.TSLA_RECEIPT, - LibProdTokensBase.TSLA_RECEIPT_VAULT, - LibProdTokensBase.TSLA_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.TSLA_RECEIPT, + LibTokenInvariants.TSLA_RECEIPT_VAULT, + LibTokenInvariants.TSLA_WRAPPED_TOKEN_VAULT, "tTSLA", "wtTSLA" ); @@ -544,9 +544,9 @@ contract LibProdTokensBaseTest is Test { function testCoinTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.COIN_RECEIPT, - LibProdTokensBase.COIN_RECEIPT_VAULT, - LibProdTokensBase.COIN_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.COIN_RECEIPT, + LibTokenInvariants.COIN_RECEIPT_VAULT, + LibTokenInvariants.COIN_WRAPPED_TOKEN_VAULT, "tCOIN", "wtCOIN" ); @@ -555,9 +555,9 @@ contract LibProdTokensBaseTest is Test { function testSpymTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.SPYM_RECEIPT, - LibProdTokensBase.SPYM_RECEIPT_VAULT, - LibProdTokensBase.SPYM_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.SPYM_RECEIPT, + LibTokenInvariants.SPYM_RECEIPT_VAULT, + LibTokenInvariants.SPYM_WRAPPED_TOKEN_VAULT, "tSPYM", "wtSPYM" ); @@ -566,9 +566,9 @@ contract LibProdTokensBaseTest is Test { function testSivrTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.SIVR_RECEIPT, - LibProdTokensBase.SIVR_RECEIPT_VAULT, - LibProdTokensBase.SIVR_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.SIVR_RECEIPT, + LibTokenInvariants.SIVR_RECEIPT_VAULT, + LibTokenInvariants.SIVR_WRAPPED_TOKEN_VAULT, "tSIVR", "wtSIVR" ); @@ -577,9 +577,9 @@ contract LibProdTokensBaseTest is Test { function testCrclTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.CRCL_RECEIPT, - LibProdTokensBase.CRCL_RECEIPT_VAULT, - LibProdTokensBase.CRCL_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.CRCL_RECEIPT, + LibTokenInvariants.CRCL_RECEIPT_VAULT, + LibTokenInvariants.CRCL_WRAPPED_TOKEN_VAULT, "tCRCL", "wtCRCL" ); @@ -588,9 +588,9 @@ contract LibProdTokensBaseTest is Test { function testNvdaTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.NVDA_RECEIPT, - LibProdTokensBase.NVDA_RECEIPT_VAULT, - LibProdTokensBase.NVDA_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.NVDA_RECEIPT, + LibTokenInvariants.NVDA_RECEIPT_VAULT, + LibTokenInvariants.NVDA_WRAPPED_TOKEN_VAULT, "tNVDA", "wtNVDA" ); @@ -599,9 +599,9 @@ contract LibProdTokensBaseTest is Test { function testIauTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.IAU_RECEIPT, - LibProdTokensBase.IAU_RECEIPT_VAULT, - LibProdTokensBase.IAU_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.IAU_RECEIPT, + LibTokenInvariants.IAU_RECEIPT_VAULT, + LibTokenInvariants.IAU_WRAPPED_TOKEN_VAULT, "tIAU", "wtIAU" ); @@ -610,9 +610,9 @@ contract LibProdTokensBaseTest is Test { function testPpltTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.PPLT_RECEIPT, - LibProdTokensBase.PPLT_RECEIPT_VAULT, - LibProdTokensBase.PPLT_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.PPLT_RECEIPT, + LibTokenInvariants.PPLT_RECEIPT_VAULT, + LibTokenInvariants.PPLT_WRAPPED_TOKEN_VAULT, "tPPLT", "wtPPLT" ); @@ -621,9 +621,9 @@ contract LibProdTokensBaseTest is Test { function testAmznTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.AMZN_RECEIPT, - LibProdTokensBase.AMZN_RECEIPT_VAULT, - LibProdTokensBase.AMZN_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.AMZN_RECEIPT, + LibTokenInvariants.AMZN_RECEIPT_VAULT, + LibTokenInvariants.AMZN_WRAPPED_TOKEN_VAULT, "tAMZN", "wtAMZN" ); @@ -632,9 +632,9 @@ contract LibProdTokensBaseTest is Test { function testBmnrTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.BMNR_RECEIPT, - LibProdTokensBase.BMNR_RECEIPT_VAULT, - LibProdTokensBase.BMNR_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.BMNR_RECEIPT, + LibTokenInvariants.BMNR_RECEIPT_VAULT, + LibTokenInvariants.BMNR_WRAPPED_TOKEN_VAULT, "tBMNR", "wtBMNR" ); @@ -643,9 +643,9 @@ contract LibProdTokensBaseTest is Test { function testIbhgTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.IBHG_RECEIPT, - LibProdTokensBase.IBHG_RECEIPT_VAULT, - LibProdTokensBase.IBHG_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.IBHG_RECEIPT, + LibTokenInvariants.IBHG_RECEIPT_VAULT, + LibTokenInvariants.IBHG_WRAPPED_TOKEN_VAULT, "tIBHG", "wtIBHG" ); @@ -654,9 +654,9 @@ contract LibProdTokensBaseTest is Test { function testSgovTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.SGOV_RECEIPT, - LibProdTokensBase.SGOV_RECEIPT_VAULT, - LibProdTokensBase.SGOV_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.SGOV_RECEIPT, + LibTokenInvariants.SGOV_RECEIPT_VAULT, + LibTokenInvariants.SGOV_WRAPPED_TOKEN_VAULT, "tSGOV", "wtSGOV" ); diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol new file mode 100644 index 00000000..f361392e --- /dev/null +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -0,0 +1,67 @@ +// 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 {LibTokenInvariants, IOwnable, ReceiptVaultOwnerMismatch} from "../../../src/lib/LibTokenInvariants.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {LibTokenInvariantsHarness} from "./LibTokenInvariantsHarness.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; + +/// @title LibTokenInvariantsTest +/// @notice Fork tests for the token-side uniformity invariants: every +/// production receipt vault on Base shares the same `owner()` and the same +/// `authorizer()`. +/// +/// 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 +/// 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 +/// prod-state drift detectors in this repo), so the next CI run reflects the +/// current on-chain wiring. Pinning would freeze the invariant assertions +/// against a stale snapshot and let new drift slip through unnoticed. +contract LibTokenInvariantsTest is Test { + /// @notice External-call harness deployed fresh per test against the + /// active fork. + LibTokenInvariantsHarness internal harness; + + /// @notice Selects the Base fork at chain head — deliberately unpinned. + /// Live drift detector; see contract-level rationale. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + harness = new LibTokenInvariantsHarness(); + } + + /// @notice Every production receipt vault reports + /// `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE` as its `owner()`. Passes against + /// the live chain state: vault ownership is uniform. + function testProdReceiptVaultsUniformOwnership() external { + selectBaseFork(); + LibTokenInvariants.assertUniformOwnership(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + } + + /// @notice Every production receipt vault reports + /// `LibTokenInvariants.STOX_PROD_AUTHORISER`. Passes against + /// the live chain state: vault authoriser is uniform. + function testProdReceiptVaultsShareUniformAuthoriser() external { + selectBaseFork(); + LibTokenInvariants.assertUniformAuthoriser(LibTokenInvariants.STOX_PROD_AUTHORISER); + } + + /// @notice Token-side ownership drift trips `ReceiptVaultOwnerMismatch`. + /// Simulated by mocking a single vault's `owner()` to a rogue address; + /// the assertion reverts surfacing the offending vault. The victim vault + /// address comes from `LibTokenInvariants` (the source of truth for + /// production receipt vaults). + function testInvertedUniformOwnershipDrift() external { + selectBaseFork(); + address expectedOwner = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + address rogueOwner = address(0xBADC0DE); + address victim = LibTokenInvariants.MSTR_RECEIPT_VAULT; + vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); + vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, expectedOwner, rogueOwner)); + harness.callAssertUniformOwnership(expectedOwner); + } +} diff --git a/test/src/lib/LibTokenInvariantsHarness.sol b/test/src/lib/LibTokenInvariantsHarness.sol new file mode 100644 index 00000000..8afe5f0b --- /dev/null +++ b/test/src/lib/LibTokenInvariantsHarness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibTokenInvariants} from "../../../src/lib/LibTokenInvariants.sol"; + +/// @title LibTokenInvariantsHarness +/// @notice External-call shim around the internal library so +/// `vm.expectRevert` can intercept the typed errors. `vm.expectRevert` only +/// catches reverts from external calls; library `internal` functions inline +/// and would fail the depth check otherwise. +contract LibTokenInvariantsHarness { + function callAssertUniformOwnership(address expectedOwner) external view { + LibTokenInvariants.assertUniformOwnership(expectedOwner); + } + + function callAssertUniformAuthoriser(address expected) external view { + LibTokenInvariants.assertUniformAuthoriser(expected); + } +}