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..01da7573 --- /dev/null +++ b/script/20260722-swap-rklb-authoriser.s.sol @@ -0,0 +1,223 @@ +// 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 **PENDING.** Authors the SINGLE-tx Safe bundle that swaps the +/// RKLB receipt vault onto the V4 authoriser +/// (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE`). Dispatch via +/// `Actions → run-script` with `script = 20260722-swap-rklb-authoriser` and +/// `sig = run()`. Flips to `**EXECUTED YYYY-MM-DD.**` in the post-execution +/// pin PR. +/// +/// 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/src/lib/LibProdTokenConfig.sol b/src/lib/LibProdTokenConfig.sol index f5c52cba..7cbca09d 100644 --- a/src/lib/LibProdTokenConfig.sol +++ b/src/lib/LibProdTokenConfig.sol @@ -45,7 +45,7 @@ library LibProdTokenConfig { /// @notice The 28 production token deploy configs, Base table order. /// @return configs The name/symbol table. function productionTokenConfigs() internal pure returns (TokenConfig[] memory configs) { - configs = new TokenConfig[](28); + configs = new TokenConfig[](29); configs[0] = TokenConfig("MSTR", "MicroStrategy Incorporated ST0x", "tMSTR"); configs[1] = TokenConfig("TSLA", "Tesla Inc ST0x", "tTSLA"); configs[2] = TokenConfig("COIN", "Coinbase Global Inc ST0x", "tCOIN"); @@ -75,5 +75,6 @@ library LibProdTokenConfig { configs[25] = TokenConfig("AMAT", "Applied Materials, Inc. ST0x", "tAMAT"); configs[26] = TokenConfig("LRCX", "Lam Research Corporation ST0x", "tLRCX"); configs[27] = TokenConfig("TTWO", "Take-Two Interactive Software, Inc. ST0x", "tTTWO"); + configs[28] = TokenConfig("RKLB", "Rocket Lab USA Inc ST0x", "tRKLB"); } } diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index c3a4fe67..2e6038bf 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -285,14 +285,22 @@ library LibTokenInvariants { /// https://basescan.org/address/0x045Fb493D970f94a54FeaF931033622fC82192e6 address internal constant TTWO_WRAPPED_TOKEN_VAULT = address(0x045Fb493D970f94a54FeaF931033622fC82192e6); - /// @notice Returns the 28 production token instance triples on Base, in + // ---- tRKLB / wtRKLB — Rocket Lab USA Inc ST0x ---- + /// https://basescan.org/address/0x34Bf3d8DFaa92e554FBCf48135E5d814210DA1dd + address internal constant RKLB_RECEIPT = address(0x34Bf3d8DFaa92e554FBCf48135E5d814210DA1dd); + /// https://basescan.org/address/0xf6744Fd94e27c2f58F6110aa9fDC77A87e41766B + address internal constant RKLB_RECEIPT_VAULT = address(0xf6744Fd94e27c2f58F6110aa9fDC77A87e41766B); + /// https://basescan.org/address/0xF4f8c66085910d583c01f3b4e44Bf731D4e2c565 + address internal constant RKLB_WRAPPED_TOKEN_VAULT = address(0xF4f8c66085910d583c01f3b4e44Bf731D4e2c565); + + /// @notice Returns the 29 production token instance triples on Base, in /// the order they were deployed. This is the structured source of truth /// the flat `productionReceiptVaults()` accessor derives from; consumers /// that need the receipt / wrapped-vault legs or the underlying join key /// (cross-chain parity, per-token config checks) iterate this instead. /// @return tokens The 28 production token instances on Base. function productionTokensBase() internal pure returns (TokenInstance[] memory tokens) { - tokens = new TokenInstance[](28); + tokens = new TokenInstance[](29); tokens[0] = TokenInstance("MSTR", MSTR_RECEIPT, MSTR_RECEIPT_VAULT, MSTR_WRAPPED_TOKEN_VAULT); tokens[1] = TokenInstance("TSLA", TSLA_RECEIPT, TSLA_RECEIPT_VAULT, TSLA_WRAPPED_TOKEN_VAULT); tokens[2] = TokenInstance("COIN", COIN_RECEIPT, COIN_RECEIPT_VAULT, COIN_WRAPPED_TOKEN_VAULT); @@ -321,6 +329,7 @@ library LibTokenInvariants { tokens[25] = TokenInstance("AMAT", AMAT_RECEIPT, AMAT_RECEIPT_VAULT, AMAT_WRAPPED_TOKEN_VAULT); tokens[26] = TokenInstance("LRCX", LRCX_RECEIPT, LRCX_RECEIPT_VAULT, LRCX_WRAPPED_TOKEN_VAULT); tokens[27] = TokenInstance("TTWO", TTWO_RECEIPT, TTWO_RECEIPT_VAULT, TTWO_WRAPPED_TOKEN_VAULT); + tokens[28] = TokenInstance("RKLB", RKLB_RECEIPT, RKLB_RECEIPT_VAULT, RKLB_WRAPPED_TOKEN_VAULT); } /// @notice Returns the production token instance triples on Ethereum @@ -343,7 +352,7 @@ library LibTokenInvariants { // run's logged (underlying, receipt, receiptVault, wrapped) tuples. // Order and underlyings match Base row-for-row (the cross-chain // parity pin asserts this). - tokens = new TokenInstance[](28); + tokens = new TokenInstance[](29); tokens[0] = TokenInstance( "MSTR", address(0xE3772C8695c2cf3dcAA2Dd29759f4Bb91a342763), @@ -512,6 +521,17 @@ library LibTokenInvariants { address(0xb62E913f0cC881862527Fa7e41e1C98eEf09cedD), address(0x1D6F0763e58FA6d472d470Eaaef0a4C08080d208) ); + // RKLB was accidentally omitted from the table when the 28-token + // Ethereum broadcast ran; the gap-filling + // `20260722-deploy-missing-tokens-ethereum` broadcast (EXECUTED + // 2026-07-22, manual-broadcast run 29924926246) deployed it and this + // row pins the logged tuple. + tokens[28] = TokenInstance( + "RKLB", + address(0xFf5b15a4f478F296893b0b244D9b118Be87bCda2), + address(0xED0c085d92C262FB46937CB0B3C9763Af7fCCf30), + address(0x8FC87Be766C0cB6f254F1FDc9351D4B85B560FB3) + ); } /// @notice Returns the 28 production receipt vault addresses on Base, in 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..46b20b0c --- /dev/null +++ b/test/script/20260722-deploy-missing-tokens-ethereum.t.sol @@ -0,0 +1,55 @@ +// 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"; + +/// @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(); + } +} + +/// @dev Exposes the internal selection for the pure selection test. +contract DeployMissingTokensEthereumHarness is DeployMissingTokensEthereum { + function selectMissing() external pure returns (TokenConfig[] memory) { + return _selectMissing(); + } +} 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..38aae7ef --- /dev/null +++ b/test/script/20260722-swap-rklb-authoriser.t.sol @@ -0,0 +1,83 @@ +// 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: while RKLB is still on V3 the happy path +/// authors the single tx; once the swap EXECUTES on-chain it flips red on +/// `RklbAlreadySwapped` and the post-execution pin PR retires it (the +/// inverted guards keep covering the error paths). +contract SwapRklbAuthoriserTest is Test { + SwapRklbAuthoriser internal script; + + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + script = new SwapRklbAuthoriser(); + } + + /// @notice Happy path against live Base state: `run()` completes and the + /// artifact carries exactly one tx targeting the RKLB receipt vault. + /// Red once the swap executes on-chain (`RklbAlreadySwapped`); retire in + /// the post-execution pin PR. + function testRunCompletesAndWritesArtifact() external { + selectBaseFork(); + script.run(); + + string memory json = vm.readFile("out/20260722-rklb-authoriser-swap.json"); + assertEq( + vm.parseJsonString(json, ".meta.name"), + "ST0x authoriser swap: RKLB onto the V4 authoriser", + "artifact bundle name" + ); + assertEq( + vm.parseJsonAddress(json, ".transactions[0].to"), + LibTokenInvariants.RKLB_RECEIPT_VAULT, + "single tx targets the RKLB receipt vault" + ); + assertFalse(vm.keyExistsJson(json, ".transactions[1].to"), "no extra txs"); + } + + /// @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(); + } +}