diff --git a/.github/workflows/rainix-sol-scheduled.yaml b/.github/workflows/rainix-sol-scheduled.yaml new file mode 100644 index 00000000..a20e4d6b --- /dev/null +++ b/.github/workflows/rainix-sol-scheduled.yaml @@ -0,0 +1,24 @@ +# Scheduled re-run of the full sol suite (including every fork test and the +# cross-chain parity pin). Push-triggered CI only catches drift +# that arrives WITH a code change; state drift introduced directly on-chain +# between pushes — a role grant, a beacon upgrade, an authoriser swap — is +# invisible to it. This schedule closes that gap: the prod-state pins and +# the parity suite run against live chain state daily regardless of repo +# activity. +name: rainix-sol-scheduled +on: + schedule: + # Daily, 06:17 UTC (off the hour to dodge the GitHub cron thundering + # herd, which can delay or drop on-the-hour scheduled runs). + - cron: "17 6 * * *" + workflow_dispatch: +jobs: + rainix-sol: + uses: rainlanguage/rainix/.github/workflows/rainix-sol.yaml@main + secrets: + RPC_URL_ARBITRUM_FORK: ${{ secrets.RPC_URL_ARBITRUM_FORK }} + RPC_URL_BASE_FORK: ${{ secrets.RPC_URL_BASE_FORK }} + RPC_URL_BASE_SEPOLIA_FORK: ${{ secrets.RPC_URL_BASE_SEPOLIA_FORK }} + RPC_URL_ETHEREUM_FORK: ${{ secrets.RPC_URL_ETHEREUM_FORK }} + RPC_URL_FLARE_FORK: ${{ secrets.RPC_URL_FLARE_FORK }} + RPC_URL_POLYGON_FORK: ${{ secrets.RPC_URL_POLYGON_FORK }} diff --git a/src/lib/LibProdTokenConfig.sol b/src/lib/LibProdTokenConfig.sol new file mode 100644 index 00000000..f5c52cba --- /dev/null +++ b/src/lib/LibProdTokenConfig.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @notice The deploy-time configuration for one production token — the +/// inputs `StoxUnifiedDeployer.newTokenAndWrapperVault` needs to reproduce +/// a Base token instance on another chain. +/// @dev Only `name` + `symbol` are captured: the offchain-asset receipt +/// vault takes `asset = address(0)` (the asset is offchain) and +/// `receipt = address(0)` (the beacon-set deployer wires the receipt), +/// `initialAdmin` is the target chain's Safe (supplied by the deploy +/// script, not the table), `decimals` is fixed by the shared vault +/// implementation bytecode, and the wrapped token vault derives its own +/// name/symbol on-chain (`"Wrapped " + name`, `"w" + symbol`). So the +/// receipt vault's `name` + `symbol` are the ONLY free deploy inputs. +/// @param underlying The chain-agnostic ticker join key, matching +/// `LibTokenInvariants.TokenInstance.underlying`. +/// @param name The receipt vault's ERC-20 `name()`, verbatim from Base. +/// @param symbol The receipt vault's ERC-20 `symbol()`, verbatim from Base. +struct TokenConfig { + string underlying; + string name; + string symbol; +} + +/// @title LibProdTokenConfig +/// @notice The canonical name/symbol table for the 28 ST0x production +/// tokens, captured verbatim from the live Base receipt vaults so a new +/// chain's token set can be deployed byte-identical to Base. This is the +/// deploy-input companion to `LibTokenInvariants` (which holds the deployed +/// addresses): the deploy script reads this to author the +/// `newTokenAndWrapperVault` calls, and it is the CANONICAL BASELINE the +/// cross-chain parity pin asserts every chain's live `name`/`symbol` against +/// (Base included — `LibProdTokenConfigTest` pins this table to live Base, so +/// the baseline itself is validated, not just chain-vs-chain). +/// +/// @dev Entries are in the same order as +/// `LibTokenInvariants.productionTokensBase()` so the two tables pair by +/// index as well as by `underlying` key; `LibProdTokenConfigTest` pins that +/// alignment. Strings are reproduced EXACTLY, including quirks that exist on +/// Base — notably `SGOV`'s name has a leading space. Matching Base "exactly" +/// means carrying that space forward; the parity pin would flag it as a +/// divergence otherwise. +library LibProdTokenConfig { + /// @notice The 28 production token deploy configs, Base table order. + /// @return configs The name/symbol table. + function productionTokenConfigs() internal pure returns (TokenConfig[] memory configs) { + configs = new TokenConfig[](28); + configs[0] = TokenConfig("MSTR", "MicroStrategy Incorporated ST0x", "tMSTR"); + configs[1] = TokenConfig("TSLA", "Tesla Inc ST0x", "tTSLA"); + configs[2] = TokenConfig("COIN", "Coinbase Global Inc ST0x", "tCOIN"); + configs[3] = TokenConfig("SPYM", "State Street SPDR Portfolio S&P 500 ETF ST0x", "tSPYM"); + configs[4] = TokenConfig("SIVR", "abrdn Physical Silver Shares ETF ST0x", "tSIVR"); + configs[5] = TokenConfig("CRCL", "Circle Internet Group Inc ST0x", "tCRCL"); + configs[6] = TokenConfig("NVDA", "NVIDIA Corporation ST0x", "tNVDA"); + configs[7] = TokenConfig("IAU", "iShares Gold Trust ST0x", "tIAU"); + configs[8] = TokenConfig("PPLT", "abrdn Physical Platinum Shares ETF ST0x", "tPPLT"); + configs[9] = TokenConfig("AMZN", "Amazon.com Inc ST0x", "tAMZN"); + configs[10] = TokenConfig("BMNR", "Bitmine Immersion Technologies, Inc ST0x", "tBMNR"); + configs[11] = TokenConfig("IBHG", "iShares iBonds 2027 Term High Yield and Income ETF ST0x", "tIBHG"); + // NB: leading space is present on Base and is reproduced verbatim. + configs[12] = TokenConfig("SGOV", " iShares 0-3 Month Treasury Bond ETF ST0x", "tSGOV"); + configs[13] = TokenConfig("QQQM", "Invesco NASDAQ 100 ETF ST0x", "tQQQM"); + configs[14] = TokenConfig("VWO", "Vanguard Emerging Markets Stock Index Fund ST0x", "tVWO"); + configs[15] = TokenConfig("ARKK", "ARK Innovation ETF ST0x", "tARKK"); + configs[16] = TokenConfig("SPCX", "Space Exploration Technologies Corp. ST0x", "tSPCX"); + configs[17] = TokenConfig("CEG", "Constellation Energy Corporation ST0x", "tCEG"); + configs[18] = TokenConfig("DRAM", "Roundhill Memory ETF ST0x", "tDRAM"); + configs[19] = TokenConfig("TSM", "Taiwan Semiconductor Manufacturing Company Limited ADR ST0x", "tTSM"); + configs[20] = TokenConfig("SKHY", "SK hynix Inc. ADR ST0x", "tSKHY"); + configs[21] = TokenConfig("ASML", "ASML Holding N.V. ST0x", "tASML"); + configs[22] = TokenConfig("MU", "Micron Technology, Inc. ST0x", "tMU"); + configs[23] = TokenConfig("AMD", "Advanced Micro Devices, Inc. ST0x", "tAMD"); + configs[24] = TokenConfig("AVGO", "Broadcom Inc. ST0x", "tAVGO"); + configs[25] = TokenConfig("AMAT", "Applied Materials, Inc. ST0x", "tAMAT"); + configs[26] = TokenConfig("LRCX", "Lam Research Corporation ST0x", "tLRCX"); + configs[27] = TokenConfig("TTWO", "Take-Two Interactive Software, Inc. ST0x", "tTTWO"); + } +} diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index b4d985fc..00b5f381 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -323,6 +323,56 @@ library LibTokenInvariants { tokens[27] = TokenInstance("TTWO", TTWO_RECEIPT, TTWO_RECEIPT_VAULT, TTWO_WRAPPED_TOKEN_VAULT); } + /// @notice Returns the production token instance triples on Ethereum + /// mainnet — the same 28 underlyings as Base, in the same order, so the + /// two tables pair by index as well as by key. + /// + /// **ALL PLACEHOLDERS** (`address(0)`) until the Ethereum token + /// deployments execute and the post-execution pin PR hydrates every entry + /// in one reviewed change. Hydration is all-or-nothing across the table: + /// the multichain issuance cutover is lockstep over the full token set, so + /// a partially hydrated table is an error state, which the cross-chain + /// parity suite rejects rather than half-checks. + /// @return tokens The 28 production token instances on Ethereum. + function productionTokensEthereum() internal pure returns (TokenInstance[] memory tokens) { + // PLACEHOLDER TABLE. Every address is `address(0)` until the 28 tokens + // are deployed on Ethereum and their addresses pinned here (the token + // pin PR — a per-row literal swap). Laid out as explicit rows mirroring + // `productionTokensBase()` so the two tables are formatted consistently + // across chains and the pin diff is a clean per-token change. Order and + // underlyings MUST match Base row-for-row (the cross-chain parity pin + // asserts this). + tokens = new TokenInstance[](28); + tokens[0] = TokenInstance("MSTR", address(0), address(0), address(0)); + tokens[1] = TokenInstance("TSLA", address(0), address(0), address(0)); + tokens[2] = TokenInstance("COIN", address(0), address(0), address(0)); + tokens[3] = TokenInstance("SPYM", address(0), address(0), address(0)); + tokens[4] = TokenInstance("SIVR", address(0), address(0), address(0)); + tokens[5] = TokenInstance("CRCL", address(0), address(0), address(0)); + tokens[6] = TokenInstance("NVDA", address(0), address(0), address(0)); + tokens[7] = TokenInstance("IAU", address(0), address(0), address(0)); + tokens[8] = TokenInstance("PPLT", address(0), address(0), address(0)); + tokens[9] = TokenInstance("AMZN", address(0), address(0), address(0)); + tokens[10] = TokenInstance("BMNR", address(0), address(0), address(0)); + tokens[11] = TokenInstance("IBHG", address(0), address(0), address(0)); + tokens[12] = TokenInstance("SGOV", address(0), address(0), address(0)); + tokens[13] = TokenInstance("QQQM", address(0), address(0), address(0)); + tokens[14] = TokenInstance("VWO", address(0), address(0), address(0)); + tokens[15] = TokenInstance("ARKK", address(0), address(0), address(0)); + tokens[16] = TokenInstance("SPCX", address(0), address(0), address(0)); + tokens[17] = TokenInstance("CEG", address(0), address(0), address(0)); + tokens[18] = TokenInstance("DRAM", address(0), address(0), address(0)); + tokens[19] = TokenInstance("TSM", address(0), address(0), address(0)); + tokens[20] = TokenInstance("SKHY", address(0), address(0), address(0)); + tokens[21] = TokenInstance("ASML", address(0), address(0), address(0)); + tokens[22] = TokenInstance("MU", address(0), address(0), address(0)); + tokens[23] = TokenInstance("AMD", address(0), address(0), address(0)); + tokens[24] = TokenInstance("AVGO", address(0), address(0), address(0)); + tokens[25] = TokenInstance("AMAT", address(0), address(0), address(0)); + tokens[26] = TokenInstance("LRCX", address(0), address(0), address(0)); + tokens[27] = TokenInstance("TTWO", address(0), address(0), address(0)); + } + /// @notice Returns the 28 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 diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol new file mode 100644 index 00000000..0e4150b4 --- /dev/null +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -0,0 +1,580 @@ +// 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 {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; +import {IERC4626} from "@openzeppelin-contracts-5.6.1/interfaces/IERC4626.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {ERC1967Utils} from "@openzeppelin-contracts-5.6.1/proxy/ERC1967/ERC1967Utils.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {IGnosisSafe} from "../../../../src/interface/IGnosisSafe.sol"; +import {IOwnable} from "../../../../src/interface/IOwnable.sol"; +import {LibAuthoriserInvariants} from "../../../../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; +import {LibProdDeployV4} from "../../../../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; +import {LibTokenInvariants, TokenInstance} from "../../../../src/lib/LibTokenInvariants.sol"; +import {LibProdTokenConfig, TokenConfig} from "../../../../src/lib/LibProdTokenConfig.sol"; + +/// @notice Minimal surface for the receipt vault's ERC-1155 receipt getter, +/// used to assert the vault points at this token's pinned receipt. +interface IReceiptVaultReceipt { + function receipt() external view returns (address); +} + +/// @notice Minimal surface for the ERC-1155 receipt's manager getter. The +/// receipt has no owner or authoriser of its own — its only access control is +/// `manager` (the receipt vault, which alone can mint/burn), so the wiring +/// check is `receipt.manager() == receiptVault`. +interface IReceiptManager { + function manager() external view returns (address); +} + +/// @notice Everything the parity suite reads per token instance on one +/// chain, captured on each chain's own fork (Base included). The vault +/// `name`/`symbol` are asserted against the canonical `LibProdTokenConfig` +/// baseline — itself pinned to live Base by `LibProdTokenConfigTest` — so +/// every chain is checked against the pinned table, not merely against Base's +/// live values. The remaining fields (decimals + the wrapped legs) are then +/// compared field-by-field across chains, with Base as the reference. +/// @param underlying The chain-agnostic join key from the token table. +/// @param vaultName The receipt vault's ERC-20 `name()`. +/// @param vaultSymbol The receipt vault's ERC-20 `symbol()`. +/// @param vaultDecimals The receipt vault's ERC-20 `decimals()`. +/// @param wrappedName The wrapped token vault's ERC-20 `name()`. +/// @param wrappedSymbol The wrapped token vault's ERC-20 `symbol()`. +/// @param wrappedDecimals The wrapped token vault's ERC-20 `decimals()`. +struct TokenConfigSnapshot { + string underlying; + string vaultName; + string vaultSymbol; + uint8 vaultDecimals; + string wrappedName; + string wrappedSymbol; + uint8 wrappedDecimals; +} + +/// @notice What one chain's live legs asserted, captured for the cross-chain +/// comparison. Each leg's fields are only meaningful when its `*Live` flag is +/// true; a pending (placeholder) leg is skipped, leaving its flag false. +/// @param safeLive The Safe pin is set and its policy was asserted. +/// @param owners The Safe's live owner set (valid iff `safeLive`). +/// @param threshold The Safe's live threshold (valid iff `safeLive`). +/// @param cloneLive The authoriser clone pin is set and its codehash asserted. +/// @param cloneCodehash The clone's runtime codehash (valid iff `cloneLive`). +/// @param tokenLegLive Safe + clone + full token table all live; token +/// ownership / sole-authoriser / config asserted. +/// @param tokenConfigs Per-token config snapshots (valid iff `tokenLegLive`). +/// @param beaconImpl The receipt-vault beacon implementation (valid iff +/// `tokenLegLive`). +/// @param beaconImplCodehash The beacon implementation's codehash (valid iff +/// `tokenLegLive`). +/// @param receiptBeaconImpl The ERC-1155 receipt beacon implementation (valid +/// iff `tokenLegLive`). +/// @param receiptBeaconImplCodehash The receipt beacon implementation's +/// codehash (valid iff `tokenLegLive`). +struct ChainLegs { + bool safeLive; + address[] owners; + uint256 threshold; + bool cloneLive; + bytes32 cloneCodehash; + bool tokenLegLive; + TokenConfigSnapshot[] tokenConfigs; + address beaconImpl; + bytes32 beaconImplCodehash; + address receiptBeaconImpl; + bytes32 receiptBeaconImplCodehash; +} + +/// @title StoxCrossChainParityTest +/// @notice The cross-chain deployment-parity pin: an automated +/// invariant asserting every ST0x chain carries an IDENTICAL deployment — +/// identical core artifacts, identically-configured token instances, +/// identical permission structure — so parity cannot silently drift once +/// multichain is live. Runs in CI on every push and on the scheduled +/// workflow (drift introduced on-chain between pushes — a role grant, a +/// beacon upgrade — is caught by the schedule, not just by code changes). +/// +/// Parity layers, per non-baseline chain vs Base (the baseline chain, +/// which carries the original production state): +/// +/// 1. **Core artifacts** — the deterministic Zoltu addresses + codehashes +/// are asserted per-network by `StoxProdV4Test.checkAllV4OnChain`; +/// equality across chains follows because every network is checked +/// against the same pinned constants. This suite re-asserts only the +/// per-chain authoriser clones (the one non-deterministic core +/// artifact): pinned address, shared EIP-1167 codehash. +/// 2. **Token instances** — for every underlying in the per-chain token +/// tables: `name` / `symbol` of both vault legs equal the canonical +/// `LibProdTokenConfig` baseline (asserted on every chain, Base included, +/// against the pinned table) and `decimals` equals the Base baseline; +/// receipt + wrapped wiring is internally consistent +/// (`wrapped.asset() == receiptVault`); every receipt vault's +/// `authorizer()` is the chain's pinned V4 clone and its `owner()` is +/// the chain's token-owner Safe; all of a chain's proxies share one +/// runtime codehash per leg (beacon proxies — the codehash embeds the +/// beacon address, so it is uniform WITHIN a chain but legitimately +/// differs ACROSS chains; cross-chain implementation parity is asserted +/// through the beacon instead). +/// 3. **Beacon lineage** — each chain's receipt + receipt-vault proxies +/// resolve (via the ERC-1967 beacon slot) to a single beacon per leg +/// serving the V4 impl. The beacon ADDRESSES are per-chain (they never get +/// upgraded — only the impl they point at does — so which deployer version +/// created them is irrelevant), and each is owned by THAT chain's +/// token-owner Safe (a per-chain check; the addresses and Safe owners both +/// differ by chain). Cross-chain parity is on where the beacons POINT: +/// the receipt + receipt-vault beacon impls (address + codehash) are +/// asserted identical across chains, as is the authoriser clone impl. +/// 4. **Role parity** — `LibAuthoriserInvariants.assertExpectedGrants` runs +/// against each chain's clone with that chain's token-owner Safe: the +/// identical grant STRUCTURE on every chain, the service-signer holder +/// shared, the Safe holder the chain's own per-chain Safe. The Safe policy +/// (owner set, threshold, v1.4.1 identity) is asserted equal to Base's, and +/// the Ethereum Safe's live owner set + threshold are compared directly to +/// Base's. Per-chain: the Safe address, the clone address, the token +/// addresses. +/// +/// **Known-divergence carve-out (Base V2 beacon corruption).** Base's V2 +/// OARV beacon set was corrupted post-deploy (impl downgrade + ownership +/// lock, pinned in `LibProdDeployV2BaseOverrides`). Production tokens on +/// Base never used those beacons (they run on the healthy V1 set), and no +/// new chain deploys V2 at all — Ethereum bootstraps directly at V4. The +/// carve-out is encoded, not implied: `assertCleanV4Lineage` asserts that +/// no non-baseline chain's token proxies resolve to ANY pinned V2 beacon +/// address, so the corrupted artifacts can neither mask drift on Base nor +/// leak into expectations for chains that deploy clean. +/// +/// **Per-leg placeholder gating.** Each chain's per-chain deploy artifacts — +/// the token-owner Safe address, the authoriser clone address, the token +/// addresses — start as `address(0)` placeholders and are hydrated by pin PRs +/// as each is deployed. The suite asserts each leg only when its pins are set, +/// skipping placeholder legs with a loud `PARITY PENDING` log (never a silent +/// skip), and the cross-chain comparisons gate on both chains carrying the +/// leg. The legs nest by dependency: the **Safe leg** needs the Safe; the +/// **authoriser leg** needs the clone (its grant map also needs the Safe) and +/// is assertable as soon as the clone is up, independent of the tokens; the +/// **token leg** needs the Safe + clone + full token table. This is what lets +/// the whole multichain stack merge green before any chain is bootstrapped: +/// every leg skips, and each pin PR turns its leg (and its cross-chain +/// comparison) on. The one hard failure is a PARTIALLY-hydrated token table +/// (some triples set, some placeholder) — an operator error the token pin PR +/// must avoid by setting all triples together. +contract StoxCrossChainParityTest is Test { + /// @notice Read the address stored in `proxy`'s ERC-1967 beacon slot. + /// @param proxy The beacon-proxy address on the active fork. + /// @return beacon The beacon address backing the proxy. + function readBeacon(address proxy) internal view returns (address beacon) { + beacon = address(uint160(uint256(vm.load(proxy, ERC1967Utils.BEACON_SLOT)))); + } + + /// @notice Capture one chain's per-token config snapshot on the ACTIVE + /// fork and assert the parity-specific per-token properties the shared + /// framework does not cover: receipt/wrapped wiring, per-leg proxy + /// codehash uniformity within the chain, and the single shared beacon. + /// @dev The uniform owner + sole-authoriser checks are NOT here — the token + /// leg in `assertChainLegs` asserts them via + /// `LibTokenInvariants.assertAll(tokens, safe, clone)`; this function adds + /// only the per-token config snapshot + within-chain uniformity that the + /// cross-chain comparison builds on. + /// @param tokens The chain's token table. + /// @return snapshots Per-token config snapshots, table order. + /// @return receiptVaultBeacon The single beacon backing every receipt + /// vault proxy on this chain. + /// @return receiptBeacon The single beacon backing every ERC-1155 receipt + /// proxy on this chain. + function assertChainAndSnapshot(TokenInstance[] memory tokens) + internal + view + returns (TokenConfigSnapshot[] memory snapshots, address receiptVaultBeacon, address receiptBeacon) + { + snapshots = new TokenConfigSnapshot[](tokens.length); + + // The canonical name/symbol baseline every chain is asserted against + // (Base included). `LibProdTokenConfigTest` pins this table to live + // Base, so parity is against a validated source of truth, not merely + // chain-vs-chain. + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + + // Per-leg proxy-codehash uniformity within the chain. + bytes32 receiptVaultProxyCodehash = tokens[0].receiptVault.codehash; + bytes32 wrappedProxyCodehash = tokens[0].wrappedTokenVault.codehash; + bytes32 receiptProxyCodehash = tokens[0].receipt.codehash; + receiptVaultBeacon = readBeacon(tokens[0].receiptVault); + receiptBeacon = readBeacon(tokens[0].receipt); + + for (uint256 i = 0; i < tokens.length; i++) { + TokenInstance memory token = tokens[i]; + + // Read the receipt vault + wrapped vault metadata from chain: + // name/symbol are asserted against the canonical config baseline + // below; decimals + the wrapped-vault fields feed the cross-chain + // snapshot comparison. + snapshots[i] = TokenConfigSnapshot({ + underlying: token.underlying, + vaultName: IERC20Metadata(token.receiptVault).name(), + vaultSymbol: IERC20Metadata(token.receiptVault).symbol(), + vaultDecimals: IERC20Metadata(token.receiptVault).decimals(), + wrappedName: IERC20Metadata(token.wrappedTokenVault).name(), + wrappedSymbol: IERC20Metadata(token.wrappedTokenVault).symbol(), + wrappedDecimals: IERC20Metadata(token.wrappedTokenVault).decimals() + }); + + // Baseline: the receipt vault's live name/symbol equal the + // canonical config — asserted on every chain, so parity is against + // the pinned table, not merely Base-vs-others. + assertEq( + snapshots[i].vaultName, + configs[i].name, + string.concat(token.underlying, ": vault name != canonical config baseline") + ); + assertEq( + snapshots[i].vaultSymbol, + configs[i].symbol, + string.concat(token.underlying, ": vault symbol != canonical config baseline") + ); + + // Wiring: the wrapped vault wraps this token's receipt vault. + assertEq( + IERC4626(token.wrappedTokenVault).asset(), + token.receiptVault, + string.concat(token.underlying, ": wrapped.asset() != receiptVault") + ); + + // Uniform proxy bytecode within the chain, per leg. + assertEq( + token.receiptVault.codehash, + receiptVaultProxyCodehash, + string.concat(token.underlying, ": receipt vault proxy codehash not uniform on-chain") + ); + assertEq( + token.wrappedTokenVault.codehash, + wrappedProxyCodehash, + string.concat(token.underlying, ": wrapped vault proxy codehash not uniform on-chain") + ); + + // Single shared beacon per chain for the receipt-vault leg. + assertEq( + readBeacon(token.receiptVault), + receiptVaultBeacon, + string.concat(token.underlying, ": receipt vault proxies do not share one beacon") + ); + + // ERC-1155 receipt leg: the vault points at this token's pinned + // receipt, the receipt points back at the vault as its manager (the + // receipt has no owner/authoriser — `manager` is its only access + // control), and the receipt proxies are uniform bytecode + share one + // beacon within the chain — the same guarantees as the vault legs. + assertEq( + IReceiptVaultReceipt(token.receiptVault).receipt(), + token.receipt, + string.concat(token.underlying, ": receiptVault.receipt() != pinned receipt") + ); + assertEq( + IReceiptManager(token.receipt).manager(), + token.receiptVault, + string.concat(token.underlying, ": receipt.manager() != receiptVault") + ); + assertEq( + token.receipt.codehash, + receiptProxyCodehash, + string.concat(token.underlying, ": receipt proxy codehash not uniform on-chain") + ); + assertEq( + readBeacon(token.receipt), + receiptBeacon, + string.concat(token.underlying, ": receipt proxies do not share one beacon") + ); + } + } + + /// @notice Assert the chain's authoriser clone is deployed at its + /// per-chain pin with the shared EIP-1167 codehash. This is the + /// deploy-artifact half (address + bytecode); the clone's role-grant + /// map is asserted through the shared framework + /// (`LibInvariants.assertProductionState` → + /// `LibAuthoriserInvariants.assertExpectedGrants`) in + /// `testCrossChainParity`, so it is not repeated here. + /// @param clone The chain's pinned V4 authoriser clone. + function assertCloneParity(address clone) internal view { + assertTrue(clone.code.length > 0, "V4 authoriser clone not deployed"); + assertEq( + clone.codehash, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, + "V4 authoriser clone codehash mismatch (shared EIP-1167 pin)" + ); + } + + /// @notice The Base-V2-corruption carve-out, stated as a positive + /// invariant on clean chains. Base's V2 OARV beacons were corrupted + /// post-deploy (impl downgraded, ownership locked into the V2 + /// contracts — the exact values are pinned in + /// `LibProdDeployV2BaseOverrides`). That corruption is a named, + /// Base-only exception: production tokens on Base never used those + /// beacons, and no clean chain deploys V2 at all. This assertion makes + /// the exception explicit on the clean side — a non-baseline chain's + /// beacon must not carry any corruption-era value — so the carve-out + /// can neither mask new drift on Base nor leak into expectations for + /// chains that bootstrap directly at V4. + /// @param receiptVaultBeacon The beacon backing the chain's receipt + /// vault proxies (already asserted to serve the V4 impl). + function assertCleanV4Lineage(address receiptVaultBeacon) internal view { + assertTrue( + IBeacon(receiptVaultBeacon).implementation() != LibProdDeployV2BaseOverrides.RECEIPT_BEACON_IMPLEMENTATION + && IBeacon(receiptVaultBeacon).implementation() + != LibProdDeployV2BaseOverrides.VAULT_BEACON_IMPLEMENTATION, + "clean chain's beacon serves a V2 corruption-era implementation" + ); + assertTrue( + IOwnable(receiptVaultBeacon).owner() != LibProdDeployV2BaseOverrides.RECEIPT_BEACON_OWNER + && IOwnable(receiptVaultBeacon).owner() != LibProdDeployV2BaseOverrides.VAULT_BEACON_OWNER, + "clean chain's beacon is owned by a V2 corruption-era owner" + ); + } + + /// @notice Token-table hydration state: whether ANY entry and whether ALL + /// entries are fully set (all three addresses non-zero). A partially-set + /// table (some entries set, some placeholder) is neither — the caller + /// rejects that as an operator error. + /// @param tokens The chain's token table. + /// @return anySet At least one entry has a non-placeholder address. + /// @return allSet Every entry is fully hydrated. + function _tokenTableState(TokenInstance[] memory tokens) internal pure returns (bool anySet, bool allSet) { + allSet = true; + for (uint256 i = 0; i < tokens.length; i++) { + bool entrySet = tokens[i].receipt != address(0) && tokens[i].receiptVault != address(0) + && tokens[i].wrappedTokenVault != address(0); + bool entryClear = tokens[i].receipt == address(0) && tokens[i].receiptVault == address(0) + && tokens[i].wrappedTokenVault == address(0); + anySet = anySet || !entryClear; + allSet = allSet && entrySet; + } + } + + /// @notice Assert two owner rosters are equal as SETS (same length, same + /// members) — order-insensitive. Safe forbids duplicate owners, so equal + /// lengths plus one-way membership is full set equality. + /// @param a One roster. + /// @param b The other roster. + function assertSameOwnerSet(address[] memory a, address[] memory b) internal pure { + assertEq(a.length, b.length, "Safe owner count diverges cross-chain"); + for (uint256 i = 0; i < a.length; i++) { + bool found = false; + for (uint256 j = 0; j < b.length; j++) { + if (a[i] == b[j]) { + found = true; + break; + } + } + assertTrue(found, "Safe owner set diverges cross-chain"); + } + } + + /// @notice Unix timestamp past which Ethereum's legs must have armed. + /// `2026-10-01T00:00:00Z`. Before it, a pending Ethereum leg is the + /// expected mid-bootstrap state; after it, a leg that has never armed is a + /// chain nothing asserts anything about. A later PR can move this earlier + /// to tighten the forcing function or later if the bootstrap slips — the + /// point is that the choice is made deliberately rather than by silence. + uint256 internal constant ETHEREUM_PARITY_DEADLINE = 1_790_812_800; + + /// @notice Assert every LIVE leg of a chain on the ACTIVE fork, skipping + /// (with a loud PENDING log) any leg whose pins are still placeholders, and + /// capture what it read for the cross-chain comparison. The legs are nested + /// by dependency: + /// - **Safe leg** (needs the Safe): the Safe matches Base's policy. + /// - **Authoriser leg** (needs the clone; the grant map also needs the + /// Safe): the clone codehash + the role-grant map. Assertable as soon as + /// the clone is up — it does NOT wait on the tokens. + /// - **Token leg** (needs Safe + clone + the full token table): ownership + /// by the Safe, the clone as sole authoriser, config + beacon. + /// Skipping placeholder legs is what lets the whole stack merge green: an + /// un-bootstrapped chain skips every leg, and each pin PR turns its leg on. + /// @param label Human chain name, used in the PENDING logs. + /// @param safe The chain's token-owner Safe pin. + /// @param clone The chain's authoriser clone pin. + /// @param tokens The chain's token table. + /// @return legs What the live legs asserted + captured, for cross-chain use. + function assertChainLegs(string memory label, address safe, address clone, TokenInstance[] memory tokens) + internal + returns (ChainLegs memory legs) + { + // --- Safe leg (needs: Safe) --- + legs.safeLive = safe != address(0); + if (legs.safeLive) { + LibSafeInvariants.assertTokenOwnerSafePolicy(IGnosisSafe(safe)); + legs.owners = IGnosisSafe(safe).getOwners(); + legs.threshold = IGnosisSafe(safe).getThreshold(); + } else { + emit log(string.concat("PARITY PENDING: ", label, " Safe pin placeholder - Safe leg skipped")); + } + + // --- Authoriser leg (needs: clone; grant map also needs the Safe) --- + legs.cloneLive = clone != address(0); + if (legs.cloneLive) { + assertCloneParity(clone); + legs.cloneCodehash = clone.codehash; + if (legs.safeLive) { + // The grant map is assertable as soon as the clone is up — its + // only blocker is the Safe, independent of the tokens. + LibAuthoriserInvariants.assertExpectedGrants(clone, safe); + } + } else { + emit log(string.concat("PARITY PENDING: ", label, " clone pin placeholder - authoriser leg skipped")); + } + + // --- Token leg (needs: Safe + clone + full token table) --- + (bool anyToken, bool allTokens) = _tokenTableState(tokens); + assertTrue( + !anyToken || allTokens, string.concat(label, " token table partially hydrated - pin all triples together") + ); + legs.tokenLegLive = legs.safeLive && legs.cloneLive && allTokens; + if (legs.tokenLegLive) { + // Ownership (Safe) + sole authoriser (clone) across every vault. + LibTokenInvariants.assertAll(tokens, safe, clone); + address beacon; + address receiptBeacon; + (legs.tokenConfigs, beacon, receiptBeacon) = assertChainAndSnapshot(tokens); + // The audited 0.1.1 impls, NOT `LibProdDeployCurrent`: the + // current tag tracks the latest BUILD, but production on every + // chain serves the audited 0.1.1 deployment (Base's V1-address + // beacons were upgraded to it; Ethereum bootstrapped at it) — + // the same pins `LibProdBeaconsBase/Ethereum.implementations()` + // resolve. When a beacon upgrade migration moves production, + // these pins move with it. + assertEq( + IBeacon(beacon).implementation(), + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, + string.concat(label, " receipt-vault beacon does not serve the audited production impl") + ); + assertEq( + IBeacon(receiptBeacon).implementation(), + LibProdDeployV4.STOX_RECEIPT_0_1_1, + string.concat(label, " receipt beacon does not serve the audited production impl") + ); + assertCleanV4Lineage(beacon); + assertCleanV4Lineage(receiptBeacon); + // Each chain's beacons are owned by that chain's OWN token-owner + // Safe (migrated from the deploy key) — a per-chain check, not a + // cross-chain equality: the beacon addresses and their Safe owners + // both differ by chain. Cross-chain parity is on the impl the + // beacons point at, asserted below. + assertEq( + IOwnable(beacon).owner(), + safe, + string.concat(label, " receipt-vault beacon not owned by the chain's Safe") + ); + assertEq( + IOwnable(receiptBeacon).owner(), + safe, + string.concat(label, " receipt beacon not owned by the chain's Safe") + ); + legs.beaconImpl = IBeacon(beacon).implementation(); + legs.beaconImplCodehash = legs.beaconImpl.codehash; + legs.receiptBeaconImpl = IBeacon(receiptBeacon).implementation(); + legs.receiptBeaconImplCodehash = legs.receiptBeaconImpl.codehash; + } else if (legs.safeLive && legs.cloneLive) { + emit log(string.concat("PARITY PENDING: ", label, " token table placeholder - token leg skipped")); + } + } + + /// @notice The cross-chain parity pin. Asserts each chain's LIVE legs on + /// its own fork (pending legs skipped + logged), then compares whatever is + /// live on BOTH chains. Every comparison is gated on both sides carrying + /// the relevant leg, so an un-bootstrapped chain leaves the suite green and + /// each pin PR turns its comparisons on. + function testCrossChainParity() external { + vm.createSelectFork(LibRainDeploy.BASE); + ChainLegs memory base = assertChainLegs( + "Base", + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, + LibTokenInvariants.productionTokensBase() + ); + + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + ChainLegs memory eth = assertChainLegs( + "Ethereum", + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, + LibTokenInvariants.productionTokensEthereum() + ); + + // ---- Cross-chain comparisons, each gated on both sides being live ---- + + // Safe policy: same owner SET (order-insensitive) + threshold, compared + // against Base's LIVE Safe (the Ethereum Safe is a distinct per-chain + // address that must still carry Base's exact policy). + if (base.safeLive && eth.safeLive) { + assertEq(eth.threshold, base.threshold, "Safe threshold diverges cross-chain"); + assertSameOwnerSet(base.owners, eth.owners); + } + + // Authoriser clone: EIP-1167 over the same impl on every chain, so the + // clone codehashes match. + if (base.cloneLive && eth.cloneLive) { + assertEq(eth.cloneCodehash, base.cloneCodehash, "authoriser clone impl codehash diverges cross-chain"); + } + + // Token leg: identical receipt-vault implementation (address + codehash) + // through the beacon, the shared beacon deployer, and identical per-token + // config in identical table order. + if (base.tokenLegLive && eth.tokenLegLive) { + assertEq(eth.beaconImpl, base.beaconImpl, "receipt-vault beacon impl diverges cross-chain"); + assertEq( + eth.beaconImplCodehash, + base.beaconImplCodehash, + "receipt-vault beacon impl codehash diverges cross-chain" + ); + assertEq(eth.receiptBeaconImpl, base.receiptBeaconImpl, "receipt beacon impl diverges cross-chain"); + assertEq( + eth.receiptBeaconImplCodehash, + base.receiptBeaconImplCodehash, + "receipt beacon impl codehash diverges cross-chain" + ); + + assertEq(base.tokenConfigs.length, eth.tokenConfigs.length, "token table lengths diverge"); + for (uint256 i = 0; i < base.tokenConfigs.length; i++) { + TokenConfigSnapshot memory b = base.tokenConfigs[i]; + TokenConfigSnapshot memory o = eth.tokenConfigs[i]; + assertEq(o.underlying, b.underlying, "token table underlying order diverges"); + assertEq(o.vaultName, b.vaultName, string.concat(b.underlying, ": vault name diverges")); + assertEq(o.vaultSymbol, b.vaultSymbol, string.concat(b.underlying, ": vault symbol diverges")); + assertEq(o.vaultDecimals, b.vaultDecimals, string.concat(b.underlying, ": vault decimals diverge")); + assertEq(o.wrappedName, b.wrappedName, string.concat(b.underlying, ": wrapped name diverges")); + assertEq(o.wrappedSymbol, b.wrappedSymbol, string.concat(b.underlying, ": wrapped symbol diverges")); + assertEq( + o.wrappedDecimals, b.wrappedDecimals, string.concat(b.underlying, ": wrapped decimals diverge") + ); + } + } + + // ---- The suite must prove it actually ran ---- + + // Every comparison above is gated on both chains carrying the leg, so a + // suite that skipped all of them is green in precisely the same way as + // one that checked all of them. These assertions are what separate the + // two signals. + + // Base is fully bootstrapped. A pending leg here is not a pending + // bootstrap — it is the placeholder detection reading a live pin as a + // placeholder, which silently disables every comparison in this test. + assertTrue(base.safeLive, "Base Safe leg reported pending - parity comparisons are disabled"); + assertTrue(base.cloneLive, "Base authoriser leg reported pending - parity comparisons are disabled"); + assertTrue(base.tokenLegLive, "Base token leg reported pending - parity comparisons are disabled"); + + // Ethereum's legs arm as its pins hydrate. Past the deadline a still- + // pending leg stops being "not yet" and becomes an unasserted chain, so + // the invariant forces the same operator choice as the beacon-owner + // migration pin: land the pins, move the deadline, or delete the + // invariant deliberately. Without it, a leg that never arms is + // indistinguishable from one that passes, forever. + if (block.timestamp >= ETHEREUM_PARITY_DEADLINE) { + assertTrue(eth.safeLive, "Ethereum Safe leg still pending past the parity deadline"); + assertTrue(eth.cloneLive, "Ethereum authoriser leg still pending past the parity deadline"); + assertTrue(eth.tokenLegLive, "Ethereum token leg still pending past the parity deadline"); + } + } +} diff --git a/test/src/lib/LibProdTokenConfig.t.sol b/test/src/lib/LibProdTokenConfig.t.sol new file mode 100644 index 00000000..57f5c7d0 --- /dev/null +++ b/test/src/lib/LibProdTokenConfig.t.sol @@ -0,0 +1,75 @@ +// 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 {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {LibProdTokenConfig, TokenConfig} from "../../../src/lib/LibProdTokenConfig.sol"; +import {LibTokenInvariants, TokenInstance} from "../../../src/lib/LibTokenInvariants.sol"; + +/// @title LibProdTokenConfigTest +/// @notice Pins the deploy-input token config against two sources of truth: +/// the deployed Base address table (order + keys must align) and — on a +/// Base fork — the live receipt vaults themselves (the captured name/symbol +/// must equal what Base actually reports, so a typo or a stale capture +/// fails here rather than silently producing a mismatched token on the new +/// chain). This is what makes the config a trustworthy cross-chain baseline: +/// the parity pin asserts every chain against this table, and this test +/// asserts the table against live Base. +contract LibProdTokenConfigTest is Test { + /// The config table and the Base address table pair by index: same + /// length, same `underlying` in the same order. + function testConfigAlignsWithBaseTokenTable() external pure { + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + TokenInstance[] memory tokens = LibTokenInvariants.productionTokensBase(); + assertEq(configs.length, tokens.length, "config/table length mismatch"); + for (uint256 i = 0; i < configs.length; i++) { + assertEq(configs[i].underlying, tokens[i].underlying, "underlying order/key mismatch"); + } + } + + /// Every config's name/symbol equals the live Base receipt vault's — + /// the exact-match guarantee that lets the new chain reproduce Base. + /// Runs on a Base fork. + function testConfigMatchesLiveBase() external { + vm.createSelectFork(LibRainDeploy.BASE); + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + TokenInstance[] memory tokens = LibTokenInvariants.productionTokensBase(); + for (uint256 i = 0; i < configs.length; i++) { + IERC20Metadata vault = IERC20Metadata(tokens[i].receiptVault); + assertEq( + configs[i].name, vault.name(), string.concat(configs[i].underlying, ": config name != live Base name") + ); + assertEq( + configs[i].symbol, + vault.symbol(), + string.concat(configs[i].underlying, ": config symbol != live Base symbol") + ); + } + } + + /// The wrapped vault's derived name/symbol on Base are exactly + /// `"Wrapped " + name` / `"w" + symbol` — pinning the derivation the + /// deploy relies on (the wrapped leg takes no name/symbol input, so if + /// this derivation ever changed the config table would be insufficient + /// to reproduce Base's wrapped tokens). + function testWrappedDerivationHoldsOnBase() external { + vm.createSelectFork(LibRainDeploy.BASE); + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + TokenInstance[] memory tokens = LibTokenInvariants.productionTokensBase(); + for (uint256 i = 0; i < configs.length; i++) { + IERC20Metadata wrapped = IERC20Metadata(tokens[i].wrappedTokenVault); + assertEq( + wrapped.name(), + string.concat("Wrapped ", configs[i].name), + string.concat(configs[i].underlying, ": wrapped name derivation drift") + ); + assertEq( + wrapped.symbol(), + string.concat("w", configs[i].symbol), + string.concat(configs[i].underlying, ": wrapped symbol derivation drift") + ); + } + } +} diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol index 58d63273..f3bb46fc 100644 --- a/test/src/lib/LibTokenInvariants.t.sol +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -4,7 +4,12 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibProdDeployV4} from "../../../src/generated/LibProdDeployV4.sol"; -import {LibTokenInvariants, IOwnable, ReceiptVaultOwnerMismatch} from "../../../src/lib/LibTokenInvariants.sol"; +import { + LibTokenInvariants, + TokenInstance, + 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.4/src/lib/LibRainDeploy.sol"; @@ -70,4 +75,18 @@ contract LibTokenInvariantsTest is Test { vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, expectedOwner, rogueOwner)); harness.callAssertUniformOwnership(expectedOwner); } + + /// The Ethereum token table mirrors Base row-for-row on the `underlying` + /// key — same length, same order — so the two per-chain tables cannot + /// drift now that the Ethereum table is written out explicitly rather than + /// derived from Base. (The addresses are still per-chain placeholders, + /// hydrated by the token pin PR; this guards only the shared shape.) + function testEthereumTokenTableMirrorsBaseUnderlyings() external pure { + TokenInstance[] memory base = LibTokenInvariants.productionTokensBase(); + TokenInstance[] memory eth = LibTokenInvariants.productionTokensEthereum(); + assertEq(eth.length, base.length, "Ethereum token table length diverges from Base"); + for (uint256 i = 0; i < base.length; i++) { + assertEq(eth[i].underlying, base[i].underlying, "Ethereum token underlying diverges from Base"); + } + } }