From 2e2ae2c77fffb4319647ef0964b4c0a67e7df894 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 22 Jul 2026 13:53:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?ops(script):=20RKLB=20catch-up=20=E2=80=94?= =?UTF-8?q?=20gap-fill=20Ethereum=20deploy=20+=20single-tx=20swap=20author?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operational scripts split out of the token-table pin (#259), which stays pins-only: - `20260722-deploy-missing-tokens-ethereum` (manual-broadcast): self-scoping gap-fill of the Ethereum token set — deploys exactly the canonical config rows whose Ethereum table entry is all-zero; refuses on a fully-hydrated table (NoMissingTokens). EXECUTED 2026-07-22 (run 29924926246, RKLB); status + post-execution test shape included. - `20260722-swap-rklb-authoriser` (run-script): single-tx Safe authoring for RKLB's Base authoriser swap, kept separate from the six-vault bundle that was already 2-of-3 signed when RKLB entered the table (a regenerated 7-tx bundle would void those signatures). PENDING. Both registered in their append-only workflow dropdowns; fork suites green against live chains. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr --- .github/workflows/manual-broadcast.yaml | 1 + .github/workflows/run-script.yaml | 1 + ...60722-deploy-missing-tokens-ethereum.s.sol | 237 ++++++++++++++++++ script/20260722-swap-rklb-authoriser.s.sol | 223 ++++++++++++++++ ...60722-deploy-missing-tokens-ethereum.t.sol | 55 ++++ .../20260722-swap-rklb-authoriser.t.sol | 83 ++++++ 6 files changed, 600 insertions(+) create mode 100644 script/20260722-deploy-missing-tokens-ethereum.s.sol create mode 100644 script/20260722-swap-rklb-authoriser.s.sol create mode 100644 test/script/20260722-deploy-missing-tokens-ethereum.t.sol create mode 100644 test/script/20260722-swap-rklb-authoriser.t.sol 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/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(); + } +} From ee38675ddb3aadf24efdb16bc9e0e92e60a98409 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 23 Jul 2026 06:55:09 +0000 Subject: [PATCH 2/3] Record the RKLB swap as executed and retire its happy path The Safe executed the swap on 2026-07-23: 0xf6744Fd94e27c2f58F6110aa9fDC77A87e41766B now reports authorizer() 0x315b16faa6eE413faBCa877d3851B3818369f0cD, read from three independent Base RPCs. The script header still said PENDING. The happy-path test drove run() against live Base, so it began failing the moment the swap landed -- its own comment called for retiring it at exactly this point. Left in place it would have held the PR red on a test that is supposed to stop existing. What remains is the inverted coverage: already-swapped and unknown-authoriser. That is the coverage that still means something now, because it is what refuses a re-dispatch. Co-Authored-By: Claude Opus 4.8 --- script/20260722-swap-rklb-authoriser.s.sol | 17 ++++++---- .../20260722-swap-rklb-authoriser.t.sol | 32 ++++--------------- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/script/20260722-swap-rklb-authoriser.s.sol b/script/20260722-swap-rklb-authoriser.s.sol index 01da7573..9393d26e 100644 --- a/script/20260722-swap-rklb-authoriser.s.sol +++ b/script/20260722-swap-rklb-authoriser.s.sol @@ -44,12 +44,17 @@ error RklbSwapAuthoriserCodehashMismatch(address authoriser, bytes32 expected, b 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. +/// @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 diff --git a/test/script/20260722-swap-rklb-authoriser.t.sol b/test/script/20260722-swap-rklb-authoriser.t.sol index 38aae7ef..381b1a4c 100644 --- a/test/script/20260722-swap-rklb-authoriser.t.sol +++ b/test/script/20260722-swap-rklb-authoriser.t.sol @@ -19,10 +19,12 @@ import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; /// 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). +/// @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; @@ -31,28 +33,6 @@ contract SwapRklbAuthoriserTest is Test { 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 { From 467c048df7f6e029964e2ba4e66a762e41942300 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 23 Jul 2026 07:13:29 +0000 Subject: [PATCH 3/3] Give the deploy harness its own file rainix-sol-single-contract was failing: the .t.sol declared both the test and an inline DeployMissingTokensEthereumHarness. That gate exists precisely to stop inline helper contracts accumulating in test files, so it was doing its job rather than getting in the way. Split out to mirror test/src/lib/LibBeaconInvariantsHarness.sol, which sits beside its own .t.sol for the same reason. Co-Authored-By: Claude Opus 4.8 --- ...60722-deploy-missing-tokens-ethereum.t.sol | 8 +------- .../DeployMissingTokensEthereumHarness.sol | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 test/script/DeployMissingTokensEthereumHarness.sol diff --git a/test/script/20260722-deploy-missing-tokens-ethereum.t.sol b/test/script/20260722-deploy-missing-tokens-ethereum.t.sol index 46b20b0c..91641d4d 100644 --- a/test/script/20260722-deploy-missing-tokens-ethereum.t.sol +++ b/test/script/20260722-deploy-missing-tokens-ethereum.t.sol @@ -10,6 +10,7 @@ import { } 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 @@ -46,10 +47,3 @@ contract DeployMissingTokensEthereumTest is Test { 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/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(); + } +}