From 4fa067e1410d07502c3db2842e38d4545aeef8e6 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 6 Jul 2026 22:15:31 +0000 Subject: [PATCH 1/7] test(parity): cross-chain deployment parity pin + scheduled CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAI-1097. Automated invariant asserting every ST0x chain carries an identical deployment, re-run on every push AND daily on a schedule so parity cannot silently drift once multichain is live. StoxCrossChainParity.t.sol pins, per chain vs the Base baseline: - Core artifacts: deterministic Zoltu addresses/codehashes are already pinned per-network by StoxProdV4Test; this suite re-asserts the one non-deterministic core artifact (the per-chain authoriser clone: pinned address + shared EIP-1167 codehash). - Token instances: name/symbol/decimals of both vault legs equal the Base values per underlying; wrapped.asset() wiring; uniform authorizer() (chain's V4 clone) and owner() (chain's Safe); per-leg proxy-codehash uniformity within each chain. Cross-chain implementation parity is asserted through the beacon (proxy codehash embeds the beacon address, so it legitimately differs across chains). - Beacon lineage: all of a chain's receipt-vault proxies resolve (via the ERC-1967 beacon slot) to one beacon serving the deterministic V4 impl, owner principal-mapped from Base via the ChainPrincipals table. - Role parity: assertExpectedGrants(clone, chain principals) — same structure, per-chain addresses. - Base V2 corruption carve-out encoded explicitly: clean chains must not carry any LibProdDeployV2BaseOverrides corruption-era value. - Pending-bootstrap handling is all-or-nothing: a chain is fully pending (loud log, skip) or fully hydrated (all layers asserted); partial hydration fails. LibTokenInvariants gains productionTokensEthereum() (same 20 underlyings, address(0) placeholders until the bootstrap pin PR). rainix-sol-scheduled.yaml re-runs the full suite daily + on dispatch — on-chain drift between pushes was previously invisible to CI. View-call assumptions validated against live Base (wrapped.asset(), metadata reads, shared beacon slot 0xea08..., uniform proxy codehash). Red until the V4 stack + Ethereum bootstrap execute (Base V4 clone pin gate), same draft pattern as the stack it sits on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr --- .github/workflows/rainix-sol-scheduled.yaml | 23 ++ src/lib/LibTokenInvariants.sol | 19 + .../deploy/StoxCrossChainParity.t.sol | 370 ++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 .github/workflows/rainix-sol-scheduled.yaml create mode 100644 test/src/concrete/deploy/StoxCrossChainParity.t.sol diff --git a/.github/workflows/rainix-sol-scheduled.yaml b/.github/workflows/rainix-sol-scheduled.yaml new file mode 100644 index 00000000..9f206ce5 --- /dev/null +++ b/.github/workflows/rainix-sol-scheduled.yaml @@ -0,0 +1,23 @@ +# Scheduled re-run of the full sol suite (including every fork test and the +# cross-chain parity pin, RAI-1097). 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_FLARE_FORK: ${{ secrets.RPC_URL_FLARE_FORK }} + RPC_URL_POLYGON_FORK: ${{ secrets.RPC_URL_POLYGON_FORK }} diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index b4d985fc..1ea3aa68 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -323,6 +323,25 @@ 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) { + TokenInstance[] memory baseTokens = productionTokensBase(); + tokens = new TokenInstance[](baseTokens.length); + for (uint256 i = 0; i < baseTokens.length; i++) { + tokens[i] = TokenInstance(baseTokens[i].underlying, 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..666778fe --- /dev/null +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -0,0 +1,370 @@ +// 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 {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; +import {LibChainPrincipals, ChainPrincipals} from "../../../../src/lib/LibChainPrincipals.sol"; +import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; +import {LibProdDeployCurrent} from "../../../../src/generated/LibProdDeployCurrent.sol"; +import {LibProdAuthoriserClones} from "../../../../src/lib/LibProdAuthoriserClones.sol"; +import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; +import {LibTokenInvariants, TokenInstance} from "../../../../src/lib/LibTokenInvariants.sol"; + +/// @notice Everything the parity suite reads per token instance on one +/// chain. Captured on the baseline chain (Base) fork, then compared +/// field-by-field on every other chain's fork. +/// @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; +} + +/// @title StoxCrossChainParityTest +/// @notice The cross-chain deployment-parity pin (RAI-1097): 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` / `decimals` of both vault legs equal the +/// Base baseline values (matched names / symbols by construction); +/// 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-vault proxies resolve +/// (via the ERC-1967 beacon slot) to a single beacon whose +/// `implementation()` is the deterministic V4 receipt vault impl — +/// the SAME address on every chain — and whose `owner()` maps to the +/// same principal-kind per chain (`mapPrincipal`). +/// 4. **Role parity** — `LibAuthoriserInvariants.assertExpectedGrants` +/// runs against each chain's clone with that chain's principals: the +/// identical grant STRUCTURE, per-chain addresses. Holder addresses +/// legitimately differ per chain; an address outside the principal +/// table fails the mapping. +/// +/// **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. +/// +/// **Pending-bootstrap chains.** Until a chain's bootstrap executes, its +/// deploy-artifact pins are placeholders (the principals are always +/// concrete — same matched-address Safe + shared signer on every chain). +/// The suite accepts exactly two states per chain: fully PENDING (the +/// authoriser clone pin AND every token entry all placeholder — logged +/// loudly, parity assertions skipped) or fully LIVE (every artifact +/// hydrated — all layers asserted, including each chain independently +/// satisfying `LibInvariants.assertProductionState`). Partial hydration +/// fails the suite. +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 Map a principal address observed on the baseline chain to + /// the address expected to fill the same slot on another chain. The + /// token-owner Safe and service signer map through the principal + /// tables; any other address (e.g. the chain-agnostic + /// `BEACON_INITIAL_OWNER` EOA) is expected to be IDENTICAL across + /// chains — an unmapped chain-local address on either side fails + /// parity rather than being guessed at. + /// @param observed The address read on the baseline chain. + /// @param baseline The baseline chain's principals. + /// @param target The target chain's principals. + /// @return expected The address expected on the target chain. + function mapPrincipal(address observed, ChainPrincipals memory baseline, ChainPrincipals memory target) + internal + pure + returns (address expected) + { + if (observed == baseline.tokenOwnerSafe) return target.tokenOwnerSafe; + if (observed == baseline.serviceSigner) return target.serviceSigner; + return observed; + } + + /// @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 + authoriser checks are NOT here — they are + /// asserted through `LibInvariants.assertProductionState` (the shared + /// multichain framework) in `testCrossChainParity`, so each chain first + /// satisfies the same production-state invariant Base does, and this + /// function adds only the cross-chain-comparison scaffolding on top. + /// @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. + function assertChainAndSnapshot(TokenInstance[] memory tokens) + internal + view + returns (TokenConfigSnapshot[] memory snapshots, address receiptVaultBeacon) + { + snapshots = new TokenConfigSnapshot[](tokens.length); + + // Per-leg proxy-codehash uniformity within the chain. + bytes32 receiptVaultProxyCodehash = tokens[0].receiptVault.codehash; + bytes32 wrappedProxyCodehash = tokens[0].wrappedTokenVault.codehash; + receiptVaultBeacon = readBeacon(tokens[0].receiptVault); + + for (uint256 i = 0; i < tokens.length; i++) { + TokenInstance memory token = tokens[i]; + + // Config via view calls — covers matched names / symbols / + // decimals once compared against the baseline snapshot. + 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() + }); + + // 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") + ); + } + } + + /// @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, + LibProdAuthoriserClones.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 True when the chain is cleanly pre-bootstrap: its authoriser + /// clone pin AND every token-table entry are still `address(0)` + /// placeholders. The principals are not part of this check — they are + /// concrete pins on every chain (the matched-address Safe + shared + /// signer); "bootstrapped or not" is a property of the DEPLOY ARTIFACTS + /// (clone + token addresses), which is exactly what this reads. Any mixed + /// state returns false on both this and the fully-hydrated check, which + /// the test treats as failure. + /// @param clone The chain's authoriser clone pin. + /// @param tokens The chain's token table. + /// @return pending Whether the chain is cleanly pre-bootstrap. + function isFullyPending(address clone, TokenInstance[] memory tokens) internal pure returns (bool pending) { + pending = clone == address(0); + for (uint256 i = 0; i < tokens.length; i++) { + pending = pending && tokens[i].receipt == address(0) && tokens[i].receiptVault == address(0) + && tokens[i].wrappedTokenVault == address(0); + } + } + + /// @notice True when every deploy-artifact pin for the chain is + /// hydrated: the authoriser clone AND every token-table entry. + /// @param clone The chain's authoriser clone pin. + /// @param tokens The chain's token table. + /// @return hydrated Whether the chain is fully live. + function isFullyHydrated(address clone, TokenInstance[] memory tokens) internal pure returns (bool hydrated) { + hydrated = clone != address(0); + for (uint256 i = 0; i < tokens.length; i++) { + hydrated = hydrated && tokens[i].receipt != address(0) && tokens[i].receiptVault != address(0) + && tokens[i].wrappedTokenVault != address(0); + } + } + + /// @notice The full cross-chain parity pin: snapshot Base (asserting + /// its own uniformity + policy state as it goes), then assert every + /// other supported chain against the snapshot, or assert it is cleanly + /// pending bootstrap. + function testCrossChainParity() external { + // ---- Reference chain: Base ---- + vm.createSelectFork(LibRainDeploy.BASE); + ChainPrincipals memory basePrincipals = LibChainPrincipals.base(); + address baseClone = LibProdDeployCurrent.STOX_PROD_AUTHORISER_V4_CLONE; + assertTrue(baseClone != address(0), "Base V4 clone pin still placeholder (stack not yet executed)"); + TokenInstance[] memory baseTokens = LibTokenInvariants.productionTokensBase(); + // Shared multichain framework: Base satisfies the full production-state + // invariant (Safe identity/config, token owner + authoriser uniformity, + // authoriser grant map) — the same call the Ethereum branch makes below. + LibInvariants.assertProductionState( + IGnosisSafe(basePrincipals.tokenOwnerSafe), baseTokens, baseClone, basePrincipals + ); + assertCloneParity(baseClone); + (TokenConfigSnapshot[] memory baseline, address baseBeacon) = assertChainAndSnapshot(baseTokens); + // Base's beacon lineage: the receipt-vault beacon must serve the + // deterministic V4 impl post-upgrade. (Base's beacon ADDRESS is the + // healthy V1-era beacon — upgraded in place — which is exactly why + // implementation parity is asserted through the beacon rather than + // by comparing proxy codehashes across chains.) + assertEq( + IBeacon(baseBeacon).implementation(), + LibProdDeployCurrent.STOX_RECEIPT_VAULT, + "Base receipt-vault beacon does not serve the V4 impl" + ); + address baseBeaconOwner = IOwnable(baseBeacon).owner(); + + // ---- Ethereum ---- + ChainPrincipals memory ethPrincipals = LibChainPrincipals.ethereum(); + address ethClone = LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; + TokenInstance[] memory ethTokens = LibTokenInvariants.productionTokensEthereum(); + + if (isFullyPending(ethClone, ethTokens)) { + // Cleanly pre-bootstrap: nothing to check on-chain yet. Logged + // loudly (not a silent skip) so a green run cannot be misread + // as "Ethereum verified". + emit log("PARITY: Ethereum PENDING bootstrap - clone + token pins placeholder, parity assertions skipped"); + return; + } + // Not fully pending => must be fully hydrated. Partial hydration + // is the dangerous middle state this assertion exists to reject. + assertTrue( + isFullyHydrated(ethClone, ethTokens), + "Ethereum pins partially hydrated - hydrate the clone pin and the full token table in one pin PR" + ); + + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + // Same shared framework call as Base: Ethereum must independently + // satisfy the full production-state invariant (its matched-address + // Safe policy-aligned to Base, its vaults uniform, its clone grants + // in place) before any cross-chain comparison is meaningful. + LibInvariants.assertProductionState( + IGnosisSafe(ethPrincipals.tokenOwnerSafe), ethTokens, ethClone, ethPrincipals + ); + assertCloneParity(ethClone); + (TokenConfigSnapshot[] memory observed, address ethBeacon) = assertChainAndSnapshot(ethTokens); + + // Layer 3: identical implementation through the beacon, clean V4 + // lineage, principal-mapped beacon owner. + assertEq( + IBeacon(ethBeacon).implementation(), + LibProdDeployCurrent.STOX_RECEIPT_VAULT, + "Ethereum receipt-vault beacon does not serve the V4 impl" + ); + assertCleanV4Lineage(ethBeacon); + assertEq( + IOwnable(ethBeacon).owner(), + mapPrincipal(baseBeaconOwner, basePrincipals, ethPrincipals), + "Ethereum receipt-vault beacon owner does not map from Base's" + ); + + // Layer 2 cross-chain: identical config per underlying, in + // identical table order. + assertEq(baseline.length, observed.length, "token table lengths diverge"); + for (uint256 i = 0; i < baseline.length; i++) { + assertEq(observed[i].underlying, baseline[i].underlying, "token table underlying order diverges"); + string memory key = baseline[i].underlying; + assertEq(observed[i].vaultName, baseline[i].vaultName, string.concat(key, ": vault name diverges")); + assertEq(observed[i].vaultSymbol, baseline[i].vaultSymbol, string.concat(key, ": vault symbol diverges")); + assertEq( + observed[i].vaultDecimals, baseline[i].vaultDecimals, string.concat(key, ": vault decimals diverge") + ); + assertEq(observed[i].wrappedName, baseline[i].wrappedName, string.concat(key, ": wrapped name diverges")); + assertEq( + observed[i].wrappedSymbol, baseline[i].wrappedSymbol, string.concat(key, ": wrapped symbol diverges") + ); + assertEq( + observed[i].wrappedDecimals, + baseline[i].wrappedDecimals, + string.concat(key, ": wrapped decimals diverge") + ); + } + } +} From b1778a39e81966bccad7597c783835038686e3ca Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Jul 2026 14:55:20 +0000 Subject: [PATCH 2/7] refactor(parity): shared principals + explicit cross-chain impl-codehash parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the LibChainPrincipals removal: the token-owner Safe and the grant map are shared across chains, so the parity suite drops `mapPrincipal` (now an identity) and the per-chain principal plumbing. - `assertProductionState` uses the new `(tokens, authoriser)` signature. - `baseClone` reads from `LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_BASE` (its new home) instead of the removed generated alias, so both chains' clones now read from the same hand-maintained lib. - Beacon-owner parity is a straight equality against Base's owner (shared Safe), not a principal mapping. - Add EXPLICIT cross-chain impl-codehash assertions (Josh): the authoriser clone codehash (EIP-1167 over the shared impl) and the receipt-vault beacon impl codehash must match Base's — so the only per-chain-unique artifacts (clone address, token addresses) still resolve to identical implementations everywhere. Still red-until-executed at the Base clone placeholder guard (unchanged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr --- .../deploy/StoxCrossChainParity.t.sol | 84 +++++++++---------- 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol index 666778fe..9544b001 100644 --- a/test/src/concrete/deploy/StoxCrossChainParity.t.sol +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -8,10 +8,8 @@ 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 {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; -import {LibChainPrincipals, ChainPrincipals} from "../../../../src/lib/LibChainPrincipals.sol"; import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; import {LibProdDeployCurrent} from "../../../../src/generated/LibProdDeployCurrent.sol"; import {LibProdAuthoriserClones} from "../../../../src/lib/LibProdAuthoriserClones.sol"; @@ -70,13 +68,15 @@ struct TokenConfigSnapshot { /// 3. **Beacon lineage** — each chain's receipt-vault proxies resolve /// (via the ERC-1967 beacon slot) to a single beacon whose /// `implementation()` is the deterministic V4 receipt vault impl — -/// the SAME address on every chain — and whose `owner()` maps to the -/// same principal-kind per chain (`mapPrincipal`). +/// the SAME address on every chain — and whose `owner()` is the shared +/// token-owner Safe (the same address on every chain). Cross-chain impl +/// codehash parity is asserted explicitly for both the authoriser clone +/// (EIP-1167 over the shared impl) and the receipt-vault beacon impl. /// 4. **Role parity** — `LibAuthoriserInvariants.assertExpectedGrants` -/// runs against each chain's clone with that chain's principals: the -/// identical grant STRUCTURE, per-chain addresses. Holder addresses -/// legitimately differ per chain; an address outside the principal -/// table fails the mapping. +/// runs against each chain's clone with the SHARED grant map: identical +/// structure AND identical holder addresses on every chain (the token- +/// owner Safe and service signer are shared). Only the clone ADDRESS is +/// per-chain; its grants are not. /// /// **Known-divergence carve-out (Base V2 beacon corruption).** Base's V2 /// OARV beacon set was corrupted post-deploy (impl downgrade + ownership @@ -89,8 +89,9 @@ struct TokenConfigSnapshot { /// leak into expectations for chains that deploy clean. /// /// **Pending-bootstrap chains.** Until a chain's bootstrap executes, its -/// deploy-artifact pins are placeholders (the principals are always -/// concrete — same matched-address Safe + shared signer on every chain). +/// deploy-artifact pins are placeholders (the token-owner Safe + grant map +/// are shared and always concrete — same matched-address Safe + shared +/// signer on every chain; only the clone + token addresses are per-chain). /// The suite accepts exactly two states per chain: fully PENDING (the /// authoriser clone pin AND every token entry all placeholder — logged /// loudly, parity assertions skipped) or fully LIVE (every artifact @@ -105,27 +106,6 @@ contract StoxCrossChainParityTest is Test { beacon = address(uint160(uint256(vm.load(proxy, ERC1967Utils.BEACON_SLOT)))); } - /// @notice Map a principal address observed on the baseline chain to - /// the address expected to fill the same slot on another chain. The - /// token-owner Safe and service signer map through the principal - /// tables; any other address (e.g. the chain-agnostic - /// `BEACON_INITIAL_OWNER` EOA) is expected to be IDENTICAL across - /// chains — an unmapped chain-local address on either side fails - /// parity rather than being guessed at. - /// @param observed The address read on the baseline chain. - /// @param baseline The baseline chain's principals. - /// @param target The target chain's principals. - /// @return expected The address expected on the target chain. - function mapPrincipal(address observed, ChainPrincipals memory baseline, ChainPrincipals memory target) - internal - pure - returns (address expected) - { - if (observed == baseline.tokenOwnerSafe) return target.tokenOwnerSafe; - if (observed == baseline.serviceSigner) return target.serviceSigner; - return observed; - } - /// @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 @@ -277,16 +257,15 @@ contract StoxCrossChainParityTest is Test { function testCrossChainParity() external { // ---- Reference chain: Base ---- vm.createSelectFork(LibRainDeploy.BASE); - ChainPrincipals memory basePrincipals = LibChainPrincipals.base(); - address baseClone = LibProdDeployCurrent.STOX_PROD_AUTHORISER_V4_CLONE; + address baseClone = LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_BASE; assertTrue(baseClone != address(0), "Base V4 clone pin still placeholder (stack not yet executed)"); TokenInstance[] memory baseTokens = LibTokenInvariants.productionTokensBase(); // Shared multichain framework: Base satisfies the full production-state - // invariant (Safe identity/config, token owner + authoriser uniformity, - // authoriser grant map) — the same call the Ethereum branch makes below. - LibInvariants.assertProductionState( - IGnosisSafe(basePrincipals.tokenOwnerSafe), baseTokens, baseClone, basePrincipals - ); + // invariant (shared Safe identity/config, token owner + authoriser + // uniformity, shared authoriser grant map) — the same call the Ethereum + // branch makes below. Only the token table + clone address are passed; + // the Safe and grant map are shared across chains. + LibInvariants.assertProductionState(baseTokens, baseClone); assertCloneParity(baseClone); (TokenConfigSnapshot[] memory baseline, address baseBeacon) = assertChainAndSnapshot(baseTokens); // Base's beacon lineage: the receipt-vault beacon must serve the @@ -302,7 +281,6 @@ contract StoxCrossChainParityTest is Test { address baseBeaconOwner = IOwnable(baseBeacon).owner(); // ---- Ethereum ---- - ChainPrincipals memory ethPrincipals = LibChainPrincipals.ethereum(); address ethClone = LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; TokenInstance[] memory ethTokens = LibTokenInvariants.productionTokensEthereum(); @@ -325,24 +303,40 @@ contract StoxCrossChainParityTest is Test { // satisfy the full production-state invariant (its matched-address // Safe policy-aligned to Base, its vaults uniform, its clone grants // in place) before any cross-chain comparison is meaningful. - LibInvariants.assertProductionState( - IGnosisSafe(ethPrincipals.tokenOwnerSafe), ethTokens, ethClone, ethPrincipals - ); + LibInvariants.assertProductionState(ethTokens, ethClone); assertCloneParity(ethClone); (TokenConfigSnapshot[] memory observed, address ethBeacon) = assertChainAndSnapshot(ethTokens); // Layer 3: identical implementation through the beacon, clean V4 - // lineage, principal-mapped beacon owner. + // lineage, shared beacon owner. assertEq( IBeacon(ethBeacon).implementation(), LibProdDeployCurrent.STOX_RECEIPT_VAULT, "Ethereum receipt-vault beacon does not serve the V4 impl" ); assertCleanV4Lineage(ethBeacon); + // The beacon owner is the shared token-owner Safe (same address on + // every chain), so it must be byte-for-byte Base's beacon owner. assertEq( IOwnable(ethBeacon).owner(), - mapPrincipal(baseBeaconOwner, basePrincipals, ethPrincipals), - "Ethereum receipt-vault beacon owner does not map from Base's" + baseBeaconOwner, + "Ethereum receipt-vault beacon owner diverges from Base's shared owner" + ); + + // Cross-chain IMPL CODEHASH parity — the per-chain-unique artifacts + // (authoriser clone address, token addresses) must still resolve to + // the SAME implementations on every chain: + // - the authoriser clone is an EIP-1167 proxy whose runtime embeds + // the impl address, so equal clone codehashes prove equal impls; + // - the receipt-vault beacon serves the deterministic V4 impl (its + // address already asserted equal above), so its codehash matches. + // Both are asserted against Base explicitly here, on top of each + // chain's clone being checked against the shared codehash pin. + assertEq(ethClone.codehash, baseClone.codehash, "authoriser clone impl codehash diverges cross-chain"); + assertEq( + IBeacon(ethBeacon).implementation().codehash, + IBeacon(baseBeacon).implementation().codehash, + "receipt-vault beacon impl codehash diverges cross-chain" ); // Layer 2 cross-chain: identical config per underlying, in From ea18d047fa6ae9260ad587d67f4cd432fdc0c792 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Jul 2026 15:24:25 +0000 Subject: [PATCH 3/7] test(parity): per-chain Safe address + direct cross-chain Safe policy compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token-owner Safe is now a per-chain deploy artifact, so the parity suite: - folds the Safe pin into isFullyPending / isFullyHydrated (a chain is pending until its Safe, clone AND tokens are all pinned); - resolves each chain's Safe inside assertProductionState (by chain id) and, on Ethereum, compares the live Safe's owner SET + threshold DIRECTLY to Base's live Safe (order-insensitive) — matching Base "in every way that matters" against Base's actual current state, not only the shared pins; - corrects the beacon-owner assertion's rationale: the beacon owner is the chain-agnostic deployer (BEACON_INITIAL_OWNER), shared across chains — the Safe owns the vaults, not the beacon. Still red-until-executed at the Base clone placeholder guard (unchanged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr --- .../deploy/StoxCrossChainParity.t.sol | 116 +++++++++++------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol index 9544b001..1d5432d1 100644 --- a/test/src/concrete/deploy/StoxCrossChainParity.t.sol +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -8,11 +8,13 @@ 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 {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; import {LibProdDeployCurrent} from "../../../../src/generated/LibProdDeployCurrent.sol"; import {LibProdAuthoriserClones} from "../../../../src/lib/LibProdAuthoriserClones.sol"; +import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; import {LibTokenInvariants, TokenInstance} from "../../../../src/lib/LibTokenInvariants.sol"; @@ -68,15 +70,20 @@ struct TokenConfigSnapshot { /// 3. **Beacon lineage** — each chain's receipt-vault proxies resolve /// (via the ERC-1967 beacon slot) to a single beacon whose /// `implementation()` is the deterministic V4 receipt vault impl — -/// the SAME address on every chain — and whose `owner()` is the shared -/// token-owner Safe (the same address on every chain). Cross-chain impl -/// codehash parity is asserted explicitly for both the authoriser clone -/// (EIP-1167 over the shared impl) and the receipt-vault beacon impl. -/// 4. **Role parity** — `LibAuthoriserInvariants.assertExpectedGrants` -/// runs against each chain's clone with the SHARED grant map: identical -/// structure AND identical holder addresses on every chain (the token- -/// owner Safe and service signer are shared). Only the clone ADDRESS is -/// per-chain; its grants are not. +/// the SAME address on every chain — and whose `owner()` is the +/// chain-agnostic deployer (`BEACON_INITIAL_OWNER`), the same address on +/// every chain (the beacon is deployer-owned; the token-owner Safe owns +/// the vaults, not the beacon). Cross-chain impl codehash parity is +/// asserted explicitly for both the authoriser clone (EIP-1167 over the +/// shared impl) and the receipt-vault beacon 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 @@ -89,12 +96,13 @@ struct TokenConfigSnapshot { /// leak into expectations for chains that deploy clean. /// /// **Pending-bootstrap chains.** Until a chain's bootstrap executes, its -/// deploy-artifact pins are placeholders (the token-owner Safe + grant map -/// are shared and always concrete — same matched-address Safe + shared -/// signer on every chain; only the clone + token addresses are per-chain). -/// The suite accepts exactly two states per chain: fully PENDING (the -/// authoriser clone pin AND every token entry all placeholder — logged -/// loudly, parity assertions skipped) or fully LIVE (every artifact +/// per-chain deploy-artifact pins are placeholders: the token-owner Safe +/// address, the authoriser clone address, and the token addresses (the Safe +/// POLICY and the service signer are shared, but each chain's Safe is a +/// distinct address deployed and pinned per chain). The suite accepts exactly +/// two states per chain: fully PENDING (the Safe pin, the authoriser clone pin +/// AND every token entry all placeholder — logged loudly, parity assertions +/// skipped) or fully LIVE (every artifact /// hydrated — all layers asserted, including each chain independently /// satisfying `LibInvariants.assertProductionState`). Partial hydration /// fails the suite. @@ -218,19 +226,23 @@ contract StoxCrossChainParityTest is Test { ); } - /// @notice True when the chain is cleanly pre-bootstrap: its authoriser - /// clone pin AND every token-table entry are still `address(0)` - /// placeholders. The principals are not part of this check — they are - /// concrete pins on every chain (the matched-address Safe + shared - /// signer); "bootstrapped or not" is a property of the DEPLOY ARTIFACTS - /// (clone + token addresses), which is exactly what this reads. Any mixed - /// state returns false on both this and the fully-hydrated check, which - /// the test treats as failure. + /// @notice True when the chain is cleanly pre-bootstrap: its token-owner + /// Safe pin, its authoriser clone pin AND every token-table entry are still + /// `address(0)` placeholders. "Bootstrapped or not" is a property of the + /// per-chain DEPLOY ARTIFACTS (Safe + clone + token addresses), which is + /// exactly what this reads; the Safe POLICY and the service signer are + /// shared and always concrete. Any mixed state returns false on both this + /// and the fully-hydrated check, which the test treats as failure. + /// @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 pending Whether the chain is cleanly pre-bootstrap. - function isFullyPending(address clone, TokenInstance[] memory tokens) internal pure returns (bool pending) { - pending = clone == address(0); + function isFullyPending(address safe, address clone, TokenInstance[] memory tokens) + internal + pure + returns (bool pending) + { + pending = safe == address(0) && clone == address(0); for (uint256 i = 0; i < tokens.length; i++) { pending = pending && tokens[i].receipt == address(0) && tokens[i].receiptVault == address(0) && tokens[i].wrappedTokenVault == address(0); @@ -238,12 +250,18 @@ contract StoxCrossChainParityTest is Test { } /// @notice True when every deploy-artifact pin for the chain is - /// hydrated: the authoriser clone AND every token-table entry. + /// hydrated: the token-owner Safe, the authoriser clone AND every + /// token-table entry. + /// @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 hydrated Whether the chain is fully live. - function isFullyHydrated(address clone, TokenInstance[] memory tokens) internal pure returns (bool hydrated) { - hydrated = clone != address(0); + function isFullyHydrated(address safe, address clone, TokenInstance[] memory tokens) + internal + pure + returns (bool hydrated) + { + hydrated = safe != address(0) && clone != address(0); for (uint256 i = 0; i < tokens.length; i++) { hydrated = hydrated && tokens[i].receipt != address(0) && tokens[i].receiptVault != address(0) && tokens[i].wrappedTokenVault != address(0); @@ -261,11 +279,16 @@ contract StoxCrossChainParityTest is Test { assertTrue(baseClone != address(0), "Base V4 clone pin still placeholder (stack not yet executed)"); TokenInstance[] memory baseTokens = LibTokenInvariants.productionTokensBase(); // Shared multichain framework: Base satisfies the full production-state - // invariant (shared Safe identity/config, token owner + authoriser - // uniformity, shared authoriser grant map) — the same call the Ethereum - // branch makes below. Only the token table + clone address are passed; - // the Safe and grant map are shared across chains. + // invariant (per-chain Safe policy-matched to Base, token owner + + // authoriser uniformity, grant map for its Safe) — the same call the + // Ethereum branch makes below. Only the token table + clone address are + // passed; the Safe is resolved per chain by chain id inside the bundle. LibInvariants.assertProductionState(baseTokens, baseClone); + // Capture Base's LIVE Safe policy (owner set + threshold) for the direct + // cross-chain comparison below — the Ethereum Safe is a distinct + // per-chain address that must still carry this exact policy. + address[] memory baseSafeOwners = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE).getOwners(); + uint256 baseSafeThreshold = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE).getThreshold(); assertCloneParity(baseClone); (TokenConfigSnapshot[] memory baseline, address baseBeacon) = assertChainAndSnapshot(baseTokens); // Base's beacon lineage: the receipt-vault beacon must serve the @@ -281,29 +304,36 @@ contract StoxCrossChainParityTest is Test { address baseBeaconOwner = IOwnable(baseBeacon).owner(); // ---- Ethereum ---- + address ethSafe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM; address ethClone = LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; TokenInstance[] memory ethTokens = LibTokenInvariants.productionTokensEthereum(); - if (isFullyPending(ethClone, ethTokens)) { + if (isFullyPending(ethSafe, ethClone, ethTokens)) { // Cleanly pre-bootstrap: nothing to check on-chain yet. Logged // loudly (not a silent skip) so a green run cannot be misread // as "Ethereum verified". - emit log("PARITY: Ethereum PENDING bootstrap - clone + token pins placeholder, parity assertions skipped"); + emit log("PARITY: Ethereum PENDING bootstrap - Safe + clone + token pins placeholder, parity assertions skipped"); return; } // Not fully pending => must be fully hydrated. Partial hydration // is the dangerous middle state this assertion exists to reject. assertTrue( - isFullyHydrated(ethClone, ethTokens), - "Ethereum pins partially hydrated - hydrate the clone pin and the full token table in one pin PR" + isFullyHydrated(ethSafe, ethClone, ethTokens), + "Ethereum pins partially hydrated - hydrate the Safe, the clone pin and the full token table in one pin PR" ); vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); // Same shared framework call as Base: Ethereum must independently - // satisfy the full production-state invariant (its matched-address - // Safe policy-aligned to Base, its vaults uniform, its clone grants - // in place) before any cross-chain comparison is meaningful. + // satisfy the full production-state invariant (its per-chain Safe + // policy-matched to Base, its vaults uniform, its clone grants in + // place) before any cross-chain comparison is meaningful. LibInvariants.assertProductionState(ethTokens, ethClone); + // Direct cross-chain Safe policy: the Ethereum Safe is a DISTINCT + // per-chain address, but its live owner SET and threshold must equal + // Base's LIVE Safe (order-insensitive) — matching Base in every way + // that matters, against Base's actual current state, not only the pins. + LibSafeInvariants.assertThreshold(IGnosisSafe(ethSafe), baseSafeThreshold); + LibSafeInvariants.assertOwnerSetUnordered(IGnosisSafe(ethSafe), baseSafeOwners); assertCloneParity(ethClone); (TokenConfigSnapshot[] memory observed, address ethBeacon) = assertChainAndSnapshot(ethTokens); @@ -315,12 +345,14 @@ contract StoxCrossChainParityTest is Test { "Ethereum receipt-vault beacon does not serve the V4 impl" ); assertCleanV4Lineage(ethBeacon); - // The beacon owner is the shared token-owner Safe (same address on - // every chain), so it must be byte-for-byte Base's beacon owner. + // The receipt-vault beacon owner is the chain-agnostic deployer + // (`BEACON_INITIAL_OWNER`), the same address on every chain — NOT the + // token-owner Safe (which owns the vaults, and is now per-chain). So it + // must be byte-for-byte Base's beacon owner. assertEq( IOwnable(ethBeacon).owner(), baseBeaconOwner, - "Ethereum receipt-vault beacon owner diverges from Base's shared owner" + "Ethereum receipt-vault beacon owner diverges from the shared deployer" ); // Cross-chain IMPL CODEHASH parity — the per-chain-unique artifacts From 93121ad51ab12270f8d911cffc5e9467f12e53b9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 16 Jul 2026 09:02:14 +0000 Subject: [PATCH 4/7] test(parity): per-leg placeholder gating so the stack merges green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the all-or-nothing isFullyPending/isFullyHydrated (and the hard "Base clone placeholder" red guard) with nested per-artifact gating: each chain asserts only the legs whose pins are live and skips placeholder legs with a loud PARITY PENDING log; cross-chain comparisons gate on both sides carrying the leg. Legs nest by dependency: - Safe leg (Safe): policy matches Base. - Authoriser leg (clone; grant map also needs Safe): clone codehash + grant map — assertable as soon as the clone is up, NOT blocked on tokens. - Token leg (Safe + clone + full table): ownership + sole authoriser + config. So the whole multichain stack goes green before any chain is bootstrapped, and each pin PR turns its leg (and cross-chain comparison) on. A PARTIALLY hydrated token table is the one hard failure (operator error). Verified: parity passes green with PENDING logs for Base clone + Ethereum Safe/clone. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr --- .../deploy/StoxCrossChainParity.t.sol | 353 ++++++++++-------- 1 file changed, 196 insertions(+), 157 deletions(-) diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol index 1d5432d1..a52e180a 100644 --- a/test/src/concrete/deploy/StoxCrossChainParity.t.sol +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -10,7 +10,7 @@ import {ERC1967Utils} from "@openzeppelin-contracts-5.6.1/proxy/ERC1967/ERC1967U 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 {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; +import {LibAuthoriserInvariants} from "../../../../src/lib/LibAuthoriserInvariants.sol"; import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; import {LibProdDeployCurrent} from "../../../../src/generated/LibProdDeployCurrent.sol"; import {LibProdAuthoriserClones} from "../../../../src/lib/LibProdAuthoriserClones.sol"; @@ -38,6 +38,34 @@ struct TokenConfigSnapshot { 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`). +struct ChainLegs { + bool safeLive; + address[] owners; + uint256 threshold; + bool cloneLive; + bytes32 cloneCodehash; + bool tokenLegLive; + TokenConfigSnapshot[] tokenConfigs; + address beaconImpl; + bytes32 beaconImplCodehash; + address beaconOwner; +} + /// @title StoxCrossChainParityTest /// @notice The cross-chain deployment-parity pin (RAI-1097): an automated /// invariant asserting every ST0x chain carries an IDENTICAL deployment — @@ -95,17 +123,21 @@ struct TokenConfigSnapshot { /// address, so the corrupted artifacts can neither mask drift on Base nor /// leak into expectations for chains that deploy clean. /// -/// **Pending-bootstrap chains.** Until a chain's bootstrap executes, its -/// per-chain deploy-artifact pins are placeholders: the token-owner Safe -/// address, the authoriser clone address, and the token addresses (the Safe -/// POLICY and the service signer are shared, but each chain's Safe is a -/// distinct address deployed and pinned per chain). The suite accepts exactly -/// two states per chain: fully PENDING (the Safe pin, the authoriser clone pin -/// AND every token entry all placeholder — logged loudly, parity assertions -/// skipped) or fully LIVE (every artifact -/// hydrated — all layers asserted, including each chain independently -/// satisfying `LibInvariants.assertProductionState`). Partial hydration -/// fails the suite. +/// **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. @@ -118,11 +150,11 @@ contract StoxCrossChainParityTest is Test { /// 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 + authoriser checks are NOT here — they are - /// asserted through `LibInvariants.assertProductionState` (the shared - /// multichain framework) in `testCrossChainParity`, so each chain first - /// satisfies the same production-state invariant Base does, and this - /// function adds only the cross-chain-comparison scaffolding on top. + /// @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 @@ -226,171 +258,178 @@ contract StoxCrossChainParityTest is Test { ); } - /// @notice True when the chain is cleanly pre-bootstrap: its token-owner - /// Safe pin, its authoriser clone pin AND every token-table entry are still - /// `address(0)` placeholders. "Bootstrapped or not" is a property of the - /// per-chain DEPLOY ARTIFACTS (Safe + clone + token addresses), which is - /// exactly what this reads; the Safe POLICY and the service signer are - /// shared and always concrete. Any mixed state returns false on both this - /// and the fully-hydrated check, which the test treats as failure. - /// @param safe The chain's token-owner Safe pin. - /// @param clone The chain's authoriser clone pin. + /// @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 pending Whether the chain is cleanly pre-bootstrap. - function isFullyPending(address safe, address clone, TokenInstance[] memory tokens) - internal - pure - returns (bool pending) - { - pending = safe == address(0) && clone == address(0); + /// @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++) { - pending = pending && tokens[i].receipt == address(0) && tokens[i].receiptVault == address(0) + 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 True when every deploy-artifact pin for the chain is - /// hydrated: the token-owner Safe, the authoriser clone AND every - /// token-table entry. + /// @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 hydrated Whether the chain is fully live. - function isFullyHydrated(address safe, address clone, TokenInstance[] memory tokens) + /// @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 - pure - returns (bool hydrated) + returns (ChainLegs memory legs) { - hydrated = safe != address(0) && clone != address(0); - for (uint256 i = 0; i < tokens.length; i++) { - hydrated = hydrated && tokens[i].receipt != address(0) && tokens[i].receiptVault != address(0) - && tokens[i].wrappedTokenVault != address(0); + // --- Safe leg (needs: Safe) --- + legs.safeLive = safe != address(0); + if (legs.safeLive) { + LibSafeInvariants.assertPolicyMatchesBase(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; + (legs.tokenConfigs, beacon) = assertChainAndSnapshot(tokens); + assertEq( + IBeacon(beacon).implementation(), + LibProdDeployCurrent.STOX_RECEIPT_VAULT, + string.concat(label, " receipt-vault beacon does not serve the V4 impl") + ); + assertCleanV4Lineage(beacon); + legs.beaconImpl = IBeacon(beacon).implementation(); + legs.beaconImplCodehash = legs.beaconImpl.codehash; + legs.beaconOwner = IOwnable(beacon).owner(); + } else if (legs.safeLive && legs.cloneLive) { + emit log(string.concat("PARITY PENDING: ", label, " token table placeholder - token leg skipped")); } } - /// @notice The full cross-chain parity pin: snapshot Base (asserting - /// its own uniformity + policy state as it goes), then assert every - /// other supported chain against the snapshot, or assert it is cleanly - /// pending bootstrap. + /// @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 { - // ---- Reference chain: Base ---- vm.createSelectFork(LibRainDeploy.BASE); - address baseClone = LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_BASE; - assertTrue(baseClone != address(0), "Base V4 clone pin still placeholder (stack not yet executed)"); - TokenInstance[] memory baseTokens = LibTokenInvariants.productionTokensBase(); - // Shared multichain framework: Base satisfies the full production-state - // invariant (per-chain Safe policy-matched to Base, token owner + - // authoriser uniformity, grant map for its Safe) — the same call the - // Ethereum branch makes below. Only the token table + clone address are - // passed; the Safe is resolved per chain by chain id inside the bundle. - LibInvariants.assertProductionState(baseTokens, baseClone); - // Capture Base's LIVE Safe policy (owner set + threshold) for the direct - // cross-chain comparison below — the Ethereum Safe is a distinct - // per-chain address that must still carry this exact policy. - address[] memory baseSafeOwners = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE).getOwners(); - uint256 baseSafeThreshold = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE).getThreshold(); - assertCloneParity(baseClone); - (TokenConfigSnapshot[] memory baseline, address baseBeacon) = assertChainAndSnapshot(baseTokens); - // Base's beacon lineage: the receipt-vault beacon must serve the - // deterministic V4 impl post-upgrade. (Base's beacon ADDRESS is the - // healthy V1-era beacon — upgraded in place — which is exactly why - // implementation parity is asserted through the beacon rather than - // by comparing proxy codehashes across chains.) - assertEq( - IBeacon(baseBeacon).implementation(), - LibProdDeployCurrent.STOX_RECEIPT_VAULT, - "Base receipt-vault beacon does not serve the V4 impl" + ChainLegs memory base = assertChainLegs( + "Base", + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, + LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_BASE, + LibTokenInvariants.productionTokensBase() ); - address baseBeaconOwner = IOwnable(baseBeacon).owner(); - - // ---- Ethereum ---- - address ethSafe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM; - address ethClone = LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; - TokenInstance[] memory ethTokens = LibTokenInvariants.productionTokensEthereum(); - if (isFullyPending(ethSafe, ethClone, ethTokens)) { - // Cleanly pre-bootstrap: nothing to check on-chain yet. Logged - // loudly (not a silent skip) so a green run cannot be misread - // as "Ethereum verified". - emit log("PARITY: Ethereum PENDING bootstrap - Safe + clone + token pins placeholder, parity assertions skipped"); - return; - } - // Not fully pending => must be fully hydrated. Partial hydration - // is the dangerous middle state this assertion exists to reject. - assertTrue( - isFullyHydrated(ethSafe, ethClone, ethTokens), - "Ethereum pins partially hydrated - hydrate the Safe, the clone pin and the full token table in one pin PR" + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + ChainLegs memory eth = assertChainLegs( + "Ethereum", + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM, + LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, + LibTokenInvariants.productionTokensEthereum() ); - vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); - // Same shared framework call as Base: Ethereum must independently - // satisfy the full production-state invariant (its per-chain Safe - // policy-matched to Base, its vaults uniform, its clone grants in - // place) before any cross-chain comparison is meaningful. - LibInvariants.assertProductionState(ethTokens, ethClone); - // Direct cross-chain Safe policy: the Ethereum Safe is a DISTINCT - // per-chain address, but its live owner SET and threshold must equal - // Base's LIVE Safe (order-insensitive) — matching Base in every way - // that matters, against Base's actual current state, not only the pins. - LibSafeInvariants.assertThreshold(IGnosisSafe(ethSafe), baseSafeThreshold); - LibSafeInvariants.assertOwnerSetUnordered(IGnosisSafe(ethSafe), baseSafeOwners); - assertCloneParity(ethClone); - (TokenConfigSnapshot[] memory observed, address ethBeacon) = assertChainAndSnapshot(ethTokens); + // ---- Cross-chain comparisons, each gated on both sides being live ---- - // Layer 3: identical implementation through the beacon, clean V4 - // lineage, shared beacon owner. - assertEq( - IBeacon(ethBeacon).implementation(), - LibProdDeployCurrent.STOX_RECEIPT_VAULT, - "Ethereum receipt-vault beacon does not serve the V4 impl" - ); - assertCleanV4Lineage(ethBeacon); - // The receipt-vault beacon owner is the chain-agnostic deployer - // (`BEACON_INITIAL_OWNER`), the same address on every chain — NOT the - // token-owner Safe (which owns the vaults, and is now per-chain). So it - // must be byte-for-byte Base's beacon owner. - assertEq( - IOwnable(ethBeacon).owner(), - baseBeaconOwner, - "Ethereum receipt-vault beacon owner diverges from the shared deployer" - ); + // 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); + } - // Cross-chain IMPL CODEHASH parity — the per-chain-unique artifacts - // (authoriser clone address, token addresses) must still resolve to - // the SAME implementations on every chain: - // - the authoriser clone is an EIP-1167 proxy whose runtime embeds - // the impl address, so equal clone codehashes prove equal impls; - // - the receipt-vault beacon serves the deterministic V4 impl (its - // address already asserted equal above), so its codehash matches. - // Both are asserted against Base explicitly here, on top of each - // chain's clone being checked against the shared codehash pin. - assertEq(ethClone.codehash, baseClone.codehash, "authoriser clone impl codehash diverges cross-chain"); - assertEq( - IBeacon(ethBeacon).implementation().codehash, - IBeacon(baseBeacon).implementation().codehash, - "receipt-vault beacon impl codehash diverges cross-chain" - ); + // 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"); + } - // Layer 2 cross-chain: identical config per underlying, in - // identical table order. - assertEq(baseline.length, observed.length, "token table lengths diverge"); - for (uint256 i = 0; i < baseline.length; i++) { - assertEq(observed[i].underlying, baseline[i].underlying, "token table underlying order diverges"); - string memory key = baseline[i].underlying; - assertEq(observed[i].vaultName, baseline[i].vaultName, string.concat(key, ": vault name diverges")); - assertEq(observed[i].vaultSymbol, baseline[i].vaultSymbol, string.concat(key, ": vault symbol diverges")); + // 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( - observed[i].vaultDecimals, baseline[i].vaultDecimals, string.concat(key, ": vault decimals diverge") - ); - assertEq(observed[i].wrappedName, baseline[i].wrappedName, string.concat(key, ": wrapped name diverges")); - assertEq( - observed[i].wrappedSymbol, baseline[i].wrappedSymbol, string.concat(key, ": wrapped symbol diverges") - ); - assertEq( - observed[i].wrappedDecimals, - baseline[i].wrappedDecimals, - string.concat(key, ": wrapped decimals diverge") + eth.beaconImplCodehash, + base.beaconImplCodehash, + "receipt-vault beacon impl codehash diverges cross-chain" ); + assertEq(eth.beaconOwner, base.beaconOwner, "receipt-vault beacon owner 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") + ); + } } } } From 4195dec5a6fcc5bd331118ff906b7bd7e48efc01 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 16 Jul 2026 09:05:43 +0000 Subject: [PATCH 5/7] refactor(tokens): explicit Ethereum token table (consistent formatting w/ Base) Write productionTokensEthereum() as explicit per-token rows mirroring productionTokensBase() (same underlyings, same order, all addresses address(0) placeholders) instead of a derive-from-Base loop. Consistent cross-chain formatting, and the token pin PR becomes a clean per-row literal swap. Add a pure guard test that the two tables stay row-aligned on the underlying key (the derivation guarantee is gone now that the table is explicit). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr --- .github/workflows/rainix-sol-scheduled.yaml | 2 +- src/lib/LibProdTokenConfig.sol | 79 ++++++++ src/lib/LibTokenInvariants.sol | 41 ++++- .../deploy/StoxCrossChainParity.t.sol | 169 +++++++++++++++--- test/src/lib/LibProdTokenConfig.t.sol | 75 ++++++++ test/src/lib/LibTokenInvariants.t.sol | 21 ++- 6 files changed, 351 insertions(+), 36 deletions(-) create mode 100644 src/lib/LibProdTokenConfig.sol create mode 100644 test/src/lib/LibProdTokenConfig.t.sol diff --git a/.github/workflows/rainix-sol-scheduled.yaml b/.github/workflows/rainix-sol-scheduled.yaml index 9f206ce5..2611e50f 100644 --- a/.github/workflows/rainix-sol-scheduled.yaml +++ b/.github/workflows/rainix-sol-scheduled.yaml @@ -1,5 +1,5 @@ # Scheduled re-run of the full sol suite (including every fork test and the -# cross-chain parity pin, RAI-1097). Push-triggered CI only catches drift +# 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 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 1ea3aa68..00b5f381 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -335,11 +335,42 @@ library LibTokenInvariants { /// parity suite rejects rather than half-checks. /// @return tokens The 28 production token instances on Ethereum. function productionTokensEthereum() internal pure returns (TokenInstance[] memory tokens) { - TokenInstance[] memory baseTokens = productionTokensBase(); - tokens = new TokenInstance[](baseTokens.length); - for (uint256 i = 0; i < baseTokens.length; i++) { - tokens[i] = TokenInstance(baseTokens[i].underlying, address(0), address(0), address(0)); - } + // 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 diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol index a52e180a..9e1773f7 100644 --- a/test/src/concrete/deploy/StoxCrossChainParity.t.sol +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -12,15 +12,33 @@ 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 {LibProdDeployCurrent} from "../../../../src/generated/LibProdDeployCurrent.sol"; -import {LibProdAuthoriserClones} from "../../../../src/lib/LibProdAuthoriserClones.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 the baseline chain (Base) fork, then compared -/// field-by-field on every other chain's fork. +/// 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()`. @@ -53,6 +71,10 @@ struct TokenConfigSnapshot { /// `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; @@ -63,11 +85,12 @@ struct ChainLegs { TokenConfigSnapshot[] tokenConfigs; address beaconImpl; bytes32 beaconImplCodehash; - address beaconOwner; + address receiptBeaconImpl; + bytes32 receiptBeaconImplCodehash; } /// @title StoxCrossChainParityTest -/// @notice The cross-chain deployment-parity pin (RAI-1097): an automated +/// @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 @@ -85,8 +108,9 @@ struct ChainLegs { /// 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` / `decimals` of both vault legs equal the -/// Base baseline values (matched names / symbols by construction); +/// 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 @@ -95,15 +119,15 @@ struct ChainLegs { /// 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-vault proxies resolve -/// (via the ERC-1967 beacon slot) to a single beacon whose -/// `implementation()` is the deterministic V4 receipt vault impl — -/// the SAME address on every chain — and whose `owner()` is the -/// chain-agnostic deployer (`BEACON_INITIAL_OWNER`), the same address on -/// every chain (the beacon is deployer-owned; the token-owner Safe owns -/// the vaults, not the beacon). Cross-chain impl codehash parity is -/// asserted explicitly for both the authoriser clone (EIP-1167 over the -/// shared impl) and the receipt-vault beacon impl. +/// 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 @@ -159,23 +183,35 @@ contract StoxCrossChainParityTest is Test { /// @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) + 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]; - // Config via view calls — covers matched names / symbols / - // decimals once compared against the baseline snapshot. + // 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(), @@ -186,6 +222,20 @@ contract StoxCrossChainParityTest is Test { 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(), @@ -211,6 +261,32 @@ contract StoxCrossChainParityTest is Test { 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") + ); } } @@ -226,7 +302,7 @@ contract StoxCrossChainParityTest is Test { assertTrue(clone.code.length > 0, "V4 authoriser clone not deployed"); assertEq( clone.codehash, - LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, "V4 authoriser clone codehash mismatch (shared EIP-1167 pin)" ); } @@ -320,7 +396,7 @@ contract StoxCrossChainParityTest is Test { // --- Safe leg (needs: Safe) --- legs.safeLive = safe != address(0); if (legs.safeLive) { - LibSafeInvariants.assertPolicyMatchesBase(IGnosisSafe(safe)); + LibSafeInvariants.assertTokenOwnerSafePolicy(IGnosisSafe(safe)); legs.owners = IGnosisSafe(safe).getOwners(); legs.threshold = IGnosisSafe(safe).getThreshold(); } else { @@ -351,16 +427,46 @@ contract StoxCrossChainParityTest is Test { // Ownership (Safe) + sole authoriser (clone) across every vault. LibTokenInvariants.assertAll(tokens, safe, clone); address beacon; - (legs.tokenConfigs, beacon) = assertChainAndSnapshot(tokens); + 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(), - LibProdDeployCurrent.STOX_RECEIPT_VAULT, - string.concat(label, " receipt-vault beacon does not serve the V4 impl") + 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.beaconOwner = IOwnable(beacon).owner(); + 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")); } @@ -376,7 +482,7 @@ contract StoxCrossChainParityTest is Test { ChainLegs memory base = assertChainLegs( "Base", LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, - LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_BASE, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, LibTokenInvariants.productionTokensBase() ); @@ -384,7 +490,7 @@ contract StoxCrossChainParityTest is Test { ChainLegs memory eth = assertChainLegs( "Ethereum", LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM, - LibProdAuthoriserClones.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, LibTokenInvariants.productionTokensEthereum() ); @@ -414,7 +520,12 @@ contract StoxCrossChainParityTest is Test { base.beaconImplCodehash, "receipt-vault beacon impl codehash diverges cross-chain" ); - assertEq(eth.beaconOwner, base.beaconOwner, "receipt-vault beacon owner 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++) { 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"); + } + } } From e822482a2cb821e4464067fe939cdc0675f13f3d Mon Sep 17 00:00:00 2001 From: David Meister Date: Wed, 22 Jul 2026 21:16:43 +0000 Subject: [PATCH 6/7] Make the parity suite prove it ran Every cross-chain comparison 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. Base is fully bootstrapped, so a pending leg there is not a pending bootstrap -- it is the placeholder detection reading a live pin as a placeholder, which silently disables the whole test. Asserted directly. Past the deadline a still-pending Ethereum leg stops being "not yet" and becomes a chain nothing asserts anything about, so it forces the same operator choice as the beacon-owner migration pin: land the pins, move the deadline, or delete the invariant deliberately. Co-Authored-By: Claude Opus 4.8 --- .../deploy/StoxCrossChainParity.t.sol | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol index 9e1773f7..0e4150b4 100644 --- a/test/src/concrete/deploy/StoxCrossChainParity.t.sol +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -372,6 +372,14 @@ contract StoxCrossChainParityTest is Test { } } + /// @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 @@ -542,5 +550,31 @@ contract StoxCrossChainParityTest is Test { ); } } + + // ---- 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"); + } } } From f2db6ded9e0a973af592dcbf5b3a283e744cb99b Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 23 Jul 2026 04:14:43 +0000 Subject: [PATCH 7/7] Forward the Ethereum fork RPC to the scheduled run The schedule exists to catch on-chain drift between pushes, and the parity suite it runs forks Ethereum. Without this secret the daily job cannot reach the chain the check is about, so the one thing the schedule was added for is the one thing it could not do. The push-based caller in this repo already forwards all six. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/rainix-sol-scheduled.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rainix-sol-scheduled.yaml b/.github/workflows/rainix-sol-scheduled.yaml index 2611e50f..a20e4d6b 100644 --- a/.github/workflows/rainix-sol-scheduled.yaml +++ b/.github/workflows/rainix-sol-scheduled.yaml @@ -19,5 +19,6 @@ jobs: 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 }}