From 8a9527e06be513549347ad75244f537f9fde1fda Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 26 May 2026 16:53:15 +0200 Subject: [PATCH 1/8] fix: add missing ETH<>pharos Chainlink connection --- env/connections/mainnet.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/env/connections/mainnet.json b/env/connections/mainnet.json index caed25619..ef2426cb6 100644 --- a/env/connections/mainnet.json +++ b/env/connections/mainnet.json @@ -22,6 +22,11 @@ "chains": [["monad"], ["plume", "hyper-evm", "pharos"]], "adapters": ["layerZero"], "threshold": 1 + }, + { + "chains": [["pharos"], ["ethereum"]], + "adapters": ["layerZero", "chainlink"], + "threshold": 2 } ] } From 32893cf193f5d0745d480a56d473c02a7f57622e Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 26 May 2026 16:54:16 +0200 Subject: [PATCH 2/8] ref: support spell pre fork validation --- .../integration/spell/utils/SpellForkTest.sol | 264 ++++++++++++++++++ .../utils/validation/ValidationExecutor.sol | 31 ++ 2 files changed, 295 insertions(+) create mode 100644 test/integration/spell/utils/SpellForkTest.sol diff --git a/test/integration/spell/utils/SpellForkTest.sol b/test/integration/spell/utils/SpellForkTest.sol new file mode 100644 index 000000000..5641230ea --- /dev/null +++ b/test/integration/spell/utils/SpellForkTest.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import {BaseValidator} from "./validation/BaseValidator.sol"; +import {ValidationExecutor} from "./validation/ValidationExecutor.sol"; +import {testContractsFromConfig} from "./validation/TestContracts.sol"; +import {InvestmentFlowExecutor, InvestmentFlowResult, VaultGraphQLData} from "./validation/InvestmentFlowExecutor.sol"; + +import {JsonUtils} from "../../../../script/utils/JsonUtils.s.sol"; +import {Env, EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; +import {GraphQLQuery} from "../../../../script/utils/GraphQLQuery.s.sol"; + +import {Test} from "forge-std/Test.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +import {Validate_Vaults} from "../../fork/validators/Validate_Vaults.sol"; +import {NonCoreReport} from "../../../../src/deployment/ActionBatchers.sol"; +import {Validate_Endorsements} from "../../fork/validators/Validate_Endorsements.sol"; +import {Validate_ContractWards} from "../../fork/validators/Validate_ContractWards.sol"; +import {Validate_GuardianSafes} from "../../fork/validators/Validate_GuardianSafes.sol"; +import {Validate_HookPoolEscrow} from "../../fork/validators/Validate_HookPoolEscrow.sol"; +import {Validate_RootPermissions} from "../../fork/validators/Validate_RootPermissions.sol"; +import {Validate_FileConfigurations} from "../../fork/validators/Validate_FileConfigurations.sol"; +import {Validate_AdapterConfigurations} from "../../fork/validators/Validate_AdapterConfigurations.sol"; + +/// @title SpellForkTest +/// @notice Abstract base for spell fork tests. Inheritors run the spell on a +/// live-network fork and the base contract enforces the broader +/// live-state invariants on top of any spell-specific assertions. +/// +/// @dev Per-network flow: +/// 1. Pre-cast snapshot: capture the structural validator error count +/// AND the investment-flow results, revert state. +/// 2. Spell cast (child-defined). +/// 3. Post-cast: run the structural validators again, assert the new +/// error count did not exceed the pre-cast baseline (regression +/// check; pre-existing live errors are tolerated). +/// 4. Post-cast: run flows again, diff per-vault against step-1 +/// snapshot — pre-existing failures tolerated, regressions fail. +/// 5. Spell-specific assertions (child-defined). +/// +/// @dev Live mainnet currently has pre-existing errors in both suites +/// (e.g. Pharos adapter quorum mismatch and PoolEscrow accounting +/// drift surfaced by the structural validators; cross-chain sync +/// and unlinked-vault errors surfaced by the flow executor). Per +/// William's "we want the failure DIFF instead of failure" framing, +/// we tolerate these pre-existing failures and only fail the spell +/// test on NEW regressions introduced by the spell itself. +/// +/// @dev Structural-validator diff is intentionally coarse (count-only, +/// not per-error). A per-error diff was attempted but hit a +/// legacy-codegen stack-too-deep with optimizer_runs=1; the +/// count-based variant is enough to catch regressions and the full +/// per-error report is still emitted to logs for diagnostics. +abstract contract SpellForkTest is Test { + using stdJson for string; + using JsonUtils for *; + + // ------------------------------------------------------------------ + // Virtual hooks + // ------------------------------------------------------------------ + + /// @notice Deploy the spell, schedule any required relies, and cast it. + function _castSpell(string memory network, EnvConfig memory config) internal virtual; + + /// @notice Spell-specific post-cast assertions (e.g. checking a sweep + /// transferred the expected balance). + function _customPostAssertions(string memory network, EnvConfig memory config) internal virtual {} + + /// @notice If true (default), runs the investment flow suite pre- and + /// post-cast and diffs the results. + function _runInvestmentFlowsDiff() internal pure virtual returns (bool) { + return true; + } + + // ------------------------------------------------------------------ + // Concrete driver + // ------------------------------------------------------------------ + + function _testCase(string memory network) internal { + EnvConfig memory config = Env.load(network); + vm.createSelectFork(config.network.rpcUrl()); + + VaultGraphQLData[] memory vaults = _maybeQueryVaults(config); + + uint256 preValidatorErrors = _countValidatorErrorsSnapshotted(network); + InvestmentFlowResult[] memory preFlows = _runFlowsSnapshotted(config, vaults); + + _castSpell(network, config); + + _assertValidatorErrorCountDidNotIncrease(network, preValidatorErrors); + _assertNoFlowRegressions(preFlows, _runFlowsSnapshotted(config, vaults)); + _customPostAssertions(network, config); + } + + function _maybeQueryVaults(EnvConfig memory config) internal returns (VaultGraphQLData[] memory) { + if (!_runInvestmentFlowsDiff()) return new VaultGraphQLData[](0); + GraphQLQuery indexer = new GraphQLQuery(config.network.graphQLApi()); + return _queryVaults(indexer, config.network.centrifugeId); + } + + // ------------------------------------------------------------------ + // Validator helpers + // ------------------------------------------------------------------ + + function _buildValidators() internal returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](8); + validators[0] = new Validate_RootPermissions(); + validators[1] = new Validate_ContractWards(); + validators[2] = new Validate_FileConfigurations(); + validators[3] = new Validate_Endorsements(); + validators[4] = new Validate_GuardianSafes(); + validators[5] = new Validate_AdapterConfigurations(); + validators[6] = new Validate_Vaults(); + validators[7] = new Validate_HookPoolEscrow(); + } + + /// @notice Run the 8 structural validators inside a state snapshot and + /// return the total error count without polluting surrounding + /// state. The per-validator report is still emitted to logs. + function _countValidatorErrorsSnapshotted(string memory network) internal returns (uint256 count) { + uint256 snap = vm.snapshotState(); + ValidationExecutor executor = new ValidationExecutor(network, "spell-fork-pre"); + count = executor.runValidationCountErrors(_buildValidators(), "PRE-CAST"); + vm.revertToState(snap); + } + + /// @notice Run the 8 structural validators against post-cast live state + /// and assert the total error count did not exceed the pre-cast + /// baseline. The per-validator report is emitted to logs so the + /// spell author can see exactly which errors were tolerated and + /// which (if any) are new. + function _assertValidatorErrorCountDidNotIncrease(string memory network, uint256 preErrors) internal { + ValidationExecutor executor = new ValidationExecutor(network, "spell-fork-post"); + uint256 postErrors = executor.runValidationCountErrors(_buildValidators(), "POST-CAST"); + assertLe(postErrors, preErrors, "Spell introduced new structural validator errors"); + } + + // ------------------------------------------------------------------ + // Investment flow helpers + // ------------------------------------------------------------------ + + /// @notice Run InvestmentFlowExecutor inside a state snapshot so the side + /// effects (mints, deposits, hub registrations, manager updates) + /// do not bleed into the surrounding test state. + function _runFlowsSnapshotted(EnvConfig memory config, VaultGraphQLData[] memory vaults) + internal + returns (InvestmentFlowResult[] memory) + { + if (vaults.length == 0) return new InvestmentFlowResult[](0); + + uint256 snap = vm.snapshotState(); + + NonCoreReport memory report = testContractsFromConfig(config).main; + InvestmentFlowExecutor executor = new InvestmentFlowExecutor(); + vm.deal(address(executor), 100 ether); + + InvestmentFlowResult[] memory results = executor.executeAllFlows(report, vaults, config.network.centrifugeId); + + vm.revertToState(snap); + + return results; + } + + /// @notice Compare pre-cast and post-cast flow results vault-by-vault. A + /// "regression" is a vault that passed before the spell and fails + /// after; these hard-fail the test. Failures that pre-date the + /// spell are logged but tolerated. + /// @dev Pre and post arrays index identically because both runs were + /// given the same `vaults[]` (`InvestmentFlowExecutor.executeAllFlows` + /// writes results in input order). + function _assertNoFlowRegressions(InvestmentFlowResult[] memory pre, InvestmentFlowResult[] memory post) internal { + assertEq(pre.length, post.length, "Pre/post flow result length mismatch"); + + uint256 depositRegressions; + uint256 redeemRegressions; + + emit log_string(""); + emit log_string("================================================================"); + emit log_string(" INVESTMENT FLOW DIFF (pre-cast vs post-cast)"); + emit log_string("================================================================"); + + for (uint256 i = 0; i < post.length; i++) { + InvestmentFlowResult memory b = pre[i]; + InvestmentFlowResult memory a = post[i]; + + if (b.depositPassed && !a.depositPassed) { + emit log_string(string.concat("[REGRESSION deposit] ", vm.toString(a.vault), " -> ", a.depositError)); + depositRegressions++; + } else if (!b.depositPassed && a.depositPassed) { + emit log_string(string.concat("[IMPROVED deposit] ", vm.toString(a.vault))); + } else if (!b.depositPassed && !a.depositPassed) { + emit log_string(string.concat("[PRE-EXISTING deposit] ", vm.toString(a.vault), " -> ", a.depositError)); + } + + if (b.redeemPassed && !a.redeemPassed) { + emit log_string(string.concat("[REGRESSION redeem] ", vm.toString(a.vault), " -> ", a.redeemError)); + redeemRegressions++; + } else if (!b.redeemPassed && a.redeemPassed) { + emit log_string(string.concat("[IMPROVED redeem] ", vm.toString(a.vault))); + } else if (!b.redeemPassed && !a.redeemPassed) { + emit log_string(string.concat("[PRE-EXISTING redeem] ", vm.toString(a.vault), " -> ", a.redeemError)); + } + } + + emit log_string("================================================================"); + + assertEq(depositRegressions, 0, "Spell regressed deposit flows on previously-passing vaults"); + assertEq(redeemRegressions, 0, "Spell regressed redeem flows on previously-passing vaults"); + } + + // ------------------------------------------------------------------ + // GraphQL vault query (mirrors InvestmentFlowForkTest._queryVaults) + // ------------------------------------------------------------------ + + function _queryVaults(GraphQLQuery indexer, uint16 centrifugeId) + internal + returns (VaultGraphQLData[] memory vaults) + { + string memory centrifugeIdStr = vm.toString(centrifugeId).asJsonString(); + + string memory json = indexer.queryGraphQL( + string.concat( + "vaults(limit: 1000, where: { centrifugeId: ", + centrifugeIdStr, + ", status: Linked }) { totalCount items { id poolId tokenId kind assetAddress asset { decimals symbol } token { pool { managers(where: {isHubManager: true}, limit: 1) { items { address centrifugeId } } } } } }" + ) + ); + + uint256 totalCount = json.readUint(".data.vaults.totalCount"); + if (totalCount == 0) return vaults; + + require(totalCount <= 1000, "Vault count exceeds query limit; implement pagination"); + + vaults = new VaultGraphQLData[](totalCount); + + for (uint256 i; i < totalCount; i++) { + string memory base = ".data.vaults.items"; + vaults[i].vault = json.readAddress(base.asJsonPath(i, "id")); + vaults[i].poolIdRaw = uint64(json.readUint(base.asJsonPath(i, "poolId"))); + vaults[i].tokenIdRaw = _parseBytes16(json, base.asJsonPath(i, "tokenId")); + vaults[i].kind = json.readString(base.asJsonPath(i, "kind")); + vaults[i].assetAddress = json.readAddress(base.asJsonPath(i, "assetAddress")); + vaults[i].assetDecimals = uint8(json.readUint(base.asJsonPath(i, "asset.decimals"))); + vaults[i].assetSymbol = json.readString(base.asJsonPath(i, "asset.symbol")); + + string memory managersBase = string.concat(base, "[", vm.toString(i), "].token.pool.managers.items"); + try vm.parseJsonAddress(json, string.concat(managersBase, "[0].address")) returns (address mgr) { + vaults[i].hubManager = mgr; + } catch {} + try vm.parseJsonUint(json, string.concat(managersBase, "[0].centrifugeId")) returns (uint256 cid) { + vaults[i].hubCentrifugeId = uint16(cid); + } catch {} + } + } + + function _parseBytes16(string memory json, string memory path) internal pure returns (bytes16 result) { + bytes memory rawBytes = json.readBytes(path); + require(rawBytes.length == 16, "Expected 16 bytes for tokenId"); + for (uint256 i = 0; i < 16; i++) { + result |= bytes16(rawBytes[i]) >> (i * 8); + } + } +} diff --git a/test/integration/spell/utils/validation/ValidationExecutor.sol b/test/integration/spell/utils/validation/ValidationExecutor.sol index af5813eb8..7943bfc96 100644 --- a/test/integration/spell/utils/validation/ValidationExecutor.sol +++ b/test/integration/spell/utils/validation/ValidationExecutor.sol @@ -53,6 +53,37 @@ contract ValidationExecutor is Script { _execute(validators, "POST", true); } + /// @notice Run validators against live state and return the total error + /// count without reverting. Used by `SpellForkTest` to compute a + /// pre/post regression count for the structural validators — + /// tolerating pre-existing live-state errors that the spell did + /// not introduce. + /// @dev Per-error log lines are still emitted via the standard report. + function runValidationCountErrors(BaseValidator[] memory validators, string memory phaseName) + external + returns (uint256 totalErrors) + { + ctx.contracts.latest = empty; + ValidationResult[] memory results = new ValidationResult[](validators.length); + + for (uint256 i = 0; i < validators.length; i++) { + require( + !executed[validators[i]], string.concat("The validator ", validators[i].name(), " was already executed") + ); + executed[validators[i]] = true; + + validators[i].validate(ctx); + + results[i].name = validators[i].name(); + results[i].errors = validators[i].errors(); + totalErrors += results[i].errors.length; + } + + if (bytes(phaseName).length != 0) { + _displayReport(results, totalErrors, phaseName, false); + } + } + function _execute(BaseValidator[] memory validators, string memory phaseName, bool shouldRevert) internal returns (bool) From 3846d7967e7f186775a02dffcd4953038b1e0226 Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 26 May 2026 16:58:06 +0200 Subject: [PATCH 3/8] chore: checkout spell from `spell_v2cleanings-checks` --- script/spell/V2Cleanings.s.sol | 12 +-- src/spell/V2CleaningsSpell.sol | 153 +++++++++++++++++++++++++-------- 2 files changed, 120 insertions(+), 45 deletions(-) diff --git a/script/spell/V2Cleanings.s.sol b/script/spell/V2Cleanings.s.sol index 93e14ce6d..b18975b73 100644 --- a/script/spell/V2Cleanings.s.sol +++ b/script/spell/V2Cleanings.s.sol @@ -1,12 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.28; -import {Root} from "../../src/admin/Root.sol"; - import "forge-std/Script.sol"; import {V2CleaningsSpell} from "../../src/spell/V2CleaningsSpell.sol"; -import {EnvConfig, Env, prettyEnvString} from "../utils/EnvConfig.s.sol"; contract V2CleaningsDeployer is Script { function run() external { @@ -23,19 +20,16 @@ contract V2CleaningsExecutor is Script { address deployer; function run(V2CleaningsSpell spell) external { - EnvConfig memory config = Env.load(prettyEnvString("NETWORK")); - Root rootV3 = Root(config.contracts.root); - vm.startBroadcast(); - migrate(spell, rootV3); + migrate(spell); vm.stopBroadcast(); } - function migrate(V2CleaningsSpell spell, Root rootV3) public { + function migrate(V2CleaningsSpell spell) public { vm.label(address(spell), "V2CleaningsSpell"); - spell.cast(rootV3); + spell.cast(); } } diff --git a/src/spell/V2CleaningsSpell.sol b/src/spell/V2CleaningsSpell.sol index 19eaa45b0..ce9fb05bd 100644 --- a/src/spell/V2CleaningsSpell.sol +++ b/src/spell/V2CleaningsSpell.sol @@ -6,35 +6,99 @@ import {IERC20} from "../misc/interfaces/IERC20.sol"; import {Root} from "../admin/Root.sol"; +// V2 Root. refs: +// https://github.com/centrifuge/vaults-internal/blob/5e8262b26f8b3b488fa11855bfc2ee2e9943c09c/deployments/mainnet/ethereum-mainnet.json#L29 +// https://etherscan.io/address/0x0C1fDfd6a1331a875EA013F3897fc8a76ada5DfC Root constant ROOT_V2 = Root(0x0C1fDfd6a1331a875EA013F3897fc8a76ada5DfC); +// V3 root on the three V2 networks ETH, BASE, ARB on which the spell is cast. +// ref: env/{ethereum,base,arbitrum}.json contracts.root + +// https://github.com/centrifuge/documentation/blob/main/docs/developer/protocol/deployments/index.mdx +Root constant ROOT_V3 = Root(0x7Ed48C31f2fdC40d37407cBaBf0870B2b688368f); + +// v3.1.0 protocol contracts. ref for all five: env/{ethereum,base,arbitrum}.json (contracts.*) + +// https://github.com/centrifuge/documentation/blob/main/docs/developer/protocol/deployments/index.mdx address constant CONTRACT_UPDATER = 0x3B150B19245D2C366bc8f18c775b725DFB298F71; address constant FREEZE_ONLY_HOOK = 0xd5B243F05b2906F1f6C80c6096945faADa0731C1; address constant FULL_RESTRICTIONS_HOOK = 0x8E680873b4C77e6088b4Ba0aBD59d100c3D224a4; address constant FREELY_TRANSFERABLE_HOOK = 0x2a9B9C14851Baf7AD19f26607C9171CA1E7a1A61; address constant REDEMPTION_RESTRICTIONS_HOOK = 0xE5423eD8602Fa0F263e17b6212d88Efe42317f06; +// CFG token (CREATE3 vanity), described as "newCFG" in Slack. +// refs: https://kflabs.slack.com/archives/C072WRU6V6K/p1747225567523339 +// https://etherscan.io/address/0xcccCCCcCCC33D538DBC2EE4fEab0a7A1FF4e8A94 address constant CFG = 0xcccCCCcCCC33D538DBC2EE4fEab0a7A1FF4e8A94; +// Wrapped CFG (Ethereum only), described as "legacyCfg" in Slack. +// refs: https://kflabs.slack.com/archives/C072WRU6V6K/p1747225567523339 +// https://etherscan.io/address/0xc221b7E65FfC80DE234bbB6667aBDd46593D34F0 address constant WCFG = 0xc221b7E65FfC80DE234bbB6667aBDd46593D34F0; +// Legacy v2 WCFG admin multisig. +// refs: https://kflabs.slack.com/archives/C04JQ1QCGQ0/p1755074130455079 +// https://etherscan.io/address/0x3C9D25F2C76BFE63485AE25D524F7f02f2C03372 address constant WCFG_MULTISIG = 0x3C9D25F2C76BFE63485AE25D524F7f02f2C03372; +// ChainBridge ERC20 handler (v2 bridge). +// refs: https://kflabs.slack.com/archives/C04JQ1QCGQ0/p1755074130455079 +// https://etherscan.io/address/0x84D1e77F472a4aA697359168C4aF4ADD4D2a71fa address constant CHAINBRIDGE_ERC20_HANDLER = 0x84D1e77F472a4aA697359168C4aF4ADD4D2a71fa; +// CREATE3 proxy used for cross-chain CFG. +// ref: https://kflabs.slack.com/archives/C04JQ1QCGQ0/p1755074130455079 address constant CREATE3_PROXY = 0x28E6eED839a5E03D92f7A5C459430576081fadFb; +// IOU CFG, redeemed 1:1 for CFG. +// refs: https://kflabs.slack.com/archives/C072WRU6V6K/p1747730612516909 +// https://etherscan.io/address/0xACF3c07BeBd65d5f7d86bc0bc716026A0C523069 address constant IOU_CFG = 0xACF3c07BeBd65d5f7d86bc0bc716026A0C523069; +// CFG_MINTER — permanent mint authority on CFG (Ethereum) for treasury inflation. +// ref: https://kflabs.slack.com/archives/C07PG2EUR9C/p1772545104197419 address constant CFG_MINTER = 0x50a168Cd6957e07B6dE6C1A99B2f940475f70dEf; +// V2 share tokens ("tranches"). ref: +// https://www.notion.so/v2-Pools-ShareClass-Ids-ShareTokens-2312eac24e17808cb72bfeb208f594ec address constant TRANCHE_JTRSY = 0x8c213ee79581Ff4984583C6a801e5263418C4b86; // ETH_JTRSY, BASE_JTRSY, ARBITRUM_JTRSY address constant TRANCHE_JAAA = 0x5a0F93D040De44e78F251b03c43be9CF317Dcf64; // ETH_JAAA, BASE_JAAA +// ref: Slack https://kflabs.slack.com/archives/C05S1JXD37U/p1747228055896649 (V2 USDC escrow, CREATE3 vanity) address constant ESCROW_V2 = 0x0000000005F458Fd6ba9EEb5f365D83b7dA913dD; + +// V2 vault addresses per chain — these remain as wards on share tokens and must be explicitly removed. +// ref: https://www.notion.so/v2-Pools-ShareClass-Ids-ShareTokens-2312eac24e17808cb72bfeb208f594ec +address constant ETH_V2_JTRSY_VAULT = 0x36036fFd9B1C6966ab23209E073c68Eb9A992f50; +address constant ETH_V2_JAAA_VAULT = 0xE9d1f733F406D4bbbDFac6D4CfCD2e13A6ee1d01; +address constant BASE_V2_JTRSY_VAULT = 0xF9a6768034280745d7F303D3d8B7f2bF3Cc079eF; +address constant BASE_V2_JAAA_VAULT = 0xB4C8540657d67D4846cAe68EcfE2C706c80DC3c9; +address constant ARB_V2_JTRSY_VAULT = 0x16C796208c6E2d397Ec49D69D207a9cB7d072f04; +// TODO(jeroen/frederik): confirm role before cast. Jakob (head of accounting) described this as the +// "USDC funding address for WL customers to pay upfront/ongoing fees" +// (https://kflabs.slack.com/archives/C077QU14E31/p1779259744073899), but other Slack threads reference +// the same address as an investor. The two roles are incompatible — receiving drained V2-escrow USDC +// here while it also makes V3 investments could distort accounting. BLOCKING. address constant TREASURY = 0xb3DacC732509Ba6B7F25Ad149e56cA44fE901AB9; +// Native Circle USDC. ref: https://developers.circle.com/stablecoins/usdc-contract-addresses IERC20 constant USDC_ETHEREUM = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); IERC20 constant USDC_BASE = IERC20(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913); IERC20 constant USDC_ARBITRUM = IERC20(0xaf88d065e77c8cC2239327C5EDb3A432268e5831); +// ref: Slack #wg-new-cfg-token https://kflabs.slack.com/archives/C082ABDBELS/p1750237297617879 +// TODO(jeroen): formal sign-off — irreversible CFG mint recipient (Ethereum only). +address constant CNF_TREASURY_WALLET = 0xD052A46b8e0C89fAcB393805E1917AfD20f293Cb; + +// Centrifuge Chain CFG amount derivation: +// Total issuance on Centrifuge Chain: 578_423_119_298_517_443_782_165_480 +// Minus migrated CFG (4dpEcgqFp38bzzT5VruKgbfVs6zcDMFRKUetx3fz2ndLRHMu): -350_860_057_533_953_490_820_324_585 +// Minus chainbridge (4dpEcgqFp8UL6eA3b7hhtdj7qftHRZE7g1uadHyuw1WSNSgH): -192_726_451_002_520_311_526_501_638 +// = Remaining CFG on Centrifuge Chain: 34_836_610_762_043_641_435_339_257 +uint256 constant CENTRIFUGE_CHAIN_CFG_AMOUNT = 34_836_610_762_043_641_435_339_257; + +// See env/ethereum.json network.chainId uint256 constant ETHEREUM_CHAIN_ID = 1; +// See env/base.json network.chainId uint256 constant BASE_CHAIN_ID = 8453; +// See env/arbitrum.json network.chainId uint256 constant ARBITRUM_CHAIN_ID = 42161; +interface CFGTokenLike { + function mint(address to, uint256 value) external; +} + interface EscrowV2Like is IAuth { function approveMax(address token, address spender) external; } @@ -43,68 +107,74 @@ contract V2CleaningsSpell { bool public done; string public constant description = "Pending cleanings from V2"; - function cast(Root rootV3) external { + function cast() external { require(!done, "Spell already executed"); done = true; - _updateCFGWards(rootV3); - _disableRootV2FromShareTokensV2(rootV3); + _updateCFGWards(); + _disableRootV2FromShareTokensV2(); _moveFundsFromEscrowToTreasury(); - _relyContractUpdaterOnHooks(rootV3); + _relyContractUpdaterOnHooks(); + _mintCFGToTreasury(); if (address(ROOT_V2).code.length > 0) { ROOT_V2.deny(address(this)); } - rootV3.deny(address(this)); + ROOT_V3.deny(address(this)); } - function _updateCFGWards(Root rootV3) internal { + function _updateCFGWards() internal { // Check if CFG exists if (CFG.code.length > 0) { // Mainnet CFG only has the v2 root relied, need to replace with v3 root if (block.chainid == ETHEREUM_CHAIN_ID) { if (address(ROOT_V2).code.length > 0) { - ROOT_V2.relyContract(CFG, address(rootV3)); + ROOT_V2.relyContract(CFG, address(ROOT_V3)); ROOT_V2.relyContract(CFG, CFG_MINTER); - rootV3.denyContract(CFG, address(ROOT_V2)); + ROOT_V3.denyContract(CFG, address(ROOT_V2)); } - rootV3.denyContract(CFG, IOU_CFG); + ROOT_V3.denyContract(CFG, IOU_CFG); } // Deny CREATE3 proxy on new chains if (block.chainid != ETHEREUM_CHAIN_ID) { - rootV3.denyContract(CFG, CREATE3_PROXY); + ROOT_V3.denyContract(CFG, CREATE3_PROXY); } } // Check if WCFG exists (only in Ethereum) if (WCFG.code.length > 0) { if (address(ROOT_V2).code.length > 0) { - Root(ROOT_V2).relyContract(WCFG, address(rootV3)); - rootV3.denyContract(WCFG, address(ROOT_V2)); + ROOT_V2.relyContract(WCFG, address(ROOT_V3)); + ROOT_V3.denyContract(WCFG, address(ROOT_V2)); } - rootV3.denyContract(WCFG, WCFG_MULTISIG); - rootV3.denyContract(WCFG, CHAINBRIDGE_ERC20_HANDLER); + ROOT_V3.denyContract(WCFG, WCFG_MULTISIG); + ROOT_V3.denyContract(WCFG, CHAINBRIDGE_ERC20_HANDLER); } } - function _disableRootV2FromShareTokensV2(Root rootV3) internal { - address[] memory shareTokens = new address[](2); - shareTokens[0] = TRANCHE_JTRSY; - shareTokens[1] = TRANCHE_JAAA; - - for (uint256 i; i < shareTokens.length; i++) { - IAuth shareTokenV2 = IAuth(shareTokens[i]); - - // forgefmt: disable-next-item - if (address(shareTokenV2).code.length > 0 && - shareTokenV2.wards(address(ROOT_V2)) == 1 && - shareTokenV2.wards(address(rootV3)) == 1 - ) { - rootV3.relyContract(address(shareTokenV2), address(this)); - shareTokenV2.deny(address(ROOT_V2)); - rootV3.denyContract(address(shareTokenV2), address(this)); - } + function _disableRootV2FromShareTokensV2() internal { + if (block.chainid == ETHEREUM_CHAIN_ID) { + _denyV2FromShareToken(TRANCHE_JTRSY, ETH_V2_JTRSY_VAULT); + _denyV2FromShareToken(TRANCHE_JAAA, ETH_V2_JAAA_VAULT); + } else if (block.chainid == BASE_CHAIN_ID) { + _denyV2FromShareToken(TRANCHE_JTRSY, BASE_V2_JTRSY_VAULT); + _denyV2FromShareToken(TRANCHE_JAAA, BASE_V2_JAAA_VAULT); + } else if (block.chainid == ARBITRUM_CHAIN_ID) { + _denyV2FromShareToken(TRANCHE_JTRSY, ARB_V2_JTRSY_VAULT); + } + } + + // V2_ROOT is ward of these V2 vaults, so a V2_ROOT compromise could exploit the + // vault's remaining ward access on the share token via authTransferFrom. + function _denyV2FromShareToken(address shareToken, address v2Vault) internal { + IAuth shareToken_ = IAuth(shareToken); + + if (address(shareToken_).code.length > 0 && shareToken_.wards(address(ROOT_V3)) == 1) { + ROOT_V3.relyContract(shareToken, address(this)); + if (shareToken_.wards(address(ROOT_V2)) == 1) shareToken_.deny(address(ROOT_V2)); + if (shareToken_.wards(v2Vault) == 1) shareToken_.deny(v2Vault); + ROOT_V3.denyContract(shareToken, address(this)); } } @@ -130,10 +200,21 @@ contract V2CleaningsSpell { } } - function _relyContractUpdaterOnHooks(Root rootV3) internal { - rootV3.relyContract(FREEZE_ONLY_HOOK, CONTRACT_UPDATER); - rootV3.relyContract(FULL_RESTRICTIONS_HOOK, CONTRACT_UPDATER); - rootV3.relyContract(FREELY_TRANSFERABLE_HOOK, CONTRACT_UPDATER); - rootV3.relyContract(REDEMPTION_RESTRICTIONS_HOOK, CONTRACT_UPDATER); + function _relyContractUpdaterOnHooks() internal { + ROOT_V3.relyContract(FREEZE_ONLY_HOOK, CONTRACT_UPDATER); + ROOT_V3.relyContract(FULL_RESTRICTIONS_HOOK, CONTRACT_UPDATER); + ROOT_V3.relyContract(FREELY_TRANSFERABLE_HOOK, CONTRACT_UPDATER); + ROOT_V3.relyContract(REDEMPTION_RESTRICTIONS_HOOK, CONTRACT_UPDATER); + } + + function _mintCFGToTreasury() internal { + if (block.chainid == ETHEREUM_CHAIN_ID) { + // Subtract wCFG balance held by the IOU_CFG contract, since those were already + // redeemed 1:1 for CFG and the wCFG total supply was not reduced upon redemption. + uint256 amount = IERC20(WCFG).totalSupply() - IERC20(WCFG).balanceOf(IOU_CFG) + CENTRIFUGE_CHAIN_CFG_AMOUNT; + ROOT_V3.relyContract(CFG, address(this)); + CFGTokenLike(CFG).mint(CNF_TREASURY_WALLET, amount); + ROOT_V3.denyContract(CFG, address(this)); + } } } From 6eb2f8b2d6d5415da4d57db0d43322dedaa1bd38 Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 26 May 2026 16:58:24 +0200 Subject: [PATCH 4/8] ref: apply post-fork-validation tests to v2 cleanings fork tests --- test/integration/spell/V2Cleanings.t.sol | 156 +++++++++++++++-------- 1 file changed, 105 insertions(+), 51 deletions(-) diff --git a/test/integration/spell/V2Cleanings.t.sol b/test/integration/spell/V2Cleanings.t.sol index f5299cf37..45690831b 100644 --- a/test/integration/spell/V2Cleanings.t.sol +++ b/test/integration/spell/V2Cleanings.t.sol @@ -1,24 +1,26 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.28; +import {SpellForkTest} from "./utils/SpellForkTest.sol"; + import {IAuth} from "../../../src/misc/interfaces/IAuth.sol"; import {IERC20} from "../../../src/misc/interfaces/IERC20.sol"; import {Root} from "../../../src/admin/Root.sol"; -import {EnvConfig, Env} from "../../../script/utils/EnvConfig.s.sol"; - -import "forge-std/Test.sol"; +import {EnvConfig} from "../../../script/utils/EnvConfig.s.sol"; import { V2CleaningsSpell, ROOT_V2, + ROOT_V3, CONTRACT_UPDATER, FREEZE_ONLY_HOOK, FULL_RESTRICTIONS_HOOK, FREELY_TRANSFERABLE_HOOK, REDEMPTION_RESTRICTIONS_HOOK, CFG, + CFG_MINTER, WCFG, WCFG_MULTISIG, CHAINBRIDGE_ERC20_HANDLER, @@ -29,64 +31,98 @@ import { ARBITRUM_CHAIN_ID, TRANCHE_JAAA, TRANCHE_JTRSY, + ETH_V2_JTRSY_VAULT, + ETH_V2_JAAA_VAULT, + BASE_V2_JTRSY_VAULT, + BASE_V2_JAAA_VAULT, + ARB_V2_JTRSY_VAULT, TREASURY, + CNF_TREASURY_WALLET, + CENTRIFUGE_CHAIN_CFG_AMOUNT, ESCROW_V2, USDC_ETHEREUM, USDC_BASE, USDC_ARBITRUM } from "../../../src/spell/V2CleaningsSpell.sol"; -contract V2CleaningsSpellTest is Test { +contract V2CleaningsSpellTest is SpellForkTest { address constant PROTOCOL_GUARDIAN_V3_1 = 0xCEb7eD5d5B3bAD3088f6A1697738B60d829635c6; address constant GUARDIAN_V2_ETHEREUM_OR_ARBITRUM = 0x09ab10a9c3E6Eac1d18270a2322B6113F4C7f5E8; address constant GUARDIAN_V2_BASE = 0x427A1ce127b1775e4Cbd4F58ad468B9F832eA7e9; - address constant ROOT_V3_ETH = 0x7Ed48C31f2fdC40d37407cBaBf0870B2b688368f; - function _testCase(string memory network, string memory rpcUrl) public { - vm.createSelectFork(rpcUrl); - - EnvConfig memory config = Env.load(network); + V2CleaningsSpell internal _spell; + uint256 internal _preTreasuryUsdc; + uint256 internal _preEscrowUsdc; + uint256 internal _preCnfTreasuryCfg; + uint256 internal _preCfgTotalSupply; + + function _castSpell( + string memory, + /* network */ + EnvConfig memory config + ) + internal + override + { Root rootV3 = Root(config.contracts.root); - if (block.chainid == ETHEREUM_CHAIN_ID) { - assertEq(ROOT_V3_ETH, address(rootV3)); + // ROOT_V3 in the spell is hardcoded to the ETH/BASE/ARB V3 root. On those three + // chains the env's contracts.root must match, otherwise the spell would brick. + if ( + block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID + || block.chainid == ARBITRUM_CHAIN_ID + ) { + assertEq(address(ROOT_V3), address(rootV3)); } - // ----- SPELL DEPLOYMENT ----- - - V2CleaningsSpell spell = new V2CleaningsSpell(); - - // ----- PRE SPELL ----- + _spell = new V2CleaningsSpell(); IERC20 usdc = _usdc(); - uint256 preTreasuryValue; - uint256 preEscrowValue; if (address(usdc) != address(0)) { - preTreasuryValue = usdc.balanceOf(TREASURY); - preEscrowValue = usdc.balanceOf(ESCROW_V2); + _preTreasuryUsdc = usdc.balanceOf(TREASURY); + _preEscrowUsdc = usdc.balanceOf(ESCROW_V2); + } + if (block.chainid == ETHEREUM_CHAIN_ID && CFG.code.length > 0) { + _preCnfTreasuryCfg = IERC20(CFG).balanceOf(CNF_TREASURY_WALLET); + _preCfgTotalSupply = IERC20(CFG).totalSupply(); } - - // ----- REQUIRED RELIES ----- vm.prank(PROTOCOL_GUARDIAN_V3_1); - rootV3.rely(address(spell)); // Ideally through guardian.scheduleRely() + rootV3.rely(address(_spell)); // Ideally through guardian.scheduleRely() if (address(ROOT_V2).code.length > 0) { vm.prank(block.chainid == BASE_CHAIN_ID ? GUARDIAN_V2_BASE : GUARDIAN_V2_ETHEREUM_OR_ARBITRUM); - ROOT_V2.rely(address(spell)); // Ideally through guardian.scheduleRely() + ROOT_V2.rely(address(_spell)); // Ideally through guardian.scheduleRely() } - // ----- SPELL EXECUTION ----- + _spell.cast(); + } - spell.cast(rootV3); + function _customPostAssertions(string memory network, EnvConfig memory config) internal override { + emit log_named_string("V2Cleanings post-assertions", network); + assertTrue(_spell.done()); - // ----- POST SPELL ----- + Root rootV3 = Root(config.contracts.root); + _assertRootAndSpellWards(rootV3); + _assertTokenWards(rootV3); + _assertV2VaultsDeniedFromShareTokens(); + _assertSweep(); + _assertCfgMintedToTreasury(); + _assertHookWards(); + } + function _assertRootAndSpellWards(Root rootV3) internal view { if (address(ROOT_V2).code.length > 0) { - assertEq(ROOT_V2.wards(address(spell)), 0); + assertEq(ROOT_V2.wards(address(_spell)), 0); } - assertEq(rootV3.wards(address(spell)), 0); + assertEq(rootV3.wards(address(_spell)), 0); + + if (ESCROW_V2.code.length > 0) { + assertEq(IAuth(ESCROW_V2).wards(address(_spell)), 0); + } + } + function _assertTokenWards(Root rootV3) internal view { if (CFG.code.length > 0) { assertEq(IAuth(CFG).wards(address(rootV3)), 1); assertEq(IAuth(CFG).wards(CREATE3_PROXY), 0); @@ -100,35 +136,57 @@ contract V2CleaningsSpellTest is Test { assertEq(IAuth(CFG).wards(address(ROOT_V2)), 0); assertEq(IAuth(CFG).wards(IOU_CFG), 0); + assertEq(IAuth(CFG).wards(CFG_MINTER), 1); } if (TRANCHE_JTRSY.code.length > 0) { assertEq(IAuth(TRANCHE_JTRSY).wards(address(rootV3)), 1); assertEq(IAuth(TRANCHE_JTRSY).wards(address(ROOT_V2)), 0); - assertEq(IAuth(TRANCHE_JTRSY).wards(address(spell)), 0); + assertEq(IAuth(TRANCHE_JTRSY).wards(address(_spell)), 0); } if (block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID) { assertEq(IAuth(TRANCHE_JAAA).wards(address(rootV3)), 1); assertEq(IAuth(TRANCHE_JAAA).wards(address(ROOT_V2)), 0); - assertEq(IAuth(TRANCHE_JAAA).wards(address(spell)), 0); + assertEq(IAuth(TRANCHE_JAAA).wards(address(_spell)), 0); } + } - if (address(usdc) != address(0)) { - assertEq(usdc.balanceOf(TREASURY) - preTreasuryValue, preEscrowValue); - assertEq(usdc.balanceOf(ESCROW_V2), 0); + function _assertV2VaultsDeniedFromShareTokens() internal view { + if (block.chainid == ETHEREUM_CHAIN_ID) { + assertEq(IAuth(TRANCHE_JTRSY).wards(ETH_V2_JTRSY_VAULT), 0); + assertEq(IAuth(TRANCHE_JAAA).wards(ETH_V2_JAAA_VAULT), 0); + } else if (block.chainid == BASE_CHAIN_ID) { + assertEq(IAuth(TRANCHE_JTRSY).wards(BASE_V2_JTRSY_VAULT), 0); + assertEq(IAuth(TRANCHE_JAAA).wards(BASE_V2_JAAA_VAULT), 0); + } else if (block.chainid == ARBITRUM_CHAIN_ID) { + assertEq(IAuth(TRANCHE_JTRSY).wards(ARB_V2_JTRSY_VAULT), 0); } + } - if (ESCROW_V2.code.length > 0) { - assertEq(IAuth(ESCROW_V2).wards(address(spell)), 0); + function _assertCfgMintedToTreasury() internal view { + if (block.chainid != ETHEREUM_CHAIN_ID || CFG.code.length == 0) return; + // Expected mint: wCFG total supply minus IOU_CFG's wCFG balance (already redeemed + // 1:1 for CFG without wCFG total-supply reduction) plus Centrifuge Chain CFG. + uint256 expectedMint = + IERC20(WCFG).totalSupply() - IERC20(WCFG).balanceOf(IOU_CFG) + CENTRIFUGE_CHAIN_CFG_AMOUNT; + assertEq(IERC20(CFG).balanceOf(CNF_TREASURY_WALLET) - _preCnfTreasuryCfg, expectedMint); + assertEq(IERC20(CFG).totalSupply() - _preCfgTotalSupply, expectedMint); + } + + function _assertSweep() internal view { + IERC20 usdc = _usdc(); + if (address(usdc) != address(0)) { + assertEq(usdc.balanceOf(TREASURY) - _preTreasuryUsdc, _preEscrowUsdc); + assertEq(usdc.balanceOf(ESCROW_V2), 0); } + } + function _assertHookWards() internal view { assertEq(IAuth(FREEZE_ONLY_HOOK).wards(CONTRACT_UPDATER), 1); assertEq(IAuth(FULL_RESTRICTIONS_HOOK).wards(CONTRACT_UPDATER), 1); assertEq(IAuth(FREELY_TRANSFERABLE_HOOK).wards(CONTRACT_UPDATER), 1); assertEq(IAuth(REDEMPTION_RESTRICTIONS_HOOK).wards(CONTRACT_UPDATER), 1); - - assertTrue(spell.done()); } function _usdc() internal view returns (IERC20 usdc) { @@ -142,42 +200,38 @@ contract V2CleaningsSpellTest is Test { } function testV2CleaningsEthereumMainnet() external { - _testCase("ethereum", string.concat("https://eth-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY"))); + _testCase("ethereum"); } function testV2CleaningsBaseMainnet() external { - _testCase("base", string.concat("https://base-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY"))); + _testCase("base"); } function testV2CleaningsArbitrumMainnet() external { - _testCase("arbitrum", string.concat("https://arb-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY"))); + _testCase("arbitrum"); } function testV2CleaningsAvalancheMainnet() external { - _testCase("avalanche", string.concat("https://avax-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY"))); + _testCase("avalanche"); } function testV2CleaningsBnbMainnet() external { - _testCase( - "bnb-smart-chain", string.concat("https://bnb-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY")) - ); + _testCase("bnb-smart-chain"); } function testV2CleaningsOptimismMainnet() external { - _testCase("optimism", string.concat("https://opt-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY"))); + _testCase("optimism"); } function testV2CleaningsHyperEvmMainnet() external { - _testCase( - "hyper-evm", string.concat("https://hyperliquid-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY")) - ); + _testCase("hyper-evm"); } function testV2CleaningsMonadMainnet() external { - _testCase("monad", string.concat("https://monad-mainnet.g.alchemy.com/v2/", vm.envString("ALCHEMY_API_KEY"))); + _testCase("monad"); } function testV2CleaningsPlumeMainnet() external { - _testCase("plume", string.concat("https://rpc.plume.org/", vm.envString("PLUME_API_KEY"))); + _testCase("plume"); } } From 9887d4969d857fee3fab4cbe3aebf97313c7025b Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 26 May 2026 17:08:03 +0200 Subject: [PATCH 5/8] chore: run spell only on target networks --- test/integration/spell/V2Cleanings.t.sol | 31 ++++++------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/test/integration/spell/V2Cleanings.t.sol b/test/integration/spell/V2Cleanings.t.sol index 45690831b..5981d2d6f 100644 --- a/test/integration/spell/V2Cleanings.t.sol +++ b/test/integration/spell/V2Cleanings.t.sol @@ -199,6 +199,13 @@ contract V2CleaningsSpellTest is SpellForkTest { } } + // V2CleaningsSpell hardcodes ROOT_V3 to 0x7Ed48C31..., which is the V3 root only on + // ETH/BASE/ARB (and incidentally Avalanche/BNB/Plume). On Optimism/HyperEVM/Monad the + // V3 root lives at a different address (0xdc9456e7e...), so the spell's tail-end + // ROOT_V3.relyContract / ROOT_V3.deny calls revert. The spell's own doc comment scopes + // it to "ETH, BASE, ARB" — we mirror that scope here. If the spell is ever generalised + // to all networks (e.g. by accepting `Root` as a constructor or cast() argument again), + // restore the other six test methods. function testV2CleaningsEthereumMainnet() external { _testCase("ethereum"); } @@ -210,28 +217,4 @@ contract V2CleaningsSpellTest is SpellForkTest { function testV2CleaningsArbitrumMainnet() external { _testCase("arbitrum"); } - - function testV2CleaningsAvalancheMainnet() external { - _testCase("avalanche"); - } - - function testV2CleaningsBnbMainnet() external { - _testCase("bnb-smart-chain"); - } - - function testV2CleaningsOptimismMainnet() external { - _testCase("optimism"); - } - - function testV2CleaningsHyperEvmMainnet() external { - _testCase("hyper-evm"); - } - - function testV2CleaningsMonadMainnet() external { - _testCase("monad"); - } - - function testV2CleaningsPlumeMainnet() external { - _testCase("plume"); - } } From 956d81bd2fcfe875f264f64bbcbfb654946c520b Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 26 May 2026 17:45:59 +0200 Subject: [PATCH 6/8] fmt --- test/integration/spell/V2Cleanings.t.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/integration/spell/V2Cleanings.t.sol b/test/integration/spell/V2Cleanings.t.sol index 5981d2d6f..8b4cefdeb 100644 --- a/test/integration/spell/V2Cleanings.t.sol +++ b/test/integration/spell/V2Cleanings.t.sol @@ -68,10 +68,8 @@ contract V2CleaningsSpellTest is SpellForkTest { // ROOT_V3 in the spell is hardcoded to the ETH/BASE/ARB V3 root. On those three // chains the env's contracts.root must match, otherwise the spell would brick. - if ( - block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID - || block.chainid == ARBITRUM_CHAIN_ID - ) { + if (block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID || block.chainid == ARBITRUM_CHAIN_ID) + { assertEq(address(ROOT_V3), address(rootV3)); } From 665e20b2dc81014dbf8e790acc7f0f87e599cef4 Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Wed, 3 Jun 2026 10:39:45 +0200 Subject: [PATCH 7/8] ref: new pattern --- .../fork/InvestmentFlowForkTest.t.sol | 76 +---- test/integration/spell/V2Cleanings.t.sol | 143 +++------- .../spell/utils/FlowRegression.sol | 154 ++++++++++ .../spell/utils/FlowRegression.t.sol | 49 ++++ .../integration/spell/utils/SpellForkTest.sol | 264 ------------------ .../spell/utils/SpellRegressionTest.sol | 182 ++++++++++++ .../spell/utils/validation/BaseValidator.sol | 35 ++- .../spell/utils/validation/README.md | 101 ++++++- .../utils/validation/ValidationExecutor.sol | 175 +++++++++--- .../spell/v2-cleanings/V2CleaningsCast.sol | 56 ++++ .../V2CleaningsValidatorTest.t.sol | 58 ++++ .../validators/Validate_V2Cleanings.sol | 178 ++++++++++++ 12 files changed, 971 insertions(+), 500 deletions(-) create mode 100644 test/integration/spell/utils/FlowRegression.sol create mode 100644 test/integration/spell/utils/FlowRegression.t.sol delete mode 100644 test/integration/spell/utils/SpellForkTest.sol create mode 100644 test/integration/spell/utils/SpellRegressionTest.sol create mode 100644 test/integration/spell/v2-cleanings/V2CleaningsCast.sol create mode 100644 test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol create mode 100644 test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol diff --git a/test/integration/fork/InvestmentFlowForkTest.t.sol b/test/integration/fork/InvestmentFlowForkTest.t.sol index 4bd9de734..f0214793c 100644 --- a/test/integration/fork/InvestmentFlowForkTest.t.sol +++ b/test/integration/fork/InvestmentFlowForkTest.t.sol @@ -1,13 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.28; -import {JsonUtils} from "../../../script/utils/JsonUtils.s.sol"; import {Env, EnvConfig} from "../../../script/utils/EnvConfig.s.sol"; import {GraphQLQuery} from "../../../script/utils/GraphQLQuery.s.sol"; -import {Test} from "forge-std/Test.sol"; -import {stdJson} from "forge-std/StdJson.sol"; - +import {FlowRegression} from "../spell/utils/FlowRegression.sol"; import {NonCoreReport} from "../../../src/deployment/ActionBatchers.sol"; import {testContractsFromConfig} from "../spell/utils/validation/TestContracts.sol"; import { @@ -18,16 +15,10 @@ import { /// @title InvestmentFlowForkTest /// @notice Fork test that runs end-to-end deposit and redeem flows on all live vaults per network. -/// Queries vault metadata from the GraphQL indexer and delegates flow execution to -/// InvestmentFlowExecutor, which handles local async, cross-chain async, and sync deposit flows. -contract InvestmentFlowForkTest is Test { - using stdJson for string; - using JsonUtils for *; - - // ============================================ - // Entry Point - // ============================================ - +/// Queries vault metadata from the GraphQL indexer (via the shared FlowRegression mixin) and +/// delegates flow execution to InvestmentFlowExecutor, which handles local async, cross-chain +/// async, and sync deposit flows. +contract InvestmentFlowForkTest is FlowRegression { function _testCase(string memory networkName) internal { EnvConfig memory config = Env.load(networkName); vm.createSelectFork(config.network.rpcUrl()); @@ -47,63 +38,6 @@ contract InvestmentFlowForkTest is Test { _assertResults(results); } - // ============================================ - // GraphQL Vault Query - // ============================================ - - function _queryVaults(GraphQLQuery indexer, uint16 centrifugeId) - internal - returns (VaultGraphQLData[] memory vaults) - { - string memory centrifugeIdStr = vm.toString(centrifugeId).asJsonString(); - - string memory json = indexer.queryGraphQL( - string.concat( - "vaults(limit: 1000, where: { centrifugeId: ", - centrifugeIdStr, - ", status: Linked }) { totalCount items { id poolId tokenId kind assetAddress asset { decimals symbol } token { pool { managers(where: {isHubManager: true}, limit: 1) { items { address centrifugeId } } } } } }" - ) - ); - - uint256 totalCount = json.readUint(".data.vaults.totalCount"); - if (totalCount == 0) return vaults; - - require(totalCount <= 1000, "Vault count exceeds query limit; implement pagination"); - - vaults = new VaultGraphQLData[](totalCount); - - for (uint256 i; i < totalCount; i++) { - string memory base = ".data.vaults.items"; - vaults[i].vault = json.readAddress(base.asJsonPath(i, "id")); - vaults[i].poolIdRaw = uint64(json.readUint(base.asJsonPath(i, "poolId"))); - vaults[i].tokenIdRaw = _parseBytes16(json, base.asJsonPath(i, "tokenId")); - vaults[i].kind = json.readString(base.asJsonPath(i, "kind")); - vaults[i].assetAddress = json.readAddress(base.asJsonPath(i, "assetAddress")); - vaults[i].assetDecimals = uint8(json.readUint(base.asJsonPath(i, "asset.decimals"))); - vaults[i].assetSymbol = json.readString(base.asJsonPath(i, "asset.symbol")); - - string memory managersBase = string.concat(base, "[", vm.toString(i), "].token.pool.managers.items"); - try vm.parseJsonAddress(json, string.concat(managersBase, "[0].address")) returns (address mgr) { - vaults[i].hubManager = mgr; - } catch {} - try vm.parseJsonUint(json, string.concat(managersBase, "[0].centrifugeId")) returns (uint256 cid) { - vaults[i].hubCentrifugeId = uint16(cid); - } catch {} - } - } - - function _parseBytes16(string memory json, string memory path) internal pure returns (bytes16 result) { - bytes memory rawBytes = json.readBytes(path); - require(rawBytes.length == 16, "Expected 16 bytes for tokenId"); - assembly { - result := mload(add(rawBytes, 32)) - } - } - - // ============================================ - // Result Assertion - // ============================================ - function _assertResults(InvestmentFlowResult[] memory results) internal { uint256 depositFailures; uint256 redeemFailures; diff --git a/test/integration/spell/V2Cleanings.t.sol b/test/integration/spell/V2Cleanings.t.sol index 8b4cefdeb..6a649c079 100644 --- a/test/integration/spell/V2Cleanings.t.sol +++ b/test/integration/spell/V2Cleanings.t.sol @@ -1,19 +1,19 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.28; -import {SpellForkTest} from "./utils/SpellForkTest.sol"; +import {V2CleaningsCast} from "./v2-cleanings/V2CleaningsCast.sol"; import {IAuth} from "../../../src/misc/interfaces/IAuth.sol"; -import {IERC20} from "../../../src/misc/interfaces/IERC20.sol"; import {Root} from "../../../src/admin/Root.sol"; -import {EnvConfig} from "../../../script/utils/EnvConfig.s.sol"; +import {Env, EnvConfig} from "../../../script/utils/EnvConfig.s.sol"; + +import {Test} from "forge-std/Test.sol"; import { V2CleaningsSpell, ROOT_V2, - ROOT_V3, CONTRACT_UPDATER, FREEZE_ONLY_HOOK, FULL_RESTRICTIONS_HOOK, @@ -36,67 +36,49 @@ import { BASE_V2_JTRSY_VAULT, BASE_V2_JAAA_VAULT, ARB_V2_JTRSY_VAULT, - TREASURY, - CNF_TREASURY_WALLET, - CENTRIFUGE_CHAIN_CFG_AMOUNT, - ESCROW_V2, - USDC_ETHEREUM, - USDC_BASE, - USDC_ARBITRUM + ESCROW_V2 } from "../../../src/spell/V2CleaningsSpell.sol"; -contract V2CleaningsSpellTest is SpellForkTest { - address constant PROTOCOL_GUARDIAN_V3_1 = 0xCEb7eD5d5B3bAD3088f6A1697738B60d829635c6; - address constant GUARDIAN_V2_ETHEREUM_OR_ARBITRUM = 0x09ab10a9c3E6Eac1d18270a2322B6113F4C7f5E8; - address constant GUARDIAN_V2_BASE = 0x427A1ce127b1775e4Cbd4F58ad468B9F832eA7e9; - +/// @title V2CleaningsSpellTest +/// @notice Focused, forked correctness proof for the V2Cleanings spell: deploy + +/// cast on a live fork, then assert the exact absolute post-state the +/// spell guarantees (ward flips on the roots, CFG/WCFG/tranche tokens, +/// denied V2 vaults, and hook wards). +/// +/// @dev The before/after DELTA assertions (USDC sweep, CFG mint) inherently +/// need a pre-cast snapshot, so they live in the cached validators +/// (`v2-cleanings/validators/Validate_V2Cleanings.sol`) exercised by +/// `V2CleaningsValidatorTest`, not here. This test is the exhaustive +/// absolute-state proof; the validators are the reusable env check. +/// +/// @dev The spell hardcodes ROOT_V3 = 0x7Ed48C31..., which is the V3 root only +/// on ETH/BASE/ARB (and incidentally Avalanche/BNB/Plume). On +/// Optimism/HyperEVM/Monad the V3 root lives at 0xdc9456e7e..., so the +/// spell's tail-end ROOT_V3.relyContract/deny calls revert there. The +/// spell's own doc scopes it to "ETH, BASE, ARB" — mirrored here. If the +/// spell is ever generalised (e.g. taking `Root` as a cast() argument), +/// restore the other six network methods. +contract V2CleaningsSpellTest is Test { V2CleaningsSpell internal _spell; - uint256 internal _preTreasuryUsdc; - uint256 internal _preEscrowUsdc; - uint256 internal _preCnfTreasuryCfg; - uint256 internal _preCfgTotalSupply; - - function _castSpell( - string memory, - /* network */ - EnvConfig memory config - ) - internal - override - { - Root rootV3 = Root(config.contracts.root); - - // ROOT_V3 in the spell is hardcoded to the ETH/BASE/ARB V3 root. On those three - // chains the env's contracts.root must match, otherwise the spell would brick. - if (block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID || block.chainid == ARBITRUM_CHAIN_ID) - { - assertEq(address(ROOT_V3), address(rootV3)); - } - _spell = new V2CleaningsSpell(); + function testV2CleaningsEthereumMainnet() external { + _run("ethereum"); + } - IERC20 usdc = _usdc(); - if (address(usdc) != address(0)) { - _preTreasuryUsdc = usdc.balanceOf(TREASURY); - _preEscrowUsdc = usdc.balanceOf(ESCROW_V2); - } - if (block.chainid == ETHEREUM_CHAIN_ID && CFG.code.length > 0) { - _preCnfTreasuryCfg = IERC20(CFG).balanceOf(CNF_TREASURY_WALLET); - _preCfgTotalSupply = IERC20(CFG).totalSupply(); - } + function testV2CleaningsBaseMainnet() external { + _run("base"); + } - vm.prank(PROTOCOL_GUARDIAN_V3_1); - rootV3.rely(address(_spell)); // Ideally through guardian.scheduleRely() + function testV2CleaningsArbitrumMainnet() external { + _run("arbitrum"); + } - if (address(ROOT_V2).code.length > 0) { - vm.prank(block.chainid == BASE_CHAIN_ID ? GUARDIAN_V2_BASE : GUARDIAN_V2_ETHEREUM_OR_ARBITRUM); - ROOT_V2.rely(address(_spell)); // Ideally through guardian.scheduleRely() - } + function _run(string memory network) internal { + EnvConfig memory config = Env.load(network); + vm.createSelectFork(config.network.rpcUrl()); - _spell.cast(); - } + _spell = V2CleaningsCast.deployAndCast(config); - function _customPostAssertions(string memory network, EnvConfig memory config) internal override { emit log_named_string("V2Cleanings post-assertions", network); assertTrue(_spell.done()); @@ -104,8 +86,6 @@ contract V2CleaningsSpellTest is SpellForkTest { _assertRootAndSpellWards(rootV3); _assertTokenWards(rootV3); _assertV2VaultsDeniedFromShareTokens(); - _assertSweep(); - _assertCfgMintedToTreasury(); _assertHookWards(); } @@ -162,57 +142,10 @@ contract V2CleaningsSpellTest is SpellForkTest { } } - function _assertCfgMintedToTreasury() internal view { - if (block.chainid != ETHEREUM_CHAIN_ID || CFG.code.length == 0) return; - // Expected mint: wCFG total supply minus IOU_CFG's wCFG balance (already redeemed - // 1:1 for CFG without wCFG total-supply reduction) plus Centrifuge Chain CFG. - uint256 expectedMint = - IERC20(WCFG).totalSupply() - IERC20(WCFG).balanceOf(IOU_CFG) + CENTRIFUGE_CHAIN_CFG_AMOUNT; - assertEq(IERC20(CFG).balanceOf(CNF_TREASURY_WALLET) - _preCnfTreasuryCfg, expectedMint); - assertEq(IERC20(CFG).totalSupply() - _preCfgTotalSupply, expectedMint); - } - - function _assertSweep() internal view { - IERC20 usdc = _usdc(); - if (address(usdc) != address(0)) { - assertEq(usdc.balanceOf(TREASURY) - _preTreasuryUsdc, _preEscrowUsdc); - assertEq(usdc.balanceOf(ESCROW_V2), 0); - } - } - function _assertHookWards() internal view { assertEq(IAuth(FREEZE_ONLY_HOOK).wards(CONTRACT_UPDATER), 1); assertEq(IAuth(FULL_RESTRICTIONS_HOOK).wards(CONTRACT_UPDATER), 1); assertEq(IAuth(FREELY_TRANSFERABLE_HOOK).wards(CONTRACT_UPDATER), 1); assertEq(IAuth(REDEMPTION_RESTRICTIONS_HOOK).wards(CONTRACT_UPDATER), 1); } - - function _usdc() internal view returns (IERC20 usdc) { - if (block.chainid == ETHEREUM_CHAIN_ID) { - usdc = USDC_ETHEREUM; - } else if (block.chainid == BASE_CHAIN_ID) { - usdc = USDC_BASE; - } else if (block.chainid == ARBITRUM_CHAIN_ID) { - usdc = USDC_ARBITRUM; - } - } - - // V2CleaningsSpell hardcodes ROOT_V3 to 0x7Ed48C31..., which is the V3 root only on - // ETH/BASE/ARB (and incidentally Avalanche/BNB/Plume). On Optimism/HyperEVM/Monad the - // V3 root lives at a different address (0xdc9456e7e...), so the spell's tail-end - // ROOT_V3.relyContract / ROOT_V3.deny calls revert. The spell's own doc comment scopes - // it to "ETH, BASE, ARB" — we mirror that scope here. If the spell is ever generalised - // to all networks (e.g. by accepting `Root` as a constructor or cast() argument again), - // restore the other six test methods. - function testV2CleaningsEthereumMainnet() external { - _testCase("ethereum"); - } - - function testV2CleaningsBaseMainnet() external { - _testCase("base"); - } - - function testV2CleaningsArbitrumMainnet() external { - _testCase("arbitrum"); - } } diff --git a/test/integration/spell/utils/FlowRegression.sol b/test/integration/spell/utils/FlowRegression.sol new file mode 100644 index 000000000..e3b3403d4 --- /dev/null +++ b/test/integration/spell/utils/FlowRegression.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import {testContractsFromConfig} from "./validation/TestContracts.sol"; +import {InvestmentFlowExecutor, InvestmentFlowResult, VaultGraphQLData} from "./validation/InvestmentFlowExecutor.sol"; + +import {EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; +import {JsonUtils} from "../../../../script/utils/JsonUtils.s.sol"; +import {GraphQLQuery} from "../../../../script/utils/GraphQLQuery.s.sol"; + +import {Test} from "forge-std/Test.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +import {NonCoreReport} from "../../../../src/deployment/ActionBatchers.sol"; + +/// @title FlowRegression +/// @notice Reusable investment-flow regression mixin for spell fork tests. +/// Investment-flow regression is a per-vault end-to-end behavioral diff +/// (deposit/redeem), not a state invariant, so it is NOT a +/// `BaseValidator`. This mixin owns the cheatcode-driven snapshot run, +/// the GraphQL vault query (single source of truth, also inherited by +/// `InvestmentFlowForkTest`), and the pre/post regression diff. +/// +/// @dev A "regression" is a vault that passed BEFORE the spell and fails +/// AFTER it. Pre-existing failures (the live env has known broken +/// vaults) are logged but tolerated — only regressions hard-fail. +/// Implemented as an inherited mixin (internal functions, no ABI +/// boundary); see `validation/README.md`, "Legacy-codegen constraints". +abstract contract FlowRegression is Test { + using stdJson for string; + using JsonUtils for *; + + function _snapshotFlows(EnvConfig memory config, VaultGraphQLData[] memory vaults) + internal + returns (InvestmentFlowResult[] memory) + { + if (vaults.length == 0) return new InvestmentFlowResult[](0); + + uint256 snap = vm.snapshotState(); + + NonCoreReport memory report = testContractsFromConfig(config).main; + InvestmentFlowExecutor executor = new InvestmentFlowExecutor(); + vm.deal(address(executor), 100 ether); + + InvestmentFlowResult[] memory results = executor.executeAllFlows(report, vaults, config.network.centrifugeId); + + vm.revertToState(snap); + + return results; + } + + function _queryVaults(GraphQLQuery indexer, uint16 centrifugeId) + internal + returns (VaultGraphQLData[] memory vaults) + { + string memory centrifugeIdStr = vm.toString(centrifugeId).asJsonString(); + + string memory json = indexer.queryGraphQL( + string.concat( + "vaults(limit: 1000, where: { centrifugeId: ", + centrifugeIdStr, + ", status: Linked }) { totalCount items { id poolId tokenId kind assetAddress asset { decimals symbol } token { pool { managers(where: {isHubManager: true}, limit: 1) { items { address centrifugeId } } } } } }" + ) + ); + + uint256 totalCount = json.readUint(".data.vaults.totalCount"); + if (totalCount == 0) return vaults; + + require(totalCount <= 1000, "Vault count exceeds query limit; implement pagination"); + + vaults = new VaultGraphQLData[](totalCount); + + for (uint256 i; i < totalCount; i++) { + string memory base = ".data.vaults.items"; + vaults[i].vault = json.readAddress(base.asJsonPath(i, "id")); + vaults[i].poolIdRaw = uint64(json.readUint(base.asJsonPath(i, "poolId"))); + vaults[i].tokenIdRaw = _parseBytes16(json, base.asJsonPath(i, "tokenId")); + vaults[i].kind = json.readString(base.asJsonPath(i, "kind")); + vaults[i].assetAddress = json.readAddress(base.asJsonPath(i, "assetAddress")); + vaults[i].assetDecimals = uint8(json.readUint(base.asJsonPath(i, "asset.decimals"))); + vaults[i].assetSymbol = json.readString(base.asJsonPath(i, "asset.symbol")); + + string memory managersBase = string.concat(base, "[", vm.toString(i), "].token.pool.managers.items"); + try vm.parseJsonAddress(json, string.concat(managersBase, "[0].address")) returns (address mgr) { + vaults[i].hubManager = mgr; + try vm.parseJsonUint(json, string.concat(managersBase, "[0].centrifugeId")) returns (uint256 cid) { + vaults[i].hubCentrifugeId = uint16(cid); + } catch { + // Anomalous: indexer lists a hub manager but no parseable centrifugeId. + emit log_string(string.concat( + "WARN: hub manager without parseable centrifugeId for vault ", vm.toString(vaults[i].vault) + )); + } + } catch { + // Expected absence: pool has no hub manager registered in the + // indexer (managers query returned no items) — leave both fields zero. + } + } + } + + /// @dev Pre and post arrays index identically because both runs were + /// given the same `vaults[]` (`executeAllFlows` writes results in + /// input order). + function _assertNoFlowRegressions(InvestmentFlowResult[] memory pre, InvestmentFlowResult[] memory post) internal { + assertEq(pre.length, post.length, "Pre/post flow result length mismatch"); + + uint256 depositRegressions; + uint256 redeemRegressions; + + emit log_string(""); + emit log_string("================================================================"); + emit log_string(" INVESTMENT FLOW DIFF (pre-cast vs post-cast)"); + emit log_string("================================================================"); + + for (uint256 i = 0; i < post.length; i++) { + InvestmentFlowResult memory b = pre[i]; + InvestmentFlowResult memory a = post[i]; + + if (b.depositPassed && !a.depositPassed) { + emit log_string(string.concat("[REGRESSION deposit] ", vm.toString(a.vault), " -> ", a.depositError)); + depositRegressions++; + } else if (!b.depositPassed && a.depositPassed) { + emit log_string(string.concat("[IMPROVED deposit] ", vm.toString(a.vault))); + } else if (!b.depositPassed && !a.depositPassed) { + emit log_string(string.concat("[PRE-EXISTING deposit] ", vm.toString(a.vault), " -> ", a.depositError)); + } + + if (b.redeemPassed && !a.redeemPassed) { + emit log_string(string.concat("[REGRESSION redeem] ", vm.toString(a.vault), " -> ", a.redeemError)); + redeemRegressions++; + } else if (!b.redeemPassed && a.redeemPassed) { + emit log_string(string.concat("[IMPROVED redeem] ", vm.toString(a.vault))); + } else if (!b.redeemPassed && !a.redeemPassed) { + emit log_string(string.concat("[PRE-EXISTING redeem] ", vm.toString(a.vault), " -> ", a.redeemError)); + } + } + + emit log_string("================================================================"); + + assertEq(depositRegressions, 0, "Spell regressed deposit flows on previously-passing vaults"); + assertEq(redeemRegressions, 0, "Spell regressed redeem flows on previously-passing vaults"); + } + + /// @dev Loop variant (no inline assembly), preferred under optimizer_runs=1 + /// legacy codegen. Equivalence with the original assembly `mload` + /// variant is pinned by `FlowRegression.t.sol`. + function _parseBytes16(string memory json, string memory path) internal pure returns (bytes16 result) { + bytes memory rawBytes = json.readBytes(path); + require(rawBytes.length == 16, "Expected 16 bytes for tokenId"); + for (uint256 i = 0; i < 16; i++) { + result |= bytes16(rawBytes[i]) >> (i * 8); + } + } +} diff --git a/test/integration/spell/utils/FlowRegression.t.sol b/test/integration/spell/utils/FlowRegression.t.sol new file mode 100644 index 000000000..7bbd9848d --- /dev/null +++ b/test/integration/spell/utils/FlowRegression.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import {FlowRegression} from "./FlowRegression.sol"; + +import {stdJson} from "forge-std/StdJson.sol"; + +/// @title FlowRegressionTest +/// @notice Unit test for `FlowRegression._parseBytes16`. The production helper +/// uses a loop (the assembly `mload` variant participated in a +/// legacy-codegen stack-too-deep hunt), so this fuzz test pins the loop +/// variant to (a) a JSON round-trip and (b) byte-for-byte equivalence +/// with the original assembly implementation. No fork required. +contract FlowRegressionTest is FlowRegression { + using stdJson for string; + + /// @dev The original assembly implementation, kept here as the reference. + function _parseBytes16Assembly(string memory json, string memory path) internal pure returns (bytes16 result) { + bytes memory rawBytes = json.readBytes(path); + require(rawBytes.length == 16, "Expected 16 bytes for tokenId"); + assembly { + result := mload(add(rawBytes, 32)) + } + } + + function _tokenIdJson(bytes16 value) internal pure returns (string memory) { + return string.concat("{\"tokenId\": \"", vm.toString(abi.encodePacked(value)), "\"}"); + } + + function testFuzz_parseBytes16RoundTrip(bytes16 value) public pure { + assertEq(_parseBytes16(_tokenIdJson(value), ".tokenId"), value); + } + + function testFuzz_parseBytes16MatchesAssemblyVariant(bytes16 value) public pure { + string memory json = _tokenIdJson(value); + assertEq(_parseBytes16(json, ".tokenId"), _parseBytes16Assembly(json, ".tokenId")); + } + + function test_parseBytes16RevertsOnWrongLength() public { + string memory json = string.concat("{\"tokenId\": \"", vm.toString(abi.encodePacked(bytes32(0))), "\"}"); + vm.expectRevert(bytes("Expected 16 bytes for tokenId")); + this.exposed_parseBytes16(json, ".tokenId"); + } + + /// @dev External wrapper so `vm.expectRevert` applies to a call, not a jump. + function exposed_parseBytes16(string memory json, string memory path) external pure returns (bytes16) { + return _parseBytes16(json, path); + } +} diff --git a/test/integration/spell/utils/SpellForkTest.sol b/test/integration/spell/utils/SpellForkTest.sol deleted file mode 100644 index 5641230ea..000000000 --- a/test/integration/spell/utils/SpellForkTest.sol +++ /dev/null @@ -1,264 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.28; - -import {BaseValidator} from "./validation/BaseValidator.sol"; -import {ValidationExecutor} from "./validation/ValidationExecutor.sol"; -import {testContractsFromConfig} from "./validation/TestContracts.sol"; -import {InvestmentFlowExecutor, InvestmentFlowResult, VaultGraphQLData} from "./validation/InvestmentFlowExecutor.sol"; - -import {JsonUtils} from "../../../../script/utils/JsonUtils.s.sol"; -import {Env, EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; -import {GraphQLQuery} from "../../../../script/utils/GraphQLQuery.s.sol"; - -import {Test} from "forge-std/Test.sol"; -import {stdJson} from "forge-std/StdJson.sol"; - -import {Validate_Vaults} from "../../fork/validators/Validate_Vaults.sol"; -import {NonCoreReport} from "../../../../src/deployment/ActionBatchers.sol"; -import {Validate_Endorsements} from "../../fork/validators/Validate_Endorsements.sol"; -import {Validate_ContractWards} from "../../fork/validators/Validate_ContractWards.sol"; -import {Validate_GuardianSafes} from "../../fork/validators/Validate_GuardianSafes.sol"; -import {Validate_HookPoolEscrow} from "../../fork/validators/Validate_HookPoolEscrow.sol"; -import {Validate_RootPermissions} from "../../fork/validators/Validate_RootPermissions.sol"; -import {Validate_FileConfigurations} from "../../fork/validators/Validate_FileConfigurations.sol"; -import {Validate_AdapterConfigurations} from "../../fork/validators/Validate_AdapterConfigurations.sol"; - -/// @title SpellForkTest -/// @notice Abstract base for spell fork tests. Inheritors run the spell on a -/// live-network fork and the base contract enforces the broader -/// live-state invariants on top of any spell-specific assertions. -/// -/// @dev Per-network flow: -/// 1. Pre-cast snapshot: capture the structural validator error count -/// AND the investment-flow results, revert state. -/// 2. Spell cast (child-defined). -/// 3. Post-cast: run the structural validators again, assert the new -/// error count did not exceed the pre-cast baseline (regression -/// check; pre-existing live errors are tolerated). -/// 4. Post-cast: run flows again, diff per-vault against step-1 -/// snapshot — pre-existing failures tolerated, regressions fail. -/// 5. Spell-specific assertions (child-defined). -/// -/// @dev Live mainnet currently has pre-existing errors in both suites -/// (e.g. Pharos adapter quorum mismatch and PoolEscrow accounting -/// drift surfaced by the structural validators; cross-chain sync -/// and unlinked-vault errors surfaced by the flow executor). Per -/// William's "we want the failure DIFF instead of failure" framing, -/// we tolerate these pre-existing failures and only fail the spell -/// test on NEW regressions introduced by the spell itself. -/// -/// @dev Structural-validator diff is intentionally coarse (count-only, -/// not per-error). A per-error diff was attempted but hit a -/// legacy-codegen stack-too-deep with optimizer_runs=1; the -/// count-based variant is enough to catch regressions and the full -/// per-error report is still emitted to logs for diagnostics. -abstract contract SpellForkTest is Test { - using stdJson for string; - using JsonUtils for *; - - // ------------------------------------------------------------------ - // Virtual hooks - // ------------------------------------------------------------------ - - /// @notice Deploy the spell, schedule any required relies, and cast it. - function _castSpell(string memory network, EnvConfig memory config) internal virtual; - - /// @notice Spell-specific post-cast assertions (e.g. checking a sweep - /// transferred the expected balance). - function _customPostAssertions(string memory network, EnvConfig memory config) internal virtual {} - - /// @notice If true (default), runs the investment flow suite pre- and - /// post-cast and diffs the results. - function _runInvestmentFlowsDiff() internal pure virtual returns (bool) { - return true; - } - - // ------------------------------------------------------------------ - // Concrete driver - // ------------------------------------------------------------------ - - function _testCase(string memory network) internal { - EnvConfig memory config = Env.load(network); - vm.createSelectFork(config.network.rpcUrl()); - - VaultGraphQLData[] memory vaults = _maybeQueryVaults(config); - - uint256 preValidatorErrors = _countValidatorErrorsSnapshotted(network); - InvestmentFlowResult[] memory preFlows = _runFlowsSnapshotted(config, vaults); - - _castSpell(network, config); - - _assertValidatorErrorCountDidNotIncrease(network, preValidatorErrors); - _assertNoFlowRegressions(preFlows, _runFlowsSnapshotted(config, vaults)); - _customPostAssertions(network, config); - } - - function _maybeQueryVaults(EnvConfig memory config) internal returns (VaultGraphQLData[] memory) { - if (!_runInvestmentFlowsDiff()) return new VaultGraphQLData[](0); - GraphQLQuery indexer = new GraphQLQuery(config.network.graphQLApi()); - return _queryVaults(indexer, config.network.centrifugeId); - } - - // ------------------------------------------------------------------ - // Validator helpers - // ------------------------------------------------------------------ - - function _buildValidators() internal returns (BaseValidator[] memory validators) { - validators = new BaseValidator[](8); - validators[0] = new Validate_RootPermissions(); - validators[1] = new Validate_ContractWards(); - validators[2] = new Validate_FileConfigurations(); - validators[3] = new Validate_Endorsements(); - validators[4] = new Validate_GuardianSafes(); - validators[5] = new Validate_AdapterConfigurations(); - validators[6] = new Validate_Vaults(); - validators[7] = new Validate_HookPoolEscrow(); - } - - /// @notice Run the 8 structural validators inside a state snapshot and - /// return the total error count without polluting surrounding - /// state. The per-validator report is still emitted to logs. - function _countValidatorErrorsSnapshotted(string memory network) internal returns (uint256 count) { - uint256 snap = vm.snapshotState(); - ValidationExecutor executor = new ValidationExecutor(network, "spell-fork-pre"); - count = executor.runValidationCountErrors(_buildValidators(), "PRE-CAST"); - vm.revertToState(snap); - } - - /// @notice Run the 8 structural validators against post-cast live state - /// and assert the total error count did not exceed the pre-cast - /// baseline. The per-validator report is emitted to logs so the - /// spell author can see exactly which errors were tolerated and - /// which (if any) are new. - function _assertValidatorErrorCountDidNotIncrease(string memory network, uint256 preErrors) internal { - ValidationExecutor executor = new ValidationExecutor(network, "spell-fork-post"); - uint256 postErrors = executor.runValidationCountErrors(_buildValidators(), "POST-CAST"); - assertLe(postErrors, preErrors, "Spell introduced new structural validator errors"); - } - - // ------------------------------------------------------------------ - // Investment flow helpers - // ------------------------------------------------------------------ - - /// @notice Run InvestmentFlowExecutor inside a state snapshot so the side - /// effects (mints, deposits, hub registrations, manager updates) - /// do not bleed into the surrounding test state. - function _runFlowsSnapshotted(EnvConfig memory config, VaultGraphQLData[] memory vaults) - internal - returns (InvestmentFlowResult[] memory) - { - if (vaults.length == 0) return new InvestmentFlowResult[](0); - - uint256 snap = vm.snapshotState(); - - NonCoreReport memory report = testContractsFromConfig(config).main; - InvestmentFlowExecutor executor = new InvestmentFlowExecutor(); - vm.deal(address(executor), 100 ether); - - InvestmentFlowResult[] memory results = executor.executeAllFlows(report, vaults, config.network.centrifugeId); - - vm.revertToState(snap); - - return results; - } - - /// @notice Compare pre-cast and post-cast flow results vault-by-vault. A - /// "regression" is a vault that passed before the spell and fails - /// after; these hard-fail the test. Failures that pre-date the - /// spell are logged but tolerated. - /// @dev Pre and post arrays index identically because both runs were - /// given the same `vaults[]` (`InvestmentFlowExecutor.executeAllFlows` - /// writes results in input order). - function _assertNoFlowRegressions(InvestmentFlowResult[] memory pre, InvestmentFlowResult[] memory post) internal { - assertEq(pre.length, post.length, "Pre/post flow result length mismatch"); - - uint256 depositRegressions; - uint256 redeemRegressions; - - emit log_string(""); - emit log_string("================================================================"); - emit log_string(" INVESTMENT FLOW DIFF (pre-cast vs post-cast)"); - emit log_string("================================================================"); - - for (uint256 i = 0; i < post.length; i++) { - InvestmentFlowResult memory b = pre[i]; - InvestmentFlowResult memory a = post[i]; - - if (b.depositPassed && !a.depositPassed) { - emit log_string(string.concat("[REGRESSION deposit] ", vm.toString(a.vault), " -> ", a.depositError)); - depositRegressions++; - } else if (!b.depositPassed && a.depositPassed) { - emit log_string(string.concat("[IMPROVED deposit] ", vm.toString(a.vault))); - } else if (!b.depositPassed && !a.depositPassed) { - emit log_string(string.concat("[PRE-EXISTING deposit] ", vm.toString(a.vault), " -> ", a.depositError)); - } - - if (b.redeemPassed && !a.redeemPassed) { - emit log_string(string.concat("[REGRESSION redeem] ", vm.toString(a.vault), " -> ", a.redeemError)); - redeemRegressions++; - } else if (!b.redeemPassed && a.redeemPassed) { - emit log_string(string.concat("[IMPROVED redeem] ", vm.toString(a.vault))); - } else if (!b.redeemPassed && !a.redeemPassed) { - emit log_string(string.concat("[PRE-EXISTING redeem] ", vm.toString(a.vault), " -> ", a.redeemError)); - } - } - - emit log_string("================================================================"); - - assertEq(depositRegressions, 0, "Spell regressed deposit flows on previously-passing vaults"); - assertEq(redeemRegressions, 0, "Spell regressed redeem flows on previously-passing vaults"); - } - - // ------------------------------------------------------------------ - // GraphQL vault query (mirrors InvestmentFlowForkTest._queryVaults) - // ------------------------------------------------------------------ - - function _queryVaults(GraphQLQuery indexer, uint16 centrifugeId) - internal - returns (VaultGraphQLData[] memory vaults) - { - string memory centrifugeIdStr = vm.toString(centrifugeId).asJsonString(); - - string memory json = indexer.queryGraphQL( - string.concat( - "vaults(limit: 1000, where: { centrifugeId: ", - centrifugeIdStr, - ", status: Linked }) { totalCount items { id poolId tokenId kind assetAddress asset { decimals symbol } token { pool { managers(where: {isHubManager: true}, limit: 1) { items { address centrifugeId } } } } } }" - ) - ); - - uint256 totalCount = json.readUint(".data.vaults.totalCount"); - if (totalCount == 0) return vaults; - - require(totalCount <= 1000, "Vault count exceeds query limit; implement pagination"); - - vaults = new VaultGraphQLData[](totalCount); - - for (uint256 i; i < totalCount; i++) { - string memory base = ".data.vaults.items"; - vaults[i].vault = json.readAddress(base.asJsonPath(i, "id")); - vaults[i].poolIdRaw = uint64(json.readUint(base.asJsonPath(i, "poolId"))); - vaults[i].tokenIdRaw = _parseBytes16(json, base.asJsonPath(i, "tokenId")); - vaults[i].kind = json.readString(base.asJsonPath(i, "kind")); - vaults[i].assetAddress = json.readAddress(base.asJsonPath(i, "assetAddress")); - vaults[i].assetDecimals = uint8(json.readUint(base.asJsonPath(i, "asset.decimals"))); - vaults[i].assetSymbol = json.readString(base.asJsonPath(i, "asset.symbol")); - - string memory managersBase = string.concat(base, "[", vm.toString(i), "].token.pool.managers.items"); - try vm.parseJsonAddress(json, string.concat(managersBase, "[0].address")) returns (address mgr) { - vaults[i].hubManager = mgr; - } catch {} - try vm.parseJsonUint(json, string.concat(managersBase, "[0].centrifugeId")) returns (uint256 cid) { - vaults[i].hubCentrifugeId = uint16(cid); - } catch {} - } - } - - function _parseBytes16(string memory json, string memory path) internal pure returns (bytes16 result) { - bytes memory rawBytes = json.readBytes(path); - require(rawBytes.length == 16, "Expected 16 bytes for tokenId"); - for (uint256 i = 0; i < 16; i++) { - result |= bytes16(rawBytes[i]) >> (i * 8); - } - } -} diff --git a/test/integration/spell/utils/SpellRegressionTest.sol b/test/integration/spell/utils/SpellRegressionTest.sol new file mode 100644 index 000000000..bd0156014 --- /dev/null +++ b/test/integration/spell/utils/SpellRegressionTest.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import {FlowRegression} from "./FlowRegression.sol"; +import {BaseValidator} from "./validation/BaseValidator.sol"; +import {ValidationExecutor} from "./validation/ValidationExecutor.sol"; +import {InvestmentFlowResult, VaultGraphQLData} from "./validation/InvestmentFlowExecutor.sol"; + +import {Env, EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; +import {GraphQLQuery} from "../../../../script/utils/GraphQLQuery.s.sol"; + +import {Validate_Vaults} from "../../fork/validators/Validate_Vaults.sol"; +import {Validate_Endorsements} from "../../fork/validators/Validate_Endorsements.sol"; +import {Validate_ContractWards} from "../../fork/validators/Validate_ContractWards.sol"; +import {Validate_GuardianSafes} from "../../fork/validators/Validate_GuardianSafes.sol"; +import {Validate_HookPoolEscrow} from "../../fork/validators/Validate_HookPoolEscrow.sol"; +import {Validate_RootPermissions} from "../../fork/validators/Validate_RootPermissions.sol"; +import {Validate_FileConfigurations} from "../../fork/validators/Validate_FileConfigurations.sol"; +import {Validate_AdapterConfigurations} from "../../fork/validators/Validate_AdapterConfigurations.sol"; + +/// @title SpellRegressionTest +/// @notice Abstract base for the *environment regression* layer of a spell test. +/// Scoped strictly to "did the spell break the live environment?" — the +/// focused, spell-specific correctness proof lives in the spell's own +/// `Test`-derived test, not here. +/// +/// @dev Per network (`_networks()`), the driver runs three post-cast layers — +/// structural validator diff, spell-specific pre/cache/post validators, +/// and investment-flow regression — all tolerant of pre-existing live +/// errors and failing only on regressions the spell itself introduced. +/// Architecture, author guide, and the legacy-codegen (stack-too-deep) +/// constraints behind the internals are documented in +/// `test/integration/spell/utils/validation/README.md`. +abstract contract SpellRegressionTest is FlowRegression { + /// @dev One executor reused across the cast: it captures the structural + /// baseline (in its own storage) pre-cast and diffs against it post-cast, + /// so the baseline never crosses an ABI boundary nor touches disk. + ValidationExecutor private _exec; + /// @dev abi.encode(InvestmentFlowResult[]) pre-cast flow results, carried across the cast. + bytes private _preFlowsBlob; + + // ------------------------------------------------------------------ + // Virtual hooks (children override) + // ------------------------------------------------------------------ + + /// @notice Networks to run the regression against (e.g. ["ethereum", "base"]). + function _networks() internal view virtual returns (string[] memory); + + /// @notice Cache namespace, shared by the pre/post executors (e.g. "v2cleanings"). + function _executorName() internal pure virtual returns (string memory); + + /// @notice Deploy the spell, schedule any required relies, and cast it. + function _castSpell(string memory network, EnvConfig memory config) internal virtual; + + function _structuralValidators() internal virtual returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](8); + validators[0] = new Validate_RootPermissions(); + validators[1] = new Validate_ContractWards(); + validators[2] = new Validate_FileConfigurations(); + validators[3] = new Validate_Endorsements(); + validators[4] = new Validate_GuardianSafes(); + validators[5] = new Validate_AdapterConfigurations(); + validators[6] = new Validate_Vaults(); + validators[7] = new Validate_HookPoolEscrow(); + } + + function _preValidators() internal virtual returns (BaseValidator[] memory) { + return new BaseValidator[](0); + } + + function _cacheValidators() internal virtual returns (BaseValidator[] memory) { + return new BaseValidator[](0); + } + + function _postValidators() internal virtual returns (BaseValidator[] memory) { + return new BaseValidator[](0); + } + + function _runInvestmentFlowsDiff() internal pure virtual returns (bool) { + return true; + } + + // ------------------------------------------------------------------ + // Entry point + // ------------------------------------------------------------------ + + /// @notice Runs the regression suite across every network in `_networks()`. + /// Each network is isolated via an external self-call: a failure is + /// recorded and the remaining networks still run, then the test + /// fails at the end if any network failed. + function test_spellRegression() external virtual { + string[] memory networks = _networks(); + uint256 failures; + + for (uint256 i = 0; i < networks.length; i++) { + try this.runSpellRegressionCase(networks[i]) { + emit log_string(string.concat("[NETWORK OK] ", networks[i])); + } catch Error(string memory reason) { + failures++; + emit log_string(string.concat("[NETWORK FAILED] ", networks[i], " -> ", reason)); + } catch { + failures++; + emit log_string(string.concat("[NETWORK FAILED] ", networks[i])); + } + } + + assertEq(failures, 0, "Spell regression failed on at least one network"); + } + + /// @dev External wrapper so each network's run can be try/catch-isolated. + /// Not a test entry point (no `test` prefix); self-call only. + function runSpellRegressionCase(string memory network) external { + require(msg.sender == address(this), "SpellRegressionTest: self-call only"); + _runCase(network); + } + + // ------------------------------------------------------------------ + // Per-network driver + // ------------------------------------------------------------------ + + function _runCase(string memory network) internal { + EnvConfig memory config = Env.load(network); + vm.createSelectFork(config.network.rpcUrl()); + + VaultGraphQLData[] memory vaults = _maybeQueryVaults(config); + + _capturePreCast(network, config, vaults); + _castSpell(network, config); + _verifyPostCast(config, vaults); + } + + function _maybeQueryVaults(EnvConfig memory config) internal returns (VaultGraphQLData[] memory) { + if (!_runInvestmentFlowsDiff()) return new VaultGraphQLData[](0); + return _queryVaults(new GraphQLQuery(config.network.graphQLApi()), config.network.centrifugeId); + } + + /// @dev Capture every pre-cast artifact the post-cast verification needs: + /// spell-specific PRE/CACHE state, the structural baseline (in the + /// executor's storage), and the flow baseline. The executor is stored in + /// `_exec` and reused post-cast so the baseline survives without crossing + /// an ABI boundary or touching disk. + function _capturePreCast(string memory network, EnvConfig memory config, VaultGraphQLData[] memory vaults) + internal + { + _exec = new ValidationExecutor(network, _executorName()); + + // Spell-specific PRE (soft) + CACHE (cleans dir, writes files surviving the cast). + _exec.runPreValidation(_preValidators(), false); + _exec.runCacheValidation(_cacheValidators()); + + // Structural baseline stored in `_exec`. NOT snapshot-wrapped: the + // validators are read-only, and a snapshot revert would also revert the + // executor's baseline storage write (it is EVM state, unlike a file). + _exec.captureErrorBaseline(_structuralValidators()); + + if (vaults.length > 0) { + _preFlowsBlob = abi.encode(_snapshotFlows(config, vaults)); + } + } + + /// @dev Run the three post-cast verification layers. Reuses `_exec` so the + /// structural diff reads the baseline it captured pre-cast and the POST + /// validators read what the CACHE validators wrote. Fresh validator + /// instances are passed (the `executed` guard blocks reuse). + function _verifyPostCast(EnvConfig memory config, VaultGraphQLData[] memory vaults) internal { + // Layer 1: structural diff against the pre-cast baseline (in `_exec` storage). + _exec.runValidationDiffPost(_structuralValidators()); + + // Layer 2: spell-specific POST via the migration-aware overload (no + // `latest`). This base is scoped to spells that deploy no new core + // contracts, so POST validators read cache + on-chain state only; a + // validator that reads `ctx.latest` gets the zero struct and fails loudly. + _exec.runPostValidation(_postValidators()); + + // Layer 3: investment-flow regression diff. + if (vaults.length > 0) { + _assertNoFlowRegressions( + abi.decode(_preFlowsBlob, (InvestmentFlowResult[])), _snapshotFlows(config, vaults) + ); + } + } +} diff --git a/test/integration/spell/utils/validation/BaseValidator.sol b/test/integration/spell/utils/validation/BaseValidator.sol index f7f7cf81b..afe4632b9 100644 --- a/test/integration/spell/utils/validation/BaseValidator.sol +++ b/test/integration/spell/utils/validation/BaseValidator.sol @@ -34,7 +34,6 @@ struct ValidationContext { /// @title BaseValidator /// @notice Abstract base class for pre/post migration validators -/// @dev Each validator must implement validate(ValidationContext) /// @dev JSON Parsing: Use stdJson helpers (readUint, readString) per field instead of /// vm.parseJson + abi.decode, which fails silently with mixed-type structs. abstract contract BaseValidator is Test { @@ -42,7 +41,7 @@ abstract contract BaseValidator is Test { using JsonUtils for *; string public name; - ValidationError[] _errors; // Array of all errors found (empty if passed) + ValidationError[] _errors; constructor(string memory name_) { name = name_; @@ -60,24 +59,17 @@ abstract contract BaseValidator is Test { struct ValidationError { string field; // Field that failed (e.g., "pendingAssetsAmount") string value; // Identifier (e.g., "Pool 281474976710659") - string expected; // Expected value (e.g., "0") - string actual; // Actual value (e.g., "10000000") - string message; // Human-readable message (e.g., "Pool has 10 USDC pending") + string expected; + string actual; + string message; } - // ============================================ - // ABSTRACT INTERFACE - // ============================================ - - /// @notice Execute validation checks - /// @param ctx Validation context with contracts, indexer, and cache function validate(ValidationContext memory ctx) public virtual; // ============================================ // SHARED HELPERS // ============================================ - /// @notice Build a validation error function _buildError( string memory field, string memory value, @@ -88,7 +80,6 @@ abstract contract BaseValidator is Test { return ValidationError({field: field, value: value, expected: expected, actual: actual, message: message}); } - /// @notice Build JSON array string from PoolId array for GraphQL queries function _buildPoolIdsJson(PoolId[] memory pools) internal pure returns (string memory) { string memory json = "["; for (uint256 i = 0; i < pools.length; i++) { @@ -100,7 +91,6 @@ abstract contract BaseValidator is Test { return string.concat(json, "]"); } - /// @notice Check that wardHolder has ward on wardedContract function _checkWard(address wardedContract, address wardHolder, string memory label) internal { if (wardedContract == address(0) || wardHolder == address(0)) return; if (wardedContract.code.length == 0) return; @@ -113,4 +103,21 @@ abstract contract BaseValidator is Test { _errors.push(_buildError("ward", label, "callable", "reverted", string.concat("wards() reverted: ", label))); } } + + /// @notice Check that wardHolder does NOT have ward on wardedContract (inverse of _checkWard). + /// @dev Useful for migration spells that revoke permissions (e.g. denying a stale root). + function _checkNoWard(address wardedContract, address wardHolder, string memory label) internal { + if (wardedContract == address(0) || wardHolder == address(0)) return; + if (wardedContract.code.length == 0) return; + + try IAuth(wardedContract).wards(wardHolder) returns (uint256 val) { + if (val != 0) { + _errors.push( + _buildError("ward", label, "0", vm.toString(val), string.concat("Ward should be removed: ", label)) + ); + } + } catch { + _errors.push(_buildError("ward", label, "callable", "reverted", string.concat("wards() reverted: ", label))); + } + } } diff --git a/test/integration/spell/utils/validation/README.md b/test/integration/spell/utils/validation/README.md index 1bdf93f5e..f6c1221c8 100644 --- a/test/integration/spell/utils/validation/README.md +++ b/test/integration/spell/utils/validation/README.md @@ -1,21 +1,29 @@ # Spell Validation Framework -Validates Centrifuge protocol state before and after executing governance spells. Queries the GraphQL indexer to ensure no blocking operations exist (PRE) and that state is correctly preserved (POST). +Validates Centrifuge protocol state before and after executing governance spells. Queries the GraphQL indexer to ensure no blocking operations exist (PRE) and that state is correctly preserved (POST), and provides a spell-agnostic **environment regression** harness (`SpellRegressionTest`) that tolerates pre-existing live errors and fails only on regressions a spell introduces. ## Architecture ``` test/integration/spell/ ├── utils/ +│ ├── SpellRegressionTest.sol # Abstract env-regression orchestrator for spells +│ ├── FlowRegression.sol # Investment-flow regression mixin (query/snapshot/diff) │ └── validation/ # Framework (shared) │ ├── BaseValidator.sol # Abstract base with ValidationContext + helpers -│ ├── ValidationExecutor.sol # Runs validators and displays report +│ ├── ValidationExecutor.sol # Runs validators, reports, regression diff │ ├── TestContracts.sol # TestContracts struct + factory functions │ └── InvestmentFlowExecutor.sol # Investment flow execution for fork tests -└── example-spell/ # Example usage - ├── ExampleTest.t.sol # Example test wiring PRE/cache/POST phases +├── example-spell/ # Example: PRE/cache/POST wiring +│ ├── ExampleTest.t.sol +│ └── validators/ +│ └── Validate_Example.sol +├── V2Cleanings.t.sol # Focused spell test (absolute post-state proof) +└── v2-cleanings/ # Env regression for the same spell + ├── V2CleaningsCast.sol # Shared deploy + rely + cast() preamble + ├── V2CleaningsValidatorTest.t.sol # extends SpellRegressionTest └── validators/ - └── Validate_Example.sol # Example: PRE query, cache, POST read + └── Validate_V2Cleanings.sol # Pre (soft) / Cache / Post (hard) validators ``` ## Validation Flow @@ -35,7 +43,7 @@ contract MySpellTest { } function testSpell() public { - ValidationExecutor executor = new ValidationExecutor("ethereum"); + ValidationExecutor executor = new ValidationExecutor("ethereum", "my-spell"); // 1. PRE validation (soft failures = warnings) executor.runPreValidation(pre, false); @@ -54,6 +62,8 @@ contract MySpellTest { PRE validators query live state and cache results. Cache validators run silently to store data without reporting. POST validators read cached data and compare with the newly deployed contracts. +**Migration spells (no new contracts):** use the `latest`-less overload `runPostValidation(post)` instead. It leaves `ctx.contracts.latest` as the zero struct, so a POST validator that erroneously reads `latest` fails loudly rather than silently aliasing the live set. The `latest`-taking overload remains for spells that actually deploy contracts. + ### ValidationContext Every validator receives a `ValidationContext` with: @@ -61,12 +71,83 @@ Every validator receives a `ValidationContext` with: | Field | Description | | ------------------ | ------------------------------------------------ | | `contracts.live` | Current deployed addresses (from `env/*.json`) | -| `contracts.latest` | Newly deployed contracts (empty for PRE phase) | +| `contracts.latest` | Newly deployed contracts (empty for PRE phase and for migration-spell POST) | | `localCentrifugeId`| Chain's centrifuge ID | | `indexer` | `GraphQLQuery` instance for the chain's API | | `cache` | `CacheStore` for cross-phase data persistence | | `isMainnet` | Whether the chain is mainnet | +## Spell Environment Regression (`SpellRegressionTest`) + +A spell ships with **two tests**: + +| Test | Extends | Purpose | +| ---- | ------- | ------- | +| `.t.sol` (focused) | `Test` | Exhaustive forked correctness proof: cast + absolute post-state assertions (ward flips, `done()`, …) | +| `/ValidatorTest.t.sol` | `SpellRegressionTest` | Environment regression: did the spell break anything *else* on the live network? | + +Both call the same shared `Cast` preamble (deploy + guardian relies + `cast()`) so they can never drift. + +Per network, `SpellRegressionTest` runs three post-cast verification layers. All of them tolerate **pre-existing** live errors (live mainnet has known ones) and fail only on **regressions** the spell introduced: + +1. **Structural validator diff** — the 8 live validators (`test/integration/fork/validators/`) run pre-cast (baseline) and post-cast. Error identity is `keccak256(validatorName | field | value)`; `actual` is deliberately excluded because it drifts (e.g. balances). Each post-cast error is classified `PRE-EXISTING` (tolerated), `REGRESSION` (fails), or `IMPROVED` (resolved by the spell). +2. **Spell-specific pre/cache/post validators** — the `example-spell/` pattern: PRE (soft warnings) + CACHE (file-backed snapshot for delta checks) run pre-cast; POST (hard) reads the cache + on-chain state post-cast. +3. **Investment-flow regression** — per-vault end-to-end deposit/redeem diff via the `FlowRegression` mixin. A vault that passed pre-cast and fails post-cast is a regression; pre-existing failures are logged and tolerated. Default-on; spells that don't touch vaults opt out. + +Networks run isolated from each other: a failure on one network is recorded, the remaining networks still run, and the test fails at the end if any network failed. + +### Adding a new spell (author guide) + +1. **Focused spell test** at `test/integration/spell/.t.sol` extending `Test`: fork, call the shared cast helper, assert the exact absolute post-state the spell guarantees. +2. **Shared cast preamble** at `test/integration/spell//Cast.sol` (library): deploy the spell, prank the guardian relies, `cast()`. +3. **Validators** at `test/integration/spell//validators/Validate_.sol`, mirroring `Validate_Example.sol` / `Validate_V2Cleanings.sol`: + - `Validate_Pre` (soft): assert there IS work to do. + - `Validate_Cache`: `ctx.cache.set(...)` the pre-cast values needed for delta checks. + - `Validate_Post` (hard): read the cache, assert the deltas + absolute invariants (`_checkWard` / `_checkNoWard`). +4. **Validator test** at `test/integration/spell//ValidatorTest.t.sol`: + +```solidity +contract MySpellValidatorTest is SpellRegressionTest { + function _networks() internal pure override returns (string[] memory networks) { + networks = new string[](2); + networks[0] = "ethereum"; + networks[1] = "base"; + } + + function _executorName() internal pure override returns (string memory) { + return "myspell"; // cache namespace + } + + function _castSpell(string memory, EnvConfig memory config) internal override { + MySpellCast.deployAndCast(config); + } + + function _preValidators() internal override returns (BaseValidator[] memory v) { ... } + function _cacheValidators() internal override returns (BaseValidator[] memory v) { ... } + function _postValidators() internal override returns (BaseValidator[] memory v) { ... } + + // Default true — override to false for spells that don't touch vaults + // (e.g. adapter rewiring), where flow regression is irrelevant. + function _runInvestmentFlowsDiff() internal pure override returns (bool) { return false; } +} +``` + +5. Run (fork tests need RPC keys): `set -a; . .env; set +a; forge test --match-contract MySpellValidatorTest -vv` + +### Config-changing spells and the structural diff + +`Validate_AdapterConfigurations` (and friends) compare on-chain state to the `env/` files. A spell that intentionally changes that state (e.g. swapping adapters) must update the `env/` file **in the same change** to the post-spell target. The diff then sees: pre-cast on-chain (old) vs env (new) = tolerated `PRE-EXISTING` mismatch; post-cast on-chain (new) vs env (new) = clean, reported as `IMPROVED`. A plain hard-fail POST would false-positive on the intended change — this is exactly why the diff mode exists. + +### Legacy-codegen constraints (why the internals look the way they do) + +The repo compiles with `optimizer_runs=1` and **no `via_ir`**. Legacy codegen cannot ABI-code deeply nested dynamic types (`ValidationResult[]`, `InvestmentFlowResult[]`, `EnvConfig`) across an `external` call without stack-too-deep. Consequences: + +- The structural baseline never crosses an ABI boundary: `captureErrorBaseline(validators)` serializes error-identity keys into the **executor's own storage**, and `runValidationDiffPost(validators)` must be called on the **same executor instance** (which therefore survives the cast). +- Baseline capture is **not** snapshot-wrapped — a `vm.revertToState` would revert the executor's baseline storage write. The 8 structural validators are read-only, so no isolation is needed. +- `FlowRegression` is an **inherited mixin** (internal functions), not an externally-called helper. +- Pre-cast flow results are carried across the cast as `abi.encode`'d `bytes` in orchestrator storage (1 slot). +- `FlowRegression._parseBytes16` uses a loop, not assembly (equivalence pinned by `FlowRegression.t.sol`). + ## Adding a Validator ### 1. Create Validator Contract @@ -78,7 +159,7 @@ Create a validator contract (e.g., `test/integration/spell/your-spell/validators pragma solidity 0.8.28; import {stdJson} from "forge-std/StdJson.sol"; -import {BaseValidator, ValidationContext} from "../../validation/BaseValidator.sol"; +import {BaseValidator, ValidationContext} from "../../utils/validation/BaseValidator.sol"; contract Validate_YourCheck is BaseValidator("YourCheck") { using stdJson for string; @@ -106,6 +187,8 @@ contract Validate_YourCheck is BaseValidator("YourCheck") { } ``` +`BaseValidator` ships reusable ward helpers: `_checkWard(target, holder, label)` asserts `holder` IS a ward on `target`; `_checkNoWard(target, holder, label)` asserts it is NOT (useful for spells that revoke permissions). + ### 2. Register in Your Test Add validators to the appropriate phase array in your test contract: @@ -137,7 +220,7 @@ function validate(ValidationContext memory ctx) public override { } ``` -Cache files are stored under `spell-cache/validation//` with filenames derived from the key. +Cache files are stored under `spell-cache/validation///` with filenames derived from the key. The cache is file-backed, so values written pre-cast survive the spell cast within a test run. Plain (non-JSON) values work too: store with `vm.toString(value)` and read back with `vm.parseUint(...)` (see `Validate_CacheV2Cleanings`). ## JSON Parsing diff --git a/test/integration/spell/utils/validation/ValidationExecutor.sol b/test/integration/spell/utils/validation/ValidationExecutor.sol index 7943bfc96..8479d7cb8 100644 --- a/test/integration/spell/utils/validation/ValidationExecutor.sol +++ b/test/integration/spell/utils/validation/ValidationExecutor.sol @@ -23,6 +23,12 @@ contract ValidationExecutor is Script { TestContracts empty; mapping(BaseValidator => bool) executed; + /// @dev Serialized baseline error-identity keys, captured pre-cast and read + /// post-cast from this SAME executor instance. Kept in storage as a + /// compact string so it survives the cast without crossing an ABI + /// boundary (see README, "Legacy-codegen constraints"). + string private _baselineKeys; + constructor(string memory network, string memory executorName) { EnvConfig memory config = Env.load(network); @@ -41,7 +47,6 @@ contract ValidationExecutor is Script { _execute(validators, "PRE", shouldRevert); } - /// @notice cleans old cache and runs the cache-related validators function runCacheValidation(BaseValidator[] memory validators) external { ctx.cache.cleanAndCreateCacheDir(); ctx.contracts.latest = empty; @@ -53,43 +58,51 @@ contract ValidationExecutor is Script { _execute(validators, "POST", true); } - /// @notice Run validators against live state and return the total error - /// count without reverting. Used by `SpellForkTest` to compute a - /// pre/post regression count for the structural validators — - /// tolerating pre-existing live-state errors that the spell did - /// not introduce. - /// @dev Per-error log lines are still emitted via the standard report. - function runValidationCountErrors(BaseValidator[] memory validators, string memory phaseName) - external - returns (uint256 totalErrors) - { + /// @notice Migration-aware POST: no `latest`. Use for spells that deploy no + /// new core contracts (e.g. `V2CleaningsSpell`). `ctx.contracts.latest` + /// is left empty so a validator that erroneously reads it gets the + /// zero struct and fails loudly, rather than silently aliasing `live`. + function runPostValidation(BaseValidator[] memory validators) external { ctx.contracts.latest = empty; - ValidationResult[] memory results = new ValidationResult[](validators.length); + _execute(validators, "POST", true); + } - for (uint256 i = 0; i < validators.length; i++) { - require( - !executed[validators[i]], string.concat("The validator ", validators[i].name(), " was already executed") - ); - executed[validators[i]] = true; + /// @notice Run validators against current (pre-cast) state and store a + /// baseline of error-identity keys in this executor's storage, so the + /// post-cast diff can tolerate errors that pre-date the spell. + /// @dev The matching `runValidationDiffPost` must be called on this SAME + /// executor instance. The full per-validator report is still emitted. + function captureErrorBaseline(BaseValidator[] memory validators) external { + ctx.contracts.latest = empty; + ValidationResult[] memory results = _run(validators); + _baselineKeys = _serializeKeys(results); + _displayReport(results, _countErrors(results), "PRE-CAST BASELINE", false); + } - validators[i].validate(ctx); + /// @notice Run validators against current (post-cast) state and revert ONLY + /// if an error is present now whose identity key was NOT in the + /// baseline captured by `captureErrorBaseline` on this same executor. + /// Errors are classified PRE-EXISTING / REGRESSION / IMPROVED. + /// @dev Error identity is `keccak256(name | field | value)` — `actual` is + /// deliberately excluded because it legitimately drifts (e.g. balances) + /// and would mask a tolerated pre-existing error as a fresh regression. + function runValidationDiffPost(BaseValidator[] memory validators) external { + ctx.contracts.latest = empty; + ValidationResult[] memory results = _run(validators); - results[i].name = validators[i].name(); - results[i].errors = validators[i].errors(); - totalErrors += results[i].errors.length; - } + uint256 regressions = _displayDiffReport(results, _baselineKeys); - if (bytes(phaseName).length != 0) { - _displayReport(results, totalErrors, phaseName, false); + if (regressions > 0) { + revert( + string.concat( + "Spell introduced ", vm.toString(regressions), " new structural validator error(s) (REGRESSION)" + ) + ); } } - function _execute(BaseValidator[] memory validators, string memory phaseName, bool shouldRevert) - internal - returns (bool) - { - ValidationResult[] memory results = new ValidationResult[](validators.length); - uint256 totalErrors = 0; + function _run(BaseValidator[] memory validators) internal returns (ValidationResult[] memory results) { + results = new ValidationResult[](validators.length); for (uint256 i = 0; i < validators.length; i++) { require( @@ -101,22 +114,110 @@ contract ValidationExecutor is Script { results[i].name = validators[i].name(); results[i].errors = validators[i].errors(); - - totalErrors += results[i].errors.length; } + } + + function _execute(BaseValidator[] memory validators, string memory phaseName, bool shouldRevert) internal { + ValidationResult[] memory results = _run(validators); + uint256 totalErrors = _countErrors(results); if (bytes(phaseName).length != 0) { _displayReport(results, totalErrors, phaseName, shouldRevert); } - if (totalErrors > 0) { - if (shouldRevert) { - revert(string.concat(phaseName, " validation failed: ", vm.toString(totalErrors), " errors")); + if (shouldRevert && totalErrors > 0) { + revert(string.concat(phaseName, " validation failed: ", vm.toString(totalErrors), " errors")); + } + } + + function _countErrors(ValidationResult[] memory results) private pure returns (uint256 total) { + for (uint256 i = 0; i < results.length; i++) { + total += results[i].errors.length; + } + } + + /// @dev `vm.toString(bytes32)` is "0x" + 64 hex chars (66); plus the "\n" + /// record separator that makes 67 the fixed stride per baseline key. + uint256 private constant _KEY_STRIDE = 67; + + /// @dev Error identity excludes `actual` (drifts) and `expected`/`message` + /// (presentation). Two errors are "the same" iff same validator name, + /// field and value. + function _errorKey(string memory name, BaseValidator.ValidationError memory err) private pure returns (bytes32) { + return keccak256(abi.encodePacked(name, "|", err.field, "|", err.value)); + } + + /// @dev Serialize every error's identity key as a newline-separated list of + /// fixed-width hex strings — the on-disk baseline format. + function _serializeKeys(ValidationResult[] memory results) private pure returns (string memory out) { + for (uint256 i = 0; i < results.length; i++) { + for (uint256 j = 0; j < results[i].errors.length; j++) { + out = string.concat(out, vm.toString(_errorKey(results[i].name, results[i].errors[j])), "\n"); + } + } + } + + /// @dev Substring search; needle is a fixed-width hex key, and the newline + /// separators make accidental cross-record matches impossible. + function _contains(bytes memory haystack, bytes memory needle) private pure returns (bool) { + if (needle.length == 0) return true; + if (haystack.length < needle.length) return false; + for (uint256 i = 0; i <= haystack.length - needle.length; i++) { + bool matched = true; + for (uint256 j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; + } + + /// @dev Classify every post-cast error against the cached baseline keys and + /// log it. Returns the count of REGRESSION errors (present now, absent + /// from the baseline). IMPROVED is derived: every baseline error is + /// either still present (PRE-EXISTING) or resolved (IMPROVED). + function _displayDiffReport(ValidationResult[] memory results, string memory baseline) + private + returns (uint256 regressions) + { + emit log_string(""); + emit log_string("================================================================"); + emit log_string(" STRUCTURAL VALIDATOR DIFF (pre-cast vs post-cast)"); + emit log_string("================================================================"); + + bytes memory baselineBytes = bytes(baseline); + uint256 preExisting; + + for (uint256 i = 0; i < results.length; i++) { + for (uint256 j = 0; j < results[i].errors.length; j++) { + BaseValidator.ValidationError memory err = results[i].errors[j]; + string memory keyHex = vm.toString(_errorKey(results[i].name, err)); + + if (_contains(baselineBytes, bytes(keyHex))) { + preExisting++; + emit log_string(string.concat( + "[PRE-EXISTING] ", results[i].name, ": ", err.field, " / ", err.value + )); + } else { + regressions++; + emit log_string(string.concat( + "[REGRESSION] ", results[i].name, ": ", err.field, " / ", err.value, " -> ", err.message + )); + } } - return false; } - return true; + uint256 baselineCount = baselineBytes.length / _KEY_STRIDE; + uint256 improved = baselineCount > preExisting ? baselineCount - preExisting : 0; + + emit log_string("----------------------------------------------------------------"); + emit log_string(string.concat("Pre-existing (tolerated): ", vm.toString(preExisting))); + emit log_string(string.concat("Improved (resolved): ", vm.toString(improved))); + emit log_string(string.concat("Regressions (NEW): ", vm.toString(regressions))); + emit log_string("================================================================"); } function _displayReport( diff --git a/test/integration/spell/v2-cleanings/V2CleaningsCast.sol b/test/integration/spell/v2-cleanings/V2CleaningsCast.sol new file mode 100644 index 000000000..efe2d276a --- /dev/null +++ b/test/integration/spell/v2-cleanings/V2CleaningsCast.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {Root} from "../../../../src/admin/Root.sol"; + +import {EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; + +import {Vm} from "forge-std/Vm.sol"; + +import { + V2CleaningsSpell, + ROOT_V2, + ROOT_V3, + ETHEREUM_CHAIN_ID, + BASE_CHAIN_ID, + ARBITRUM_CHAIN_ID +} from "../../../../src/spell/V2CleaningsSpell.sol"; + +/// @title V2CleaningsCast +/// @notice Shared cast preamble for the V2Cleanings spell. Consumed by BOTH the +/// focused spell test (`V2Cleanings.t.sol`) and the environment +/// regression test (`v2-cleanings/V2CleaningsValidatorTest.t.sol`) so the +/// deploy + two-guardian rely + `cast()` flow lives in one place and the +/// two tests can never drift. +library V2CleaningsCast { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + // Guardians holding rely rights over the V3 and V2 roots on mainnet. + address internal constant PROTOCOL_GUARDIAN_V3_1 = 0xCEb7eD5d5B3bAD3088f6A1697738B60d829635c6; + address internal constant GUARDIAN_V2_ETHEREUM_OR_ARBITRUM = 0x09ab10a9c3E6Eac1d18270a2322B6113F4C7f5E8; + address internal constant GUARDIAN_V2_BASE = 0x427A1ce127b1775e4Cbd4F58ad468B9F832eA7e9; + + /// @dev The spell hardcodes ROOT_V3 to the ETH/BASE/ARB V3 root; on those + /// chains the env's root must match or the spell's tail-end + /// relyContract/deny calls would brick. + function deployAndCast(EnvConfig memory config) internal returns (V2CleaningsSpell spell) { + Root rootV3 = Root(config.contracts.root); + + if (block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID || block.chainid == ARBITRUM_CHAIN_ID) + { + require(address(ROOT_V3) == address(rootV3), "V2CleaningsCast: ROOT_V3 mismatch"); + } + + spell = new V2CleaningsSpell(); + + vm.prank(PROTOCOL_GUARDIAN_V3_1); + rootV3.rely(address(spell)); // Ideally through guardian.scheduleRely() + + if (address(ROOT_V2).code.length > 0) { + vm.prank(block.chainid == BASE_CHAIN_ID ? GUARDIAN_V2_BASE : GUARDIAN_V2_ETHEREUM_OR_ARBITRUM); + ROOT_V2.rely(address(spell)); // Ideally through guardian.scheduleRely() + } + + spell.cast(); + } +} diff --git a/test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol b/test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol new file mode 100644 index 000000000..6021ae0e8 --- /dev/null +++ b/test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {V2CleaningsCast} from "./V2CleaningsCast.sol"; +import { + Validate_PreV2Cleanings, + Validate_CacheV2Cleanings, + Validate_PostV2Cleanings +} from "./validators/Validate_V2Cleanings.sol"; + +import {EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; + +import {BaseValidator} from "../utils/validation/BaseValidator.sol"; +import {SpellRegressionTest} from "../utils/SpellRegressionTest.sol"; + +/// @title V2CleaningsValidatorTest +/// @notice Environment regression test for the V2Cleanings spell. Uses the +/// shared `V2CleaningsCast` helper so the deploy+cast flow is identical +/// to the focused `V2CleaningsSpellTest`. Investment-flow regression is +/// enabled by default because the spell touches share-token wards. +contract V2CleaningsValidatorTest is SpellRegressionTest { + function _networks() internal pure override returns (string[] memory networks) { + networks = new string[](3); + networks[0] = "ethereum"; + networks[1] = "base"; + networks[2] = "arbitrum"; + } + + function _executorName() internal pure override returns (string memory) { + return "v2cleanings"; + } + + function _castSpell( + string memory, + /* network */ + EnvConfig memory config + ) + internal + override + { + V2CleaningsCast.deployAndCast(config); + } + + function _preValidators() internal override returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](1); + validators[0] = new Validate_PreV2Cleanings(); + } + + function _cacheValidators() internal override returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](1); + validators[0] = new Validate_CacheV2Cleanings(); + } + + function _postValidators() internal override returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](1); + validators[0] = new Validate_PostV2Cleanings(); + } +} diff --git a/test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol b/test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol new file mode 100644 index 000000000..fb551632f --- /dev/null +++ b/test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import {IERC20} from "../../../../../src/misc/interfaces/IERC20.sol"; + +import {BaseValidator, ValidationContext} from "../../utils/validation/BaseValidator.sol"; +import { + ROOT_V2, + CFG, + WCFG, + IOU_CFG, + ESCROW_V2, + TREASURY, + CNF_TREASURY_WALLET, + CENTRIFUGE_CHAIN_CFG_AMOUNT, + TRANCHE_JAAA, + TRANCHE_JTRSY, + USDC_ETHEREUM, + USDC_BASE, + USDC_ARBITRUM, + ETHEREUM_CHAIN_ID, + BASE_CHAIN_ID, + ARBITRUM_CHAIN_ID +} from "../../../../../src/spell/V2CleaningsSpell.sol"; + +function _usdc() view returns (IERC20) { + if (block.chainid == ETHEREUM_CHAIN_ID) return USDC_ETHEREUM; + if (block.chainid == BASE_CHAIN_ID) return USDC_BASE; + if (block.chainid == ARBITRUM_CHAIN_ID) return USDC_ARBITRUM; + return IERC20(address(0)); +} + +// Cache keys shared between the CACHE and POST validators (file-backed, survive the cast). +string constant K_ESCROW_USDC = "escrowUsdc"; +string constant K_TREASURY_USDC = "treasuryUsdc"; +string constant K_CFG_TOTAL_SUPPLY = "cfgTotalSupply"; +string constant K_CNF_TREASURY_CFG = "cnfTreasuryCfg"; + +/// @title Validate_PreV2Cleanings +/// @notice Soft pre-cast check: asserts there IS work for the spell to do. Emits +/// warnings (never hard-fails) so an unexpected pre-state surfaces in +/// the report without blocking the regression run. +contract Validate_PreV2Cleanings is BaseValidator("PreV2Cleanings") { + function validate(ValidationContext memory) public override { + // The spell denies ROOT_V2 from the CFG/WCFG/tranche tokens — so it + // should still be a ward pre-cast. _checkWard warns if it is not. + _checkWard(CFG, address(ROOT_V2), "ROOT_V2 ward on CFG (pre)"); + + if (block.chainid == ETHEREUM_CHAIN_ID) { + _checkWard(WCFG, address(ROOT_V2), "ROOT_V2 ward on WCFG (pre)"); + _checkWard(TRANCHE_JTRSY, address(ROOT_V2), "ROOT_V2 ward on JTRSY (pre)"); + _checkWard(TRANCHE_JAAA, address(ROOT_V2), "ROOT_V2 ward on JAAA (pre)"); + } + + // The spell sweeps ESCROW_V2's USDC to the treasury — so it should hold some. + IERC20 usdc = _usdc(); + if (address(usdc) != address(0) && ESCROW_V2.code.length > 0) { + if (usdc.balanceOf(ESCROW_V2) == 0) { + _errors.push( + _buildError( + "balance", "ESCROW_V2 USDC", "> 0", "0", "ESCROW_V2 holds no USDC pre-cast; sweep is a no-op" + ) + ); + } + } + } +} + +/// @title Validate_CacheV2Cleanings +/// @notice Caches the pre-cast values the POST validator needs for delta checks. +/// Values are stored as plain decimal strings (read back with vm.parseUint). +contract Validate_CacheV2Cleanings is BaseValidator("CacheV2Cleanings") { + function validate(ValidationContext memory ctx) public override { + IERC20 usdc = _usdc(); + uint256 escrowUsdc = address(usdc) != address(0) ? usdc.balanceOf(ESCROW_V2) : 0; + uint256 treasuryUsdc = address(usdc) != address(0) ? usdc.balanceOf(TREASURY) : 0; + + ctx.cache.set(K_ESCROW_USDC, vm.toString(escrowUsdc)); + ctx.cache.set(K_TREASURY_USDC, vm.toString(treasuryUsdc)); + + uint256 cfgTotalSupply = CFG.code.length > 0 ? IERC20(CFG).totalSupply() : 0; + uint256 cnfTreasuryCfg = CFG.code.length > 0 ? IERC20(CFG).balanceOf(CNF_TREASURY_WALLET) : 0; + + ctx.cache.set(K_CFG_TOTAL_SUPPLY, vm.toString(cfgTotalSupply)); + ctx.cache.set(K_CNF_TREASURY_CFG, vm.toString(cnfTreasuryCfg)); + } +} + +/// @title Validate_PostV2Cleanings +/// @notice Hard post-cast check (reverts on failure). Asserts the spell's +/// before/after deltas (USDC sweep, CFG mint) against the cached +/// pre-cast values, plus the security-critical absolute invariant that +/// the stale V2 root is no longer a ward anywhere it was denied. +contract Validate_PostV2Cleanings is BaseValidator("PostV2Cleanings") { + function validate(ValidationContext memory ctx) public override { + _assertSweep(ctx); + _assertCfgMint(ctx); + _assertV2RootDenied(); + } + + /// @dev TREASURY USDC delta == cached ESCROW_V2 balance, and ESCROW_V2 fully swept to 0. + function _assertSweep(ValidationContext memory ctx) internal { + IERC20 usdc = _usdc(); + if (address(usdc) == address(0)) return; + + uint256 preEscrow = vm.parseUint(ctx.cache.get(K_ESCROW_USDC)); + uint256 preTreasury = vm.parseUint(ctx.cache.get(K_TREASURY_USDC)); + + uint256 treasuryDelta = usdc.balanceOf(TREASURY) - preTreasury; + if (treasuryDelta != preEscrow) { + _errors.push( + _buildError( + "usdcSweep", + "TREASURY", + vm.toString(preEscrow), + vm.toString(treasuryDelta), + "Treasury USDC delta does not match swept ESCROW_V2 balance" + ) + ); + } + + uint256 escrowAfter = usdc.balanceOf(ESCROW_V2); + if (escrowAfter != 0) { + _errors.push( + _buildError("usdcSweep", "ESCROW_V2", "0", vm.toString(escrowAfter), "ESCROW_V2 USDC not fully swept") + ); + } + } + + /// @dev On ETH only: CFG totalSupply delta and CNF treasury CFG delta both + /// equal the expected mint (wCFG supply minus IOU_CFG's wCFG balance, + /// plus the Centrifuge Chain CFG amount). + function _assertCfgMint(ValidationContext memory ctx) internal { + if (block.chainid != ETHEREUM_CHAIN_ID || CFG.code.length == 0) return; + + uint256 expectedMint = + IERC20(WCFG).totalSupply() - IERC20(WCFG).balanceOf(IOU_CFG) + CENTRIFUGE_CHAIN_CFG_AMOUNT; + + uint256 supplyDelta = IERC20(CFG).totalSupply() - vm.parseUint(ctx.cache.get(K_CFG_TOTAL_SUPPLY)); + if (supplyDelta != expectedMint) { + _errors.push( + _buildError( + "cfgMint", + "CFG.totalSupply", + vm.toString(expectedMint), + vm.toString(supplyDelta), + "CFG total supply delta does not match expected mint" + ) + ); + } + + uint256 treasuryDelta = + IERC20(CFG).balanceOf(CNF_TREASURY_WALLET) - vm.parseUint(ctx.cache.get(K_CNF_TREASURY_CFG)); + if (treasuryDelta != expectedMint) { + _errors.push( + _buildError( + "cfgMint", + "CNF_TREASURY CFG", + vm.toString(expectedMint), + vm.toString(treasuryDelta), + "CNF treasury CFG delta does not match expected mint" + ) + ); + } + } + + /// @dev Security-critical reusable env invariant: the stale V2 root must no + /// longer be a ward on the tokens the spell denied it from. + function _assertV2RootDenied() internal { + _checkNoWard(CFG, address(ROOT_V2), "ROOT_V2 denied from CFG"); + + if (block.chainid == ETHEREUM_CHAIN_ID) { + _checkNoWard(WCFG, address(ROOT_V2), "ROOT_V2 denied from WCFG"); + _checkNoWard(TRANCHE_JTRSY, address(ROOT_V2), "ROOT_V2 denied from JTRSY"); + _checkNoWard(TRANCHE_JAAA, address(ROOT_V2), "ROOT_V2 denied from JAAA"); + } + } +} From bf6c5ff0f9a35a26c88dd559f8858f2ccd42b963 Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Mon, 8 Jun 2026 14:49:05 +0200 Subject: [PATCH 8/8] ref: remove V2Cleaning fully --- script/spell/V2Cleanings.s.sol | 35 --- script/spell/test-v2cleanings-fork.sh | 140 ----------- src/spell/V2CleaningsSpell.sol | 220 ------------------ test/integration/spell/V2Cleanings.t.sol | 151 ------------ .../ExampleSpellValidatorTest.t.sol | 67 ++++++ .../spell/utils/SpellRegressionTest.sol | 2 +- .../spell/utils/validation/README.md | 21 +- .../utils/validation/ValidationExecutor.sol | 2 +- .../spell/v2-cleanings/V2CleaningsCast.sol | 56 ----- .../V2CleaningsValidatorTest.t.sol | 58 ----- .../validators/Validate_V2Cleanings.sol | 178 -------------- 11 files changed, 78 insertions(+), 852 deletions(-) delete mode 100644 script/spell/V2Cleanings.s.sol delete mode 100755 script/spell/test-v2cleanings-fork.sh delete mode 100644 src/spell/V2CleaningsSpell.sol delete mode 100644 test/integration/spell/V2Cleanings.t.sol create mode 100644 test/integration/spell/example-spell/ExampleSpellValidatorTest.t.sol delete mode 100644 test/integration/spell/v2-cleanings/V2CleaningsCast.sol delete mode 100644 test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol delete mode 100644 test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol diff --git a/script/spell/V2Cleanings.s.sol b/script/spell/V2Cleanings.s.sol deleted file mode 100644 index b18975b73..000000000 --- a/script/spell/V2Cleanings.s.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; - -import "forge-std/Script.sol"; - -import {V2CleaningsSpell} from "../../src/spell/V2CleaningsSpell.sol"; - -contract V2CleaningsDeployer is Script { - function run() external { - vm.startBroadcast(); - - new V2CleaningsSpell(); - - vm.stopBroadcast(); - } -} - -contract V2CleaningsExecutor is Script { - bytes32 constant NEW_VERSION = "v3.1"; - address deployer; - - function run(V2CleaningsSpell spell) external { - vm.startBroadcast(); - - migrate(spell); - - vm.stopBroadcast(); - } - - function migrate(V2CleaningsSpell spell) public { - vm.label(address(spell), "V2CleaningsSpell"); - - spell.cast(); - } -} diff --git a/script/spell/test-v2cleanings-fork.sh b/script/spell/test-v2cleanings-fork.sh deleted file mode 100755 index 58d8abf37..000000000 --- a/script/spell/test-v2cleanings-fork.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Example of different runs: -# ./script/spell/test-v2cleanings-fork.sh ethereum -# ./script/spell/test-v2cleanings-fork.sh base -# ./script/spell/test-v2cleanings-fork.sh arbitrum -# -# Only requirements is to have ALCHEMY_API_KEY in the .env file - -export NETWORK=$1 - -ALCHEMY_API_KEY=$(grep -E '^ALCHEMY_API_KEY=' .env | cut -d= -f2-) -BASE_RPC_URL=$(jq -r '.network.baseRpcUrl' env/"$NETWORK".json) -REMOTE_RPC_URL="${BASE_RPC_URL}${ALCHEMY_API_KEY}" - -PROTOCOL_GUARDIAN="0xCEb7eD5d5B3bAD3088f6A1697738B60d829635c6" -GUARDIAN_V2_ETHEREUM_OR_ARBITRUM="0x09ab10a9c3E6Eac1d18270a2322B6113F4C7f5E8"; -GUARDIAN_V2_BASE="0x427A1ce127b1775e4Cbd4F58ad468B9F832eA7e9"; - -PROTOCOL_ADMIN=$(jq -r '.network.protocolAdmin' env/"$NETWORK".json) -ADMIN_V2_ETHEREUM="0xD9D30ab47c0f096b0AA67e9B8B1624504a63e7FD" -ADMIN_V2_BASE="0x8b83962fB9dB346a20c95D98d4E312f17f4C0d9b" -ADMIN_V2_ARBITRUM="0xa36caE0ACd40C6BbA61014282f6AE51c7807A433" - -ANY="0x1234567890000000000000000000000000000000" - -case "$NETWORK" in - ethereum) - GUARDIAN_V2=$GUARDIAN_V2_ETHEREUM_OR_ARBITRUM - ADMIN_V2=$ADMIN_V2_ETHEREUM - ;; - base) - GUARDIAN_V2=$GUARDIAN_V2_BASE - ADMIN_V2=$ADMIN_V2_BASE - ;; - arbitrum) - GUARDIAN_V2=$GUARDIAN_V2_ETHEREUM_OR_ARBITRUM - ADMIN_V2=$ADMIN_V2_ARBITRUM - ;; -esac - -echo "" -echo "##########################################################################" -echo "# STEP 0: Start anvil in fork mode" -echo "##########################################################################" -echo "" - -anvil --fork-url "$REMOTE_RPC_URL" & -ANVIL_PID=$! -trap "kill $ANVIL_PID" EXIT - -LOCAL_RPC_URL="http://127.0.0.1:8545" #anvil -sleep 3.0 # Wait ensuring Anvil is up - -mock_addr() { - cast rpc anvil_impersonateAccount "$1" \ - --rpc-url "$LOCAL_RPC_URL" - - cast rpc anvil_setBalance "$1" $(cast --to-hex 1000000000000000000000) \ - --rpc-url "$LOCAL_RPC_URL" -} - -mock_addr "$PROTOCOL_ADMIN" -mock_addr "$ADMIN_V2" -mock_addr "$ANY" - -CHAIN_ID=$(cast chain-id --rpc-url "$LOCAL_RPC_URL") -ROOT_V3=$(cast call $PROTOCOL_GUARDIAN "root()(address)" --rpc-url "$LOCAL_RPC_URL") -ROOT_V2=$(cast call $GUARDIAN_V2 "root()(address)" --rpc-url "$LOCAL_RPC_URL") - -echo "" -echo "##########################################################################" -echo "# STEP 1: Deploy spell" -echo "##########################################################################" -echo "" - -forge script script/spell/V2Cleanings.s.sol:V2CleaningsDeployer \ - --optimize \ - --rpc-url "$LOCAL_RPC_URL" \ - --unlocked --sender "$ANY" \ - --broadcast - -SPELL=$(jq -r '.transactions[] | select(.contractName=="V2CleaningsSpell") | .contractAddress' \ - broadcast/V2Cleanings.s.sol/"$CHAIN_ID"/run-latest.json) - -echo "" -echo "##########################################################################" -echo "# STEP 2: Request root permissions to the spell" -echo "##########################################################################" -echo "" - -cast send $PROTOCOL_GUARDIAN "scheduleRely(address)" "$SPELL" \ - --rpc-url "$LOCAL_RPC_URL" \ - --unlocked --from "$PROTOCOL_ADMIN" - -cast send $GUARDIAN_V2 "scheduleRely(address)" "$SPELL" \ - --rpc-url "$LOCAL_RPC_URL" \ - --unlocked --from "$ADMIN_V2" - -echo "" -echo "##########################################################################" -echo "# INTERLUDE: Mock passing 48 hours" -echo "##########################################################################" -echo "" - -# As a mocked process to skip 48 hours of delay -cast rpc evm_increaseTime 172800 \ - --rpc-url $LOCAL_RPC_URL \ - -# Mine a new block to set the new timestamp -cast rpc evm_mine \ - --rpc-url $LOCAL_RPC_URL - -echo "" -echo "##########################################################################" -echo "# STEP 3: Get root permissions to the spell" -echo "##########################################################################" -echo "" - -cast send "$ROOT_V3" "executeScheduledRely(address)" "$SPELL" \ - --rpc-url "$LOCAL_RPC_URL" \ - --unlocked --from "$ANY" - -cast send "$ROOT_V2" "executeScheduledRely(address)" "$SPELL" \ - --rpc-url "$LOCAL_RPC_URL" \ - --unlocked --from "$ANY" - -echo "" -echo "##########################################################################" -echo "# STEP 4: Run migration" -echo "##########################################################################" -echo "" - -forge script script/spell/V2Cleanings.s.sol:V2CleaningsExecutor \ - --sig "run(address)" "$SPELL" \ - --optimize \ - --rpc-url "$LOCAL_RPC_URL" \ - --unlocked --sender "$ANY" \ - --broadcast diff --git a/src/spell/V2CleaningsSpell.sol b/src/spell/V2CleaningsSpell.sol deleted file mode 100644 index ce9fb05bd..000000000 --- a/src/spell/V2CleaningsSpell.sol +++ /dev/null @@ -1,220 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; - -import {IAuth} from "../misc/interfaces/IAuth.sol"; -import {IERC20} from "../misc/interfaces/IERC20.sol"; - -import {Root} from "../admin/Root.sol"; - -// V2 Root. refs: -// https://github.com/centrifuge/vaults-internal/blob/5e8262b26f8b3b488fa11855bfc2ee2e9943c09c/deployments/mainnet/ethereum-mainnet.json#L29 -// https://etherscan.io/address/0x0C1fDfd6a1331a875EA013F3897fc8a76ada5DfC -Root constant ROOT_V2 = Root(0x0C1fDfd6a1331a875EA013F3897fc8a76ada5DfC); - -// V3 root on the three V2 networks ETH, BASE, ARB on which the spell is cast. -// ref: env/{ethereum,base,arbitrum}.json contracts.root + -// https://github.com/centrifuge/documentation/blob/main/docs/developer/protocol/deployments/index.mdx -Root constant ROOT_V3 = Root(0x7Ed48C31f2fdC40d37407cBaBf0870B2b688368f); - -// v3.1.0 protocol contracts. ref for all five: env/{ethereum,base,arbitrum}.json (contracts.*) + -// https://github.com/centrifuge/documentation/blob/main/docs/developer/protocol/deployments/index.mdx -address constant CONTRACT_UPDATER = 0x3B150B19245D2C366bc8f18c775b725DFB298F71; -address constant FREEZE_ONLY_HOOK = 0xd5B243F05b2906F1f6C80c6096945faADa0731C1; -address constant FULL_RESTRICTIONS_HOOK = 0x8E680873b4C77e6088b4Ba0aBD59d100c3D224a4; -address constant FREELY_TRANSFERABLE_HOOK = 0x2a9B9C14851Baf7AD19f26607C9171CA1E7a1A61; -address constant REDEMPTION_RESTRICTIONS_HOOK = 0xE5423eD8602Fa0F263e17b6212d88Efe42317f06; - -// CFG token (CREATE3 vanity), described as "newCFG" in Slack. -// refs: https://kflabs.slack.com/archives/C072WRU6V6K/p1747225567523339 -// https://etherscan.io/address/0xcccCCCcCCC33D538DBC2EE4fEab0a7A1FF4e8A94 -address constant CFG = 0xcccCCCcCCC33D538DBC2EE4fEab0a7A1FF4e8A94; -// Wrapped CFG (Ethereum only), described as "legacyCfg" in Slack. -// refs: https://kflabs.slack.com/archives/C072WRU6V6K/p1747225567523339 -// https://etherscan.io/address/0xc221b7E65FfC80DE234bbB6667aBDd46593D34F0 -address constant WCFG = 0xc221b7E65FfC80DE234bbB6667aBDd46593D34F0; -// Legacy v2 WCFG admin multisig. -// refs: https://kflabs.slack.com/archives/C04JQ1QCGQ0/p1755074130455079 -// https://etherscan.io/address/0x3C9D25F2C76BFE63485AE25D524F7f02f2C03372 -address constant WCFG_MULTISIG = 0x3C9D25F2C76BFE63485AE25D524F7f02f2C03372; -// ChainBridge ERC20 handler (v2 bridge). -// refs: https://kflabs.slack.com/archives/C04JQ1QCGQ0/p1755074130455079 -// https://etherscan.io/address/0x84D1e77F472a4aA697359168C4aF4ADD4D2a71fa -address constant CHAINBRIDGE_ERC20_HANDLER = 0x84D1e77F472a4aA697359168C4aF4ADD4D2a71fa; -// CREATE3 proxy used for cross-chain CFG. -// ref: https://kflabs.slack.com/archives/C04JQ1QCGQ0/p1755074130455079 -address constant CREATE3_PROXY = 0x28E6eED839a5E03D92f7A5C459430576081fadFb; -// IOU CFG, redeemed 1:1 for CFG. -// refs: https://kflabs.slack.com/archives/C072WRU6V6K/p1747730612516909 -// https://etherscan.io/address/0xACF3c07BeBd65d5f7d86bc0bc716026A0C523069 -address constant IOU_CFG = 0xACF3c07BeBd65d5f7d86bc0bc716026A0C523069; -// CFG_MINTER — permanent mint authority on CFG (Ethereum) for treasury inflation. -// ref: https://kflabs.slack.com/archives/C07PG2EUR9C/p1772545104197419 -address constant CFG_MINTER = 0x50a168Cd6957e07B6dE6C1A99B2f940475f70dEf; - -// V2 share tokens ("tranches"). ref: -// https://www.notion.so/v2-Pools-ShareClass-Ids-ShareTokens-2312eac24e17808cb72bfeb208f594ec -address constant TRANCHE_JTRSY = 0x8c213ee79581Ff4984583C6a801e5263418C4b86; // ETH_JTRSY, BASE_JTRSY, ARBITRUM_JTRSY -address constant TRANCHE_JAAA = 0x5a0F93D040De44e78F251b03c43be9CF317Dcf64; // ETH_JAAA, BASE_JAAA -// ref: Slack https://kflabs.slack.com/archives/C05S1JXD37U/p1747228055896649 (V2 USDC escrow, CREATE3 vanity) -address constant ESCROW_V2 = 0x0000000005F458Fd6ba9EEb5f365D83b7dA913dD; - -// V2 vault addresses per chain — these remain as wards on share tokens and must be explicitly removed. -// ref: https://www.notion.so/v2-Pools-ShareClass-Ids-ShareTokens-2312eac24e17808cb72bfeb208f594ec -address constant ETH_V2_JTRSY_VAULT = 0x36036fFd9B1C6966ab23209E073c68Eb9A992f50; -address constant ETH_V2_JAAA_VAULT = 0xE9d1f733F406D4bbbDFac6D4CfCD2e13A6ee1d01; -address constant BASE_V2_JTRSY_VAULT = 0xF9a6768034280745d7F303D3d8B7f2bF3Cc079eF; -address constant BASE_V2_JAAA_VAULT = 0xB4C8540657d67D4846cAe68EcfE2C706c80DC3c9; -address constant ARB_V2_JTRSY_VAULT = 0x16C796208c6E2d397Ec49D69D207a9cB7d072f04; -// TODO(jeroen/frederik): confirm role before cast. Jakob (head of accounting) described this as the -// "USDC funding address for WL customers to pay upfront/ongoing fees" -// (https://kflabs.slack.com/archives/C077QU14E31/p1779259744073899), but other Slack threads reference -// the same address as an investor. The two roles are incompatible — receiving drained V2-escrow USDC -// here while it also makes V3 investments could distort accounting. BLOCKING. -address constant TREASURY = 0xb3DacC732509Ba6B7F25Ad149e56cA44fE901AB9; - -// Native Circle USDC. ref: https://developers.circle.com/stablecoins/usdc-contract-addresses -IERC20 constant USDC_ETHEREUM = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); -IERC20 constant USDC_BASE = IERC20(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913); -IERC20 constant USDC_ARBITRUM = IERC20(0xaf88d065e77c8cC2239327C5EDb3A432268e5831); - -// ref: Slack #wg-new-cfg-token https://kflabs.slack.com/archives/C082ABDBELS/p1750237297617879 -// TODO(jeroen): formal sign-off — irreversible CFG mint recipient (Ethereum only). -address constant CNF_TREASURY_WALLET = 0xD052A46b8e0C89fAcB393805E1917AfD20f293Cb; - -// Centrifuge Chain CFG amount derivation: -// Total issuance on Centrifuge Chain: 578_423_119_298_517_443_782_165_480 -// Minus migrated CFG (4dpEcgqFp38bzzT5VruKgbfVs6zcDMFRKUetx3fz2ndLRHMu): -350_860_057_533_953_490_820_324_585 -// Minus chainbridge (4dpEcgqFp8UL6eA3b7hhtdj7qftHRZE7g1uadHyuw1WSNSgH): -192_726_451_002_520_311_526_501_638 -// = Remaining CFG on Centrifuge Chain: 34_836_610_762_043_641_435_339_257 -uint256 constant CENTRIFUGE_CHAIN_CFG_AMOUNT = 34_836_610_762_043_641_435_339_257; - -// See env/ethereum.json network.chainId -uint256 constant ETHEREUM_CHAIN_ID = 1; -// See env/base.json network.chainId -uint256 constant BASE_CHAIN_ID = 8453; -// See env/arbitrum.json network.chainId -uint256 constant ARBITRUM_CHAIN_ID = 42161; - -interface CFGTokenLike { - function mint(address to, uint256 value) external; -} - -interface EscrowV2Like is IAuth { - function approveMax(address token, address spender) external; -} - -contract V2CleaningsSpell { - bool public done; - string public constant description = "Pending cleanings from V2"; - - function cast() external { - require(!done, "Spell already executed"); - done = true; - - _updateCFGWards(); - _disableRootV2FromShareTokensV2(); - _moveFundsFromEscrowToTreasury(); - _relyContractUpdaterOnHooks(); - _mintCFGToTreasury(); - - if (address(ROOT_V2).code.length > 0) { - ROOT_V2.deny(address(this)); - } - ROOT_V3.deny(address(this)); - } - - function _updateCFGWards() internal { - // Check if CFG exists - if (CFG.code.length > 0) { - // Mainnet CFG only has the v2 root relied, need to replace with v3 root - if (block.chainid == ETHEREUM_CHAIN_ID) { - if (address(ROOT_V2).code.length > 0) { - ROOT_V2.relyContract(CFG, address(ROOT_V3)); - ROOT_V2.relyContract(CFG, CFG_MINTER); - ROOT_V3.denyContract(CFG, address(ROOT_V2)); - } - ROOT_V3.denyContract(CFG, IOU_CFG); - } - - // Deny CREATE3 proxy on new chains - if (block.chainid != ETHEREUM_CHAIN_ID) { - ROOT_V3.denyContract(CFG, CREATE3_PROXY); - } - } - - // Check if WCFG exists (only in Ethereum) - if (WCFG.code.length > 0) { - if (address(ROOT_V2).code.length > 0) { - ROOT_V2.relyContract(WCFG, address(ROOT_V3)); - ROOT_V3.denyContract(WCFG, address(ROOT_V2)); - } - ROOT_V3.denyContract(WCFG, WCFG_MULTISIG); - ROOT_V3.denyContract(WCFG, CHAINBRIDGE_ERC20_HANDLER); - } - } - - function _disableRootV2FromShareTokensV2() internal { - if (block.chainid == ETHEREUM_CHAIN_ID) { - _denyV2FromShareToken(TRANCHE_JTRSY, ETH_V2_JTRSY_VAULT); - _denyV2FromShareToken(TRANCHE_JAAA, ETH_V2_JAAA_VAULT); - } else if (block.chainid == BASE_CHAIN_ID) { - _denyV2FromShareToken(TRANCHE_JTRSY, BASE_V2_JTRSY_VAULT); - _denyV2FromShareToken(TRANCHE_JAAA, BASE_V2_JAAA_VAULT); - } else if (block.chainid == ARBITRUM_CHAIN_ID) { - _denyV2FromShareToken(TRANCHE_JTRSY, ARB_V2_JTRSY_VAULT); - } - } - - // V2_ROOT is ward of these V2 vaults, so a V2_ROOT compromise could exploit the - // vault's remaining ward access on the share token via authTransferFrom. - function _denyV2FromShareToken(address shareToken, address v2Vault) internal { - IAuth shareToken_ = IAuth(shareToken); - - if (address(shareToken_).code.length > 0 && shareToken_.wards(address(ROOT_V3)) == 1) { - ROOT_V3.relyContract(shareToken, address(this)); - if (shareToken_.wards(address(ROOT_V2)) == 1) shareToken_.deny(address(ROOT_V2)); - if (shareToken_.wards(v2Vault) == 1) shareToken_.deny(v2Vault); - ROOT_V3.denyContract(shareToken, address(this)); - } - } - - function _moveFundsFromEscrowToTreasury() internal { - if (ESCROW_V2.code.length > 0) { - IERC20 usdc; - if (block.chainid == ETHEREUM_CHAIN_ID) { - usdc = USDC_ETHEREUM; - } else if (block.chainid == BASE_CHAIN_ID) { - usdc = USDC_BASE; - } else if (block.chainid == ARBITRUM_CHAIN_ID) { - usdc = USDC_ARBITRUM; - } else { - return; - } - - ROOT_V2.relyContract(address(ESCROW_V2), address(this)); - - EscrowV2Like(ESCROW_V2).approveMax(address(usdc), address(this)); - usdc.transferFrom(ESCROW_V2, TREASURY, usdc.balanceOf(ESCROW_V2)); - - ROOT_V2.denyContract(address(ESCROW_V2), address(this)); - } - } - - function _relyContractUpdaterOnHooks() internal { - ROOT_V3.relyContract(FREEZE_ONLY_HOOK, CONTRACT_UPDATER); - ROOT_V3.relyContract(FULL_RESTRICTIONS_HOOK, CONTRACT_UPDATER); - ROOT_V3.relyContract(FREELY_TRANSFERABLE_HOOK, CONTRACT_UPDATER); - ROOT_V3.relyContract(REDEMPTION_RESTRICTIONS_HOOK, CONTRACT_UPDATER); - } - - function _mintCFGToTreasury() internal { - if (block.chainid == ETHEREUM_CHAIN_ID) { - // Subtract wCFG balance held by the IOU_CFG contract, since those were already - // redeemed 1:1 for CFG and the wCFG total supply was not reduced upon redemption. - uint256 amount = IERC20(WCFG).totalSupply() - IERC20(WCFG).balanceOf(IOU_CFG) + CENTRIFUGE_CHAIN_CFG_AMOUNT; - ROOT_V3.relyContract(CFG, address(this)); - CFGTokenLike(CFG).mint(CNF_TREASURY_WALLET, amount); - ROOT_V3.denyContract(CFG, address(this)); - } - } -} diff --git a/test/integration/spell/V2Cleanings.t.sol b/test/integration/spell/V2Cleanings.t.sol deleted file mode 100644 index 6a649c079..000000000 --- a/test/integration/spell/V2Cleanings.t.sol +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; - -import {V2CleaningsCast} from "./v2-cleanings/V2CleaningsCast.sol"; - -import {IAuth} from "../../../src/misc/interfaces/IAuth.sol"; - -import {Root} from "../../../src/admin/Root.sol"; - -import {Env, EnvConfig} from "../../../script/utils/EnvConfig.s.sol"; - -import {Test} from "forge-std/Test.sol"; - -import { - V2CleaningsSpell, - ROOT_V2, - CONTRACT_UPDATER, - FREEZE_ONLY_HOOK, - FULL_RESTRICTIONS_HOOK, - FREELY_TRANSFERABLE_HOOK, - REDEMPTION_RESTRICTIONS_HOOK, - CFG, - CFG_MINTER, - WCFG, - WCFG_MULTISIG, - CHAINBRIDGE_ERC20_HANDLER, - CREATE3_PROXY, - IOU_CFG, - ETHEREUM_CHAIN_ID, - BASE_CHAIN_ID, - ARBITRUM_CHAIN_ID, - TRANCHE_JAAA, - TRANCHE_JTRSY, - ETH_V2_JTRSY_VAULT, - ETH_V2_JAAA_VAULT, - BASE_V2_JTRSY_VAULT, - BASE_V2_JAAA_VAULT, - ARB_V2_JTRSY_VAULT, - ESCROW_V2 -} from "../../../src/spell/V2CleaningsSpell.sol"; - -/// @title V2CleaningsSpellTest -/// @notice Focused, forked correctness proof for the V2Cleanings spell: deploy + -/// cast on a live fork, then assert the exact absolute post-state the -/// spell guarantees (ward flips on the roots, CFG/WCFG/tranche tokens, -/// denied V2 vaults, and hook wards). -/// -/// @dev The before/after DELTA assertions (USDC sweep, CFG mint) inherently -/// need a pre-cast snapshot, so they live in the cached validators -/// (`v2-cleanings/validators/Validate_V2Cleanings.sol`) exercised by -/// `V2CleaningsValidatorTest`, not here. This test is the exhaustive -/// absolute-state proof; the validators are the reusable env check. -/// -/// @dev The spell hardcodes ROOT_V3 = 0x7Ed48C31..., which is the V3 root only -/// on ETH/BASE/ARB (and incidentally Avalanche/BNB/Plume). On -/// Optimism/HyperEVM/Monad the V3 root lives at 0xdc9456e7e..., so the -/// spell's tail-end ROOT_V3.relyContract/deny calls revert there. The -/// spell's own doc scopes it to "ETH, BASE, ARB" — mirrored here. If the -/// spell is ever generalised (e.g. taking `Root` as a cast() argument), -/// restore the other six network methods. -contract V2CleaningsSpellTest is Test { - V2CleaningsSpell internal _spell; - - function testV2CleaningsEthereumMainnet() external { - _run("ethereum"); - } - - function testV2CleaningsBaseMainnet() external { - _run("base"); - } - - function testV2CleaningsArbitrumMainnet() external { - _run("arbitrum"); - } - - function _run(string memory network) internal { - EnvConfig memory config = Env.load(network); - vm.createSelectFork(config.network.rpcUrl()); - - _spell = V2CleaningsCast.deployAndCast(config); - - emit log_named_string("V2Cleanings post-assertions", network); - assertTrue(_spell.done()); - - Root rootV3 = Root(config.contracts.root); - _assertRootAndSpellWards(rootV3); - _assertTokenWards(rootV3); - _assertV2VaultsDeniedFromShareTokens(); - _assertHookWards(); - } - - function _assertRootAndSpellWards(Root rootV3) internal view { - if (address(ROOT_V2).code.length > 0) { - assertEq(ROOT_V2.wards(address(_spell)), 0); - } - assertEq(rootV3.wards(address(_spell)), 0); - - if (ESCROW_V2.code.length > 0) { - assertEq(IAuth(ESCROW_V2).wards(address(_spell)), 0); - } - } - - function _assertTokenWards(Root rootV3) internal view { - if (CFG.code.length > 0) { - assertEq(IAuth(CFG).wards(address(rootV3)), 1); - assertEq(IAuth(CFG).wards(CREATE3_PROXY), 0); - } - - if (block.chainid == ETHEREUM_CHAIN_ID) { - assertEq(IAuth(WCFG).wards(address(rootV3)), 1); - assertEq(IAuth(WCFG).wards(WCFG_MULTISIG), 0); - assertEq(IAuth(WCFG).wards(CHAINBRIDGE_ERC20_HANDLER), 0); - assertEq(IAuth(WCFG).wards(address(ROOT_V2)), 0); - - assertEq(IAuth(CFG).wards(address(ROOT_V2)), 0); - assertEq(IAuth(CFG).wards(IOU_CFG), 0); - assertEq(IAuth(CFG).wards(CFG_MINTER), 1); - } - - if (TRANCHE_JTRSY.code.length > 0) { - assertEq(IAuth(TRANCHE_JTRSY).wards(address(rootV3)), 1); - assertEq(IAuth(TRANCHE_JTRSY).wards(address(ROOT_V2)), 0); - assertEq(IAuth(TRANCHE_JTRSY).wards(address(_spell)), 0); - } - - if (block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID) { - assertEq(IAuth(TRANCHE_JAAA).wards(address(rootV3)), 1); - assertEq(IAuth(TRANCHE_JAAA).wards(address(ROOT_V2)), 0); - assertEq(IAuth(TRANCHE_JAAA).wards(address(_spell)), 0); - } - } - - function _assertV2VaultsDeniedFromShareTokens() internal view { - if (block.chainid == ETHEREUM_CHAIN_ID) { - assertEq(IAuth(TRANCHE_JTRSY).wards(ETH_V2_JTRSY_VAULT), 0); - assertEq(IAuth(TRANCHE_JAAA).wards(ETH_V2_JAAA_VAULT), 0); - } else if (block.chainid == BASE_CHAIN_ID) { - assertEq(IAuth(TRANCHE_JTRSY).wards(BASE_V2_JTRSY_VAULT), 0); - assertEq(IAuth(TRANCHE_JAAA).wards(BASE_V2_JAAA_VAULT), 0); - } else if (block.chainid == ARBITRUM_CHAIN_ID) { - assertEq(IAuth(TRANCHE_JTRSY).wards(ARB_V2_JTRSY_VAULT), 0); - } - } - - function _assertHookWards() internal view { - assertEq(IAuth(FREEZE_ONLY_HOOK).wards(CONTRACT_UPDATER), 1); - assertEq(IAuth(FULL_RESTRICTIONS_HOOK).wards(CONTRACT_UPDATER), 1); - assertEq(IAuth(FREELY_TRANSFERABLE_HOOK).wards(CONTRACT_UPDATER), 1); - assertEq(IAuth(REDEMPTION_RESTRICTIONS_HOOK).wards(CONTRACT_UPDATER), 1); - } -} diff --git a/test/integration/spell/example-spell/ExampleSpellValidatorTest.t.sol b/test/integration/spell/example-spell/ExampleSpellValidatorTest.t.sol new file mode 100644 index 000000000..7978082ce --- /dev/null +++ b/test/integration/spell/example-spell/ExampleSpellValidatorTest.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import {Validate_PreExample, Validate_CacheExample, Validate_PostExample} from "./validators/Validate_Example.sol"; + +import {EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; + +import {BaseValidator} from "../utils/validation/BaseValidator.sol"; +import {SpellRegressionTest} from "../utils/SpellRegressionTest.sol"; + +/// @title ExampleSpellValidatorTest +/// @notice Copy-paste template AND end-to-end smoke test for the +/// `SpellRegressionTest` environment-regression harness. It wires every +/// hook (the default 8 structural validators, the example +/// pre/cache/post validators, and the on-by-default investment-flow +/// diff) and runs the full pre-cast -> cast -> post-cast pipeline. +/// +/// @dev `_castSpell` is intentionally a no-op here: with nothing cast, the +/// pre/post state is identical, so the structural diff and the flow diff +/// both report zero regressions. That proves the orchestration wiring +/// end-to-end without a real spell. A real spell replaces `_castSpell` +/// with its deploy + guardian relies + `cast()` (via a shared `Cast` +/// library) and swaps the example validators for spell-specific ones. +/// See `test/integration/spell/utils/validation/README.md`. +contract ExampleSpellValidatorTest is SpellRegressionTest { + function _networks() internal pure override returns (string[] memory networks) { + networks = new string[](1); + networks[0] = "ethereum"; + } + + function _executorName() internal pure override returns (string memory) { + return "example-spell"; + } + + /// @dev No-op: a real spell deploys + relies + casts here, e.g. + /// `MySpellCast.deployAndCast(config);`. + function _castSpell( + string memory, + /* network */ + EnvConfig memory /* config */ + ) + internal + override + {} + + function _preValidators() internal override returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](1); + validators[0] = new Validate_PreExample(); + } + + function _cacheValidators() internal override returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](1); + validators[0] = new Validate_CacheExample(); + } + + function _postValidators() internal override returns (BaseValidator[] memory validators) { + validators = new BaseValidator[](1); + validators[0] = new Validate_PostExample(); + } + + // Investment-flow regression is on by default. A spell that does not touch + // vaults (e.g. an adapter rewiring) opts out: + // + // function _runInvestmentFlowsDiff() internal pure override returns (bool) { + // return false; + // } +} diff --git a/test/integration/spell/utils/SpellRegressionTest.sol b/test/integration/spell/utils/SpellRegressionTest.sol index bd0156014..52dd22a17 100644 --- a/test/integration/spell/utils/SpellRegressionTest.sol +++ b/test/integration/spell/utils/SpellRegressionTest.sol @@ -46,7 +46,7 @@ abstract contract SpellRegressionTest is FlowRegression { /// @notice Networks to run the regression against (e.g. ["ethereum", "base"]). function _networks() internal view virtual returns (string[] memory); - /// @notice Cache namespace, shared by the pre/post executors (e.g. "v2cleanings"). + /// @notice Cache namespace, shared by the pre/post executors (e.g. "myspell"). function _executorName() internal pure virtual returns (string memory); /// @notice Deploy the spell, schedule any required relies, and cast it. diff --git a/test/integration/spell/utils/validation/README.md b/test/integration/spell/utils/validation/README.md index f6c1221c8..f409ffee8 100644 --- a/test/integration/spell/utils/validation/README.md +++ b/test/integration/spell/utils/validation/README.md @@ -14,18 +14,15 @@ test/integration/spell/ │ ├── ValidationExecutor.sol # Runs validators, reports, regression diff │ ├── TestContracts.sol # TestContracts struct + factory functions │ └── InvestmentFlowExecutor.sol # Investment flow execution for fork tests -├── example-spell/ # Example: PRE/cache/POST wiring -│ ├── ExampleTest.t.sol -│ └── validators/ -│ └── Validate_Example.sol -├── V2Cleanings.t.sol # Focused spell test (absolute post-state proof) -└── v2-cleanings/ # Env regression for the same spell - ├── V2CleaningsCast.sol # Shared deploy + rely + cast() preamble - ├── V2CleaningsValidatorTest.t.sol # extends SpellRegressionTest +└── example-spell/ # Reference template for a new spell + ├── ExampleTest.t.sol # Standalone PRE/cache/POST validator wiring + ├── ExampleSpellValidatorTest.t.sol # extends SpellRegressionTest (env-regression template) └── validators/ - └── Validate_V2Cleanings.sol # Pre (soft) / Cache / Post (hard) validators + └── Validate_Example.sol # Pre / Cache / Post validators ``` +A real spell adds, alongside `example-spell/`, its own focused `Test`-derived spell test plus a `/` directory with a `Cast` cast helper, a `ValidatorTest`, and spell-specific validators. See the author guide below. + ## Validation Flow ### PRE/POST Pattern @@ -86,7 +83,7 @@ A spell ships with **two tests**: | `.t.sol` (focused) | `Test` | Exhaustive forked correctness proof: cast + absolute post-state assertions (ward flips, `done()`, …) | | `/ValidatorTest.t.sol` | `SpellRegressionTest` | Environment regression: did the spell break anything *else* on the live network? | -Both call the same shared `Cast` preamble (deploy + guardian relies + `cast()`) so they can never drift. +Both call the same shared `Cast` preamble (deploy + guardian relies + `cast()`) so they can never drift. `example-spell/ExampleSpellValidatorTest.t.sol` is the copy-paste template for the second test (with a no-op `_castSpell`); a real spell fills in the cast and the spell-specific validators. Per network, `SpellRegressionTest` runs three post-cast verification layers. All of them tolerate **pre-existing** live errors (live mainnet has known ones) and fail only on **regressions** the spell introduced: @@ -100,7 +97,7 @@ Networks run isolated from each other: a failure on one network is recorded, the 1. **Focused spell test** at `test/integration/spell/.t.sol` extending `Test`: fork, call the shared cast helper, assert the exact absolute post-state the spell guarantees. 2. **Shared cast preamble** at `test/integration/spell//Cast.sol` (library): deploy the spell, prank the guardian relies, `cast()`. -3. **Validators** at `test/integration/spell//validators/Validate_.sol`, mirroring `Validate_Example.sol` / `Validate_V2Cleanings.sol`: +3. **Validators** at `test/integration/spell//validators/Validate_.sol`, mirroring `Validate_Example.sol`: - `Validate_Pre` (soft): assert there IS work to do. - `Validate_Cache`: `ctx.cache.set(...)` the pre-cast values needed for delta checks. - `Validate_Post` (hard): read the cache, assert the deltas + absolute invariants (`_checkWard` / `_checkNoWard`). @@ -220,7 +217,7 @@ function validate(ValidationContext memory ctx) public override { } ``` -Cache files are stored under `spell-cache/validation///` with filenames derived from the key. The cache is file-backed, so values written pre-cast survive the spell cast within a test run. Plain (non-JSON) values work too: store with `vm.toString(value)` and read back with `vm.parseUint(...)` (see `Validate_CacheV2Cleanings`). +Cache files are stored under `spell-cache/validation///` with filenames derived from the key. The cache is file-backed, so values written pre-cast survive the spell cast within a test run. Plain (non-JSON) values work too: store with `vm.toString(value)` and read back with `vm.parseUint(...)`. ## JSON Parsing diff --git a/test/integration/spell/utils/validation/ValidationExecutor.sol b/test/integration/spell/utils/validation/ValidationExecutor.sol index 8479d7cb8..0a81b02f0 100644 --- a/test/integration/spell/utils/validation/ValidationExecutor.sol +++ b/test/integration/spell/utils/validation/ValidationExecutor.sol @@ -59,7 +59,7 @@ contract ValidationExecutor is Script { } /// @notice Migration-aware POST: no `latest`. Use for spells that deploy no - /// new core contracts (e.g. `V2CleaningsSpell`). `ctx.contracts.latest` + /// new core contracts (e.g. an ops/migration spell). `ctx.contracts.latest` /// is left empty so a validator that erroneously reads it gets the /// zero struct and fails loudly, rather than silently aliasing `live`. function runPostValidation(BaseValidator[] memory validators) external { diff --git a/test/integration/spell/v2-cleanings/V2CleaningsCast.sol b/test/integration/spell/v2-cleanings/V2CleaningsCast.sol deleted file mode 100644 index efe2d276a..000000000 --- a/test/integration/spell/v2-cleanings/V2CleaningsCast.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; - -import {Root} from "../../../../src/admin/Root.sol"; - -import {EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; - -import {Vm} from "forge-std/Vm.sol"; - -import { - V2CleaningsSpell, - ROOT_V2, - ROOT_V3, - ETHEREUM_CHAIN_ID, - BASE_CHAIN_ID, - ARBITRUM_CHAIN_ID -} from "../../../../src/spell/V2CleaningsSpell.sol"; - -/// @title V2CleaningsCast -/// @notice Shared cast preamble for the V2Cleanings spell. Consumed by BOTH the -/// focused spell test (`V2Cleanings.t.sol`) and the environment -/// regression test (`v2-cleanings/V2CleaningsValidatorTest.t.sol`) so the -/// deploy + two-guardian rely + `cast()` flow lives in one place and the -/// two tests can never drift. -library V2CleaningsCast { - Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); - - // Guardians holding rely rights over the V3 and V2 roots on mainnet. - address internal constant PROTOCOL_GUARDIAN_V3_1 = 0xCEb7eD5d5B3bAD3088f6A1697738B60d829635c6; - address internal constant GUARDIAN_V2_ETHEREUM_OR_ARBITRUM = 0x09ab10a9c3E6Eac1d18270a2322B6113F4C7f5E8; - address internal constant GUARDIAN_V2_BASE = 0x427A1ce127b1775e4Cbd4F58ad468B9F832eA7e9; - - /// @dev The spell hardcodes ROOT_V3 to the ETH/BASE/ARB V3 root; on those - /// chains the env's root must match or the spell's tail-end - /// relyContract/deny calls would brick. - function deployAndCast(EnvConfig memory config) internal returns (V2CleaningsSpell spell) { - Root rootV3 = Root(config.contracts.root); - - if (block.chainid == ETHEREUM_CHAIN_ID || block.chainid == BASE_CHAIN_ID || block.chainid == ARBITRUM_CHAIN_ID) - { - require(address(ROOT_V3) == address(rootV3), "V2CleaningsCast: ROOT_V3 mismatch"); - } - - spell = new V2CleaningsSpell(); - - vm.prank(PROTOCOL_GUARDIAN_V3_1); - rootV3.rely(address(spell)); // Ideally through guardian.scheduleRely() - - if (address(ROOT_V2).code.length > 0) { - vm.prank(block.chainid == BASE_CHAIN_ID ? GUARDIAN_V2_BASE : GUARDIAN_V2_ETHEREUM_OR_ARBITRUM); - ROOT_V2.rely(address(spell)); // Ideally through guardian.scheduleRely() - } - - spell.cast(); - } -} diff --git a/test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol b/test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol deleted file mode 100644 index 6021ae0e8..000000000 --- a/test/integration/spell/v2-cleanings/V2CleaningsValidatorTest.t.sol +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; - -import {V2CleaningsCast} from "./V2CleaningsCast.sol"; -import { - Validate_PreV2Cleanings, - Validate_CacheV2Cleanings, - Validate_PostV2Cleanings -} from "./validators/Validate_V2Cleanings.sol"; - -import {EnvConfig} from "../../../../script/utils/EnvConfig.s.sol"; - -import {BaseValidator} from "../utils/validation/BaseValidator.sol"; -import {SpellRegressionTest} from "../utils/SpellRegressionTest.sol"; - -/// @title V2CleaningsValidatorTest -/// @notice Environment regression test for the V2Cleanings spell. Uses the -/// shared `V2CleaningsCast` helper so the deploy+cast flow is identical -/// to the focused `V2CleaningsSpellTest`. Investment-flow regression is -/// enabled by default because the spell touches share-token wards. -contract V2CleaningsValidatorTest is SpellRegressionTest { - function _networks() internal pure override returns (string[] memory networks) { - networks = new string[](3); - networks[0] = "ethereum"; - networks[1] = "base"; - networks[2] = "arbitrum"; - } - - function _executorName() internal pure override returns (string memory) { - return "v2cleanings"; - } - - function _castSpell( - string memory, - /* network */ - EnvConfig memory config - ) - internal - override - { - V2CleaningsCast.deployAndCast(config); - } - - function _preValidators() internal override returns (BaseValidator[] memory validators) { - validators = new BaseValidator[](1); - validators[0] = new Validate_PreV2Cleanings(); - } - - function _cacheValidators() internal override returns (BaseValidator[] memory validators) { - validators = new BaseValidator[](1); - validators[0] = new Validate_CacheV2Cleanings(); - } - - function _postValidators() internal override returns (BaseValidator[] memory validators) { - validators = new BaseValidator[](1); - validators[0] = new Validate_PostV2Cleanings(); - } -} diff --git a/test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol b/test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol deleted file mode 100644 index fb551632f..000000000 --- a/test/integration/spell/v2-cleanings/validators/Validate_V2Cleanings.sol +++ /dev/null @@ -1,178 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.28; - -import {IERC20} from "../../../../../src/misc/interfaces/IERC20.sol"; - -import {BaseValidator, ValidationContext} from "../../utils/validation/BaseValidator.sol"; -import { - ROOT_V2, - CFG, - WCFG, - IOU_CFG, - ESCROW_V2, - TREASURY, - CNF_TREASURY_WALLET, - CENTRIFUGE_CHAIN_CFG_AMOUNT, - TRANCHE_JAAA, - TRANCHE_JTRSY, - USDC_ETHEREUM, - USDC_BASE, - USDC_ARBITRUM, - ETHEREUM_CHAIN_ID, - BASE_CHAIN_ID, - ARBITRUM_CHAIN_ID -} from "../../../../../src/spell/V2CleaningsSpell.sol"; - -function _usdc() view returns (IERC20) { - if (block.chainid == ETHEREUM_CHAIN_ID) return USDC_ETHEREUM; - if (block.chainid == BASE_CHAIN_ID) return USDC_BASE; - if (block.chainid == ARBITRUM_CHAIN_ID) return USDC_ARBITRUM; - return IERC20(address(0)); -} - -// Cache keys shared between the CACHE and POST validators (file-backed, survive the cast). -string constant K_ESCROW_USDC = "escrowUsdc"; -string constant K_TREASURY_USDC = "treasuryUsdc"; -string constant K_CFG_TOTAL_SUPPLY = "cfgTotalSupply"; -string constant K_CNF_TREASURY_CFG = "cnfTreasuryCfg"; - -/// @title Validate_PreV2Cleanings -/// @notice Soft pre-cast check: asserts there IS work for the spell to do. Emits -/// warnings (never hard-fails) so an unexpected pre-state surfaces in -/// the report without blocking the regression run. -contract Validate_PreV2Cleanings is BaseValidator("PreV2Cleanings") { - function validate(ValidationContext memory) public override { - // The spell denies ROOT_V2 from the CFG/WCFG/tranche tokens — so it - // should still be a ward pre-cast. _checkWard warns if it is not. - _checkWard(CFG, address(ROOT_V2), "ROOT_V2 ward on CFG (pre)"); - - if (block.chainid == ETHEREUM_CHAIN_ID) { - _checkWard(WCFG, address(ROOT_V2), "ROOT_V2 ward on WCFG (pre)"); - _checkWard(TRANCHE_JTRSY, address(ROOT_V2), "ROOT_V2 ward on JTRSY (pre)"); - _checkWard(TRANCHE_JAAA, address(ROOT_V2), "ROOT_V2 ward on JAAA (pre)"); - } - - // The spell sweeps ESCROW_V2's USDC to the treasury — so it should hold some. - IERC20 usdc = _usdc(); - if (address(usdc) != address(0) && ESCROW_V2.code.length > 0) { - if (usdc.balanceOf(ESCROW_V2) == 0) { - _errors.push( - _buildError( - "balance", "ESCROW_V2 USDC", "> 0", "0", "ESCROW_V2 holds no USDC pre-cast; sweep is a no-op" - ) - ); - } - } - } -} - -/// @title Validate_CacheV2Cleanings -/// @notice Caches the pre-cast values the POST validator needs for delta checks. -/// Values are stored as plain decimal strings (read back with vm.parseUint). -contract Validate_CacheV2Cleanings is BaseValidator("CacheV2Cleanings") { - function validate(ValidationContext memory ctx) public override { - IERC20 usdc = _usdc(); - uint256 escrowUsdc = address(usdc) != address(0) ? usdc.balanceOf(ESCROW_V2) : 0; - uint256 treasuryUsdc = address(usdc) != address(0) ? usdc.balanceOf(TREASURY) : 0; - - ctx.cache.set(K_ESCROW_USDC, vm.toString(escrowUsdc)); - ctx.cache.set(K_TREASURY_USDC, vm.toString(treasuryUsdc)); - - uint256 cfgTotalSupply = CFG.code.length > 0 ? IERC20(CFG).totalSupply() : 0; - uint256 cnfTreasuryCfg = CFG.code.length > 0 ? IERC20(CFG).balanceOf(CNF_TREASURY_WALLET) : 0; - - ctx.cache.set(K_CFG_TOTAL_SUPPLY, vm.toString(cfgTotalSupply)); - ctx.cache.set(K_CNF_TREASURY_CFG, vm.toString(cnfTreasuryCfg)); - } -} - -/// @title Validate_PostV2Cleanings -/// @notice Hard post-cast check (reverts on failure). Asserts the spell's -/// before/after deltas (USDC sweep, CFG mint) against the cached -/// pre-cast values, plus the security-critical absolute invariant that -/// the stale V2 root is no longer a ward anywhere it was denied. -contract Validate_PostV2Cleanings is BaseValidator("PostV2Cleanings") { - function validate(ValidationContext memory ctx) public override { - _assertSweep(ctx); - _assertCfgMint(ctx); - _assertV2RootDenied(); - } - - /// @dev TREASURY USDC delta == cached ESCROW_V2 balance, and ESCROW_V2 fully swept to 0. - function _assertSweep(ValidationContext memory ctx) internal { - IERC20 usdc = _usdc(); - if (address(usdc) == address(0)) return; - - uint256 preEscrow = vm.parseUint(ctx.cache.get(K_ESCROW_USDC)); - uint256 preTreasury = vm.parseUint(ctx.cache.get(K_TREASURY_USDC)); - - uint256 treasuryDelta = usdc.balanceOf(TREASURY) - preTreasury; - if (treasuryDelta != preEscrow) { - _errors.push( - _buildError( - "usdcSweep", - "TREASURY", - vm.toString(preEscrow), - vm.toString(treasuryDelta), - "Treasury USDC delta does not match swept ESCROW_V2 balance" - ) - ); - } - - uint256 escrowAfter = usdc.balanceOf(ESCROW_V2); - if (escrowAfter != 0) { - _errors.push( - _buildError("usdcSweep", "ESCROW_V2", "0", vm.toString(escrowAfter), "ESCROW_V2 USDC not fully swept") - ); - } - } - - /// @dev On ETH only: CFG totalSupply delta and CNF treasury CFG delta both - /// equal the expected mint (wCFG supply minus IOU_CFG's wCFG balance, - /// plus the Centrifuge Chain CFG amount). - function _assertCfgMint(ValidationContext memory ctx) internal { - if (block.chainid != ETHEREUM_CHAIN_ID || CFG.code.length == 0) return; - - uint256 expectedMint = - IERC20(WCFG).totalSupply() - IERC20(WCFG).balanceOf(IOU_CFG) + CENTRIFUGE_CHAIN_CFG_AMOUNT; - - uint256 supplyDelta = IERC20(CFG).totalSupply() - vm.parseUint(ctx.cache.get(K_CFG_TOTAL_SUPPLY)); - if (supplyDelta != expectedMint) { - _errors.push( - _buildError( - "cfgMint", - "CFG.totalSupply", - vm.toString(expectedMint), - vm.toString(supplyDelta), - "CFG total supply delta does not match expected mint" - ) - ); - } - - uint256 treasuryDelta = - IERC20(CFG).balanceOf(CNF_TREASURY_WALLET) - vm.parseUint(ctx.cache.get(K_CNF_TREASURY_CFG)); - if (treasuryDelta != expectedMint) { - _errors.push( - _buildError( - "cfgMint", - "CNF_TREASURY CFG", - vm.toString(expectedMint), - vm.toString(treasuryDelta), - "CNF treasury CFG delta does not match expected mint" - ) - ); - } - } - - /// @dev Security-critical reusable env invariant: the stale V2 root must no - /// longer be a ward on the tokens the spell denied it from. - function _assertV2RootDenied() internal { - _checkNoWard(CFG, address(ROOT_V2), "ROOT_V2 denied from CFG"); - - if (block.chainid == ETHEREUM_CHAIN_ID) { - _checkNoWard(WCFG, address(ROOT_V2), "ROOT_V2 denied from WCFG"); - _checkNoWard(TRANCHE_JTRSY, address(ROOT_V2), "ROOT_V2 denied from JTRSY"); - _checkNoWard(TRANCHE_JAAA, address(ROOT_V2), "ROOT_V2 denied from JAAA"); - } - } -}