diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index aa741360..8fbda308 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -34,6 +34,7 @@ on: - 20260813-timelock-rehearsal-schedule - 20260813-timelock-rehearsal-cancel - 20260831-enable-orchestrator-roles + - 20260825-upgrade-fleet-to-0-1-30 network: description: 'Network to author against (default: base)' required: true diff --git a/script/20260825-upgrade-fleet-to-0-1-30.s.sol b/script/20260825-upgrade-fleet-to-0-1-30.s.sol new file mode 100644 index 00000000..23e8e81b --- /dev/null +++ b/script/20260825-upgrade-fleet-to-0-1-30.s.sol @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; + +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibBeaconInvariants} from "../src/lib/LibBeaconInvariants.sol"; +import {LibTokenInvariants, TokenInstance} from "../src/lib/LibTokenInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; + +/// @dev Unix timestamp (2026-10-01T00:00:00Z) by which the fleet upgrade +/// must have executed on every chain. Shared by the migration-window +/// invariants that accept either the 0.1.1 or 0.1.30 implementation until +/// then; the orchestrator rollout shares the same date — this upgrade gates +/// its cutover. +uint256 constant FLEET_UPGRADE_DEADLINE = 1_790_812_800; + +/// @notice A 0.1.30 implementation this upgrade would repoint a beacon to +/// has no runtime code (or the wrong codehash) on the active chain. Ship the +/// audited closure via `manual-sol-artifacts-0-1-30.yaml` first. +/// @param impl The pinned 0.1.30 implementation inspected. +error UpgradeTargetNotDeployed(address impl); + +/// @notice A production beacon is neither in the pre-upgrade (0.1.1) nor the +/// post-upgrade (0.1.30) implementation state — unknown drift; resolve +/// manually rather than upgrading over it. +/// @param beacon The beacon inspected. +/// @param actualImpl The implementation it points at. +error BeaconInUnknownState(address beacon, address actualImpl); + +/// @notice Both gated beacons already serve the 0.1.30 implementations — +/// the fleet upgrade has executed on this chain and a re-dispatch has +/// nothing to author. +error FleetAlreadyUpgraded(); + +/// @notice A production token's reported state changed across the simulated +/// upgrade. The 0.1.30 receipt-vault impl (the H01 remediation) must leave +/// every reported balance and supply exactly as it was. +/// @param receiptVault The vault whose reads drifted. +error UpgradeChangedTokenState(address receiptVault); + +/// @title UpgradeFleetTo0_1_30 +/// @notice **PENDING.** Authors the per-chain Safe Tx Builder bundle that +/// upgrades the production token fleet to the audited 0.1.30 +/// implementations — one atomic MultiSend repointing the chain's IN-USE +/// receipt beacon and receipt-vault beacon (`upgradeTo`) from the audited +/// 0.1.1 impls to the audited 0.1.30 impls. Every production token is a +/// proxy of these two beacons, so the whole fleet moves in one batch: this +/// is the H01 remediation going live, and the state the orchestrator +/// cutover (`20260825-cutover-orchestrator-roles`) hard-gates on. +/// +/// The wrapped-token-vault beacon is deliberately NOT repointed: the 0.1.30 +/// WTV bytecode differs only by the optimizer change (no behavioural delta, +/// no audit finding), and the coordinated-release note in `CHANGELOG.md` +/// scopes the lockstep set to the receipt vault (the fix), the +/// corporate-actions facet (baked into the new vault) and the receipt (the +/// orchestrator's lockstep partner). Repointing WTV is a separate decision. +/// +/// @dev Dispatch via `Actions → run-script` with +/// `script = 20260825-upgrade-fleet-to-0-1-30` per chain; sign and execute +/// the artifact in the Safe UI (the in-use beacons are owned by each +/// chain's token-owner Safe). Dispatch the three chains in ONE operational +/// window — cross-chain parity is red in between, by design. +/// +/// Pre-flight: the 0.1.30 receipt, receipt-vault and corporate-actions +/// facet must be live at their pins by codehash (the new vault's +/// `fallback()` delegatecalls the new facet — a code-less facet silently +/// no-ops); each gated beacon must be exactly in the 0.1.1 state (or +/// already upgraded — self-scoped); the Safe must own both beacons. The +/// simulation then proves, for EVERY production token on the chain, that +/// `totalSupply` / `symbol` / `decimals` read identically across the +/// upgrade — the H01 fix changes internal accounting, never reported state. +/// +/// The post-execution pin PR flips `LibProdBeaconsBase` / +/// `LibProdBeacons0_1_1`'s `implementations()` (and the fork asserts riding +/// them) to the 0.1.30 pins and retires this script's fixtures. +contract UpgradeFleetTo0_1_30 is Script { + /// @notice Signer-visible `meta.name` for the emitted bundle. + string internal constant BUNDLE_NAME = "ST0x fleet upgrade: receipt + receipt-vault beacons to audited 0.1.30"; + + /// @notice Chain-suffixed artifact path (three chains dispatch in one + /// window; a shared path would let bundles overwrite each other). + /// @return path The artifact path for the ACTIVE chain. + function artifactPath() internal view virtual returns (string memory path) { + path = string.concat("out/20260825-upgrade-fleet-to-0-1-30-", vm.toString(block.chainid), ".json"); + } + + /// @notice Assert one 0.1.30 upgrade target is live at its pin with the + /// audited codehash. + /// @param impl The pinned implementation address. + /// @param codehash The pinned 0.1.30 codehash. + function assertTargetDeployed(address impl, bytes32 codehash) internal view { + if (impl.code.length == 0 || impl.codehash != codehash) { + revert UpgradeTargetNotDeployed(impl); + } + } + + /// @notice Pre-flight the beacons and self-scope the bundle: one + /// `upgradeTo` per gated beacon still serving 0.1.1. A beacon in + /// neither state is unknown drift; both already at 0.1.30 refuses. + /// @param beacons The chain's in-use beacons (receipt, receipt vault, + /// wrapped token vault) — index order pinned by `LibProdBeacons*`. + /// @return txs The self-scoped upgrade transactions. + function authorBundle(address[3] memory beacons) internal view returns (SafeTx[] memory txs) { + address[2] memory gated = [ + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX] + ]; + address[2] memory pre = [LibProdDeployV4.STOX_RECEIPT_0_1_1, LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1]; + address[2] memory post = [LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30]; + + SafeTx[] memory candidates = new SafeTx[](2); + uint256 count = 0; + for (uint256 i = 0; i < gated.length; i++) { + address actual = IBeacon(gated[i]).implementation(); + if (actual == post[i]) { + continue; + } + if (actual != pre[i]) { + revert BeaconInUnknownState(gated[i], actual); + } + candidates[count++] = SafeTx({ + to: gated[i], value: 0, data: abi.encodeCall(IUpgradeableBeaconLike.upgradeTo, (post[i])), operation: 0 + }); + } + if (count == 0) { + revert FleetAlreadyUpgraded(); + } + txs = new SafeTx[](count); + for (uint256 i = 0; i < count; i++) { + txs[i] = candidates[i]; + } + } + + /// @notice The active chain's production token table (empty tables are + /// valid — a chain carrying no tokens yet has nothing to snapshot). + /// @return tokens The chain's token instances. + function activeChainTokens() internal view returns (TokenInstance[] memory tokens) { + if (block.chainid == LibSafeInvariants.BASE_CHAIN_ID) { + return LibTokenInvariants.productionTokensBase(); + } + if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + return LibTokenInvariants.productionTokensEthereum(); + } + return LibTokenInvariants.productionTokensHyperEvm(); + } + + /// @notice Snapshot every production token's reported state (receipt + /// vault `totalSupply`, `symbol` hash, `decimals`) so the post-upgrade + /// reads can be proven identical. + /// @param tokens The chain's token instances. + /// @return supplies Each vault's `totalSupply`. + /// @return metaHashes keccak of each vault's `symbol` + `decimals`. + function snapshotTokenState(TokenInstance[] memory tokens) + internal + view + returns (uint256[] memory supplies, bytes32[] memory metaHashes) + { + supplies = new uint256[](tokens.length); + metaHashes = new bytes32[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + IERC20Metadata vault = IERC20Metadata(tokens[i].receiptVault); + supplies[i] = vault.totalSupply(); + metaHashes[i] = keccak256(abi.encode(vault.symbol(), vault.decimals())); + } + } + + /// @notice Assert every token's reported state is unchanged against the + /// pre-upgrade snapshot — the H01 fix must be invisible in reads. + /// @param tokens The chain's token instances. + /// @param supplies The pre-upgrade `totalSupply` snapshot. + /// @param metaHashes The pre-upgrade metadata snapshot. + function assertTokenStatePreserved( + TokenInstance[] memory tokens, + uint256[] memory supplies, + bytes32[] memory metaHashes + ) internal view { + for (uint256 i = 0; i < tokens.length; i++) { + IERC20Metadata vault = IERC20Metadata(tokens[i].receiptVault); + if ( + vault.totalSupply() != supplies[i] + || keccak256(abi.encode(vault.symbol(), vault.decimals())) != metaHashes[i] + ) { + revert UpgradeChangedTokenState(tokens[i].receiptVault); + } + } + } + + /// @notice Author the fleet-upgrade bundle for the active chain: see + /// the contract-level flow. Does not broadcast — execution happens via + /// the Safe UI using the emitted artifact. + function run() external { + // --- Pre-flight --------------------------------------------------- + + address safeAddr = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + IGnosisSafe safe = IGnosisSafe(safeAddr); + + // The audited 0.1.30 targets (and the facet the new vault + // delegatecalls) must be live at their pins by codehash. + assertTargetDeployed(LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_CODEHASH_0_1_30); + assertTargetDeployed( + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_30 + ); + assertTargetDeployed( + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_30, + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_CODEHASH_0_1_30 + ); + + // The in-use beacons are deployed, OZ bytecode, Safe-owned. + LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + + // --- Build the bundle ---------------------------------------------- + + SafeTx[] memory txs = authorBundle(beacons); + + uint256 nonce = safe.nonce(); + bytes32 bundleSafeTxHash = LibSafeOps.computeMultiSendSafeTxHash(safe, txs, nonce); + + // --- Simulate, proving token-state preservation -------------------- + + TokenInstance[] memory tokens = activeChainTokens(); + (uint256[] memory supplies, bytes32[] memory metaHashes) = snapshotTokenState(tokens); + + for (uint256 i = 0; i < txs.length; i++) { + LibSafeOps.simulateExternalCall(safe, txs[i].to, txs[i].data); + } + + // --- Post-state --------------------------------------------------- + + require( + IBeacon(beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX]).implementation() + == LibProdDeployV4.STOX_RECEIPT_0_1_30 + && IBeacon(beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX]).implementation() + == LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, + "UpgradeFleetTo0_1_30: beacons did not land on the 0.1.30 impls" + ); + assertTokenStatePreserved(tokens, supplies, metaHashes); + LibSafeInvariants.assertImmutableInvariants(safe); + LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + + // --- Artifact ----------------------------------------------------- + + string memory json = LibSafeOps.emitTxBuilderJson(safeAddr, block.chainid, BUNDLE_NAME, txs); + vm.writeFile(artifactPath(), json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("Bundle MultiSend SafeTxHash:", vm.toString(bundleSafeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Bundle item count:", txs.length); + console2.log("Chain:", block.chainid); + console2.log("Tokens proven state-preserving across the upgrade:", tokens.length); + + // --- n+1 reversal proof -------------------------------------------- + + // The upgrade is reversible while the Safe owns the beacons: prove a + // downgrade back to 0.1.1 clears the live threshold, then re-upgrade + // so the fork ends on 0.1.30. + LibSafeOps.simulateNPlus1( + safe, + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], + abi.encodeCall(IUpgradeableBeaconLike.upgradeTo, (LibProdDeployV4.STOX_RECEIPT_0_1_1)), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD + ); + require( + IBeacon(beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX]).implementation() + == LibProdDeployV4.STOX_RECEIPT_0_1_1, + "UpgradeFleetTo0_1_30: n+1 downgrade did not land" + ); + LibSafeOps.simulateExternalCall( + safe, + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], + abi.encodeCall(IUpgradeableBeaconLike.upgradeTo, (LibProdDeployV4.STOX_RECEIPT_0_1_30)) + ); + console2.log("n+1 reversal check passed: the Safe can downgrade (and re-upgrade) under the live threshold"); + } + + /// @notice Signer-side integrity check for a CI-authored upgrade + /// artifact, run LOCALLY against a live fork before signing. Deliberately + /// NOT in the run-script dispatcher: it takes a local path and runs on + /// the signer's machine. + /// @param jsonPath Filesystem path to the downloaded Tx Builder JSON. + function verify(string calldata jsonPath) external view { + address safeAddr = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + IGnosisSafe safe = IGnosisSafe(safeAddr); + LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); + + SafeTx[] memory expected = authorBundle(LibBeaconInvariants.prodBeaconsForChainId(block.chainid)); + LibSafeOps.assertParsedTxsMatch(expected, jsonPath); + + uint256 nonce = safe.nonce(); + console2.log("Artifact verified against live state."); + console2.log( + "Bundle MultiSend SafeTxHash:", vm.toString(LibSafeOps.computeMultiSendSafeTxHash(safe, expected, nonce)) + ); + console2.log("Nonce:", nonce); + } +} + +/// @dev Local mirror of OZ `UpgradeableBeacon.upgradeTo` — rain-vats ships +/// no interface carrying it and OZ's contract is not an interface. +interface IUpgradeableBeaconLike { + function upgradeTo(address newImplementation) external; +} diff --git a/src/lib/LibBeaconInvariants.sol b/src/lib/LibBeaconInvariants.sol index bae641b8..3dd0b24a 100644 --- a/src/lib/LibBeaconInvariants.sol +++ b/src/lib/LibBeaconInvariants.sol @@ -220,6 +220,16 @@ library LibBeaconInvariants { } } + /// @notice Position of the receipt beacon in `prodBeaconsForChainId`. + uint256 internal constant RECEIPT_BEACON_INDEX = 0; + + /// @notice Position of the receipt-vault beacon in `prodBeaconsForChainId`. + uint256 internal constant RECEIPT_VAULT_BEACON_INDEX = 1; + + /// @notice Position of the wrapped-token-vault beacon in + /// `prodBeaconsForChainId`. + uint256 internal constant WRAPPED_TOKEN_VAULT_BEACON_INDEX = 2; + /// @notice The three production beacons IN USE on the active chain, in a /// fixed order (receipt, receipt vault, wrapped token vault). Beacon /// addresses are per-chain deploy artifacts that never change once a diff --git a/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol b/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol new file mode 100644 index 00000000..f7df03dd --- /dev/null +++ b/test/script/20260825-upgrade-fleet-to-0-1-30.prod.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +import { + UpgradeFleetTo0_1_30, + UpgradeTargetNotDeployed, + FleetAlreadyUpgraded, + FLEET_UPGRADE_DEADLINE +} from "../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; +import {LibBeaconInvariants} from "../../src/lib/LibBeaconInvariants.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; + +/// @notice The fleet-upgrade deadline passed with this chain still pending. +/// Run the outstanding dispatches, extend the deadline, or delete the +/// invariant. +/// @param label The chain still pending. +error FleetUpgradeOverdue(string label); + +/// @title UpgradeFleetProdTest +/// @notice PROD coverage for the fleet upgrade: what production IS on each +/// chain, read from a real fork with no mocks, walking the rollout states: +/// +/// 1. **Targets pending** (every chain today): the 0.1.30 impls are not +/// deployed — `run()` refuses `UpgradeTargetNotDeployed`, pinning that +/// the closure suites come first. +/// 2. **Ready** (targets live, beacons on 0.1.1): drives `run()` end to +/// end on the fork — bundle authored and simulated, both beacons land +/// on 0.1.30, EVERY production token's reported state proven unchanged, +/// n+1 downgrade proven. +/// 3. **Executed**: both beacons serve 0.1.30 (asserted directly) and a +/// re-dispatch refuses (`FleetAlreadyUpgraded`). +/// +/// States 1–2 stop passing at `FLEET_UPGRADE_DEADLINE`; state 3 is steady +/// and never expires (until the post-execution pin PR retires this file). +contract UpgradeFleetProdTest is Test { + /// @notice Walk the active fork's upgrade state (see the contract + /// NatSpec) and assert it. + /// @param label Human chain name, surfaced in logs and messages. + function assertFleetRollout(string memory label) internal { + UpgradeFleetTo0_1_30 script = new UpgradeFleetTo0_1_30(); + address[3] memory beacons = LibBeaconInvariants.prodBeaconsForChainId(block.chainid); + + bool targetsLive = LibProdDeployV4.STOX_RECEIPT_0_1_30.code.length != 0 + && LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30.code.length != 0 + && LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_30.code.length != 0; + if (!targetsLive) { + if (block.timestamp >= FLEET_UPGRADE_DEADLINE) { + revert FleetUpgradeOverdue(label); + } + console2.log(string.concat("PENDING [", label, "]: 0.1.30 impls not deployed - fleet upgrade blocked")); + console2.log("-> dispatch the manual-sol-artifacts-0-1-30 suites first"); + vm.expectRevert( + abi.encodeWithSelector(UpgradeTargetNotDeployed.selector, LibProdDeployV4.STOX_RECEIPT_0_1_30) + ); + script.run(); + return; + } + + bool upgraded = IBeacon(beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX]).implementation() + == LibProdDeployV4.STOX_RECEIPT_0_1_30 + && IBeacon(beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX]).implementation() + == LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30; + if (!upgraded) { + if (block.timestamp >= FLEET_UPGRADE_DEADLINE) { + revert FleetUpgradeOverdue(label); + } + console2.log(string.concat("PENDING [", label, "]: ready - driving the authoring end to end")); + // Drives the full authoring: simulation flips both beacons on + // the fork, every token's reported state is proven unchanged, + // and the n+1 downgrade path is proven. + script.run(); + return; + } + + // Executed steady state. + assertEq( + IBeacon(beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX]).implementation(), + LibProdDeployV4.STOX_RECEIPT_0_1_30, + string.concat(label, ": receipt beacon not on 0.1.30") + ); + assertEq( + IBeacon(beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX]).implementation(), + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, + string.concat(label, ": receipt-vault beacon not on 0.1.30") + ); + vm.expectRevert(FleetAlreadyUpgraded.selector); + script.run(); + } + + function testFleetRolloutBase() external { + vm.createSelectFork(LibRainDeploy.BASE); + assertFleetRollout("base"); + } + + function testFleetRolloutEthereum() external { + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + assertFleetRollout("ethereum"); + } + + function testFleetRolloutHyperEvm() external { + vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); + assertFleetRollout("hyperevm"); + } +} diff --git a/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol b/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol new file mode 100644 index 00000000..2296f9df --- /dev/null +++ b/test/script/20260825-upgrade-fleet-to-0-1-30.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; + +import { + UpgradeTargetNotDeployed, + BeaconInUnknownState, + FleetAlreadyUpgraded, + IUpgradeableBeaconLike +} from "../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; +import {UpgradeFleetHarness} from "./UpgradeFleetHarness.sol"; +import {LibBeaconInvariants} from "../../src/lib/LibBeaconInvariants.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {SafeTx} from "../../src/lib/LibSafeOps.sol"; + +/// @title UpgradeFleetTest +/// @notice Guard coverage for `20260825-upgrade-fleet-to-0-1-30` without a +/// fork: the target-deployed gate, the unknown-drift refusal, the +/// self-scoping, the already-upgraded refusal, and the exact bundle shape. +/// The live-fork walk is in `20260825-upgrade-fleet-to-0-1-30.prod.t.sol`. +contract UpgradeFleetTest is Test { + UpgradeFleetHarness internal harness; + address[3] internal beacons; + + function setUp() external { + vm.chainId(LibSafeInvariants.BASE_CHAIN_ID); + harness = new UpgradeFleetHarness(); + beacons = LibBeaconInvariants.prodBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID); + } + + /// @notice Mock both gated beacons in the pre-upgrade (0.1.1) state. + function mockPreUpgradeBeacons() internal { + vm.etch(beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], hex"fe"); + vm.etch(beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX], hex"fe"); + vm.mockCall( + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(LibProdDeployV4.STOX_RECEIPT_0_1_1) + ); + vm.mockCall( + beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX], + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1) + ); + } + + /// The target gate refuses a codeless (or wrong-codehash) 0.1.30 impl. + function testTargetGateRefusesUndeployedImpl() external { + vm.expectRevert(abi.encodeWithSelector(UpgradeTargetNotDeployed.selector, LibProdDeployV4.STOX_RECEIPT_0_1_30)); + harness.callAssertTargetDeployed( + LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_CODEHASH_0_1_30 + ); + } + + /// The target gate accepts the frozen 0.1.30 runtime at the pin. + function testTargetGateAcceptsTheFrozenRuntime() external { + vm.etch(LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_RUNTIME_CODE_0_1_30); + harness.callAssertTargetDeployed( + LibProdDeployV4.STOX_RECEIPT_0_1_30, LibProdDeployV4.STOX_RECEIPT_CODEHASH_0_1_30 + ); + } + + /// The pre-upgrade state authors both `upgradeTo` transactions. + function testAuthoringProducesBothUpgrades() external { + mockPreUpgradeBeacons(); + SafeTx[] memory txs = harness.callAuthorBundle(beacons); + assertEq(txs.length, 2); + assertEq(txs[0].to, beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX]); + assertEq(txs[0].data, abi.encodeCall(IUpgradeableBeaconLike.upgradeTo, (LibProdDeployV4.STOX_RECEIPT_0_1_30))); + assertEq(txs[1].to, beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX]); + assertEq( + txs[1].data, abi.encodeCall(IUpgradeableBeaconLike.upgradeTo, (LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30)) + ); + } + + /// A half-executed upgrade self-scopes to the remaining beacon. + function testAuthoringSelfScopesAPartialUpgrade() external { + mockPreUpgradeBeacons(); + vm.mockCall( + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(LibProdDeployV4.STOX_RECEIPT_0_1_30) + ); + SafeTx[] memory txs = harness.callAuthorBundle(beacons); + assertEq(txs.length, 1); + assertEq(txs[0].to, beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX]); + } + + /// A beacon in neither the 0.1.1 nor 0.1.30 state is unknown drift. + function testAuthoringRefusesUnknownDrift() external { + mockPreUpgradeBeacons(); + vm.mockCall( + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(address(0xBAD)) + ); + vm.expectRevert( + abi.encodeWithSelector( + BeaconInUnknownState.selector, beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], address(0xBAD) + ) + ); + harness.callAuthorBundle(beacons); + } + + /// A fully-upgraded chain refuses to author anything. + function testAuthoringRefusesWhenAlreadyUpgraded() external { + mockPreUpgradeBeacons(); + vm.mockCall( + beacons[LibBeaconInvariants.RECEIPT_BEACON_INDEX], + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(LibProdDeployV4.STOX_RECEIPT_0_1_30) + ); + vm.mockCall( + beacons[LibBeaconInvariants.RECEIPT_VAULT_BEACON_INDEX], + abi.encodeCall(IBeacon.implementation, ()), + abi.encode(LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30) + ); + vm.expectRevert(FleetAlreadyUpgraded.selector); + harness.callAuthorBundle(beacons); + } +} diff --git a/test/script/UpgradeFleetHarness.sol b/test/script/UpgradeFleetHarness.sol new file mode 100644 index 00000000..b22f7df0 --- /dev/null +++ b/test/script/UpgradeFleetHarness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2026 S01 Issuer GmbH +pragma solidity =0.8.25; + +import {UpgradeFleetTo0_1_30} from "../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; +import {SafeTx} from "../../src/lib/LibSafeOps.sol"; + +/// @dev Exposes the script's internals so the guard tests can drive them +/// directly. +contract UpgradeFleetHarness is UpgradeFleetTo0_1_30 { + /// @notice The script's `assertTargetDeployed()`, externally callable. + function callAssertTargetDeployed(address impl, bytes32 codehash) external view { + assertTargetDeployed(impl, codehash); + } + + /// @notice The script's `authorBundle()`, externally callable. + function callAuthorBundle(address[3] memory beacons) external view returns (SafeTx[] memory) { + return authorBundle(beacons); + } +} diff --git a/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol b/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol index b78fa48d..c7d0e352 100644 --- a/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol +++ b/test/src/concrete/deploy/EthereumBeaconOwnership.t.sol @@ -6,6 +6,10 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibProdBeacons0_1_1} from "../../../../src/lib/LibProdBeacons0_1_1.sol"; import {LibProdDeployV1} from "../../../../src/lib/LibProdDeployV1.sol"; import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {LibMigrationInvariant} from "../../../../src/lib/LibMigrationInvariant.sol"; +import {LibProdDeployV4} from "../../../../src/generated/LibProdDeployV4.sol"; +import {FLEET_UPGRADE_DEADLINE} from "../../../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; import {LibBeaconInvariants} from "../../../../src/lib/LibBeaconInvariants.sol"; import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; @@ -33,8 +37,26 @@ contract EthereumBeaconOwnershipTest is Test { vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); address[3] memory beacons = LibProdBeacons0_1_1.beacons(); address[3] memory impls = LibProdBeacons0_1_1.implementations(); - for (uint256 i = 0; i < beacons.length; i++) { - LibBeaconInvariants.assertBeaconInvariants(beacons[i], safe, impls[i]); - } + // The wrapped-token-vault beacon (index 2) still serves its 0.1.1 + // impl; the receipt + receipt-vault beacons ride the fleet-upgrade + // migration window (20260825-upgrade-fleet-to-0-1-30): 0.1.1 OR + // 0.1.30 until the deadline, 0.1.30 only after. + LibBeaconInvariants.assertBeaconInvariants(beacons[2], safe, impls[2]); + LibBeaconInvariants.assertBeaconInvariants(beacons[0], safe, IBeacon(beacons[0]).implementation()); + LibBeaconInvariants.assertBeaconInvariants(beacons[1], safe, IBeacon(beacons[1]).implementation()); + LibMigrationInvariant.assertMigration( + "in-use receipt beacon implementation()", + IBeacon(beacons[0]).implementation(), + LibProdDeployV4.STOX_RECEIPT_0_1_1, + LibProdDeployV4.STOX_RECEIPT_0_1_30, + FLEET_UPGRADE_DEADLINE + ); + LibMigrationInvariant.assertMigration( + "in-use receipt-vault beacon implementation()", + IBeacon(beacons[1]).implementation(), + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, + FLEET_UPGRADE_DEADLINE + ); } } diff --git a/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol b/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol index 0e5ebf4e..613c49fc 100644 --- a/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol +++ b/test/src/concrete/deploy/HyperEvmBeaconOwnership.t.sol @@ -5,6 +5,10 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibProdBeacons0_1_1} from "../../../../src/lib/LibProdBeacons0_1_1.sol"; import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {LibMigrationInvariant} from "../../../../src/lib/LibMigrationInvariant.sol"; +import {LibProdDeployV4} from "../../../../src/generated/LibProdDeployV4.sol"; +import {FLEET_UPGRADE_DEADLINE} from "../../../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; import {LibBeaconInvariants} from "../../../../src/lib/LibBeaconInvariants.sol"; import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; @@ -30,8 +34,26 @@ contract HyperEvmBeaconOwnershipTest is Test { vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); address[3] memory beacons = LibProdBeacons0_1_1.beacons(); address[3] memory impls = LibProdBeacons0_1_1.implementations(); - for (uint256 i = 0; i < beacons.length; i++) { - LibBeaconInvariants.assertBeaconInvariants(beacons[i], safe, impls[i]); - } + // The wrapped-token-vault beacon (index 2) still serves its 0.1.1 + // impl; the receipt + receipt-vault beacons ride the fleet-upgrade + // migration window (20260825-upgrade-fleet-to-0-1-30): 0.1.1 OR + // 0.1.30 until the deadline, 0.1.30 only after. + LibBeaconInvariants.assertBeaconInvariants(beacons[2], safe, impls[2]); + LibBeaconInvariants.assertBeaconInvariants(beacons[0], safe, IBeacon(beacons[0]).implementation()); + LibBeaconInvariants.assertBeaconInvariants(beacons[1], safe, IBeacon(beacons[1]).implementation()); + LibMigrationInvariant.assertMigration( + "in-use receipt beacon implementation()", + IBeacon(beacons[0]).implementation(), + LibProdDeployV4.STOX_RECEIPT_0_1_1, + LibProdDeployV4.STOX_RECEIPT_0_1_30, + FLEET_UPGRADE_DEADLINE + ); + LibMigrationInvariant.assertMigration( + "in-use receipt-vault beacon implementation()", + IBeacon(beacons[1]).implementation(), + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, + FLEET_UPGRADE_DEADLINE + ); } } diff --git a/test/src/concrete/deploy/StoxCrossChainParity.t.sol b/test/src/concrete/deploy/StoxCrossChainParity.t.sol index 8148756a..60331174 100644 --- a/test/src/concrete/deploy/StoxCrossChainParity.t.sol +++ b/test/src/concrete/deploy/StoxCrossChainParity.t.sol @@ -12,6 +12,8 @@ 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 {LibMigrationInvariant} from "../../../../src/lib/LibMigrationInvariant.sol"; +import {FLEET_UPGRADE_DEADLINE} from "../../../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; import {LibProdDeployV4} from "../../../../src/generated/LibProdDeployV4.sol"; import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; @@ -450,15 +452,19 @@ contract StoxCrossChainParityTest is Test { // the same pins `LibProdBeaconsBase/Ethereum.implementations()` // resolve. When a beacon upgrade migration moves production, // these pins move with it. - assertEq( + LibMigrationInvariant.assertMigration( + string.concat(label, " in-use receipt-vault beacon implementation()"), IBeacon(beacon).implementation(), LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, - string.concat(label, " receipt-vault beacon does not serve the audited production impl") + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, + FLEET_UPGRADE_DEADLINE ); - assertEq( + LibMigrationInvariant.assertMigration( + string.concat(label, " in-use receipt beacon implementation()"), IBeacon(receiptBeacon).implementation(), LibProdDeployV4.STOX_RECEIPT_0_1_1, - string.concat(label, " receipt beacon does not serve the audited production impl") + LibProdDeployV4.STOX_RECEIPT_0_1_30, + FLEET_UPGRADE_DEADLINE ); assertCleanV4Lineage(beacon); assertCleanV4Lineage(receiptBeacon); diff --git a/test/src/concrete/deploy/StoxProdV4.t.sol b/test/src/concrete/deploy/StoxProdV4.t.sol index 6700e02e..7f0ea267 100644 --- a/test/src/concrete/deploy/StoxProdV4.t.sol +++ b/test/src/concrete/deploy/StoxProdV4.t.sol @@ -3,6 +3,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibMigrationInvariant} from "../../../../src/lib/LibMigrationInvariant.sol"; +import {FLEET_UPGRADE_DEADLINE} from "../../../../script/20260825-upgrade-fleet-to-0-1-30.s.sol"; import {LibProdDeployV4} from "../../../../src/generated/LibProdDeployV4.sol"; import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol"; @@ -49,7 +51,7 @@ contract StoxProdV4Test is Test { /// tests. On Base the 0.1.1-address beacons checked here are an unadopted /// deploy artifact whose owner is irrelevant; on Ethereum they ARE the /// in-use beacons and the per-chain assert covers them. - function checkProd_0_1_1OnChain() internal view { + function checkProd_0_1_1OnChain(bool oarvBeaconsAreInUse) internal view { assertTrue(LibProdDeployV4.STOX_RECEIPT_0_1_1.code.length > 0, "V4 StoxReceipt not deployed"); assertEq(LibProdDeployV4.STOX_RECEIPT_0_1_1.codehash, LibProdDeployV4.STOX_RECEIPT_CODEHASH_0_1_1); assertEq(LibProdDeployV4.STOX_RECEIPT_0_1_1.code, LibProdDeployV4.STOX_RECEIPT_RUNTIME_CODE_0_1_1); @@ -173,18 +175,41 @@ contract StoxProdV4Test is Test { ); IBeacon receiptBeacon = oarvDeployer.iReceiptBeacon(); - assertEq( - receiptBeacon.implementation(), - LibProdDeployV4.STOX_RECEIPT_0_1_1, - "V4 OARV receipt beacon implementation mismatch" - ); - IBeacon vaultBeacon = oarvDeployer.iOffchainAssetReceiptVaultBeacon(); - assertEq( - vaultBeacon.implementation(), - LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, - "V4 OARV vault beacon implementation mismatch" - ); + if (oarvBeaconsAreInUse) { + // On the bootstrap chains these ARE the in-use production + // beacons, so they ride the fleet-upgrade migration window + // (20260825-upgrade-fleet-to-0-1-30): 0.1.1 OR 0.1.30 until the + // deadline, 0.1.30 only after. + LibMigrationInvariant.assertMigration( + "OARV receipt beacon implementation()", + receiptBeacon.implementation(), + LibProdDeployV4.STOX_RECEIPT_0_1_1, + LibProdDeployV4.STOX_RECEIPT_0_1_30, + FLEET_UPGRADE_DEADLINE + ); + LibMigrationInvariant.assertMigration( + "OARV vault beacon implementation()", + vaultBeacon.implementation(), + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_30, + FLEET_UPGRADE_DEADLINE + ); + } else { + // On Base these beacons are unadopted deploy artifacts + // (production runs on the V1-address beacons) — never + // repointed, frozen at the 0.1.1 impls their constructor baked. + assertEq( + receiptBeacon.implementation(), + LibProdDeployV4.STOX_RECEIPT_0_1_1, + "V4 OARV receipt beacon implementation mismatch" + ); + assertEq( + vaultBeacon.implementation(), + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, + "V4 OARV vault beacon implementation mismatch" + ); + } } /// The Base fork verifies only the frozen audited 0.1.1 set — that is what @@ -195,7 +220,7 @@ contract StoxProdV4Test is Test { /// instead. The in-use beacons MUST be owned by Base's token-owner Safe. function testProdDeployBaseV4() external { vm.createSelectFork(LibRainDeploy.BASE); - checkProd_0_1_1OnChain(); + checkProd_0_1_1OnChain(false); LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); } @@ -207,7 +232,7 @@ contract StoxProdV4Test is Test { /// Ethereum's token-owner Safe — asserted via the per-chain in-use pin. function testProdDeployEthereumV4() external { vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); - checkProd_0_1_1OnChain(); + checkProd_0_1_1OnChain(true); LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); } @@ -220,7 +245,7 @@ contract StoxProdV4Test is Test { /// the HyperEVM token-owner Safe; green otherwise, catching later drift. function testProdDeployHyperEvmV4() external { vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); - checkProd_0_1_1OnChain(); + checkProd_0_1_1OnChain(true); LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); } }