diff --git a/.github/workflows/manual-broadcast.yaml b/.github/workflows/manual-broadcast.yaml index 594bda9f..91a07a2c 100644 --- a/.github/workflows/manual-broadcast.yaml +++ b/.github/workflows/manual-broadcast.yaml @@ -7,10 +7,22 @@ on: required: true type: choice options: - # Append-only registry: add new entries at the bottom; never reorder - # or delete. Re-dispatching a historical (executed) script must - # remain possible — a signer/auditor may want to re-run its - # pre-flight against on-chain reality to confirm what landed. + # Append-only registry: add new entries at the bottom; never reorder. + # Re-dispatching a historical (executed) script must remain possible + # — a signer/auditor may want to re-run its pre-flight against + # on-chain reality to confirm what landed. + # + # An entry may be DELETED only when a named successor listed here + # covers its pre-flight at least as strongly, and the deleted + # script's broadcast run id has been carried into the pin its run + # produced. `20260722-deploy-missing-tokens-ethereum` and + # `-hyperevm` were removed under that rule: dispatching + # `20260807-deploy-missing-tokens` at the same `network` re-runs the + # same gates and answers `NoMissingTokens` when everything landed, + # checked against Base's CURRENT token set rather than the config + # table the deleted scripts joined against. Their run ids are pinned + # in `LibTokenInvariants.productionTokensEthereum()` and + # `productionTokensHyperEvm()`. # # Each entry is the date-prefixed filename (without `.s.sol`) of a # broadcast script under `script/`. Convention: @@ -20,8 +32,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 - - 20260722-deploy-missing-tokens-hyperevm + - 20260807-deploy-missing-tokens network: description: 'Network to broadcast against (default: base)' required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a2e5ab..f445d26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,49 @@ `forge test -vvv`) and should be regenerated in CI, where the fork-test RPC secrets are available. +### Deploy scripts + +- **The per-chain "deploy missing tokens" scripts are merged into one.** + `20260722-deploy-missing-tokens-ethereum` and `-hyperevm` were byte-identical + apart from the chain each hardcoded; both are replaced by + `20260807-deploy-missing-tokens`, which resolves the target chain's token + table, authoriser and Safe from `block.chainid`. Adding a target chain is now + a table plus an authoriser pin rather than a new copy of the script. +- **"Missing" is now defined against Base, not against the canonical config.** A + token is missing when its `underlying` appears in `productionTokensBase()` and + not in the target chain's table, matched by key rather than by index. Base is + the source of truth for what the token set is — a ticker is deployed there + first, pinned, then copied outward — so the target chain's table simply lags + Base until the script runs. Matching by key is what lets the two tables carry + different lengths in that window; the previous index-join required every table + to be the same length at all times. Cross-chain parity is red between the Base + pin and the copy, which is accurate: the chains genuinely differ until the + copy lands. +- Dispatching the script against Base, or any chain without a token table, + reverts `UnsupportedTargetChain` rather than resolving to an empty table and + reading as "copy everything". +- **The canonical config table is allowed to run ahead of Base.** A row is + authored when a ticker is chosen and Base is pinned when it is deployed, so + the config table leads in that window; only the rows Base carries are read, + so the excess is inert. The genuine error is a config table SHORTER than + Base — a deployed Base row with no name/symbol to deploy under — which + reverts `TokenTableTooShort(configsLength, baseLength)`. Row-for-row key + drift between the two tables still reverts `TokenTableMisaligned`. +- **Three checks the gap-fill scripts had dropped are back, matching + `20260706-deploy-tokens-ethereum`.** Each deployed vault is now read back + before the loop moves on — `AuthoriserNotWired` if it is not routed to the + chain's authoriser, `OwnershipHandoffFailed` if ownership did not land on the + Safe — so a silent miss cannot finish the broadcast leaving a production vault + inoperable or owned by the CI deploy key. A deploy call that emitted no + `Deployment` event reverts `DeploymentEventMissing(underlying)` rather than + carrying a zero address into the receipt readback. +- The two per-chain scripts' broadcast run ids are pinned into the token tables + their runs produced, so deleting the scripts does not delete the record of + what deployed each chain's set. `manual-broadcast.yaml`'s append-only rule now + states the exception the deletion was made under: an entry may be dropped only + when a named successor listed there covers its pre-flight at least as + strongly, and the run id has been carried into the pin. + ### New contracts - **ST0xOrchestrator**: Singleton mint/burn proxy for the whole ST0x diff --git a/script/20260722-deploy-missing-tokens-ethereum.s.sol b/script/20260722-deploy-missing-tokens-ethereum.s.sol deleted file mode 100644 index dc02a09b..00000000 --- a/script/20260722-deploy-missing-tokens-ethereum.s.sol +++ /dev/null @@ -1,237 +0,0 @@ -// 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-deploy-missing-tokens-hyperevm.s.sol b/script/20260722-deploy-missing-tokens-hyperevm.s.sol deleted file mode 100644 index e4b26fbc..00000000 --- a/script/20260722-deploy-missing-tokens-hyperevm.s.sol +++ /dev/null @@ -1,237 +0,0 @@ -// 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 HYPEREVM Safe — wrong-network dispatch, or the pin has not -/// landed. -/// @param safe The Safe address the active chain resolved to. -error HyperEvmSafeNotReady(address safe); - -/// @notice Pre-flight failed: the pinned HyperEVM V4 authoriser is not ready -/// (unpinned / no code / wrong codehash). -/// @param authoriser The authoriser address inspected. -error HyperEvmAuthoriserNotReady(address authoriser); - -/// @notice Every canonical config row already has a fully-hydrated HyperEVM -/// 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 HyperEVM 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 HyperEVM table's underlying at that row. -error TokenTableMisaligned(uint256 index, string configUnderlying, string tableUnderlying); - -/// @title DeployMissingTokensHyperEvm -/// @notice **EXECUTED 2026-07-24** (manual-broadcast run 30114307165: all 29 -/// tokens deployed, wired, and handed to the Safe; tuples pinned in -/// `LibTokenInvariants.productionTokensHyperEvm()`). The HyperEVM token -/// deploy (RAI-1511): deploys, on HyperEVM, exactly the canonical config -/// rows whose `LibTokenInvariants.productionTokensHyperEvm()` entry is -/// still all-zero. Dispatch via `Actions → manual-broadcast` with -/// `script = 20260722-deploy-missing-tokens-hyperevm` and -/// `network = hyperevm`. -/// -/// Deliberately SELF-SCOPING, the same shape as the Ethereum gap-fill -/// (`20260722-deploy-missing-tokens-ethereum`): joins the canonical config -/// against the HyperEVM table row-by-row (aborting on any underlying -/// misalignment) and deploys only the all-zero rows — the explicit "missing -/// on this chain" state. Partial-failure recovery and late-added tokens are -/// both just re-dispatches; a fully-hydrated table refuses to deploy -/// anything (`NoMissingTokens`). -/// -/// Per deployed token, identical to the executed Ethereum flow: deploy via -/// the 0.1.1 unified deployer (initialAdmin = deploy key) -> read back the -/// ERC-1155 receipt -> `setAuthorizer(HyperEVM V4 authoriser)` -> -/// `transferOwnership(HyperEVM Safe)` — one deploy-key broadcast, no Safe -/// signature. Logs each (underlying, receipt, receiptVault, wrapped) tuple -/// for the pin PR. Ordering per RAI-1511: pre-flight hard-gates on the -/// 0.1.1 core, the in-use beacons being HyperEVM-Safe-owned (the -/// beacon-owner migration), the hydrated authoriser pin, and the hydrated -/// Safe pin — dispatching early is a typed revert, never a partial deploy. -contract DeployMissingTokensHyperEvm 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 HYPEREVM's Safe. - /// @return safe The validated HyperEVM token-owner Safe address. - function _assertSafeReady() internal view returns (address safe) { - safe = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); - if (safe != LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM) { - revert HyperEvmSafeNotReady(safe); - } - } - - /// @notice Assert the HyperEVM 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_HYPEREVM; - if ( - authoriser == address(0) || authoriser.code.length == 0 - || authoriser.codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH - ) { - revert HyperEvmAuthoriserNotReady(authoriser); - } - } - - /// @notice Select the configs to deploy: canonical config rows whose - /// HyperEVM 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 HyperEVM. - function _selectMissing() internal pure returns (TokenConfig[] memory missing) { - TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); - TokenInstance[] memory table = LibTokenInvariants.productionTokensHyperEvm(); - 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 HyperEVM - /// 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.productionTokensHyperEvm()" " 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/20260807-deploy-missing-tokens.s.sol b/script/20260807-deploy-missing-tokens.s.sol new file mode 100644 index 00000000..1df6ea73 --- /dev/null +++ b/script/20260807-deploy-missing-tokens.s.sol @@ -0,0 +1,363 @@ +// 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 Dispatched against a chain this script cannot copy Base's token +/// set onto. Base itself is rejected too — it is the SOURCE, so "missing on +/// Base" is not a state this script can resolve. +/// @param chainId The active chain id. +error UnsupportedTargetChain(uint256 chainId); + +/// @notice Pre-flight failed: the active chain's V4 authoriser is not ready +/// (unpinned, no code, or the wrong codehash). +/// @param authoriser The authoriser address inspected. +error AuthoriserNotReady(address authoriser); + +/// @notice The canonical name/symbol table and the Base token table have +/// drifted out of row alignment. Every name/symbol this script deploys is +/// read from the config row at the Base row's index, so a misaligned pair +/// would deploy a token under the wrong underlying's strings. +/// @param index The misaligned row. +/// @param configUnderlying The config table's underlying at that row. +/// @param baseUnderlying The Base table's underlying at that row. +error TokenTableMisaligned(uint256 index, string configUnderlying, string baseUnderlying); + +/// @notice The canonical name/symbol table has fewer rows than the Base token +/// table, so a deployed Base token has no config row to read its name and +/// symbol from. The reverse — a config table running AHEAD of Base — is a +/// normal state: rows are authored when a ticker is chosen and Base is pinned +/// when it is deployed, so the config table leads until the Base deploy lands. +/// @param configsLength The canonical config table's row count. +/// @param baseLength The Base token table's row count. +error TokenTableTooShort(uint256 configsLength, uint256 baseLength); + +/// @notice Every token on Base already exists on the target chain, so there +/// is nothing to copy. This is also what stops a re-dispatch from minting +/// duplicates of tokens that already landed. +error NoMissingTokens(); + +/// @notice The unified deployer emitted no `Deployment` event this iteration, +/// so there is no address to wire or hand over. Named rather than left to +/// fault on a zero address, because it happens MID-BROADCAST with earlier +/// tokens already live. +/// @param underlying The token whose deployment could not be resolved. +error DeploymentEventMissing(string underlying); + +/// @notice A freshly deployed vault did not end up owned by the Safe. A vault +/// left on the deploy key is a production asset held by a CI secret. +/// @param receiptVault The vault whose handoff did not land. +/// @param expected The Safe ownership was meant to land on. +/// @param actual The owner actually read back. +error OwnershipHandoffFailed(address receiptVault, address expected, address actual); + +/// @notice A freshly deployed vault is not routed to the chain's authoriser. +/// Until `setAuthorizer` lands, every operation on the vault reverts. +/// @param receiptVault The vault whose authoriser did not land. +/// @param expected The authoriser it must route to. +/// @param actual The authoriser actually read back. +error AuthoriserNotWired(address receiptVault, address expected, address actual); + +/// @title DeployMissingTokens +/// @notice Copies Base's production token set onto whichever chain this is +/// dispatched against, deploying only the tokens that chain does not already +/// have. Supersedes the per-chain `20260722-deploy-missing-tokens-ethereum` +/// and `-hyperevm` scripts, which were byte-identical apart from the chain +/// they hardcoded. +/// +/// @dev "Missing" is defined against BASE, not against the canonical config: +/// a token is missing when its `underlying` appears in +/// `LibTokenInvariants.productionTokensBase()` and not in the target chain's +/// table. Base is the source of truth for what the token set IS — a ticker is +/// deployed there first, pinned, and then copied outward — so the target +/// chain's table simply lags Base until this script runs. +/// +/// Defining it that way is what lets the tables carry different lengths +/// between a Base deploy and its copy. The rollout is: +/// +/// 1. the tokens are deployed on Base and pinned into `productionTokensBase()` +/// 2. this script is dispatched per target chain, deploying the difference +/// 3. each run's logged tuples are pinned into that chain's table +/// +/// Cross-chain parity is red between (1) and (3) — the chains genuinely do +/// differ in that window, and the suite says so rather than being taught to +/// tolerate it. +/// +/// @dev Dispatch via `Actions → manual-broadcast` with +/// `script = 20260807-deploy-missing-tokens` and `network` set to the target +/// chain. One dispatch covers one chain because the workflow supplies a +/// single RPC per run; the SELECTION is chain-generic, so the same script +/// serves every target chain and any chain added later needs only a table and +/// an authoriser pin here. +/// +/// Per token, identical to the scripts it replaces: deploy via the 0.1.1 +/// unified deployer (initialAdmin = deploy key) -> read back the ERC-1155 +/// receipt -> `setAuthorizer(target chain's V4 authoriser)` -> +/// `transferOwnership(target chain's token-owner Safe)`. One deploy-key +/// broadcast, no Safe signature. Logs each +/// (underlying, receipt, receiptVault, wrapped) tuple for the pin. +contract DeployMissingTokens is Script { + /// @notice Assert a deployer contract is present at its pinned address. + /// @param deployer The pinned deployer address to check. + function _assertDeployer(address deployer) internal view { + if (deployer == address(0) || deployer.code.length == 0) { + revert DeployerNotDeployed(deployer); + } + } + + /// @notice The active chain's already-deployed token table. + /// @dev Base is deliberately absent: it is the source this script copies + /// FROM, so dispatching against it is a wrong-network dispatch rather + /// than a no-op. A new target chain is onboarded by adding its table + /// here alongside its authoriser pin below. + /// @return tokens The target chain's token instances. + function _targetTokens() internal view returns (TokenInstance[] memory tokens) { + if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + return LibTokenInvariants.productionTokensEthereum(); + } + if (block.chainid == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + return LibTokenInvariants.productionTokensHyperEvm(); + } + revert UnsupportedTargetChain(block.chainid); + } + + /// @notice The active chain's V4 authoriser clone, asserted deployed at + /// its pin with the shared EIP-1167 codehash. + /// @return authoriser The validated authoriser address. + function _assertAuthoriserReady() internal view returns (address authoriser) { + if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; + } else if (block.chainid == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM; + } else { + revert UnsupportedTargetChain(block.chainid); + } + + if ( + authoriser == address(0) || authoriser.code.length == 0 + || authoriser.codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH + ) { + revert AuthoriserNotReady(authoriser); + } + } + + /// @notice Select the tokens on Base that the target chain does not have, + /// returning their canonical deploy configs. + /// @dev The config table pairs with the BASE table by index, so it is + /// checked row-for-row here; the target chain is matched by `underlying` + /// rather than index, which is what allows its table to be shorter than + /// Base's while a copy is outstanding. + /// + /// The config table is allowed to run LONGER than Base — a row is authored + /// when a ticker is chosen and Base is pinned when it is deployed, so the + /// config table leads in that window. Only the trailing rows Base actually + /// carries are read, so the excess is inert; a config table SHORTER than + /// Base is the genuine error, because a Base row would then have no + /// name/symbol to deploy under. + /// + /// Both tables are taken as parameters rather than read from the libraries + /// so that the misalignment guards below can be driven from a test — they + /// are the only thing standing between a drifted table and deploying a + /// token under the wrong underlying's strings, so they are worth being + /// able to exercise directly. `run()` passes the canonical tables. + /// @param configs The canonical name/symbol table, Base row order. + /// @param base The Base token table — the source of truth for the set. + /// @param targetTokens The target chain's existing token table. + /// @return missing The deploy configs for the tokens to copy. + function _selectMissing( + TokenConfig[] memory configs, + TokenInstance[] memory base, + TokenInstance[] memory targetTokens + ) internal pure returns (TokenConfig[] memory missing) { + if (configs.length < base.length) { + revert TokenTableTooShort(configs.length, base.length); + } + + TokenConfig[] memory candidates = new TokenConfig[](base.length); + uint256 count = 0; + for (uint256 i = 0; i < base.length; i++) { + if (keccak256(bytes(configs[i].underlying)) != keccak256(bytes(base[i].underlying))) { + revert TokenTableMisaligned(i, configs[i].underlying, base[i].underlying); + } + if (_hasUnderlying(targetTokens, base[i].underlying)) { + 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 Resolve the deployed pair out of the unified deployer's + /// `Deployment(sender, asset, wrapper)` event. + /// @dev Its own function, taking the logs rather than calling + /// `vm.getRecordedLogs()` itself, so the miss can be shown to revert: the + /// alternative is a zero address flowing into the receipt readback and + /// faulting as a raw call to an empty account, mid-broadcast, with earlier + /// tokens already live. Logs from other emitters and other events from the + /// deployer are both ignored rather than mistaken for a deployment. + /// @param logs The logs recorded across the deploy call. + /// @param unifiedDeployer The deployer whose event is authoritative. + /// @param underlying The token being deployed, for the revert. + /// @return receiptVault The deployed receipt vault. + /// @return wrapped The deployed wrapped token vault. + function _readDeployment(Vm.Log[] memory logs, address unifiedDeployer, string memory underlying) + internal + pure + returns (address receiptVault, address wrapped) + { + bytes32 deploymentTopic = keccak256("Deployment(address,address,address)"); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter == unifiedDeployer && logs[i].topics.length > 0 && logs[i].topics[0] == deploymentTopic) + { + (, receiptVault, wrapped) = abi.decode(logs[i].data, (address, address, address)); + } + } + if (receiptVault == address(0)) { + revert DeploymentEventMissing(underlying); + } + } + + /// @notice Assert a freshly deployed vault ended up wired to the chain's + /// authoriser and owned by its Safe. + /// @dev Each token is wired and handed over inside the same broadcast, so + /// a silent miss leaves a live production vault either inoperable or owned + /// by the CI deploy key, and nothing catches it until the pin PR's + /// acceptance tests run. Public so it can be driven directly: an assertion + /// reachable only from inside a broadcast loop cannot be shown to fire. + /// Mirrors `20260706-deploy-tokens-ethereum`'s check of the same name. + /// @param receiptVault The vault just deployed. + /// @param expectedAuthoriser The authoriser it must route to. + /// @param expectedSafe The Safe ownership must have landed on. + function assertHandoffLanded(address receiptVault, address expectedAuthoriser, address expectedSafe) public view { + address wiredAuthoriser = ISetAuthorizer(receiptVault).authorizer(); + if (wiredAuthoriser != expectedAuthoriser) { + revert AuthoriserNotWired(receiptVault, expectedAuthoriser, wiredAuthoriser); + } + address landedOwner = Ownable(receiptVault).owner(); + if (landedOwner != expectedSafe) { + revert OwnershipHandoffFailed(receiptVault, expectedSafe, landedOwner); + } + } + + /// @notice Whether `tokens` already carries an entry for `underlying`. + /// @param tokens The table to search. + /// @param underlying The ticker to look for. + /// @return True when the ticker is already present. + function _hasUnderlying(TokenInstance[] memory tokens, string memory underlying) internal pure returns (bool) { + bytes32 target = keccak256(bytes(underlying)); + for (uint256 i = 0; i < tokens.length; i++) { + if (keccak256(bytes(tokens[i].underlying)) == target) { + return true; + } + } + return false; + } + + /// @notice Deploy every Base token the active chain is missing via the + /// 0.1.1 unified deployer, wire each onto that chain's V4 authoriser, and + /// hand ownership to its token-owner Safe — one deploy-key broadcast. + /// Logs each deployed tuple for the pin. + function run() external { + 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 = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + + TokenConfig[] memory configs = _selectMissing( + LibProdTokenConfig.productionTokenConfigs(), LibTokenInvariants.productionTokensBase(), _targetTokens() + ); + + vm.startBroadcast(); + + // Deployer identity — inside `vm.startBroadcast()` msg.sender + // resolves to the broadcast address (`--private-key` in production). + address deployer = msg.sender; + + console2.log("Copying", configs.length, "Base tokens onto 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); + + (address receiptVault, address wrapped) = + _readDeployment(vm.getRecordedLogs(), unifiedDeployer, cfg.underlying); + + // The unified deployer's event drops the ERC-1155 receipt, so read + // it back off the vault for the pin 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); + assertHandoffLanded(receiptVault, authoriser, 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." + " Pin the logged tuples into this chain's LibTokenInvariants table." + ); + } +} + +/// @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; + function authorizer() external view returns (address); +} diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index bc802be6..a4cded3f 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -515,10 +515,10 @@ library LibTokenInvariants { 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 + // Ethereum broadcast ran; a gap-filling broadcast (EXECUTED // 2026-07-22, manual-broadcast run 29924926246) deployed it and this - // row pins the logged tuple. + // row pins the logged tuple. That per-chain script has since been + // superseded by `20260807-deploy-missing-tokens`. tokens[28] = TokenInstance( "RKLB", address(0xFf5b15a4f478F296893b0b244D9b118Be87bCda2), @@ -532,6 +532,13 @@ library LibTokenInvariants { /// parity pin asserts the alignment). /// @return tokens The 29 production token instances on HyperEVM. function productionTokensHyperEvm() internal pure returns (TokenInstance[] memory tokens) { + // Deployed on HyperEVM 2026-07-24 (manual-broadcast run 30114307165): + // all 29 tokens via the 0.1.1 unified deployer, each wired onto the + // HyperEVM V4 authoriser and handed to the HyperEVM token-owner Safe + // in the same broadcast. Addresses pinned from the run's logged + // (underlying, receipt, receiptVault, wrapped) tuples. The script that + // ran it was per-chain and has since been superseded by + // `20260807-deploy-missing-tokens`, so this is the record of the run. tokens = new TokenInstance[](29); tokens[0] = TokenInstance( "MSTR", diff --git a/test/script/20260722-deploy-missing-tokens-ethereum.t.sol b/test/script/20260722-deploy-missing-tokens-ethereum.t.sol deleted file mode 100644 index 91641d4d..00000000 --- a/test/script/20260722-deploy-missing-tokens-ethereum.t.sol +++ /dev/null @@ -1,49 +0,0 @@ -// 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-deploy-missing-tokens-hyperevm.t.sol b/test/script/20260722-deploy-missing-tokens-hyperevm.t.sol deleted file mode 100644 index abc21320..00000000 --- a/test/script/20260722-deploy-missing-tokens-hyperevm.t.sol +++ /dev/null @@ -1,49 +0,0 @@ -// 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 { - DeployMissingTokensHyperEvm, - DeployerNotDeployed, - NoMissingTokens -} from "../../script/20260722-deploy-missing-tokens-hyperevm.s.sol"; -import {DeployMissingTokensHyperEvmHarness} from "./DeployMissingTokensHyperEvmHarness.sol"; -import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; -import {TokenConfig} from "../../src/lib/LibProdTokenConfig.sol"; - -/// @title DeployMissingTokensHyperEvmTest -/// @notice Coverage for the HyperEVM token deploy (RAI-1511). The script is -/// self-scoping over the in-code tables (canonical config vs the HyperEVM -/// token table), so the selection logic is PURE — testable without a fork — -/// and the deploy pre-flight reuses the gate chain the per-chain prod pins -/// already exercise against live HyperEVM. -contract DeployMissingTokensHyperEvmTest is Test { - DeployMissingTokensHyperEvm internal script; - - function setUp() external { - script = new DeployMissingTokensHyperEvm(); - } - - /// @notice The HyperEVM table is fully hydrated (the bootstrap deploy - /// EXECUTED 2026-07-24 and every 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 HyperEVM - /// row, this flips back to a positive selection expectation. - function testSelectionRevertsWhenTableFullyHydrated() external { - DeployMissingTokensHyperEvmHarness harness = new DeployMissingTokensHyperEvmHarness(); - vm.expectRevert(NoMissingTokens.selector); - harness.selectMissing(); - } - - /// @notice `run()` reverts `DeployerNotDeployed` when the 0.1.1 core has - /// not been broadcast to the active chain — the pre-bootstrap HyperEVM - /// 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/20260807-deploy-missing-tokens.t.sol b/test/script/20260807-deploy-missing-tokens.t.sol new file mode 100644 index 00000000..0f1c3f53 --- /dev/null +++ b/test/script/20260807-deploy-missing-tokens.t.sol @@ -0,0 +1,377 @@ +// 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 {Vm} from "forge-std-1.16.1/src/Vm.sol"; +import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol"; +import { + AuthoriserNotReady, + AuthoriserNotWired, + DeployMissingTokens, + DeployerNotDeployed, + DeploymentEventMissing, + NoMissingTokens, + OwnershipHandoffFailed, + TokenTableMisaligned, + TokenTableTooShort, + UnsupportedTargetChain +} from "../../script/20260807-deploy-missing-tokens.s.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibTokenInvariants, TokenInstance} from "../../src/lib/LibTokenInvariants.sol"; +import {LibProdTokenConfig, TokenConfig} from "../../src/lib/LibProdTokenConfig.sol"; +import {DeployMissingTokensHarness} from "./DeployMissingTokensHarness.sol"; + +/// @title DeployMissingTokensTest +/// @notice Coverage for the unified token-copy script. The selection joins +/// two in-code tables (Base vs the target chain), so it is PURE — testable +/// without a fork — and the deploy pre-flight reuses the gate chain the +/// per-chain prod pins already exercise against live state. +contract DeployMissingTokensTest is Test { + /// @dev The 0.1.1 authoriser implementation the production V4 authoriser + /// clones. Written out rather than imported so this test states the + /// implementation it expects the clone to proxy, independently of the + /// generated pointers it would otherwise be checking against themselves. + address constant AUTHORISER_IMPL_0_1_1 = address(0x2EA0d35d0B1F57C42e6130f298930228bCbFDe9b); + + /// @dev Arbitrary non-empty runtime, for standing something at an address + /// that the code-presence checks accept and the codehash checks reject. + bytes constant STUB_CODE = hex"600160005260206000f3"; + + address constant DEPLOYER = address(0xDEB01); + address constant RECEIPT_VAULT = address(0x1A17); + address constant WRAPPED = address(0x1A18); + address constant AUTHORISER = address(0xA077); + address constant SAFE = address(0x5AFE); + address constant STRAY = address(0xDEADBEEF); + + DeployMissingTokens internal script; + DeployMissingTokensHarness internal harness; + + function setUp() external { + script = new DeployMissingTokens(); + harness = new DeployMissingTokensHarness(); + } + + /// @notice A chain whose table already carries every Base underlying has + /// nothing to copy. This is the guard that stops a re-dispatch minting + /// duplicates of tokens that already landed. + function testSelectionRevertsWhenTargetMatchesBase() external { + vm.expectRevert(NoMissingTokens.selector); + harness.selectMissing( + LibProdTokenConfig.productionTokenConfigs(), + LibTokenInvariants.productionTokensBase(), + LibTokenInvariants.productionTokensBase() + ); + } + + /// @notice A target chain missing a Base token selects exactly that token, + /// by `underlying`, and nothing else. Built by dropping one row from a + /// copy of Base rather than by hardcoding a ticker, so the test does not + /// go stale as the token set grows. + function testSelectionPicksTheTokensTheTargetLacks() external view { + TokenInstance[] memory base = LibTokenInvariants.productionTokensBase(); + assertGt(base.length, 1, "need at least two Base tokens to drop one"); + + uint256 dropped = base.length - 1; + TokenInstance[] memory target = new TokenInstance[](base.length - 1); + for (uint256 i = 0; i < dropped; i++) { + target[i] = base[i]; + } + + TokenConfig[] memory missing = harness.selectMissing(LibProdTokenConfig.productionTokenConfigs(), base, target); + assertEq(missing.length, 1, "expected exactly the dropped token"); + assertEq(missing[0].underlying, base[dropped].underlying, "selected the wrong token"); + } + + /// @notice An empty target table selects the whole Base set — the + /// bootstrap case for a brand new chain. + function testSelectionOnEmptyTargetCopiesEverything() external view { + TokenConfig[] memory missing = harness.selectMissing( + LibProdTokenConfig.productionTokenConfigs(), + LibTokenInvariants.productionTokensBase(), + new TokenInstance[](0) + ); + assertEq(missing.length, LibTokenInvariants.productionTokensBase().length, "expected the full Base set"); + } + + /// @notice A config table running AHEAD of Base is a normal state, not a + /// drift: rows are authored when a ticker is chosen and Base is pinned + /// when it is deployed, so the config table leads until the Base deploy + /// lands. The trailing rows Base does not carry are inert — selection + /// still reads only the Base rows, and only the ones the target lacks. + function testSelectionToleratesAConfigTableAheadOfBase() external view { + TokenInstance[] memory base = LibTokenInvariants.productionTokensBase(); + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + + TokenConfig[] memory ahead = new TokenConfig[](configs.length + 2); + for (uint256 i = 0; i < configs.length; i++) { + ahead[i] = configs[i]; + } + ahead[configs.length] = TokenConfig("AAAA", "Ahead Of Base One ST0x", "tAAAA"); + ahead[configs.length + 1] = TokenConfig("BBBB", "Ahead Of Base Two ST0x", "tBBBB"); + + TokenConfig[] memory missing = harness.selectMissing(ahead, base, new TokenInstance[](0)); + assertEq(missing.length, base.length, "the un-deployed rows must not be selected"); + assertEq(missing[base.length - 1].underlying, base[base.length - 1].underlying, "selected past the Base table"); + } + + /// @notice A config table SHORTER than Base is the genuine error: a + /// deployed Base row would have no name/symbol to deploy under. + function testSelectionRevertsWhenConfigTableIsShorterThanBase() external { + TokenInstance[] memory base = LibTokenInvariants.productionTokensBase(); + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + + TokenConfig[] memory short = new TokenConfig[](base.length - 1); + for (uint256 i = 0; i < short.length; i++) { + short[i] = configs[i]; + } + + vm.expectRevert(abi.encodeWithSelector(TokenTableTooShort.selector, base.length - 1, base.length)); + harness.selectMissing(short, base, new TokenInstance[](0)); + } + + /// @notice Every name/symbol deployed is read from the config row at the + /// Base row's index, so a row-for-row key mismatch must abort rather than + /// deploy a token under the wrong underlying's strings. Drifted by + /// swapping two adjacent config rows, which leaves both tables the same + /// length — the failure the length check cannot see. + function testSelectionRevertsWhenConfigRowsDriftFromBase() external { + TokenInstance[] memory base = LibTokenInvariants.productionTokensBase(); + TokenConfig[] memory configs = LibProdTokenConfig.productionTokenConfigs(); + assertGt(base.length, 1, "need at least two rows to swap"); + + TokenConfig[] memory drifted = new TokenConfig[](configs.length); + for (uint256 i = 0; i < configs.length; i++) { + drifted[i] = configs[i]; + } + (drifted[0], drifted[1]) = (drifted[1], drifted[0]); + + vm.expectRevert( + abi.encodeWithSelector(TokenTableMisaligned.selector, 0, drifted[0].underlying, base[0].underlying) + ); + harness.selectMissing(drifted, base, new TokenInstance[](0)); + } + + /// @notice Base is the SOURCE, so dispatching against it is a + /// wrong-network dispatch rather than a no-op. + function testTargetTokensRejectsBase() external { + vm.chainId(LibSafeInvariants.BASE_CHAIN_ID); + vm.expectRevert(abi.encodeWithSelector(UnsupportedTargetChain.selector, LibSafeInvariants.BASE_CHAIN_ID)); + harness.targetTokens(); + } + + /// @notice An unknown chain is rejected rather than silently resolving to + /// an empty table, which would otherwise read as "copy everything". + function testTargetTokensRejectsUnknownChain() external { + vm.chainId(123456); + vm.expectRevert(abi.encodeWithSelector(UnsupportedTargetChain.selector, 123456)); + harness.targetTokens(); + } + + /// @notice Each supported target chain resolves to its own table, keyed + /// by chain id. + function testTargetTokensResolvesPerChain() external { + vm.chainId(LibSafeInvariants.ETHEREUM_CHAIN_ID); + TokenInstance[] memory ethereum = harness.targetTokens(); + assertEq(ethereum.length, LibTokenInvariants.productionTokensEthereum().length, "Ethereum table mismatch"); + + vm.chainId(LibSafeInvariants.HYPEREVM_CHAIN_ID); + TokenInstance[] memory hyperevm = harness.targetTokens(); + assertEq(hyperevm.length, LibTokenInvariants.productionTokensHyperEvm().length, "HyperEVM table mismatch"); + } + + /// @notice `run()` reverts `DeployerNotDeployed` when the 0.1.1 core has + /// not been broadcast to the active chain — 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(); + } + + /// @notice The core gate is three checks, not one: with the unified + /// deployer present the next two are still enforced, each naming the + /// deployer it found missing. Without this, a partial 0.1.1 bootstrap — + /// the deployer landed, a beacon-set deployer did not — passes the gate + /// and faults later, inside the broadcast. + function testRunChecksEveryDeployerInTheCore() external { + vm.etch(LibProdDeployV4.STOX_UNIFIED_DEPLOYER_0_1_1, STUB_CODE); + vm.expectRevert( + abi.encodeWithSelector( + DeployerNotDeployed.selector, + LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_1 + ) + ); + script.run(); + + vm.etch(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_1, STUB_CODE); + vm.expectRevert( + abi.encodeWithSelector( + DeployerNotDeployed.selector, LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_BEACON_SET_DEPLOYER_0_1_1 + ) + ); + script.run(); + } + + /// @notice The chain's authoriser pin is rejected when nothing is deployed + /// at it. Base and unknown chains never reach the readiness check at all — + /// they have no authoriser to resolve, so they are a dispatch error. + function testAuthoriserReadyRejectsChainsWithNoPin() external { + vm.chainId(LibSafeInvariants.BASE_CHAIN_ID); + vm.expectRevert(abi.encodeWithSelector(UnsupportedTargetChain.selector, LibSafeInvariants.BASE_CHAIN_ID)); + harness.assertAuthoriserReady(); + + vm.chainId(123456); + vm.expectRevert(abi.encodeWithSelector(UnsupportedTargetChain.selector, 123456)); + harness.assertAuthoriserReady(); + } + + /// @notice An unpinned or undeployed authoriser is caught before any + /// broadcast — dispatching ahead of the authoriser clone landing on the + /// target chain would otherwise deploy tokens with nothing to wire them to. + function testAuthoriserNotReadyWhenNothingIsDeployedAtThePin() external { + vm.chainId(LibSafeInvariants.ETHEREUM_CHAIN_ID); + vm.expectRevert( + abi.encodeWithSelector(AuthoriserNotReady.selector, LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM) + ); + harness.assertAuthoriserReady(); + } + + /// @notice Code at the pin is not enough: a contract that is not the + /// audited clone is rejected on codehash. This is the check that makes the + /// pin mean "the V4 authoriser" rather than "some contract". + function testAuthoriserNotReadyWhenTheCodehashIsWrong() external { + vm.chainId(LibSafeInvariants.HYPEREVM_CHAIN_ID); + vm.etch(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM, STUB_CODE); + vm.expectRevert( + abi.encodeWithSelector(AuthoriserNotReady.selector, LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM) + ); + harness.assertAuthoriserReady(); + } + + /// @notice The audited clone passes on every supported target chain, so + /// the rejections above are not vacuous. + /// + /// The runtime is rebuilt here rather than fetched, which also pins what + /// the codehash MEANS: `STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH` is the + /// EIP-1167 runtime embedding the 0.1.1 authoriser implementation, and + /// nothing else asserted that derivation — the invariants lib states it in + /// prose and compares hashes. A clone of a different implementation would + /// fail here rather than silently satisfy every codehash check in the repo. + function testAuthoriserReadyAcceptsTheAuditedClone() external { + bytes memory cloneRuntime = + abi.encodePacked(hex"363d3d373d3d3d363d73", AUTHORISER_IMPL_0_1_1, hex"5af43d82803e903d91602b57fd5bf3"); + assertEq( + keccak256(cloneRuntime), + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, + "the pinned codehash is not an EIP-1167 clone of the 0.1.1 authoriser" + ); + + vm.chainId(LibSafeInvariants.ETHEREUM_CHAIN_ID); + vm.etch(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, cloneRuntime); + assertEq( + harness.assertAuthoriserReady(), + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, + "Ethereum authoriser rejected" + ); + + vm.chainId(LibSafeInvariants.HYPEREVM_CHAIN_ID); + vm.etch(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM, cloneRuntime); + assertEq( + harness.assertAuthoriserReady(), + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM, + "HyperEVM authoriser rejected" + ); + } + + /// @notice A deploy call that emitted no `Deployment` event reverts named, + /// rather than carrying a zero address into the receipt readback and + /// faulting as a raw call to an empty account — mid-broadcast, with the + /// tokens before it already live. + function testReadDeploymentRevertsWhenTheEventIsMissing() external { + vm.expectRevert(abi.encodeWithSelector(DeploymentEventMissing.selector, "RKLB")); + harness.readDeployment(new Vm.Log[](0), DEPLOYER, "RKLB"); + } + + /// @notice A `Deployment` from anything other than the unified deployer is + /// not this script's deployment. Nothing stops an unrelated contract in + /// the same transaction emitting the same signature. + function testReadDeploymentIgnoresOtherEmitters() external { + Vm.Log[] memory logs = new Vm.Log[](1); + logs[0] = _deploymentLog(address(0xBAD), RECEIPT_VAULT, WRAPPED); + + vm.expectRevert(abi.encodeWithSelector(DeploymentEventMissing.selector, "RKLB")); + harness.readDeployment(logs, DEPLOYER, "RKLB"); + } + + /// @notice Other events from the unified deployer are not deployments + /// either — the topic is matched, not just the emitter. + function testReadDeploymentIgnoresOtherEventsFromTheDeployer() external { + Vm.Log[] memory logs = new Vm.Log[](1); + logs[0] = _deploymentLog(DEPLOYER, RECEIPT_VAULT, WRAPPED); + logs[0].topics[0] = keccak256("SomethingElse(address,address,address)"); + + vm.expectRevert(abi.encodeWithSelector(DeploymentEventMissing.selector, "RKLB")); + harness.readDeployment(logs, DEPLOYER, "RKLB"); + } + + /// @notice The matching event's pair is decoded, picked out from among + /// logs that do not match — so the rejections above are not vacuous. + function testReadDeploymentDecodesTheDeployedPair() external view { + Vm.Log[] memory logs = new Vm.Log[](3); + logs[0] = _deploymentLog(address(0xBAD), address(0xDEAD), address(0xDEAD)); + logs[1] = _deploymentLog(DEPLOYER, RECEIPT_VAULT, WRAPPED); + logs[2] = _deploymentLog(DEPLOYER, RECEIPT_VAULT, WRAPPED); + logs[2].topics[0] = keccak256("SomethingElse(address,address,address)"); + + (address receiptVault, address wrapped) = harness.readDeployment(logs, DEPLOYER, "RKLB"); + assertEq(receiptVault, RECEIPT_VAULT, "wrong receipt vault decoded"); + assertEq(wrapped, WRAPPED, "wrong wrapped vault decoded"); + } + + /// @notice Ownership that did not land on the Safe is caught: otherwise + /// the broadcast finishes "successfully" leaving a production vault owned + /// by the CI deploy key. + function testHandoffCaughtWhenOwnershipDidNotLand() external { + vm.mockCall(RECEIPT_VAULT, abi.encodeWithSignature("authorizer()"), abi.encode(AUTHORISER)); + vm.mockCall(RECEIPT_VAULT, abi.encodeWithSelector(Ownable.owner.selector), abi.encode(STRAY)); + vm.expectRevert(abi.encodeWithSelector(OwnershipHandoffFailed.selector, RECEIPT_VAULT, SAFE, STRAY)); + script.assertHandoffLanded(RECEIPT_VAULT, AUTHORISER, SAFE); + } + + /// @notice A vault left on the wrong authoriser is caught — until + /// `setAuthorizer` lands, every operation on the vault reverts. + function testHandoffCaughtWhenAuthoriserNotWired() external { + vm.mockCall(RECEIPT_VAULT, abi.encodeWithSignature("authorizer()"), abi.encode(STRAY)); + vm.mockCall(RECEIPT_VAULT, abi.encodeWithSelector(Ownable.owner.selector), abi.encode(SAFE)); + vm.expectRevert(abi.encodeWithSelector(AuthoriserNotWired.selector, RECEIPT_VAULT, AUTHORISER, STRAY)); + script.assertHandoffLanded(RECEIPT_VAULT, AUTHORISER, SAFE); + } + + /// @notice Both landed passes, so the two above are not vacuous. + function testHandoffPassesWhenBothLanded() external { + vm.mockCall(RECEIPT_VAULT, abi.encodeWithSignature("authorizer()"), abi.encode(AUTHORISER)); + vm.mockCall(RECEIPT_VAULT, abi.encodeWithSelector(Ownable.owner.selector), abi.encode(SAFE)); + script.assertHandoffLanded(RECEIPT_VAULT, AUTHORISER, SAFE); + } + + /// @notice A `Deployment(sender, asset, wrapper)` log as the unified + /// deployer emits it. + /// @param emitter The contract the log is attributed to. + /// @param receiptVault The deployed receipt vault. + /// @param wrapped The deployed wrapped token vault. + /// @return log The synthesised log. + function _deploymentLog(address emitter, address receiptVault, address wrapped) + internal + pure + returns (Vm.Log memory log) + { + log.topics = new bytes32[](1); + log.topics[0] = keccak256("Deployment(address,address,address)"); + log.data = abi.encode(address(0x5E11E4), receiptVault, wrapped); + log.emitter = emitter; + } +} diff --git a/test/script/DeployMissingTokensEthereumHarness.sol b/test/script/DeployMissingTokensEthereumHarness.sol deleted file mode 100644 index 5f813799..00000000 --- a/test/script/DeployMissingTokensEthereumHarness.sol +++ /dev/null @@ -1,20 +0,0 @@ -// 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(); - } -} diff --git a/test/script/DeployMissingTokensHarness.sol b/test/script/DeployMissingTokensHarness.sol new file mode 100644 index 00000000..0ba42bd5 --- /dev/null +++ b/test/script/DeployMissingTokensHarness.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Vm} from "forge-std-1.16.1/src/Vm.sol"; +import {DeployMissingTokens} from "../../script/20260807-deploy-missing-tokens.s.sol"; +import {TokenInstance} from "../../src/lib/LibTokenInvariants.sol"; +import {TokenConfig} from "../../src/lib/LibProdTokenConfig.sol"; + +/// @dev Exposes the script's internals for the pure selection tests. +contract DeployMissingTokensHarness is DeployMissingTokens { + /// @notice The script's `_selectMissing()`, externally callable. Takes the + /// config and Base tables so a test can drive the alignment guards with a + /// deliberately drifted pair, which the canonical tables cannot express. + /// @param configs The canonical name/symbol table, Base row order. + /// @param base The Base token table. + /// @param target The target chain's existing token table. Named `target` + /// rather than `targetTokens` so it does not shadow `targetTokens()` below. + /// @return The Base tokens absent from `target`. + function selectMissing(TokenConfig[] memory configs, TokenInstance[] memory base, TokenInstance[] memory target) + external + pure + returns (TokenConfig[] memory) + { + return _selectMissing(configs, base, target); + } + + /// @notice The script's `_targetTokens()`, externally callable. + /// @return The active chain's token table. + function targetTokens() external view returns (TokenInstance[] memory) { + return _targetTokens(); + } + + /// @notice The script's `_assertAuthoriserReady()`, externally callable. + /// @return The validated authoriser for the active chain. + function assertAuthoriserReady() external view returns (address) { + return _assertAuthoriserReady(); + } + + /// @notice The script's `_readDeployment()`, externally callable. + /// @param logs The logs to resolve the deployed pair from. + /// @param unifiedDeployer The deployer whose event is authoritative. + /// @param underlying The token being deployed, for the revert. + /// @return receiptVault The deployed receipt vault. + /// @return wrapped The deployed wrapped token vault. + function readDeployment(Vm.Log[] memory logs, address unifiedDeployer, string memory underlying) + external + pure + returns (address receiptVault, address wrapped) + { + return _readDeployment(logs, unifiedDeployer, underlying); + } +} diff --git a/test/script/DeployMissingTokensHyperEvmHarness.sol b/test/script/DeployMissingTokensHyperEvmHarness.sol deleted file mode 100644 index 0b55c36e..00000000 --- a/test/script/DeployMissingTokensHyperEvmHarness.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {DeployMissingTokensHyperEvm} from "../../script/20260722-deploy-missing-tokens-hyperevm.s.sol"; -import {TokenConfig} from "../../src/lib/LibProdTokenConfig.sol"; - -/// @title DeployMissingTokensHyperEvmHarness -/// @notice Exposes the deploy script's internal selection so the pure -/// selection test can drive it directly. Split into its own file to satisfy -/// the Rain one-contract-per-file convention, matching the existing -/// `DeployMissingTokensEthereumHarness` pattern. -contract DeployMissingTokensHyperEvmHarness is DeployMissingTokensHyperEvm { - function selectMissing() external pure returns (TokenConfig[] memory) { - return _selectMissing(); - } -} diff --git a/test/script/ProvisionAdditionalServiceSignerHarness.sol b/test/script/ProvisionAdditionalServiceSignerHarness.sol index 0a3d8353..fb2a898e 100644 --- a/test/script/ProvisionAdditionalServiceSignerHarness.sol +++ b/test/script/ProvisionAdditionalServiceSignerHarness.sol @@ -14,7 +14,7 @@ import {SafeTx} from "../../src/lib/LibSafeOps.sol"; /// from a lower call depth than the cheatcode itself). Its own file because /// Rain convention is one contract per .sol and /// `rainix-sol-single-contract` enforces it. Mirrors -/// `test/script/DeployMissingTokensEthereumHarness.sol`. +/// `test/script/DeployMissingTokensHarness.sol`. contract ProvisionAdditionalServiceSignerHarness is ProvisionAdditionalServiceSigner { /// @notice The script's `authorBundle()`, externally callable. /// @param authoriser The authoriser whose live role state is read.