Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions env/connections/mainnet.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
"chains": [["monad"], ["plume", "hyper-evm", "pharos"]],
"adapters": ["layerZero"],
"threshold": 1
},
{
"chains": [["pharos"], ["ethereum"]],
"adapters": ["layerZero", "chainlink"],
"threshold": 2
}
]
}
12 changes: 3 additions & 9 deletions script/spell/V2Cleanings.s.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
}
}
153 changes: 117 additions & 36 deletions src/spell/V2CleaningsSpell.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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));
}
}

Expand All @@ -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));
}
}
}
76 changes: 5 additions & 71 deletions test/integration/fork/InvestmentFlowForkTest.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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());
Expand All @@ -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;
Expand Down
Loading
Loading