diff --git a/.github/workflows/manual-broadcast.yaml b/.github/workflows/manual-broadcast.yaml index 0a4003f1..d1138aa4 100644 --- a/.github/workflows/manual-broadcast.yaml +++ b/.github/workflows/manual-broadcast.yaml @@ -20,6 +20,7 @@ on: # registry of *which* scripts exist, not *whether* they've run. - 20260619-deploy-v4-authoriser-clone - 20260706-deploy-tokens-ethereum + - 20260722-deploy-missing-tokens-ethereum network: description: 'Network to broadcast against (default: base)' required: true diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index fe45a67e..e89e92a3 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -27,6 +27,7 @@ on: # `--broadcast`) — useful as a pre-flight smoke test. - 20260619-deploy-v4-authoriser-clone - 20260623-upgrade-receipt-vaults-to-v4 + - 20260722-swap-rklb-authoriser sig: description: 'Entrypoint to dispatch (default: run())' required: true diff --git a/script/20260722-deploy-missing-tokens-ethereum.s.sol b/script/20260722-deploy-missing-tokens-ethereum.s.sol new file mode 100644 index 00000000..dc02a09b --- /dev/null +++ b/script/20260722-deploy-missing-tokens-ethereum.s.sol @@ -0,0 +1,237 @@ +// 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 {Vm} from "forge-std-1.16.1/src/Vm.sol"; +import { + OffchainAssetReceiptVaultConfigV2 +} from "rain-vats-0.1.6/src/concrete/deploy/OffchainAssetReceiptVaultBeaconSetDeployer.sol"; +import {ReceiptVaultConfigV2} from "rain-vats-0.1.6/src/abstract/ReceiptVault.sol"; +import {IReceiptVaultV3} from "rain-vats-0.1.6/src/interface/IReceiptVaultV3.sol"; +import {IAuthorizeV1} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; +import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; +import {LibBeaconInvariants} from "../src/lib/LibBeaconInvariants.sol"; +import {IStoxUnifiedDeployerV1} from "../src/interface/IStoxUnifiedDeployerV1.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; +import {LibProdTokenConfig, TokenConfig} from "../src/lib/LibProdTokenConfig.sol"; +import {LibTokenInvariants, TokenInstance} from "../src/lib/LibTokenInvariants.sol"; + +/// @notice Pre-flight failed: a required deployer contract has no runtime +/// code at its pinned 0.1.1 address on the active fork. +/// @param deployer The pinned deployer address that is missing. +error DeployerNotDeployed(address deployer); + +/// @notice Pre-flight failed: the active chain's resolved token-owner Safe is +/// not the pinned ETHEREUM Safe — wrong-network dispatch, or the pin has not +/// landed. +/// @param safe The Safe address the active chain resolved to. +error EthereumSafeNotReady(address safe); + +/// @notice Pre-flight failed: the pinned Ethereum V4 authoriser is not ready +/// (unpinned / no code / wrong codehash). +/// @param authoriser The authoriser address inspected. +error EthereumAuthoriserNotReady(address authoriser); + +/// @notice Every canonical config row already has a fully-hydrated Ethereum +/// table entry — there is nothing left to deploy. Re-dispatching would mint +/// duplicate tokens, which is never meaningful. +error NoMissingTokens(); + +/// @notice The canonical config table and the Ethereum token table have +/// drifted out of row alignment. The gap-filling join is by index, so a +/// misaligned row must abort the deploy rather than deploy under the wrong +/// underlying. +/// @param index The misaligned row. +/// @param configUnderlying The config table's underlying at that row. +/// @param tableUnderlying The Ethereum table's underlying at that row. +error TokenTableMisaligned(uint256 index, string configUnderlying, string tableUnderlying); + +/// @title DeployMissingTokensEthereum +/// @notice **EXECUTED 2026-07-22** (manual-broadcast run 29924926246: RKLB +/// deployed, wired, and handed to the Safe; tuple pinned in +/// `LibTokenInvariants.productionTokensEthereum()`). Gap-filling counterpart of +/// `20260706-deploy-tokens-ethereum` (EXECUTED 2026-07-22): deploys, on +/// Ethereum mainnet, exactly the canonical config rows whose +/// `LibTokenInvariants.productionTokensEthereum()` entry is still all-zero — +/// tokens added to the canonical set after the original broadcast ran (RKLB +/// as of this script's authoring). Dispatch via `Actions → manual-broadcast` +/// with `script = 20260722-deploy-missing-tokens-ethereum` and +/// `network = ethereum`. Flips to `**EXECUTED YYYY-MM-DD.**` in the +/// post-execution pin PR. +/// +/// Deliberately SELF-SCOPING, mirroring the 20260722 authoriser-swap +/// authoring on the Base side: the executed 20260706 script deploys EVERY +/// config row, so re-dispatching it after the table grows would mint 28 +/// duplicates. This script joins the canonical config against the Ethereum +/// table row-by-row (aborting on any underlying misalignment) and deploys +/// only the all-zero rows — the explicit "missing on this chain" state. A +/// future late-added token is picked up by re-dispatch; a fully-hydrated +/// table refuses to deploy anything (`NoMissingTokens`). +/// +/// Per deployed token, identical to 20260706: deploy via the 0.1.1 unified +/// deployer (initialAdmin = deploy key) -> read back the ERC-1155 receipt -> +/// `setAuthorizer(Ethereum V4 authoriser)` -> `transferOwnership(Ethereum +/// Safe)` — one deploy-key broadcast, no Safe signature. Logs each +/// (underlying, receipt, receiptVault, wrapped) tuple for the pin PR. +contract DeployMissingTokensEthereum is Script { + /// @notice Assert a deployer contract is present at its pinned address. + /// @param deployer The pinned deployer address. + function _assertDeployer(address deployer) internal view { + if (deployer.code.length == 0) revert DeployerNotDeployed(deployer); + } + + /// @notice The active chain's token-owner Safe, resolved + policy-asserted + /// through the shared entry point, then guarded to be ETHEREUM's Safe. + /// @return safe The validated Ethereum token-owner Safe address. + function _assertSafeReady() internal view returns (address safe) { + safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + if (safe != LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM) { + revert EthereumSafeNotReady(safe); + } + } + + /// @notice Assert the Ethereum V4 authoriser is deployed at its pin with + /// the shared EIP-1167 codehash. + /// @return authoriser The validated authoriser address. + function _assertAuthoriserReady() internal view returns (address authoriser) { + authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; + if ( + authoriser == address(0) || authoriser.code.length == 0 + || authoriser.codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH + ) { + revert EthereumAuthoriserNotReady(authoriser); + } + } + + /// @notice Select the configs to deploy: canonical config rows whose + /// Ethereum table entry is all-zero. Joined by index with the underlying + /// asserted equal row-for-row (the same alignment the cross-chain parity + /// pin enforces); any drift aborts (`TokenTableMisaligned`). Reverts + /// `NoMissingTokens` when the table is fully hydrated. + /// @return missing The config rows still missing on Ethereum. + function _selectMissing() internal pure returns (TokenConfig[] memory missing) { + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + TokenInstance[] memory table = LibTokenInvariants.productionTokensEthereum(); + if (configs.length != table.length) { + revert TokenTableMisaligned( + configs.length < table.length ? configs.length : table.length, "", "" + ); + } + TokenConfig[] memory candidates = new TokenConfig[](configs.length); + uint256 count = 0; + for (uint256 i = 0; i < configs.length; i++) { + if (keccak256(bytes(configs[i].underlying)) != keccak256(bytes(table[i].underlying))) { + revert TokenTableMisaligned(i, configs[i].underlying, table[i].underlying); + } + bool entryClear = table[i].receipt == address(0) && table[i].receiptVault == address(0) + && table[i].wrappedTokenVault == address(0); + if (!entryClear) { + continue; + } + candidates[count] = configs[i]; + count++; + } + if (count == 0) { + revert NoMissingTokens(); + } + missing = new TokenConfig[](count); + for (uint256 i = 0; i < count; i++) { + missing[i] = candidates[i]; + } + } + + /// @notice Deploy every canonical token still missing from the Ethereum + /// table via the 0.1.1 unified deployer, wire each onto the V4 + /// authoriser, and hand ownership to the Safe — one deploy-key + /// broadcast, matched to the executed 20260706 flow. Broadcasts as the + /// key `manual-broadcast.yaml` supplies via `--private-key`. Logs each + /// deployed tuple for the pin PR. + function run() external { + // Pre-flight: identical gate chain to 20260706 — the 0.1.1 core + // (whose beacon set IS the chain's in-use production beacons), the + // in-use beacons Safe-owned, the authoriser, the Safe. + address unifiedDeployer = LibProdDeployV4.STOX_UNIFIED_DEPLOYER_0_1_1; + _assertDeployer(unifiedDeployer); + _assertDeployer(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_1); + _assertDeployer(LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_BEACON_SET_DEPLOYER_0_1_1); + LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid); + address authoriser = _assertAuthoriserReady(); + address safe = _assertSafeReady(); + + TokenConfig[] memory configs = _selectMissing(); + + bytes32 deploymentTopic = keccak256("Deployment(address,address,address)"); + + vm.startBroadcast(); + + // Deployer identity — inside `vm.startBroadcast()` msg.sender + // resolves to the broadcast address (`--private-key` in production). + address deployer = msg.sender; + + console2.log("Deploying", configs.length, "missing tokens on chain id", block.chainid); + console2.log("initialAdmin (deploy key, handed to Safe):", deployer); + console2.log("token-owner Safe:", safe); + console2.log("V4 authoriser:", authoriser); + + for (uint256 i = 0; i < configs.length; i++) { + TokenConfig memory cfg = configs[i]; + OffchainAssetReceiptVaultConfigV2 memory vaultConfig = OffchainAssetReceiptVaultConfigV2({ + // The deploy key is the transient owner: it setAuthorizer's the + // vault then hands ownership to the Safe, all below. + initialAdmin: deployer, + receiptVaultConfig: ReceiptVaultConfigV2({ + asset: address(0), name: cfg.name, symbol: cfg.symbol, receipt: address(0) + }) + }); + + vm.recordLogs(); + IStoxUnifiedDeployerV1(unifiedDeployer).newTokenAndWrapperVault(vaultConfig); + + // Fish the deployed pair out of the unified deployer's + // `Deployment(sender, asset, wrapper)` event. + Vm.Log[] memory logs = vm.getRecordedLogs(); + (address receiptVault, address wrapped) = (address(0), address(0)); + for (uint256 j = 0; j < logs.length; j++) { + if ( + logs[j].emitter == unifiedDeployer && logs[j].topics.length > 0 + && logs[j].topics[0] == deploymentTopic + ) { + (, receiptVault, wrapped) = abi.decode(logs[j].data, (address, address, address)); + } + } + + // The unified deployer's event drops the ERC-1155 receipt, so read + // it back off the vault for the pin PR to hydrate. + address receipt = address(IReceiptVaultV3(payable(receiptVault)).receipt()); + + // Wire onto the authoriser (deploy key is still owner), then + // relinquish ownership to the Safe. Order matters: `setAuthorizer` + // is `onlyOwner`, so it must precede the handoff. + ISetAuthorizer(receiptVault).setAuthorizer(IAuthorizeV1(authoriser)); + Ownable(receiptVault).transferOwnership(safe); + + console2.log("==== TOKEN DEPLOYED ===="); + console2.log("underlying:", cfg.underlying); + console2.log("receipt (ERC-1155):", vm.toString(receipt)); + console2.log("receiptVault:", vm.toString(receiptVault)); + console2.log("wrappedTokenVault:", vm.toString(wrapped)); + } + + vm.stopBroadcast(); + + console2.log( + "All missing tokens deployed, authorised, and handed to the Safe." + " Hydrate the all-zero LibTokenInvariants.productionTokensEthereum()" " rows from the logged tuples." + ); + } +} + +/// @dev Local mirror of the receipt-vault `setAuthorizer(IAuthorizeV1)` +/// owner-gated selector — rain-vats ships no interface carrying it; see the +/// 20260706 script for the full rationale. +interface ISetAuthorizer { + function setAuthorizer(IAuthorizeV1 newAuthorizer) external; +} diff --git a/script/20260722-swap-rklb-authoriser.s.sol b/script/20260722-swap-rklb-authoriser.s.sol new file mode 100644 index 00000000..9393d26e --- /dev/null +++ b/script/20260722-swap-rklb-authoriser.s.sol @@ -0,0 +1,228 @@ +// 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 {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 {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibTokenInvariants} from "../src/lib/LibTokenInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; + +/// @notice The RKLB receipt vault already reports the V4 authoriser — the +/// swap has executed and this script has nothing to author. +/// @param actual The authoriser the vault reports (the V4 authoriser). +error RklbAlreadySwapped(address actual); + +/// @notice The RKLB receipt vault reports an authoriser that is neither the +/// V3 authoriser (the only acceptable pre-swap state) nor the V4 authoriser +/// (already swapped). Unknown drift must abort the authoring, never be +/// papered over with a blind `setAuthorizer`. +/// @param actual The unexpected address returned by `authorizer()`. +error UnexpectedRklbAuthoriser(address actual); + +/// @notice The pinned V4 authoriser has no runtime code. +/// @param authoriser The authoriser address that has no code. +error RklbSwapAuthoriserNotDeployed(address authoriser); + +/// @notice The V4 authoriser's runtime codehash does not match the pin. +/// @param authoriser The authoriser address inspected. +/// @param expected The pinned codehash. +/// @param actual The codehash observed on-chain. +error RklbSwapAuthoriserCodehashMismatch(address authoriser, bytes32 expected, bytes32 actual); + +/// @notice The V4 authoriser is missing one of the pinned role grants. +/// @param authoriser The authoriser address inspected. +/// @param role The missing role. +/// @param grantee The grantee that should hold the role. +error RklbSwapAuthoriserGrantMissing(address authoriser, bytes32 role, address grantee); + +/// @title SwapRklbAuthoriser +/// @notice **EXECUTED 2026-07-23.** Authored the SINGLE-tx Safe bundle that +/// swapped the RKLB receipt vault onto the V4 authoriser +/// (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE`), and the Safe executed +/// it. `0xf6744Fd94e27c2f58F6110aa9fDC77A87e41766B` now reports +/// `authorizer() == 0x315b16faa6eE413faBCa877d3851B3818369f0cD`, read from +/// three independent Base RPCs. Dispatched via `Actions → run-script` with +/// `script = 20260722-swap-rklb-authoriser` and `sig = run()`. +/// +/// Re-running is refused rather than repeated: the pre-flight reverts +/// `RklbAlreadySwapped` now that the vault is on the V4 authoriser, so this +/// script is self-disarming. +/// +/// Deliberately scoped to RKLB ALONE: the six other still-V3 vaults are +/// covered by a bundle authored from +/// `20260722-swap-remaining-vault-authorisers` that is already partially +/// signed (2-of-3 at the time of this script's authoring). Regenerating a +/// combined 7-tx bundle would change the SafeTxHash and void those +/// signatures, so RKLB — added to the canonical table after that bundle was +/// authored — gets its own single tx instead. The two bundles target +/// disjoint vaults and are order-independent. Once both execute, the +/// self-scoping general script reverts `NoVaultsLeftToSwap` and the strict +/// uniform-authoriser invariants go green across the whole table. +/// +/// This is a Safe-routed operation (`setAuthorizer` is `onlyOwner`; the +/// vault is Safe-owned), so the script emits a Safe Tx Builder JSON artifact +/// for signer review + execution via the Safe UI. It never broadcasts. +/// +/// @dev Flow mirrors the general swap authoring, narrowed to one vault: +/// pre-flight (Safe state, V4 authoriser codehash + full grant map, RKLB +/// strictly on V3), build the single `setAuthorizer` tx, compute its +/// `SafeTxHash` against the live nonce, simulate as the Safe, assert the +/// post-state (RKLB on the V4 authoriser; Safe identity + threshold +/// unchanged — deliberately NOT whole-table uniformity, since the six-vault +/// bundle executes independently), emit the artifact to +/// `out/20260722-rklb-authoriser-swap.json`, and prove the forward-only n+1 +/// re-issue clears the live threshold (rollback to V3 is structurally +/// impossible: the V4 vault impl rejects an authoriser without a +/// corporate-action role admin). +contract SwapRklbAuthoriser is Script { + /// @notice The RKLB receipt vault the single tx targets. + address internal constant RKLB_RECEIPT_VAULT = LibTokenInvariants.RKLB_RECEIPT_VAULT; + + /// @notice The V4 authoriser the vault is rewired onto. + address internal constant V4_AUTHORISER = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + + /// @notice The V3 authoriser — the only acceptable pre-swap state. + address internal constant V3_AUTHORISER = LibAuthoriserInvariants.STOX_PROD_AUTHORISER; + + /// @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 authoriser swap: RKLB onto the V4 authoriser"; + + /// @notice Output path (relative to the project root) for the Tx Builder + /// JSON artifact. + string internal constant ARTIFACT_PATH = "out/20260722-rklb-authoriser-swap.json"; + + /// @notice Author the RKLB authoriser swap: pre-flight invariants, + /// simulate the single `setAuthorizer`, assert the post-state, emit the + /// Tx Builder JSON, log the SafeTxHash, and prove the forward-only n+1 + /// re-issue clears the live threshold. Does not broadcast — execution + /// happens via the Safe UI using the emitted artifact. + function run() external { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + + // --- Pre-flight --------------------------------------------------- + + LibSafeInvariants.assertAll(safe); + _preflightAuthoriser(); + + // RKLB must be STRICTLY on the V3 authoriser: already-swapped means + // done (revert, nothing to author); anything else is unknown drift. + address actual = address(IAuthorizableV1(RKLB_RECEIPT_VAULT).authorizer()); + if (actual == V4_AUTHORISER) { + revert RklbAlreadySwapped(actual); + } + if (actual != V3_AUTHORISER) { + revert UnexpectedRklbAuthoriser(actual); + } + + // --- Build the single tx ------------------------------------------ + + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({ + to: RKLB_RECEIPT_VAULT, + value: 0, + data: abi.encodeCall(OffchainAssetReceiptVaultLike.setAuthorizer, (IAuthorizeV1(V4_AUTHORISER))), + operation: 0 + }); + + // Capture the nonce before simulation so the hash binds to the + // current Safe state. + uint256 nonce = safe.nonce(); + bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + // --- Simulate ----------------------------------------------------- + + LibSafeOps.simulateExternalCall(safe, txs[0].to, txs[0].data); + + // --- Post-state --------------------------------------------------- + + // RKLB now reports the V4 authoriser. Deliberately NOT whole-table + // uniformity: the six-vault bundle executes independently and its + // state must not gate this authoring. Safe identity + threshold + // unchanged. + address post = address(IAuthorizableV1(RKLB_RECEIPT_VAULT).authorizer()); + require(post == V4_AUTHORISER, "SwapRklbAuthoriser: simulated swap did not land on the V4 authoriser"); + LibSafeInvariants.assertImmutableInvariants(safe); + LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + + // --- 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("SafeTxHash:", vm.toString(safeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Target vault (RKLB):", RKLB_RECEIPT_VAULT); + + // --- n+1 re-issue --------------------------------------------------- + + // Forward-only recovery proof; see the general swap script for why a + // V3 rollback is structurally impossible on V4 vault impls. + bytes memory reissueData = + abi.encodeCall(OffchainAssetReceiptVaultLike.setAuthorizer, (IAuthorizeV1(V4_AUTHORISER))); + LibSafeOps.simulateNPlus1( + safe, RKLB_RECEIPT_VAULT, reissueData, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD + ); + require( + address(IAuthorizableV1(RKLB_RECEIPT_VAULT).authorizer()) == V4_AUTHORISER, + "SwapRklbAuthoriser: n+1 re-issue did not leave the vault on the V4 authoriser" + ); + console2.log("n+1 re-issue check passed: the Safe can re-point the vault under the live threshold"); + } + + /// @notice Pre-flight: the V4 authoriser is deployed at its pin with the + /// pinned EIP-1167 codehash and carries the full grant map — the 11 + /// `expectedGrants()` pairs plus all seven auto-granted `_ADMIN` roles + /// on the Safe. The vault must never be pointed at an authoriser whose + /// configuration has drifted. + function _preflightAuthoriser() internal view { + if (V4_AUTHORISER.code.length == 0) { + revert RklbSwapAuthoriserNotDeployed(V4_AUTHORISER); + } + bytes32 codehash = V4_AUTHORISER.codehash; + if (codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH) { + revert RklbSwapAuthoriserCodehashMismatch( + V4_AUTHORISER, LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, codehash + ); + } + IAccessControl acl = IAccessControl(V4_AUTHORISER); + RoleGrant[] memory expected = LibAuthoriserInvariants.expectedGrants(); + for (uint256 i = 0; i < expected.length; i++) { + if (!acl.hasRole(expected[i].role, expected[i].grantee)) { + revert RklbSwapAuthoriserGrantMissing(V4_AUTHORISER, expected[i].role, expected[i].grantee); + } + } + 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 (!acl.hasRole(adminRoles[i], ownerSafe)) { + revert RklbSwapAuthoriserGrantMissing(V4_AUTHORISER, adminRoles[i], ownerSafe); + } + } + } +} + +/// @dev Local mirror of the receipt-vault `setAuthorizer(IAuthorizeV1)` +/// selector; see the 20260706 script for the full rationale. +interface OffchainAssetReceiptVaultLike { + function setAuthorizer(IAuthorizeV1 newAuthorizer) external; +} diff --git a/test/script/20260722-deploy-missing-tokens-ethereum.t.sol b/test/script/20260722-deploy-missing-tokens-ethereum.t.sol new file mode 100644 index 00000000..91641d4d --- /dev/null +++ b/test/script/20260722-deploy-missing-tokens-ethereum.t.sol @@ -0,0 +1,49 @@ +// 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 { + DeployMissingTokensEthereum, + DeployerNotDeployed, + NoMissingTokens +} from "../../script/20260722-deploy-missing-tokens-ethereum.s.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {TokenConfig} from "../../src/lib/LibProdTokenConfig.sol"; +import {DeployMissingTokensEthereumHarness} from "./DeployMissingTokensEthereumHarness.sol"; + +/// @title DeployMissingTokensEthereumTest +/// @notice Coverage for the gap-filling Ethereum token deploy. The script is +/// self-scoping over the in-code tables (canonical config vs the Ethereum +/// token table), so the selection logic is PURE — testable without a fork — +/// and the deploy pre-flight reuses the gate chain the 20260706 suite and +/// the per-chain prod pins already exercise against live Ethereum. +contract DeployMissingTokensEthereumTest is Test { + DeployMissingTokensEthereum internal script; + + function setUp() external { + script = new DeployMissingTokensEthereum(); + } + + /// @notice The Ethereum table is fully hydrated (the RKLB gap-fill + /// EXECUTED 2026-07-22 and its row is pinned), so the selection refuses + /// to author anything: `NoMissingTokens`. This is the guard that keeps a + /// re-dispatch of the EXECUTED script from minting duplicates. When a + /// future token lands in the canonical config with an all-zero Ethereum + /// row, this flips back to a positive selection expectation. + function testSelectionRevertsWhenTableFullyHydrated() external { + DeployMissingTokensEthereumHarness harness = new DeployMissingTokensEthereumHarness(); + vm.expectRevert(NoMissingTokens.selector); + harness.selectMissing(); + } + + /// @notice `run()` reverts `DeployerNotDeployed` when the 0.1.1 core has + /// not been broadcast to the active chain (no fork: the pre-bootstrap + /// state, and the first guard in the pre-flight chain). + function testRunRevertsWhenCoreNotDeployed() external { + vm.expectRevert( + abi.encodeWithSelector(DeployerNotDeployed.selector, LibProdDeployV4.STOX_UNIFIED_DEPLOYER_0_1_1) + ); + script.run(); + } +} diff --git a/test/script/20260722-swap-rklb-authoriser.t.sol b/test/script/20260722-swap-rklb-authoriser.t.sol new file mode 100644 index 00000000..381b1a4c --- /dev/null +++ b/test/script/20260722-swap-rklb-authoriser.t.sol @@ -0,0 +1,63 @@ +// 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 {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol"; + +import { + SwapRklbAuthoriser, + RklbAlreadySwapped, + UnexpectedRklbAuthoriser +} from "../../script/20260722-swap-rklb-authoriser.s.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; + +/// @title SwapRklbAuthoriserTest +/// @notice Live-fork coverage for the RKLB-only swap authoring. RKLB gets a +/// dedicated single-tx bundle because the six-vault bundle from the general +/// swap script was already partially signed when RKLB entered the table — +/// regenerating a combined bundle would void those signatures. +/// @dev Unpinned Base head fork. The swap EXECUTED 2026-07-23, so the happy +/// path is gone: `run()` now reverts `RklbAlreadySwapped` against live Base, +/// which is the state `testRunRevertsWhenAlreadySwapped` asserts directly. +/// What remains is the inverted coverage — already-swapped, unknown +/// authoriser — which is the coverage that keeps meaning something after +/// execution. +contract SwapRklbAuthoriserTest is Test { + SwapRklbAuthoriser internal script; + + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + script = new SwapRklbAuthoriser(); + } + + /// @notice `run()` reverts `RklbAlreadySwapped` once the vault reports + /// the V4 authoriser — the exact post-execution state. + function testRunRevertsWhenAlreadySwapped() external { + selectBaseFork(); + vm.mockCall( + LibTokenInvariants.RKLB_RECEIPT_VAULT, + abi.encodeWithSelector(IAuthorizableV1.authorizer.selector), + abi.encode(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE) + ); + vm.expectRevert( + abi.encodeWithSelector(RklbAlreadySwapped.selector, LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE) + ); + script.run(); + } + + /// @notice `run()` reverts `UnexpectedRklbAuthoriser` on unknown drift. + function testRunRejectsUnknownAuthoriser() external { + selectBaseFork(); + address rogue = makeAddr("rogueAuthoriser"); + vm.mockCall( + LibTokenInvariants.RKLB_RECEIPT_VAULT, + abi.encodeWithSelector(IAuthorizableV1.authorizer.selector), + abi.encode(rogue) + ); + vm.expectRevert(abi.encodeWithSelector(UnexpectedRklbAuthoriser.selector, rogue)); + script.run(); + } +} diff --git a/test/script/DeployMissingTokensEthereumHarness.sol b/test/script/DeployMissingTokensEthereumHarness.sol new file mode 100644 index 00000000..5f813799 --- /dev/null +++ b/test/script/DeployMissingTokensEthereumHarness.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 {DeployMissingTokensEthereum} from "../../script/20260722-deploy-missing-tokens-ethereum.s.sol"; +import {TokenConfig} from "../../src/lib/LibProdTokenConfig.sol"; + +/// @title DeployMissingTokensEthereumHarness +/// @notice Exposes the script's internal selection so the pure selection +/// logic can be driven directly. Its own file because Rain convention is one +/// contract per .sol and `rainix-sol-single-contract` enforces it — an inline +/// harness in the .t.sol is exactly the accumulation that gate exists to +/// stop. Mirrors `test/src/lib/LibBeaconInvariantsHarness.sol`. +contract DeployMissingTokensEthereumHarness is DeployMissingTokensEthereum { + /// @notice The script's `_selectMissing()`, externally callable. + /// @return The canonical config rows whose Ethereum table entry is unset. + function selectMissing() external pure returns (TokenConfig[] memory) { + return _selectMissing(); + } +}