diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index be5a7845..fe45a67e 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -26,6 +26,7 @@ on: # too, but dispatching via run-script.yaml is DRY-RUN ONLY (no # `--broadcast`) — useful as a pre-flight smoke test. - 20260619-deploy-v4-authoriser-clone + - 20260623-upgrade-receipt-vaults-to-v4 sig: description: 'Entrypoint to dispatch (default: run())' required: true diff --git a/README.md b/README.md index d3e9df2d..7c8a6d90 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,52 @@ The `multisig-artifact` GitHub workflow runs the dry-run on `workflow_dispatch` dependencies, uploading `out/*.json` as a build artifact so reviewers can download the bundle directly from the run. +### Beacon ownership migration + +`script/MigrateBeaconOwners.s.sol` transfers ownership of the three production +V1 beacons that live tokens use — the receipt beacon, the receipt vault beacon, +and the wrapped token vault beacon — from the rainlang.eth EOA to the +`STOX_TOKEN_OWNER_SAFE`, so beacon upgrades route through the multisig. Unlike +the threshold migration this is a direct EOA broadcast (an `Ownable` beacon +transfers ownership by the current owner calling `transferOwnership`), so the +script emits no Tx Builder JSON — the output is the on-chain `transferOwnership` +transactions. It still runs the full operational treatment: a pre-flight +`assertBeaconInvariants` against the expected EOA-owned state, the broadcast +transfers, a post-state `assertBeaconInvariants` against the Safe-owned state, +and an n+1 reversibility check per beacon (an idempotent +`upgradeTo(currentImpl)` routed through the Safe's `execTransaction`, proving +the Safe can act on each beacon after the migration). + +```shell +forge script script/MigrateBeaconOwners.s.sol \ + --rpc-url base --broadcast --private-key +``` + +### Receipt vault V3 upgrade + +`script/UpgradeReceiptVaultToV3.s.sol` authors the Safe transaction that points +`STOX_RECEIPT_VAULT_BEACON_V1` at the V3 receipt vault implementation (corporate +actions). After execution every live receipt vault routes corporate-action +selectors into the V3 facet via fallback delegatecall. The beacon must already +be Safe-owned (run the beacon ownership migration first) and the V3 +implementation must already be deployed at its deterministic Zoltu address with +the audited codehash. The script runs `assertAll(safe)` + +`assertBeaconInvariants(beacon, safe, V1 impl)` as pre-flight, simulates the +`upgradeTo`, asserts the post-state (`beacon -> V3 impl`, Safe unchanged), emits +the Tx Builder JSON to `out/v3-upgrade.json`, prints the `SafeTxHash`, and +proves n+1 reversibility back to the V1 implementation. + +```shell +BASE_RPC_URL=https://base-rpc.publicnode.com \ + forge script script/UpgradeReceiptVaultToV3.s.sol --rpc-url base +``` + +The post-upgrade behaviour of live tokens is verified by the shadow-fork suite +`test/src/concrete/upgrade/V3UpgradeShadowFork.t.sol`, which applies the upgrade +to a Base head fork and exercises corporate-action fallback routing, +backwards-compatible reads, authoriser and receipt wiring, and certification +against a real on-chain receipt vault. + ## License LicenseRef-DCL-1.0 (DecentraLicense). REUSE-compliant. diff --git a/script/20260623-upgrade-receipt-vaults-to-v4.s.sol b/script/20260623-upgrade-receipt-vaults-to-v4.s.sol new file mode 100644 index 00000000..7b6aea1b --- /dev/null +++ b/script/20260623-upgrade-receipt-vaults-to-v4.s.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; +import {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol"; +import {IAuthorizeV1} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV1} from "../src/lib/LibProdDeployV1.sol"; +import {LibProdDeployV4} from "../src/lib/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibTokenInvariants} from "../src/lib/LibTokenInvariants.sol"; +import {LibBeaconInvariants} from "../src/lib/LibBeaconInvariants.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx, IUpgradeableBeacon} from "../src/lib/LibSafeOps.sol"; + +/// @notice The V4 receipt vault implementation has no runtime code at its +/// (post-rebuild) Zoltu address. The upgrade cannot be authored until the V4 +/// implementation is deployed on-chain. +/// @param implementation The V4 implementation address that has no code. +error V4ImplementationNotDeployed(address implementation); + +/// @notice The V4 receipt vault implementation's runtime codehash does not +/// match the pinned `LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1`. +/// Signals the on-chain bytecode is not the audited V4 build. +/// @param implementation The implementation address inspected. +/// @param expected The pinned V4 codehash. +/// @param actual The codehash observed on-chain. +error V4CodehashMismatch(address implementation, bytes32 expected, bytes32 actual); + +/// @notice The V4 authoriser clone constant in `LibAuthoriserInvariants` is still +/// the `address(0)` placeholder. The clone must be deployed (and its address +/// dropped into the lib) before the upgrade can be authored. +error V4AuthoriserCloneNotPinned(); + +/// @notice The V4 authoriser clone address is pinned but has no runtime code. +/// @param clone The clone address that has no code. +error V4AuthoriserCloneNotDeployed(address clone); + +/// @notice The V4 authoriser clone's runtime codehash does not match the +/// pinned `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH` — the +/// clone has been etched over, or the EIP-1167 runtime does not embed the +/// pinned V4 impl address. +/// @param clone The clone address inspected. +/// @param expected The pinned clone codehash. +/// @param actual The codehash observed on-chain. +error V4AuthoriserCloneCodehashMismatch(address clone, bytes32 expected, bytes32 actual); + +/// @notice The V4 authoriser clone is missing one of the role grants pinned +/// in `LibAuthoriserInvariants.expectedGrants()`. The clone's initialization + +/// grant-mirror must complete before the upgrade can be authored. +/// @param clone The clone address inspected. +/// @param role The missing role. +/// @param grantee The grantee that should hold the role. +error V4AuthoriserCloneExpectedGrantMissing(address clone, bytes32 role, address grantee); + +/// @notice A production receipt vault's `authorizer()` is not the V4 clone +/// after the upgrade simulates. The `setAuthorizer` bundle item for this +/// vault failed to take effect. +/// @param vault The receipt vault inspected. +/// @param expected The pinned V4 clone address. +/// @param actual The address returned by `authorizer()`. +error VaultAuthoriserMismatchPostUpgrade(address vault, address expected, address actual); + +/// @title UpgradeReceiptVaultsToV4 +/// @notice **PENDING.** Forge script that authors the receipt-vault V4 +/// upgrade plus the authoriser swap onto the corporate-action-aware V4 +/// clone. Dispatch via `Actions → run-script` with +/// `script = 20260623-upgrade-receipt-vaults-to-v4` and `sig = run()` once +/// the V4 authoriser clone is pinned in `LibProdDeployV4` +/// (`STOX_PROD_AUTHORISER_V4_CLONE` non-zero) and all 13 grants are +/// mirrored onto it. The ST0x token-owner Safe signs a single bundle +/// that: +/// +/// 1. Points the receipt-vault beacon at the V4 implementation +/// (`LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1`). +/// 2. Calls `setAuthorizer(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE)` +/// on every production receipt vault. +/// +/// After execution every live receipt vault routes corporate-action selectors +/// into the V4 facet via fallback delegatecall AND is gated by the V4 authoriser +/// clone whose role-grant map matches `LibAuthoriserInvariants.expectedGrants()`. +/// +/// This is a Safe-routed operation (the beacon is Safe-owned post-#196 and every +/// vault is Safe-owned by construction), so the script emits a Safe Tx Builder +/// JSON artifact for signer review + execution via the Safe UI. +/// +/// @dev Flow: +/// 1. **Pre-flight** — +/// - `assertAll(safe)` (Safe is in its current expected state), +/// - `assertBeaconInvariants(beacon, safe, V1 impl)` (beacon Safe-owned, V1), +/// - the V4 impl is deployed with the pinned codehash, +/// - the V4 authoriser clone is pinned (non-zero), deployed, has the pinned +/// EIP-1167 codehash, and holds every `LibAuthoriserInvariants.expectedGrants()` +/// pair. +/// The V4 impl at `STOX_RECEIPT_VAULT_0_1_1` is deployed on Base with the +/// pinned codehash, so the impl-side pre-flight passes; the clone-pin check +/// fails red today because `STOX_PROD_AUTHORISER_V4_CLONE` is still +/// `address(0)` — the forcing function blocking the upgrade until the clone +/// is deployed and pinned. +/// 2. **Build** — a multi-tx bundle: one `upgradeTo(V4 impl)` call on the +/// beacon, plus one `setAuthorizer(V4 clone)` call per production receipt +/// vault (count = `LibTokenInvariants.productionReceiptVaults().length`, +/// 20 as of 2026-06-26; the bundle grows in lockstep as new vaults are +/// added to the lib). Compute the canonical `SafeTxHash` against the live +/// nonce for the beacon-upgrade tx for signer cross-check. +/// 3. **Simulate** — prank-route each bundle item as the Safe so post-state +/// assertions reflect the executed state. +/// 4. **Post-state** — +/// - `assertBeaconInvariants(beacon, safe, V4 impl)` (beacon at V4, still +/// Safe-owned), +/// - every production receipt vault's `authorizer()` is the V4 clone, +/// - Safe identity / config + threshold unchanged. +/// 5. **Artifact** — emit the Tx Builder JSON to `out/v4-upgrade.json`, frame +/// it in the console log, print the `SafeTxHash` of the beacon-upgrade tx. +/// 6. **n+1 reversibility** — `simulateNPlus1` proves the Safe can roll the +/// beacon back to V1 under the live threshold (and that the threshold gate +/// rejects undersigned attempts). The `setAuthorizer` reversibility is +/// intrinsic — every vault is Safe-owned so the Safe can re-issue +/// `setAuthorizer(old clone)` symmetrically. +contract UpgradeReceiptVaultsToV4 is Script { + /// @notice The receipt-vault beacon whose implementation is upgraded. + address internal constant BEACON = LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1; + + /// @notice The V1 implementation the beacon points at before the upgrade, + /// and the rollback target for the n+1 reversibility inverse op. + address internal constant V1_IMPL = LibProdDeployV1.STOX_RECEIPT_VAULT_IMPLEMENTATION; + + /// @notice The V4 implementation the beacon is upgraded to — the audited + /// `0.1.1` receipt-vault build, deployed on Base at this pinned address. + address internal constant V4_IMPL = LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1; + + /// @notice The V4 authoriser clone that every production receipt vault is + /// rewired onto. Placeholder until the clone is deployed. + address internal constant V4_AUTHORISER_CLONE = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + + /// @notice Human-readable name embedded in the emitted Tx Builder JSON's + /// `meta.name`. Visible to signers in the Safe Tx Builder UI. + string internal constant BUNDLE_NAME = "ST0x receipt vault V4 upgrade + authoriser swap"; + + /// @notice Output path (relative to the project root) for the Tx Builder + /// JSON artifact. + string internal constant ARTIFACT_PATH = "out/v4-upgrade.json"; + + /// @notice Dry-run the V4 upgrade + authoriser swap: pre-flight invariants, + /// simulate the beacon upgrade + every `setAuthorizer`, assert the + /// post-state, emit the Tx Builder JSON, log the SafeTxHash for the + /// beacon-upgrade tx, and prove n+1 reversibility back to V1. Does not + /// broadcast — execution happens via the Safe UI using the emitted + /// artifact. + function run() external { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); + + // --- Pre-flight --------------------------------------------------- + + // Safe identity / config + token-side uniformity. + LibSafeInvariants.assertAll(safe); + // Beacon is Safe-owned and still at V1. + LibBeaconInvariants.assertBeaconInvariants(BEACON, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, V1_IMPL); + + // V4 implementation pinned, deployed, audited codehash. + if (V4_IMPL.code.length == 0) { + revert V4ImplementationNotDeployed(V4_IMPL); + } + bytes32 v4Codehash = V4_IMPL.codehash; + if (v4Codehash != LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1) { + revert V4CodehashMismatch(V4_IMPL, LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1, v4Codehash); + } + + // V4 authoriser clone pinned, deployed, codehash matches EIP-1167 + // runtime with V4 impl embedded, and the grant map matches the lib. + if (V4_AUTHORISER_CLONE == address(0)) { + revert V4AuthoriserCloneNotPinned(); + } + if (V4_AUTHORISER_CLONE.code.length == 0) { + revert V4AuthoriserCloneNotDeployed(V4_AUTHORISER_CLONE); + } + bytes32 cloneCodehash = V4_AUTHORISER_CLONE.codehash; + if (cloneCodehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH) { + revert V4AuthoriserCloneCodehashMismatch( + V4_AUTHORISER_CLONE, LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, cloneCodehash + ); + } + IAccessControl cloneAcl = IAccessControl(V4_AUTHORISER_CLONE); + RoleGrant[] memory expected = LibAuthoriserInvariants.expectedGrants(); + for (uint256 i = 0; i < expected.length; i++) { + if (!cloneAcl.hasRole(expected[i].role, expected[i].grantee)) { + revert V4AuthoriserCloneExpectedGrantMissing(V4_AUTHORISER_CLONE, expected[i].role, expected[i].grantee); + } + } + + // Verify all seven auto-granted `_ADMIN` roles hold on the Safe — + // including the two V4-only corporate-action admins + // (`SCHEDULE_/CANCEL_CORPORATE_ACTION_ADMIN`) that `expectedGrants()` + // doesn't carry. Without them the swapped clone can't admin corporate + // actions, so this is the enforcement point that must reject a clone + // deployed missing them. (The five base admins overlap the + // `expectedGrants()` sweep above; re-checking them keeps the admin-set + // assertion self-contained and matches the V4 post-swap pin.) + bytes32[7] memory adminRoles = [ + keccak256("CERTIFY_ADMIN"), + keccak256("CONFISCATE_RECEIPT_ADMIN"), + keccak256("CONFISCATE_SHARES_ADMIN"), + keccak256("DEPOSIT_ADMIN"), + keccak256("WITHDRAW_ADMIN"), + keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), + keccak256("CANCEL_CORPORATE_ACTION_ADMIN") + ]; + address ownerSafe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + for (uint256 i = 0; i < adminRoles.length; i++) { + if (!cloneAcl.hasRole(adminRoles[i], ownerSafe)) { + revert V4AuthoriserCloneExpectedGrantMissing(V4_AUTHORISER_CLONE, adminRoles[i], ownerSafe); + } + } + + // --- Build the bundle -------------------------------------------- + + // One beacon upgrade + one setAuthorizer per production receipt vault. + SafeTx[] memory txs = new SafeTx[](1 + vaults.length); + bytes memory beaconData = abi.encodeCall(IUpgradeableBeacon.upgradeTo, (V4_IMPL)); + txs[0] = SafeTx({to: BEACON, value: 0, data: beaconData, operation: 0}); + bytes memory setAuthoriserData = + abi.encodeCall(OffchainAssetReceiptVaultLike.setAuthorizer, (IAuthorizeV1(V4_AUTHORISER_CLONE))); + for (uint256 i = 0; i < vaults.length; i++) { + txs[i + 1] = SafeTx({to: vaults[i], value: 0, data: setAuthoriserData, operation: 0}); + } + + // Capture the nonce before any simulation. `simulateExternalCall` + // does not advance the nonce, so the hash binds to the current Safe + // state. + uint256 nonce = safe.nonce(); + bytes32 beaconSafeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + // --- Simulate ----------------------------------------------------- + + LibSafeOps.simulateExternalCall(safe, BEACON, beaconData); + for (uint256 i = 0; i < vaults.length; i++) { + LibSafeOps.simulateExternalCall(safe, vaults[i], setAuthoriserData); + } + + // --- Post-state --------------------------------------------------- + + _assertPostState(safe, vaults); + + // --- Artifact ----------------------------------------------------- + + string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, BUNDLE_NAME, txs); + vm.writeFile(ARTIFACT_PATH, json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("Beacon-upgrade SafeTxHash:", vm.toString(beaconSafeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Bundle item count:", txs.length); + + // --- n+1 reversibility ------------------------------------------- + + bytes memory inverseData = abi.encodeCall(IUpgradeableBeacon.upgradeTo, (V1_IMPL)); + LibSafeOps.simulateNPlus1(safe, BEACON, inverseData, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + require( + IBeacon(BEACON).implementation() == V1_IMPL, + "UpgradeReceiptVaultsToV4: n+1 did not roll beacon back to V1 impl" + ); + console2.log("n+1 reversibility check passed: beacon rolled back to V1 impl"); + } + + /// @notice Post-state assertions after the upgrade + swap simulate: the + /// beacon is at V4 and Safe-owned, every production receipt vault reports + /// the V4 clone as its authoriser, and the Safe identity + threshold are + /// unchanged. Split from `run()` so tests can drive it against a + /// deliberately-malformed post-state (e.g. an un-swapped vault) and assert + /// the `VaultAuthoriserMismatchPostUpgrade` guard fires. + /// @param safe The ST0x token-owner Safe. + /// @param vaults The production receipt vaults the swap targets. + function _assertPostState(IGnosisSafe safe, address[] memory vaults) internal view { + // Beacon now at V4, still Safe-owned. + LibBeaconInvariants.assertBeaconInvariants(BEACON, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, V4_IMPL); + // Every production receipt vault now authorised by the V4 clone. + for (uint256 i = 0; i < vaults.length; i++) { + address actual = address(IAuthorizableV1(vaults[i]).authorizer()); + if (actual != V4_AUTHORISER_CLONE) { + revert VaultAuthoriserMismatchPostUpgrade(vaults[i], V4_AUTHORISER_CLONE, actual); + } + } + // Safe identity + threshold unchanged. (The explicit per-vault + // V4-clone loop above is deliberately STRICTER than the + // migration-window authoriser leg in `LibInvariants.assertAll` — + // this is the post-state of the swap itself, so only the V4 clone + // is acceptable regardless of the window. The Safe-side legs are + // asserted piecemeal to avoid re-running the token-side legs the + // loop above already covers.) + LibSafeInvariants.assertImmutableInvariants(safe); + LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + } +} + +/// @dev Local mirror of the receipt-vault `setAuthorizer(IAuthorizeV1)` +/// selector. Avoids dragging the full `OffchainAssetReceiptVault` storage +/// inheritance into this script just to encode one selector. +interface OffchainAssetReceiptVaultLike { + function setAuthorizer(IAuthorizeV1 newAuthorizer) external; +} diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol index cfcf73eb..0c08a75c 100644 --- a/src/lib/LibInvariants.sol +++ b/src/lib/LibInvariants.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; import {LibAuthoriserInvariants} from "./LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "./LibProdDeployV4.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; import {LibTokenInvariants} from "./LibTokenInvariants.sol"; @@ -31,10 +32,28 @@ library LibInvariants { /// pinned current truth (e.g. a migration script's post-state re-check /// after it has simulated `changeThreshold`); the no-arg overload /// fills in the `LibSafeInvariants`-pinned defaults. + /// + /// The authoriser leg is migration-window gated for the V4 swap: + /// every vault's `authorizer()` may be the V3 authoriser + /// (`LibAuthoriserInvariants.STOX_PROD_AUTHORISER`) or the V4 clone + /// (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE`) until + /// `LibProdDeployV4.V4_SWAP_DEADLINE`; only the V4 clone after. This + /// keeps the bundle green across the swap with no post-execution lib + /// repoint: before the swap the pre-state matches, after the swap the + /// post-state matches, and past the deadline an un-run swap red-lines + /// cron. `LibAuthoriserInvariants.assertAll()` continues to validate + /// the V3 clone's own impl pin + grant map — properties of that + /// contract which stay true after the swap (the swap does not revoke + /// anything on the old clone). /// @param safe The Safe to validate against the pinned current truth. function assertAll(IGnosisSafe safe) internal view { LibSafeInvariants.assertAll(safe); - LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertUniformOwnership(address(safe)); + LibTokenInvariants.assertUniformAuthoriserMigration( + LibAuthoriserInvariants.STOX_PROD_AUTHORISER, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, + LibProdDeployV4.V4_SWAP_DEADLINE + ); LibAuthoriserInvariants.assertAll(); } @@ -42,14 +61,19 @@ library LibInvariants { /// threshold or owner set from `LibSafeInvariants`' current-truth pins — /// typically only when running a script that intentionally changes /// one of those (post-state assertion). The token-side and authoriser- - /// side legs use the pinned current-truth defaults from - /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`. + /// side legs match the no-arg overload, including the V4 swap + /// migration window on the authoriser leg. /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. /// @param expectedOwners The expected owner set in `getOwners()` order. function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) internal view { LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); - LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertUniformOwnership(address(safe)); + LibTokenInvariants.assertUniformAuthoriserMigration( + LibAuthoriserInvariants.STOX_PROD_AUTHORISER, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, + LibProdDeployV4.V4_SWAP_DEADLINE + ); LibAuthoriserInvariants.assertAll(); } } diff --git a/src/lib/LibProdDeployV4.sol b/src/lib/LibProdDeployV4.sol index b6d95f79..66a1f1ec 100644 --- a/src/lib/LibProdDeployV4.sol +++ b/src/lib/LibProdDeployV4.sol @@ -475,6 +475,20 @@ library LibProdDeployV4 { bytes32 constant STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH = 0x2089950d3cc1112dd66a58adcfadeadc490b50053ac67be8bc676b4a2dcd1717; + /// @notice Unix timestamp past which the V4 authoriser swap must have + /// landed on Base: every production receipt vault's `authorizer()` + /// must report `STOX_PROD_AUTHORISER_V4_CLONE` (not the V3 authoriser) + /// from this moment on. `2026-11-01T00:00:00Z`. + /// + /// Consumed by the `LibMigrationInvariant`-gated authoriser leg in + /// `LibInvariants.assertAll` and by the V4 prod-state pins: before the + /// deadline both the V3 authoriser and the V4 clone are accepted, so + /// the invariant merges alongside the swap script instead of waiting + /// for execution; after the deadline only the V4 clone passes and cron + /// red-lines until the swap runs, the deadline is extended, or the + /// migration is explicitly abandoned. + uint256 constant V4_SWAP_DEADLINE = 1_793_491_200; + // ========================================================================= // Per-release creation + runtime bytecode (frozen historicals). // diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index 026b1cf2..0ed704d9 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.25; import {IOwnable} from "../interface/IOwnable.sol"; import {IAuthorisable} from "../interface/IAuthorisable.sol"; +import {LibMigrationInvariant} from "./LibMigrationInvariant.sol"; /// @notice A production receipt vault's `owner()` does not match the owner /// the uniform-ownership invariant expected every vault to share. Surfaces @@ -274,6 +275,30 @@ library LibTokenInvariants { } } + /// @notice Migration-window variant of `assertUniformAuthoriser`: + /// every production receipt vault's `authorizer()` must be `pre` OR + /// `post` before `deadline`, and exactly `post` at/after it. Lets the + /// authoriser-swap invariant merge alongside the swap script instead + /// of waiting for on-chain execution — both sides of the transition + /// are cron-covered, and a swap left un-run past the deadline + /// red-lines via `MigrationDeadlinePassed`. + /// @dev Each vault is asserted independently, so a half-landed swap + /// (some vaults on `pre`, some on `post`) passes before the deadline + /// — the bundle is atomic per Safe execution, but this leg does not + /// assume that. Any third value trips `MigrationStateDrift` + /// immediately regardless of the deadline. + /// @param pre The accepted authoriser before the swap runs. + /// @param post The accepted authoriser after the swap runs. + /// @param deadline Unix timestamp past which only `post` is accepted. + function assertUniformAuthoriserMigration(address pre, address post, uint256 deadline) internal view { + address[] memory vaults = productionReceiptVaults(); + for (uint256 i = 0; i < vaults.length; i++) { + LibMigrationInvariant.assertMigration( + "receiptVault.authorizer()", IAuthorisable(vaults[i]).authorizer(), pre, post, deadline + ); + } + } + /// @notice Full token-side invariant bundle: every production receipt /// vault reports the supplied Safe as its `owner()` AND the supplied /// authoriser as its `authorizer()`. Pre-flight / post-state hook for diff --git a/test/script/20260623-upgrade-receipt-vaults-to-v4.t.sol b/test/script/20260623-upgrade-receipt-vaults-to-v4.t.sol new file mode 100644 index 00000000..e0fbe159 --- /dev/null +++ b/test/script/20260623-upgrade-receipt-vaults-to-v4.t.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; +import {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol"; + +import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; +import {LibAuthoriserInvariants} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "../../src/lib/LibProdDeployV4.sol"; +import {LibProdDeployV1} from "../../src/lib/LibProdDeployV1.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; +import {IUpgradeableBeacon} from "../../src/lib/LibSafeOps.sol"; +import { + UpgradeReceiptVaultsToV4, + V4ImplementationNotDeployed, + V4CodehashMismatch, + V4AuthoriserCloneNotPinned, + VaultAuthoriserMismatchPostUpgrade +} from "../../script/20260623-upgrade-receipt-vaults-to-v4.s.sol"; +import {UpgradeReceiptVaultsToV4Harness} from "./UpgradeReceiptVaultsToV4Harness.sol"; + +/// @title UpgradeReceiptVaultsToV4Test +/// @notice Live-fork pin of the vault-authoriser transition executed by +/// `script/20260623-upgrade-receipt-vaults-to-v4.s.sol`, via the same +/// migration-window leg `LibInvariants.assertAll` composes: +/// `LibTokenInvariants.assertUniformAuthoriserMigration(V3, V4 clone, +/// V4_SWAP_DEADLINE)`. Before the deadline each vault may report the V3 +/// authoriser or the V4 clone; after it only the V4 clone passes and cron +/// red-lines until the swap runs, the deadline is extended, or the +/// migration is explicitly abandoned. +/// +/// @dev While the V4 clone pin is still `address(0)` (clone-address PR not +/// yet landed), the window collapses to "must be V3 authoriser" — every +/// live vault reports the V3 authoriser and passes. Once the clone is +/// pinned + the swap has run on Base, the live reads flip to the V4 clone +/// and the same test still passes with no code change. +/// +/// Uses an unpinned Base head fork so `block.timestamp` is real. Pinning a +/// block would freeze the deadline check to whichever timestamp the pinned +/// block carried, which is exactly the wrong behaviour for a deadline-gated +/// invariant. +contract UpgradeReceiptVaultsToV4Test is Test { + /// @notice Every production receipt vault's `authorizer()` is within + /// the V4 swap migration window. Base-only — no other network carries + /// live production receipt vaults. + function testVaultAuthoriserInMigrationWindow() external { + vm.createSelectFork(LibRainDeploy.BASE); + LibTokenInvariants.assertUniformAuthoriserMigration( + LibAuthoriserInvariants.STOX_PROD_AUTHORISER, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, + LibProdDeployV4.V4_SWAP_DEADLINE + ); + } + + /// @notice The receipt-vault beacon whose implementation the script + /// upgrades. + address internal constant BEACON = LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1; + + /// @notice The V4 receipt-vault implementation the script upgrades the + /// beacon to (placeholder Zoltu address until the patched build lands). + address internal constant V4_IMPL = LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1; + + /// @notice Fork Base and simulate PR #196 (transfer the receipt-vault + /// beacon from the deploy EOA to the Safe) so the script's beacon + /// pre-flight (`assertBeaconInvariants`, Safe-owned + at V1) passes and + /// `run()` reaches the V4 impl + clone checks. + function _forkAndMigrateBeaconOwnership() internal { + vm.createSelectFork(LibRainDeploy.BASE); + vm.prank(LibProdDeployV1.BEACON_INITIAL_OWNER); + Ownable(BEACON).transferOwnership(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + } + + /// @notice `run()` pre-flight reverts `V4ImplementationNotDeployed` when + /// the V4 receipt-vault impl has no code at its Zoltu address. The impl is + /// already live on Base, so the undeployed state is forced with + /// `vm.etch(V4_IMPL, "")`. Also pins pre-flight ordering: the Safe + beacon + /// invariants pass first (real live state + simulated #196), so the revert + /// is specifically the V4-impl gate, not an earlier one. + function testRunRevertsWhenV4ImplNotDeployed() external { + _forkAndMigrateBeaconOwnership(); + vm.etch(V4_IMPL, ""); + UpgradeReceiptVaultsToV4 upgradeScript = new UpgradeReceiptVaultsToV4(); + vm.expectRevert(abi.encodeWithSelector(V4ImplementationNotDeployed.selector, V4_IMPL)); + upgradeScript.run(); + } + + /// @notice `run()` pre-flight reverts `V4CodehashMismatch` when code exists + /// at the V4 impl address but its codehash differs from the pinned V4 + /// codehash — the on-chain bytecode is not the audited V4 build. + function testRunRevertsWhenV4CodehashMismatches() external { + _forkAndMigrateBeaconOwnership(); + bytes memory bogus = hex"60016000526001601ff3"; + vm.etch(V4_IMPL, bogus); + UpgradeReceiptVaultsToV4 upgradeScript = new UpgradeReceiptVaultsToV4(); + vm.expectRevert( + abi.encodeWithSelector( + V4CodehashMismatch.selector, + V4_IMPL, + LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1, + keccak256(bogus) + ) + ); + upgradeScript.run(); + } + + /// @notice The audited V4 impl is already deployed on Base at `V4_IMPL` + /// with the pinned codehash, so the impl-side pre-flight passes against the + /// live fork (no planting needed — planting the current source would carry + /// a later release's bytecode, not the pinned `0.1.1` build) and `run()` + /// reaches the next forcing function: `V4AuthoriserCloneNotPinned` (the + /// clone pin is still `address(0)`). The codehash assert doubles as a + /// prod-state pin: the on-chain V4 impl must match the lib's pinned + /// codehash. + function testRunRevertsWhenV4AuthoriserCloneNotPinned() external { + _forkAndMigrateBeaconOwnership(); + assertEq( + V4_IMPL.codehash, + LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1, + "live V4 impl codehash != pinned codehash" + ); + UpgradeReceiptVaultsToV4 upgradeScript = new UpgradeReceiptVaultsToV4(); + vm.expectRevert(V4AuthoriserCloneNotPinned.selector); + upgradeScript.run(); + } + + /// @notice `_assertPostState` reverts `VaultAuthoriserMismatchPostUpgrade` + /// when a production vault still reports a non-V4-clone authoriser after + /// the beacon leg. Drives the beacon to V4 (so the beacon post-state + /// passes) but leaves authorisers un-swapped, so the per-vault loop trips + /// on vault 0. Exercises the post-state guard the placeholder clone pin + /// otherwise keeps `run()` from ever reaching. + function testAssertPostStateRevertsWhenVaultNotSwapped() external { + _forkAndMigrateBeaconOwnership(); + deployCodeTo("src/concrete/StoxReceiptVault.sol:StoxReceiptVault", V4_IMPL); + deployCodeTo( + "src/concrete/StoxCorporateActionsFacet.sol:StoxCorporateActionsFacet", + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_1 + ); + vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + IUpgradeableBeacon(BEACON).upgradeTo(V4_IMPL); + + UpgradeReceiptVaultsToV4Harness harness = new UpgradeReceiptVaultsToV4Harness(); + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); + address firstAuth = address(IAuthorizableV1(vaults[0]).authorizer()); + vm.expectRevert( + abi.encodeWithSelector( + VaultAuthoriserMismatchPostUpgrade.selector, + vaults[0], + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, + firstAuth + ) + ); + harness.callAssertPostState(safe, vaults); + } +} diff --git a/test/script/UpgradeReceiptVaultsToV4Harness.sol b/test/script/UpgradeReceiptVaultsToV4Harness.sol new file mode 100644 index 00000000..cd12e134 --- /dev/null +++ b/test/script/UpgradeReceiptVaultsToV4Harness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; +import {UpgradeReceiptVaultsToV4} from "../../script/20260623-upgrade-receipt-vaults-to-v4.s.sol"; + +/// @title UpgradeReceiptVaultsToV4Harness +/// @notice Subclass of the upgrade script that exposes its `internal` +/// post-state assertion as `external` so `vm.expectRevert` can intercept the +/// typed `VaultAuthoriserMismatchPostUpgrade` it raises. The pre-flight guards +/// are exercised via `run()` directly in the tests (they revert before any +/// bundle is built); only the post-state — which `run()` reaches only after +/// the still-placeholder clone pin is hydrated — needs this seam to be driven +/// against a deliberately-malformed (un-swapped) state. +contract UpgradeReceiptVaultsToV4Harness is UpgradeReceiptVaultsToV4 { + function callAssertPostState(IGnosisSafe safe, address[] memory vaults) external view { + _assertPostState(safe, vaults); + } +} diff --git a/test/src/concrete/deploy/StoxProdV4PostSwap.t.sol b/test/src/concrete/deploy/StoxProdV4PostSwap.t.sol new file mode 100644 index 00000000..9ce7099c --- /dev/null +++ b/test/src/concrete/deploy/StoxProdV4PostSwap.t.sol @@ -0,0 +1,174 @@ +// 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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../../../../src/lib/LibAuthoriserInvariants.sol"; +import {LibMigrationInvariant} from "../../../../src/lib/LibMigrationInvariant.sol"; +import {LibProdDeployV4} from "../../../../src/lib/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {LibTokenInvariants} from "../../../../src/lib/LibTokenInvariants.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +/// @title StoxProdV4PostSwapTest +/// @notice Post-deploy + post-swap integrity pin for V4 on-chain state. +/// Two pin layers: +/// +/// **Layer 1 — V4 bytecode integrity (per-network).** The deterministic V4 +/// receipt vault implementation and the V4 corporate-actions facet must exist +/// at their post-rebuild Zoltu addresses with the audited V4 codehash on every +/// EVM network the ST0x deploy targets. Since V4 is only Zoltu-deployed on +/// Base today, the per-network check reads each address's `codehash` and +/// gates it through `LibMigrationInvariant`: either `bytes32(0)` (impl +/// undeployed) or the pinned V4 codehash is accepted until +/// `V4_CROSS_NETWORK_DEPLOY_DEADLINE`; only the pinned codehash is +/// accepted after. If the impl has not been redeployed on a network by the +/// deadline, cron red-lines against that network. +/// +/// **Layer 2 — Authoriser swap window (Base only).** Every production +/// receipt vault reports either the V3 authoriser (`LibAuthoriserInvariants. +/// STOX_PROD_AUTHORISER`) or the pinned V4 clone +/// (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE`), gated by +/// `LibMigrationInvariant` against `V4_SWAP_DEADLINE`. Before the deadline +/// both states pass; after the deadline only the V4 clone is accepted. Base- +/// only because no other network carries live production receipt vaults. +/// +/// When (and only when) the clone pin is hydrated the additional invariants +/// on the clone itself — deployed codehash matches the pin, grant map +/// matches `LibAuthoriserInvariants.expectedGrants()` — are enforced. That +/// keeps the check from tautologically asserting on `address(0)` while the +/// pin is still a placeholder. +/// +/// @dev Fork tests use an unpinned Base head fork so `block.timestamp` is +/// real and cron picks up the deadline transition automatically. +contract StoxProdV4PostSwapTest is Test { + /// @notice Unix timestamp past which every network the ST0x deploy + /// targets must carry the V4 receipt vault impl + corporate-actions + /// facet at their Zoltu addresses with the pinned codehash. + /// `2026-11-01T00:00:00Z`. + /// @dev PLACEHOLDER — set to the operator SLA for the cross-network V4 + /// Zoltu redeploy. Adjust before merge if the intended cut-off is + /// different. The Base-side swap deadline lives in + /// `LibProdDeployV4.V4_SWAP_DEADLINE` (shared with + /// `LibInvariants.assertAll`'s authoriser leg). + uint256 internal constant V4_CROSS_NETWORK_DEPLOY_DEADLINE = 1_793_491_200; + + /// @notice Assert both V4 artifacts (receipt vault impl + corporate- + /// actions facet) are either undeployed (`codehash == 0`) or deployed + /// with the pinned codehash on the active fork, gated by + /// `V4_CROSS_NETWORK_DEPLOY_DEADLINE`. Before the deadline both states + /// pass; after the deadline only the pinned codehash is accepted. + function checkAllV4OnChain() internal view { + LibMigrationInvariant.assertMigration( + "STOX_RECEIPT_VAULT_0_1_1.codehash", + LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1.codehash, + bytes32(0), + LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1, + V4_CROSS_NETWORK_DEPLOY_DEADLINE + ); + + LibMigrationInvariant.assertMigration( + "STOX_CORPORATE_ACTIONS_FACET_0_1_1.codehash", + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_1.codehash, + bytes32(0), + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_CODEHASH_0_1_1, + V4_CROSS_NETWORK_DEPLOY_DEADLINE + ); + } + + /// @notice Assert the vault-authoriser transition on Base: every prod + /// receipt vault reports V3 authoriser or V4 clone up to + /// `LibProdDeployV4.V4_SWAP_DEADLINE`, only V4 clone after. Once the + /// clone pin is hydrated (address non-zero), additionally assert the + /// clone's codehash pin + full grant map (the 11 `expectedGrants()` + /// pairs plus all 7 auto-granted `_ADMIN` roles on the Safe, covering + /// the two corporate-action admins the lib map doesn't carry). + /// Base-only. + function checkAuthoriserSwapWindowOnBase() internal view { + address v4Clone = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + + // Every prod receipt vault reports V3 authoriser (pre) or V4 clone + // (post) — the same migration-window leg `LibInvariants.assertAll` + // composes. + LibTokenInvariants.assertUniformAuthoriserMigration( + LibAuthoriserInvariants.STOX_PROD_AUTHORISER, v4Clone, LibProdDeployV4.V4_SWAP_DEADLINE + ); + + // Once the clone pin is hydrated, the clone must be deployed at + // that address with the pinned codehash and carry the expected + // grant map exactly. Before hydration `v4Clone` is `address(0)`, + // there is no clone to assert on, and these checks are skipped — + // the migration invariant above still enforces the swap by the + // deadline (via the `pre != post` branch), so the checks are not + // load-bearing while the pin is a placeholder. + if (v4Clone != address(0)) { + assertTrue(v4Clone.code.length > 0, "V4 authoriser clone not deployed"); + assertEq( + v4Clone.codehash, + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, + "V4 authoriser clone codehash mismatch" + ); + + IAccessControl cloneAcl = IAccessControl(v4Clone); + + // The 11 pairs the invariant lib pins (5 V3-era admins + 6 + // operational grants). + RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); + for (uint256 i = 0; i < grants.length; i++) { + assertTrue(cloneAcl.hasRole(grants[i].role, grants[i].grantee), "V4 clone missing expected grant"); + } + + // All 7 auto-granted `_ADMIN` roles held by the Safe — covers + // the two corporate-action admins (`SCHEDULE_...` / + // `CANCEL_CORPORATE_ACTION_ADMIN`) that `expectedGrants()` + // doesn't carry (V4-only roles granted to the Safe by the + // clone-deploy broadcast, per the RAI-731 decision). + bytes32[7] memory adminRoles = [ + keccak256("CERTIFY_ADMIN"), + keccak256("CONFISCATE_RECEIPT_ADMIN"), + keccak256("CONFISCATE_SHARES_ADMIN"), + keccak256("DEPOSIT_ADMIN"), + keccak256("WITHDRAW_ADMIN"), + keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"), + keccak256("CANCEL_CORPORATE_ACTION_ADMIN") + ]; + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + for (uint256 i = 0; i < adminRoles.length; i++) { + assertTrue(cloneAcl.hasRole(adminRoles[i], safe), "Safe missing auto-granted admin role on V4 clone"); + } + } + } + + /// V4 implementations MUST be deployed on Arbitrum. + function testProdDeployArbitrumV4() external { + vm.createSelectFork(LibRainDeploy.ARBITRUM_ONE); + checkAllV4OnChain(); + } + + /// V4 implementations MUST be deployed on Base + every live prod vault + /// reports V3 or V4 clone as authoriser, gated by `V4_SWAP_DEADLINE`. + function testProdDeployBaseV4() external { + vm.createSelectFork(LibRainDeploy.BASE); + checkAllV4OnChain(); + checkAuthoriserSwapWindowOnBase(); + } + + /// V4 implementations MUST be deployed on Base Sepolia. + function testProdDeployBaseSepoliaV4() external { + vm.createSelectFork(LibRainDeploy.BASE_SEPOLIA); + checkAllV4OnChain(); + } + + /// V4 implementations MUST be deployed on Flare. + function testProdDeployFlareV4() external { + vm.createSelectFork(LibRainDeploy.FLARE); + checkAllV4OnChain(); + } + + /// V4 implementations MUST be deployed on Polygon. + function testProdDeployPolygonV4() external { + vm.createSelectFork(LibRainDeploy.POLYGON); + checkAllV4OnChain(); + } +} diff --git a/test/src/concrete/upgrade/V3UpgradeShadowFork.t.sol b/test/src/concrete/upgrade/V3UpgradeShadowFork.t.sol new file mode 100644 index 00000000..89d3ff1e --- /dev/null +++ b/test/src/concrete/upgrade/V3UpgradeShadowFork.t.sol @@ -0,0 +1,294 @@ +// 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 {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibProdDeployV1} from "../../../../src/lib/LibProdDeployV1.sol"; +import {LibProdDeployV4} from "../../../../src/lib/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {LibAuthoriserInvariants} from "../../../../src/lib/LibAuthoriserInvariants.sol"; +import {LibTokenInvariants} from "../../../../src/lib/LibTokenInvariants.sol"; +import {LibSafeOps, IUpgradeableBeacon} from "../../../../src/lib/LibSafeOps.sol"; +import {SCHEDULE_CORPORATE_ACTION} from "../../../../src/lib/LibCorporateAction.sol"; +import { + StoxOffchainAssetReceiptVaultAuthorizerV1 +} from "../../../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; +import { + OffchainAssetReceiptVaultAuthorizerV1Config +} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; +import {ICloneableFactoryV2} from "rain-factory-0.1.1/src/interface/ICloneableFactoryV2.sol"; +import {LibCloneFactoryDeploy} from "rain-factory-0.1.1/src/lib/LibCloneFactoryDeploy.sol"; +import { + ICorporateActionsV1, + ACTION_TYPE_STOCK_SPLIT_V1, + VALID_ACTION_TYPES_MASK +} from "../../../../src/interface/ICorporateActionsV1.sol"; +import {CompletionFilter} from "../../../../src/lib/LibCorporateActionNode.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {IReceiptVaultV3} from "rain-vats-0.1.6/src/interface/IReceiptVaultV3.sol"; +import {IReceiptV3} from "rain-vats-0.1.6/src/interface/IReceiptV3.sol"; +import {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol"; +import {IAuthorizeV1, Unauthorized} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; +import {ICertifiableV1} from "rain-vats-0.1.6/src/interface/ICertifiableV1.sol"; +import {ERC1967_BEACON_SLOT} from "rain-extrospection-0.1.1/src/lib/LibExtrospectERC1967BeaconProxy.sol"; + +/// @title V3UpgradeShadowForkTest +/// @notice Shadow-fork verification of the receipt vault V3 upgrade against +/// LIVE production tokens. `setUp()` forks Base at head, applies the +/// beacon-ownership migration (PR-A) and the V3 upgrade (PR-B) to the fork, +/// then each test exercises a behaviour against a real on-chain receipt vault +/// in the upgraded state. +/// +/// This is the "ideally we'd run the current behaviour against real tokens +/// with the upgrade applied" check: it targets the V3 deltas (corporate-action +/// fallback routing) plus the critical user paths that must survive the +/// upgrade (backwards-compat reads, authoriser wiring, receipt wiring, +/// certification). It is deliberately NOT a full mixin that re-runs every +/// existing test against the upgraded fork — that is a larger refactor worth +/// building only if many upgrades accumulate. +/// +/// @dev The upgrade is applied to the fork via three cheatcode-driven steps, +/// each simulating an operational action that has not yet executed on-chain: +/// +/// 1. **Plant V3 bytecode** — the V3 receipt vault implementation and the +/// corporate-actions facet are planted at their deterministic Zoltu +/// addresses via `deployCodeTo`, which runs their real constructors at the +/// target addresses. Running the facet's constructor at its production +/// address is required so its `_SELF` immutable resolves to +/// `STOX_CORPORATE_ACTIONS_FACET`; the vault's `fallback()` hardcodes that +/// address as its delegatecall target. +/// 2. **Beacon ownership** — the receipt vault beacon is transferred from the +/// rainlang.eth EOA to the Safe (PR-A's effect). +/// 3. **Upgrade** — `vm.prank(safe); beacon.upgradeTo(V3 impl)` upgrades the +/// beacon. Every live receipt vault behind the beacon now runs V3 code. +contract V3UpgradeShadowForkTest is Test { + /// @notice The receipt vault beacon upgraded to V3. + address internal constant BEACON = LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1; + + /// @notice A representative live production receipt vault behind the + /// upgraded beacon. MSTR is the first entry in + /// `LibTokenInvariants.productionReceiptVaults`. + address internal constant LIVE_RECEIPT_VAULT = LibTokenInvariants.MSTR_RECEIPT_VAULT; + + /// @notice The live receipt (ERC-1155) paired with `LIVE_RECEIPT_VAULT`. + address internal constant LIVE_RECEIPT = LibTokenInvariants.MSTR_RECEIPT; + + /// @notice The live wrapped token vault paired with `LIVE_RECEIPT_VAULT`. + address internal constant LIVE_WRAPPED_VAULT = LibTokenInvariants.MSTR_WRAPPED_TOKEN_VAULT; + + function setUp() public { + vm.createSelectFork(LibRainDeploy.BASE); + + // 1. Plant the V3 receipt vault implementation and the corporate- + // actions facet at their deterministic addresses, running their + // constructors there so the facet's `_SELF` and the vault's pinned + // facet target line up. + deployCodeTo("src/concrete/StoxReceiptVault.sol:StoxReceiptVault", LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1); + deployCodeTo( + "src/concrete/StoxCorporateActionsFacet.sol:StoxCorporateActionsFacet", + LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_1 + ); + + // 2. Simulate PR-A: transfer the beacon from the EOA to the Safe. + vm.prank(LibProdDeployV1.BEACON_INITIAL_OWNER); + Ownable(BEACON).transferOwnership(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + + // 3. Apply the upgrade: the Safe points the beacon at the V3 impl. + vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + IUpgradeableBeacon(BEACON).upgradeTo(LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1); + } + + /// @notice Read the EIP-1967 beacon address from a proxy contract. Mirrors + /// `LibTokenInvariantsAddressesTest.beaconOf`. + function beaconOf(address proxy) internal view returns (address) { + return address(uint160(uint256(vm.load(proxy, ERC1967_BEACON_SLOT)))); + } + + /// @notice Sanity: the fork is in the upgraded state. The beacon is + /// Safe-owned, points at the V3 implementation, and the live receipt vault + /// is still behind this beacon. + function testForkIsInUpgradedState() external view { + assertEq(Ownable(BEACON).owner(), LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, "beacon Safe-owned"); + assertEq(IBeacon(BEACON).implementation(), LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1, "beacon at V3 impl"); + assertEq(beaconOf(LIVE_RECEIPT_VAULT), BEACON, "live vault behind the upgraded beacon"); + } + + /// @notice Headline V3 change: corporate-action selectors on a LIVE + /// receipt vault route into the facet via the vault's fallback + /// delegatecall. `completedActionCount()` is not a selector on the vault + /// itself — it only resolves if the fallback forwards to the facet — so a + /// non-reverting read proves the wiring. A fresh-to-corporate-actions + /// live vault returns 0 completed actions. + function testCorporateActionsFacetWiredOnLiveVault() external view { + uint256 completed = ICorporateActionsV1(LIVE_RECEIPT_VAULT).completedActionCount(); + assertEq(completed, 0, "live vault has no completed corporate actions post-upgrade"); + } + + /// @notice The traversal getters route through the fallback and return the + /// expected empty-list tuple on a live vault with no scheduled actions. + /// Exercises the read path of the V3 facet end-to-end against real state. + function testCorporateActionTraversalRoutesOnLiveVault() external view { + (uint256 cursor, uint256 actionType, uint64 effectiveTime) = + ICorporateActionsV1(LIVE_RECEIPT_VAULT).latestActionOfType(VALID_ACTION_TYPES_MASK, CompletionFilter.ALL); + assertEq(cursor, type(uint256).max, "no action -> NODE_NONE cursor"); + assertEq(actionType, 0, "no action -> zero type"); + assertEq(effectiveTime, 0, "no action -> zero effectiveTime"); + } + + /// @notice Backwards-compat smoke: the ERC-20 metadata read surface of a + /// live receipt vault is preserved post-upgrade. `decimals`, `name`, and + /// `symbol` are reads integrators depend on; the V3 fallback must not + /// shadow them. `asset()` is asserted `address(0)`: an offchain-asset + /// receipt vault has no on-chain underlying ERC-20, and the upgrade must + /// not change that. + function testBackwardsCompatReadsOnLiveVault() external view { + // decimals is pinned at 18 for prod vaults; the V3 impl must not change + // the inherited ERC-20 metadata surface. + assertEq(IERC20Metadata(LIVE_RECEIPT_VAULT).decimals(), 18, "decimals preserved"); + assertGt(bytes(IERC20Metadata(LIVE_RECEIPT_VAULT).name()).length, 0, "name still readable"); + assertGt(bytes(IERC20Metadata(LIVE_RECEIPT_VAULT).symbol()).length, 0, "symbol still readable"); + // An OffchainAssetReceiptVault holds an offchain asset, so `asset()` + // is the zero address by design. The read must still resolve (not + // route into the facet) and report zero — the upgrade does not wire an + // on-chain underlying. + assertEq( + IReceiptVaultV3(payable(LIVE_RECEIPT_VAULT)).asset(), address(0), "offchain vault has no on-chain asset" + ); + } + + /// @notice The ERC-20 token supply surface of a live receipt vault still + /// reads post-upgrade. `totalSupply` and a `balanceOf` read are inherited + /// ReceiptVault selectors (not facet selectors); the V3 fallback must not + /// shadow them, and the corporate-action rebase logic the V3 impl adds must + /// keep these resolving against real on-chain balances. + function testSupplyReadsPreservedOnLiveVault() external view { + // totalSupply resolves through the upgraded impl (rebased view). The + // load-bearing property is that the call returns a defined value + // without reverting or routing into the facet. + uint256 supply = IERC20Metadata(LIVE_RECEIPT_VAULT).totalSupply(); + assertGe(supply, 0, "totalSupply returns a defined value"); + // balanceOf of the zero address is a stable, side-effect-free read that + // exercises the rebased balance path on the upgraded impl. + assertEq(IERC20Metadata(LIVE_RECEIPT_VAULT).balanceOf(address(0)), 0, "zero-address balance is zero"); + } + + /// @notice The authoriser is still wired on the live vault post-upgrade and + /// resolves to a deployed contract. The V3 facet reads the vault's + /// authoriser for corporate-action gating, so the upgrade must preserve the + /// `authorizer()` accessor and its stored value. + function testAuthorizerStillWiredOnLiveVault() external view { + IAuthorizeV1 authorizer = IAuthorizableV1(LIVE_RECEIPT_VAULT).authorizer(); + assertTrue(address(authorizer) != address(0), "authorizer still set"); + assertTrue(address(authorizer).code.length > 0, "authorizer is a deployed contract"); + } + + /// @notice Receipt mint/burn wiring is preserved: the live vault's + /// `receipt()` resolves to the paired ERC-1155, and that receipt's + /// `manager()` is the vault. If this drifted, every deposit (mint receipt) + /// and withdraw (burn receipt) on the live token would revert. + function testReceiptWiringPreservedOnLiveVault() external view { + IReceiptV3 receipt = IReceiptVaultV3(payable(LIVE_RECEIPT_VAULT)).receipt(); + assertEq(address(receipt), LIVE_RECEIPT, "receipt address preserved"); + assertEq(receipt.manager(), LIVE_RECEIPT_VAULT, "receipt manager is the vault"); + } + + /// @notice Certification is unchanged by the upgrade: the live vault is + /// still within its certification window at the fork timestamp. The V3 + /// upgrade does not touch certification storage, so an expired flag here + /// would signal the upgrade corrupted unrelated state. + function testCertificationUnchangedOnLiveVault() external view { + assertFalse( + ICertifiableV1(LIVE_RECEIPT_VAULT).isCertificationExpired(), + "live vault still within certification window post-upgrade" + ); + } + + /// @notice The wrapped token vault wiring survives the upgrade: it still + /// reports the receipt vault as its ERC-4626 asset. The wrapped vault is + /// not upgraded (its beacon is untouched), but it depends on the receipt + /// vault, so this confirms the receipt vault upgrade did not break the + /// downstream wrapper's view of it. + function testWrappedVaultStillReferencesReceiptVault() external view { + assertEq( + IReceiptVaultV3(payable(LIVE_WRAPPED_VAULT)).asset(), + LIVE_RECEIPT_VAULT, + "wrapped vault still references the receipt vault" + ); + } + + /// @notice The OTHER half of the migration bundle — the `setAuthorizer` + /// swap — actually re-wires authorisation on a LIVE receipt vault. The + /// tests above cover the beacon (impl) leg; this covers the authoriser + /// leg: deploy a fresh V4 corporate-action-aware authoriser clone, + /// `setAuthorizer` the live vault onto it as the Safe owner, and prove the + /// vault now gates through the new clone — a granted `SCHEDULE_CORPORATE_ + /// ACTION` caller is permitted, an ungranted caller is rejected with the + /// exact `Unauthorized` error. The corporate-action permission is the V4 + /// delta: the pre-swap production authoriser configures no role admin for + /// it, so only the swapped V4 clone can gate it. + function testAuthoriserSwapReWiresGatingOnLiveVault() external { + // Pre-swap: the live vault reports the current production authoriser. + assertEq( + address(IAuthorizableV1(LIVE_RECEIPT_VAULT).authorizer()), + LibAuthoriserInvariants.STOX_PROD_AUTHORISER, + "pre-swap authoriser is the live production authoriser" + ); + + // Deploy a fresh V4 authoriser clone. The impl `_disableInitializers` + // in its constructor, so it must be cloned + initialised via the + // CloneFactory — the same path the production clone-deploy broadcast + // uses. + address cloneAdmin = makeAddr("cloneAdmin"); + StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); + address clone = ICloneableFactoryV2(LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS) + .clone(address(impl), abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: cloneAdmin}))); + + // Grant the corporate-action scheduling role to one user. `cloneAdmin` + // holds `SCHEDULE_CORPORATE_ACTION_ADMIN` from init (the V4 extension), + // so it is the role admin able to grant `SCHEDULE_CORPORATE_ACTION`. + address scheduler = makeAddr("scheduler"); + address outsider = makeAddr("outsider"); + vm.prank(cloneAdmin); + IAccessControl(clone).grantRole(SCHEDULE_CORPORATE_ACTION, scheduler); + + // Swap: the Safe (vault owner) rewires the live vault onto the clone. + vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + ISetAuthorizer(LIVE_RECEIPT_VAULT).setAuthorizer(IAuthorizeV1(clone)); + + // The swap landed: the vault now routes authorisation through the clone. + IAuthorizeV1 wired = IAuthorizableV1(LIVE_RECEIPT_VAULT).authorizer(); + assertEq(address(wired), clone, "post-swap authoriser is the new V4 clone"); + + // The swapped authoriser gates corporate actions: the granted + // scheduler is permitted (no revert)... + wired.authorize(scheduler, SCHEDULE_CORPORATE_ACTION, ""); + // ...and an ungranted caller is rejected with the exact typed error. + vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector, outsider, SCHEDULE_CORPORATE_ACTION, bytes(""))); + wired.authorize(outsider, SCHEDULE_CORPORATE_ACTION, ""); + } + + // ------------------------------------------------------------------------- + // TODO(audit): enumerate v0.1.1 findings — pending report from Josh/DM. + // + // The v0.1.1 audit report is not in-repo at the time of writing. Once it + // lands, add one focused behavioural test per finding addressed in V3, + // hitting the specific path the finding relates to against the upgraded + // live state above. Until then this shadow-fork suite covers the + // structural and behavioural deltas (corporate-action fallback routing, + // backwards-compat reads, authoriser/receipt wiring, certification) but + // does NOT yet assert finding-by-finding remediation. Do not treat the + // absence of this section's tests as evidence the findings are fixed. + // ------------------------------------------------------------------------- +} + +/// @dev Local mirror of the receipt-vault `setAuthorizer(IAuthorizeV1)` +/// owner-gated selector. Avoids dragging the full `OffchainAssetReceiptVault` +/// storage inheritance into this test just to encode one call. +interface ISetAuthorizer { + function setAuthorizer(IAuthorizeV1 newAuthorizer) external; +}