diff --git a/.env.example b/.env.example index a531937..18bef32 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Contracts admin. ADMIN= -# USDC token address. Leave empty to deploy a test one instead. -USDC= +# Rebalance caller. +REBALANCE_CALLER= # Deposits to Liquidity Hub are only allowed till this limit is reached. ASSETS_LIMIT= # Liquidity mining tiers. Multiplier will be divided by 100. So 175 will result in 1.75x. @@ -12,5 +12,5 @@ TIER_2_DAYS=180 TIER_2_MULTIPLIER=150 TIER_3_DAYS=360 TIER_3_MULTIPLIER=200 -BASETEST_PRIVATE_KEY= +BASE_SEPOLIA_PRIVATE_KEY= VERIFY=false diff --git a/.solhint.json b/.solhint.json index a768a24..48bce0d 100644 --- a/.solhint.json +++ b/.solhint.json @@ -3,6 +3,7 @@ "rules": { "max-line-length": ["off", 120], "func-visibility": ["warn", {"ignoreConstructors": true}], - "func-name-mixedcase": ["off"] + "func-name-mixedcase": ["off"], + "one-contract-per-file": ["off"] } } diff --git a/contracts/Rebalancer.sol b/contracts/Rebalancer.sol new file mode 100644 index 0000000..8b38e64 --- /dev/null +++ b/contracts/Rebalancer.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; +import {AccessControlUpgradeable} from '@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol'; +import {ERC7201Helper} from './utils/ERC7201Helper.sol'; +import {ILiquidityPool} from './interfaces/ILiquidityPool.sol'; +import {IRebalancer} from './interfaces/IRebalancer.sol'; +import {ICCTPTokenMessenger, ICCTPMessageTransmitter} from './interfaces/ICCTP.sol'; + +contract Rebalancer is IRebalancer, AccessControlUpgradeable { + using BitMaps for BitMaps.BitMap; + + ILiquidityPool immutable public LIQUIDITY_POOL; + IERC20 immutable public COLLATERAL; + ICCTPTokenMessenger immutable public CCTP_TOKEN_MESSENGER; + ICCTPMessageTransmitter immutable public CCTP_MESSAGE_TRANSMITTER; + bytes32 constant public REBALANCER_ROLE = "REBALANCER_ROLE"; + + event InitiateRebalance(uint256 amount, Domain destinationDomain, Provider provider); + event ProcessRebalance(Provider provider); + event SetRoute(Domain destinationDomain, Provider provider, bool isAllowed); + + error ZeroAddress(); + error ZeroAmount(); + error RouteDenied(); + error ProcessFailed(); + error UnsupportedDomain(); + error UnsupportedProvider(); + error InvalidLength(); + + /// @custom:storage-location erc7201:sprinter.storage.Rebalancer + struct RebalancerStorage { + BitMaps.BitMap allowedRoutes; + } + + bytes32 private constant StorageLocation = 0x81fbb040176d3bdbf3707b380997ee0038798f9e3ad0bae77fff3621ef225c00; + + constructor( + address liquidityPool, + address cctpTokenMessenger, + address cctpMessageTransmitter + ) { + ERC7201Helper.validateStorageLocation( + StorageLocation, + 'sprinter.storage.Rebalancer' + ); + if (liquidityPool == address(0)) revert ZeroAddress(); + if (cctpTokenMessenger == address(0)) revert ZeroAddress(); + if (cctpMessageTransmitter == address(0)) revert ZeroAddress(); + LIQUIDITY_POOL = ILiquidityPool(liquidityPool); + COLLATERAL = ILiquidityPool(liquidityPool).COLLATERAL(); + CCTP_TOKEN_MESSENGER = ICCTPTokenMessenger(cctpTokenMessenger); + CCTP_MESSAGE_TRANSMITTER = ICCTPMessageTransmitter(cctpMessageTransmitter); + + _disableInitializers(); + } + + function initialize( + address admin, + address rebalancer, + Domain[] memory domains, + Provider[] memory providers + ) external initializer() { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(REBALANCER_ROLE, rebalancer); + _setRoute(domains, providers, true); + } + + function setRoute( + Domain[] calldata domains, + Provider[] calldata providers, + bool isAllowed + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setRoute(domains, providers, isAllowed); + } + + function _setRoute(Domain[] memory domains, Provider[] memory providers, bool isAllowed) internal { + RebalancerStorage storage $ = _getStorage(); + require(domains.length == providers.length, InvalidLength()); + for (uint256 i = 0; i < domains.length; ++i) { + Domain domain = domains[i]; + Provider provider = providers[i]; + $.allowedRoutes.setTo(_toIndex(domain, provider), isAllowed); + emit SetRoute(domain, provider, isAllowed); + } + } + + function _toIndex(Domain domain, Provider provider) internal pure returns (uint256) { + return (uint256(domain) << 8) + uint256(provider); + } + + function isRouteAllowed(Domain domain, Provider provider) public view returns (bool) { + return _getStorage().allowedRoutes.get(_toIndex(domain, provider)); + } + + function initiateRebalance( + uint256 amount, + Domain destinationDomain, + Provider provider, + bytes calldata /*extraData*/ + ) external override onlyRole(REBALANCER_ROLE) { + require(amount > 0, ZeroAmount()); + require(isRouteAllowed(destinationDomain, provider), RouteDenied()); + + LIQUIDITY_POOL.withdraw(address(this), amount); + if (provider == Provider.CCTP) { + _initiateRebalanceCCTP(amount, destinationDomain); + } else { + // Unreachable atm, but could become so when more providers are added to enum. + revert UnsupportedProvider(); + } + + emit InitiateRebalance(amount, destinationDomain, provider); + } + + function processRebalance( + Provider provider, + bytes calldata extraData + ) external override { + if (provider == Provider.CCTP) { + _processRebalanceCCTP(extraData); + } else { + // Unreachable atm, but could become so when more providers are added to enum. + revert UnsupportedProvider(); + } + LIQUIDITY_POOL.deposit(); + + emit ProcessRebalance(provider); + } + + function _initiateRebalanceCCTP( + uint256 amount, + Domain destinationDomain + ) internal { + SafeERC20.forceApprove(COLLATERAL, address(CCTP_TOKEN_MESSENGER), amount); + CCTP_TOKEN_MESSENGER.depositForBurnWithCaller( + amount, + domainCCTP(destinationDomain), + _addressToBytes32(address(LIQUIDITY_POOL)), + address(COLLATERAL), + _addressToBytes32(address(this)) + ); + } + + function _processRebalanceCCTP( + bytes calldata extraData + ) internal { + (bytes memory message, bytes memory attestation) = abi.decode(extraData, (bytes, bytes)); + bool success = CCTP_MESSAGE_TRANSMITTER.receiveMessage(message, attestation); + require(success, ProcessFailed()); + } + + function domainCCTP(Domain destinationDomain) public pure virtual returns (uint32) { + if (false) { + // Intentional unreachable block for better code style. + return type(uint32).max; + } else if (destinationDomain == Domain.ETHEREUM) { + return 0; + } else if (destinationDomain == Domain.AVALANCHE) { + return 1; + } else if (destinationDomain == Domain.OP_MAINNET) { + return 2; + } else if (destinationDomain == Domain.ARBITRUM_ONE) { + return 3; + } else if (destinationDomain == Domain.BASE) { + return 6; + } else if (destinationDomain == Domain.POLYGON_MAINNET) { + return 7; + } else { + revert UnsupportedDomain(); + } + } + + function _addressToBytes32(address addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addr))); + } + + function _getStorage() private pure returns (RebalancerStorage storage $) { + assembly { + $.slot := StorageLocation + } + } +} diff --git a/contracts/interfaces/ICCTP.sol b/contracts/interfaces/ICCTP.sol new file mode 100644 index 0000000..39925c2 --- /dev/null +++ b/contracts/interfaces/ICCTP.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +interface ICCTPTokenMessenger { + function depositForBurnWithCaller( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller + ) external returns (uint64 nonce); +} + +interface ICCTPMessageTransmitter { + function receiveMessage(bytes calldata message, bytes calldata signature) + external + returns (bool success); +} diff --git a/contracts/interfaces/ILiquidityPool.sol b/contracts/interfaces/ILiquidityPool.sol index 11e498b..7301d8c 100644 --- a/contracts/interfaces/ILiquidityPool.sol +++ b/contracts/interfaces/ILiquidityPool.sol @@ -1,8 +1,12 @@ // SPDX-License-Identifier: LGPL-3.0-only pragma solidity 0.8.28; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; + interface ILiquidityPool { function deposit() external; function withdraw(address to, uint256 amount) external; + + function COLLATERAL() external returns (IERC20); } diff --git a/contracts/interfaces/IRebalancer.sol b/contracts/interfaces/IRebalancer.sol new file mode 100644 index 0000000..33fdf75 --- /dev/null +++ b/contracts/interfaces/IRebalancer.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +interface IRebalancer { + // Note, only extend enums at the end to maintain backward compatibility. + enum Domain { + ETHEREUM, + AVALANCHE, + OP_MAINNET, + ARBITRUM_ONE, + BASE, + POLYGON_MAINNET, + ETHEREUM_SEPOLIA, + AVALANCHE_FUJI, + OP_SEPOLIA, + ARBITRUM_SEPOLIA, + BASE_SEPOLIA, + POLYGON_AMOY + } + + enum Provider { + CCTP + } + + function initiateRebalance( + uint256 amount, + Domain destinationDomain, + Provider provider, + bytes calldata extraData + ) external; + + function processRebalance( + Provider provider, + bytes calldata extraData + ) external; +} diff --git a/contracts/testing/TestCCTP.sol b/contracts/testing/TestCCTP.sol new file mode 100644 index 0000000..5e6c59f --- /dev/null +++ b/contracts/testing/TestCCTP.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ICCTPTokenMessenger, ICCTPMessageTransmitter} from "../interfaces/ICCTP.sol"; + +interface IBurnable { + function burn(uint256 value) external; +} + +interface IMintable { + function mint(address to, uint256 value) external; +} + +contract TestCCTPTokenMessenger is ICCTPTokenMessenger { + function depositForBurnWithCaller( + uint256 amount, + uint32 /*destinationDomain*/, + bytes32 /*mintRecipient*/, + address burnToken, + bytes32 /*destinationCaller*/ + ) external override returns (uint64 nonce) { + SafeERC20.safeTransferFrom(IERC20(burnToken), msg.sender, address(this), amount); + IBurnable(burnToken).burn(amount); + return 1; + } +} + +contract TestCCTPMessageTransmitter is ICCTPMessageTransmitter { + function receiveMessage(bytes calldata message, bytes calldata signature) + external override returns (bool) + { + (address token, address to, uint256 amount) = abi.decode(message, (address, address, uint256)); + (bool isValid, bool success) = abi.decode(signature, (bool, bool)); + // solhint-disable-next-line + require(isValid); + IMintable(token).mint(to, amount); + return success; + } +} diff --git a/contracts/testing/TestLiquidityPool.sol b/contracts/testing/TestLiquidityPool.sol index edd8478..4885539 100644 --- a/contracts/testing/TestLiquidityPool.sol +++ b/contracts/testing/TestLiquidityPool.sol @@ -6,19 +6,20 @@ import {ILiquidityPool} from "../interfaces/ILiquidityPool.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; contract TestLiquidityPool is ILiquidityPool, AccessControl { - IERC20 private immutable ASSET; + IERC20 public immutable COLLATERAL; event Deposit(); - constructor(IERC20 asset) { - ASSET = asset; + constructor(IERC20 collateral) { + COLLATERAL = collateral; _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); } function deposit() external override { emit Deposit(); } + function withdraw(address to, uint256 amount) external override onlyRole(DEFAULT_ADMIN_ROLE) { - SafeERC20.safeTransfer(ASSET, to, amount); + SafeERC20.safeTransfer(COLLATERAL, to, amount); } } diff --git a/contracts/testing/TestRebalancer.sol b/contracts/testing/TestRebalancer.sol new file mode 100644 index 0000000..b02d777 --- /dev/null +++ b/contracts/testing/TestRebalancer.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {Rebalancer} from "../Rebalancer.sol"; + +contract TestRebalancer is Rebalancer { + constructor( + address liquidityPool, + address cctpTokenMessenger, + address cctpMessageTransmitter + ) Rebalancer(liquidityPool, cctpTokenMessenger, cctpMessageTransmitter) {} + + function domainCCTP(Domain destinationDomain) public pure override returns (uint32) { + if (false) { + // Intentional unreachable block for better code style. + return type(uint32).max; + } else if (destinationDomain == Domain.ETHEREUM_SEPOLIA) { + return 0; + } else if (destinationDomain == Domain.AVALANCHE_FUJI) { + return 1; + } else if (destinationDomain == Domain.OP_SEPOLIA) { + return 2; + } else if (destinationDomain == Domain.ARBITRUM_SEPOLIA) { + return 3; + } else if (destinationDomain == Domain.BASE_SEPOLIA) { + return 6; + } else if (destinationDomain == Domain.POLYGON_AMOY) { + return 7; + } else { + revert UnsupportedDomain(); + } + } +} diff --git a/contracts/testing/TestUSDC.sol b/contracts/testing/TestUSDC.sol index b0f4941..723fffe 100644 --- a/contracts/testing/TestUSDC.sol +++ b/contracts/testing/TestUSDC.sol @@ -12,4 +12,12 @@ contract TestUSDC is ERC20, ERC20Permit { function decimals() public pure override returns (uint8) { return 6; } + + function burn(uint256 amount) public { + _burn(msg.sender, amount); + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 3555d13..2087b9d 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,5 +1,6 @@ import {HardhatUserConfig} from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; +import {networkConfig, Network} from "./network.config"; function isSet(param?: string) { return param && param.length > 0; @@ -19,11 +20,11 @@ const config: HardhatUserConfig = { localhost: { url: "http://127.0.0.1:8545/", }, - basetest: { - chainId: 84532, + [Network.BASE_SEPOLIA]: { + chainId: networkConfig.BASE_SEPOLIA.chainId, url: "https://sepolia.base.org", accounts: - isSet(process.env.BASETEST_PRIVATE_KEY) ? [process.env.BASETEST_PRIVATE_KEY || ""] : [], + isSet(process.env.BASE_SEPOLIA_PRIVATE_KEY) ? [process.env.BASE_SEPOLIA_PRIVATE_KEY || ""] : [], }, }, sourcify: { diff --git a/network.config.ts b/network.config.ts new file mode 100644 index 0000000..86ec23d --- /dev/null +++ b/network.config.ts @@ -0,0 +1,180 @@ +export enum Network { + ETHEREUM = "ETHEREUM", + AVALANCHE = "AVALANCHE", + OP_MAINNET = "OP_MAINNET", + ARBITRUM_ONE = "ARBITRUM_ONE", + BASE = "BASE", + POLYGON_MAINNET = "POLYGON_MAINNET", + ETHEREUM_SEPOLIA = "ETHEREUM_SEPOLIA", + AVALANCHE_FUJI = "AVALANCHE_FUJI", + OP_SEPOLIA = "OP_SEPOLIA", + ARBITRUM_SEPOLIA = "ARBITRUM_SEPOLIA", + BASE_SEPOLIA = "BASE_SEPOLIA", + POLYGON_AMOY = "POLYGON_AMOY", +}; + +export enum Provider { + CCTP = "CCTP", +}; + +interface CCTPConfig { + TokenMessenger: string; + MessageTransmitter: string; +}; + +interface RoutesConfig { + Domains: Network[]; + Providers: Provider[]; +} + +export interface NetworkConfig { + chainId?: number; + CCTP: CCTPConfig; + USDC: string; + Routes?: RoutesConfig; + IsTest: boolean; + IsHub: boolean; +}; + +type NetworksConfig = { + [key in Network]: NetworkConfig; +}; + +export const networkConfig: NetworksConfig = { + ETHEREUM: { + chainId: 1, + CCTP: { + TokenMessenger: "0xbd3fa81b58ba92a82136038b25adec7066af3155", + MessageTransmitter: "0x0a992d191deec32afe36203ad87d7d289a738f81", + }, + USDC: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + IsTest: false, + IsHub: false, + Routes: { + Domains: [Network.BASE], + Providers: [Provider.CCTP], + }, + }, + AVALANCHE: { + chainId: 43114, + CCTP: { + TokenMessenger: "0x6b25532e1060ce10cc3b0a99e5683b91bfde6982", + MessageTransmitter: "0x8186359af5f57fbb40c6b14a588d2a59c0c29880", + }, + USDC: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + IsTest: false, + IsHub: false, + }, + OP_MAINNET: { + chainId: 10, + CCTP: { + TokenMessenger: "0x2B4069517957735bE00ceE0fadAE88a26365528f", + MessageTransmitter: "0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8", + }, + USDC: "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + IsTest: false, + IsHub: false, + }, + ARBITRUM_ONE: { + chainId: 42161, + CCTP: { + TokenMessenger: "0x19330d10D9Cc8751218eaf51E8885D058642E08A", + MessageTransmitter: "0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca", + }, + USDC: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + IsTest: false, + IsHub: false, + }, + BASE: { + chainId: 8453, + CCTP: { + TokenMessenger: "0x1682Ae6375C4E4A97e4B583BC394c861A46D8962", + MessageTransmitter: "0xAD09780d193884d503182aD4588450C416D6F9D4", + }, + USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + IsTest: false, + IsHub: true, + Routes: { + Domains: [Network.ETHEREUM], + Providers: [Provider.CCTP], + }, + }, + POLYGON_MAINNET: { + chainId: 137, + CCTP: { + TokenMessenger: "0x9daF8c91AEFAE50b9c0E69629D3F6Ca40cA3B3FE", + MessageTransmitter: "0xF3be9355363857F3e001be68856A2f96b4C39Ba9", + }, + USDC: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + IsTest: false, + IsHub: false, + }, + ETHEREUM_SEPOLIA: { + chainId: 11155111, + CCTP: { + TokenMessenger: "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5", + MessageTransmitter: "0x7865fAfC2db2093669d92c0F33AeEF291086BEFD", + }, + USDC: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + IsTest: true, + IsHub: false, + Routes: { + Domains: [Network.BASE_SEPOLIA], + Providers: [Provider.CCTP], + }, + }, + AVALANCHE_FUJI: { + chainId: 43113, + CCTP: { + TokenMessenger: "0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0", + MessageTransmitter: "0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79", + }, + USDC: "0x5425890298aed601595a70ab815c96711a31bc65", + IsTest: true, + IsHub: false, + }, + OP_SEPOLIA: { + chainId: 11155420, + CCTP: { + TokenMessenger: "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5", + MessageTransmitter: "0x7865fAfC2db2093669d92c0F33AeEF291086BEFD", + }, + USDC: "0x5fd84259d66Cd46123540766Be93DFE6D43130D7", + IsTest: true, + IsHub: false, + }, + ARBITRUM_SEPOLIA: { + chainId: 421614, + CCTP: { + TokenMessenger: "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5", + MessageTransmitter: "0xaCF1ceeF35caAc005e15888dDb8A3515C41B4872", + }, + USDC: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", + IsTest: true, + IsHub: false, + }, + BASE_SEPOLIA: { + chainId: 84532, + CCTP: { + TokenMessenger: "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5", + MessageTransmitter: "0x7865fAfC2db2093669d92c0F33AeEF291086BEFD", + }, + USDC: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + IsTest: true, + IsHub: true, + Routes: { + Domains: [Network.ETHEREUM_SEPOLIA], + Providers: [Provider.CCTP], + }, + }, + POLYGON_AMOY: { + chainId: 80002, + CCTP: { + TokenMessenger: "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5", + MessageTransmitter: "0x7865fAfC2db2093669d92c0F33AeEF291086BEFD", + }, + USDC: "0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582", + IsTest: true, + IsHub: false, + }, +}; diff --git a/package.json b/package.json index 1fe7b70..40485cd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "compile": "hardhat compile", "deploy": "hardhat run ./scripts/deploy.ts", "deploy-local": "hardhat run ./scripts/deploy.ts --network localhost", - "deploy-basetest": "hardhat run ./scripts/deploy.ts --network basetest", + "deploy-basesepolia": "hardhat run ./scripts/deploy.ts --network BASE_SEPOLIA", "node": "hardhat node", "hardhat": "hardhat", "lint": "npm run lint:solidity && npm run lint:ts", diff --git a/scripts/deploy.ts b/scripts/deploy.ts index a7f3b0c..0c47a31 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -4,90 +4,144 @@ dotenv.config(); import hre from "hardhat"; import {isAddress, MaxUint256, getBigInt} from "ethers"; import {getContractAt, getCreateAddress, deploy, ZERO_BYTES32} from "../test/helpers"; -import {assert, getVerifier, isSet} from "./helpers"; +import {assert, getVerifier, isSet, ProviderSolidity, DomainSolidity} from "./helpers"; import { TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin, - TestLiquidityPool, SprinterLiquidityMining, + TestLiquidityPool, SprinterLiquidityMining, TestCCTPTokenMessenger, TestCCTPMessageTransmitter, + Rebalancer, } from "../typechain-types"; +import {networkConfig, Network, Provider, NetworkConfig} from "../network.config"; const DAY = 60n * 60n * 24n; async function main() { const [deployer] = await hre.ethers.getSigners(); const admin: string = isAddress(process.env.ADMIN) ? process.env.ADMIN : deployer.address; + const rebalanceCaller: string = isAddress(process.env.REBALANCE_CALLER) ? + process.env.REBALANCE_CALLER : deployer.address; const adjuster: string = isAddress(process.env.ADJUSTER) ? process.env.ADJUSTER : deployer.address; const maxLimit: bigint = MaxUint256 / 10n ** 12n; const assetsLimit: bigint = getBigInt(process.env.ASSETS_LIMIT || maxLimit); - const tiers = []; - - for (let i = 1;; i++) { - if (!isSet(process.env[`TIER_${i}_DAYS`])) { - break; - } - const period = BigInt(process.env[`TIER_${i}_DAYS`] || "0") * DAY; - const multiplier = BigInt(process.env[`TIER_${i}_MULTIPLIER`] || "0"); - tiers.push({period, multiplier}); - } - - if (tiers.length == 0) { - throw new Error("Empty liquidity mining tiers configuration."); - } - - let usdc: string; - if (isAddress(process.env.USDC)) { - usdc = process.env.USDC; + let config: NetworkConfig; + if (Object.values(Network).includes(hre.network.name as Network)) { + config = networkConfig[hre.network.name as Network]; } else { const testUSDC = (await deploy("TestUSDC", deployer, {})) as TestUSDC; - usdc = await testUSDC.getAddress(); + const cctpTokenMessenger = (await deploy("TestCCTPTokenMessenger", deployer, {})) as TestCCTPTokenMessenger; + const cctpMessageTransmitter = ( + await deploy("TestCCTPMessageTransmitter", deployer, {}) + ) as TestCCTPMessageTransmitter; + + config = { + CCTP: { + TokenMessenger: await cctpTokenMessenger.getAddress(), + MessageTransmitter: await cctpMessageTransmitter.getAddress(), + }, + USDC: await testUSDC.getAddress(), + IsTest: false, + IsHub: true, + Routes: { + Domains: [Network.ETHEREUM], + Providers: [Provider.CCTP], + }, + }; } console.log("TEST: Using TEST Liquidity Pool"); - const liquidityPool = (await deploy("TestLiquidityPool", deployer, {}, usdc)) as TestLiquidityPool; - - const startingNonce = await deployer.getNonce(); + const liquidityPool = (await deploy("TestLiquidityPool", deployer, {}, config.USDC)) as TestLiquidityPool; + + const rebalancerVersion = config.IsTest ? "TestRebalancer" : "Rebalancer"; + + const rebalancerImpl = ( + await deploy(rebalancerVersion, deployer, {}, + liquidityPool.target, config.CCTP.TokenMessenger, config.CCTP.MessageTransmitter + ) + ) as Rebalancer; + const rebalancerInit = (await rebalancerImpl.initialize.populateTransaction( + admin, + rebalanceCaller, + config.Routes ? config.Routes.Domains.map(el => DomainSolidity[el]) : [], + config.Routes ? config.Routes.Providers.map(el => ProviderSolidity[el]) : [] + )).data; + const rebalancerProxy = (await deploy( + "TransparentUpgradeableProxy", deployer, {}, + rebalancerImpl.target, admin, rebalancerInit + )) as TransparentUpgradeableProxy; + const rebalancer = (await getContractAt("Rebalancer", rebalancerProxy.target, deployer)) as Rebalancer; + const rebalancerProxyAdminAddress = await getCreateAddress(rebalancerProxy, 1); + const rebalancerAdmin = (await getContractAt("ProxyAdmin", rebalancerProxyAdminAddress, deployer)) as ProxyAdmin; - const verifier = getVerifier(); + const DEFAULT_ADMIN_ROLE = ZERO_BYTES32; - const liquidityHubAddress = await getCreateAddress(deployer, startingNonce + 2); - const lpToken = ( - await verifier.deploy("SprinterUSDCLPShare", deployer, {nonce: startingNonce + 0}, liquidityHubAddress) - ) as SprinterUSDCLPShare; + console.log("TEST: Using default admin role for Rebalancer on Pool"); + await liquidityPool.grantRole(DEFAULT_ADMIN_ROLE, rebalancer.target); - const liquidityHubImpl = ( - await verifier.deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target, liquidityPool.target) - ) as LiquidityHub; - const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction( - usdc, admin, adjuster, assetsLimit - )).data; - const liquidityHubProxy = (await verifier.deploy( - "TransparentUpgradeableProxy", deployer, {nonce: startingNonce + 2}, - liquidityHubImpl.target, admin, liquidityHubInit - )) as TransparentUpgradeableProxy; - const liquidityHub = (await getContractAt("LiquidityHub", liquidityHubAddress, deployer)) as LiquidityHub; - const liquidityHubProxyAdminAddress = await getCreateAddress(liquidityHubProxy, 1); - const liquidityHubAdmin = (await getContractAt("ProxyAdmin", liquidityHubProxyAdminAddress)) as ProxyAdmin; + const verifier = getVerifier(); - assert(liquidityHubAddress == liquidityHubProxy.target, "LiquidityHub address mismatch"); + if (config.IsHub) { + const tiers = []; - const liquidityMining = ( - await deploy("SprinterLiquidityMining", deployer, {}, admin, liquidityHub.target, tiers) - ) as SprinterLiquidityMining; + for (let i = 1;; i++) { + if (!isSet(process.env[`TIER_${i}_DAYS`])) { + break; + } + const period = BigInt(process.env[`TIER_${i}_DAYS`] || "0") * DAY; + const multiplier = BigInt(process.env[`TIER_${i}_MULTIPLIER`] || "0"); + tiers.push({period, multiplier}); + } - const DEFAULT_ADMIN_ROLE = ZERO_BYTES32; + if (tiers.length == 0) { + throw new Error("Empty liquidity mining tiers configuration."); + } - console.log("TEST: Using default admin role for Hub on Pool"); - await liquidityPool.grantRole(DEFAULT_ADMIN_ROLE, liquidityHub.target); + const startingNonce = await deployer.getNonce(); + + const liquidityHubAddress = await getCreateAddress(deployer, startingNonce + 2); + const lpToken = ( + await verifier.deploy("SprinterUSDCLPShare", deployer, {nonce: startingNonce + 0}, liquidityHubAddress) + ) as SprinterUSDCLPShare; + + const liquidityHubImpl = ( + await verifier.deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target, liquidityPool.target) + ) as LiquidityHub; + const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction( + config.USDC, admin, adjuster, assetsLimit + )).data; + const liquidityHubProxy = (await verifier.deploy( + "TransparentUpgradeableProxy", deployer, {nonce: startingNonce + 2}, + liquidityHubImpl.target, admin, liquidityHubInit + )) as TransparentUpgradeableProxy; + const liquidityHub = (await getContractAt("LiquidityHub", liquidityHubAddress, deployer)) as LiquidityHub; + const liquidityHubProxyAdminAddress = await getCreateAddress(liquidityHubProxy, 1); + const liquidityHubAdmin = (await getContractAt("ProxyAdmin", liquidityHubProxyAdminAddress)) as ProxyAdmin; + + assert(liquidityHubAddress == liquidityHubProxy.target, "LiquidityHub address mismatch"); + const liquidityMining = ( + await deploy("SprinterLiquidityMining", deployer, {}, admin, liquidityHub.target, tiers) + ) as SprinterLiquidityMining; + + console.log("TEST: Using default admin role for Hub on Pool"); + await liquidityPool.grantRole(DEFAULT_ADMIN_ROLE, liquidityHub.target); + + console.log(); + console.log(`SprinterUSDCLPShare: ${lpToken.target}`); + console.log(`LiquidityHub: ${liquidityHub.target}`); + console.log(`LiquidityHubProxyAdmin: ${liquidityHubAdmin.target}`); + console.log(`SprinterLiquidityMining: ${liquidityMining.target}`); + console.log("Tiers:"); + console.table(tiers); + } - console.log(); console.log(`Admin: ${admin}`); - console.log(`SprinterUSDCLPShare: ${lpToken.target}`); - console.log(`LiquidityHub: ${liquidityHub.target}`); - console.log(`LiquidityHubProxyAdmin: ${liquidityHubAdmin.target}`); - console.log(`USDC: ${usdc}`); - console.log(`SprinterLiquidityMining: ${liquidityMining.target}`); - console.log("Tiers:"); - console.table(tiers); + console.log(`LiquidityPool: ${liquidityPool.target}`); + console.log(`USDC: ${config.USDC}`); + console.log(`Rebalancer: ${rebalancer.target}`); + console.log(`RebalancerProxyAdmin: ${rebalancerAdmin.target}`); + if (config.Routes) { + console.log("Routes:"); + console.table(config.Routes); + } if (process.env.VERIFY === "true") { await verifier.verify(); diff --git a/scripts/helpers.ts b/scripts/helpers.ts index 355dd1a..fc6f9f2 100644 --- a/scripts/helpers.ts +++ b/scripts/helpers.ts @@ -45,3 +45,22 @@ export function getVerifier() { }, }; }; + +export const ProviderSolidity = { + CCTP: 0n, +}; + +export const DomainSolidity = { + ETHEREUM: 0n, + AVALANCHE: 1n, + OP_MAINNET: 2n, + ARBITRUM_ONE: 3n, + BASE: 4n, + POLYGON_MAINNET: 5n, + ETHEREUM_SEPOLIA: 6n, + AVALANCHE_FUJI: 7n, + OP_SEPOLIA: 8n, + ARBITRUM_SEPOLIA: 9n, + BASE_SEPOLIA: 10n, + POLYGON_AMOY: 11n, +}; diff --git a/test/LiquidityHub.ts b/test/LiquidityHub.ts index d032fa7..49c1555 100644 --- a/test/LiquidityHub.ts +++ b/test/LiquidityHub.ts @@ -56,7 +56,7 @@ describe("LiquidityHub", function () { }; it("Should have default values", async function () { - const {lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP} = await loadFixture(deployAll); + const {lpToken, liquidityHub, usdc, user, user2, liquidityPool, USDC, LP, admin} = await loadFixture(deployAll); expect(await liquidityHub.SHARES()).to.equal(lpToken.target); expect(await liquidityHub.LIQUIDITY_POOL()).to.equal(liquidityPool.target); @@ -69,6 +69,9 @@ describe("LiquidityHub", function () { expect(await liquidityHub.maxDeposit(ZERO_ADDRESS)).to.equal(getBigInt(MaxUint256) * USDC / LP); expect(await liquidityHub.maxMint(ZERO_ADDRESS)).to.equal(getBigInt(MaxUint256) * USDC / LP * LP / USDC); + await expect(liquidityHub.connect(admin).initialize( + usdc.target, admin.address, admin.address, getBigInt(MaxUint256) * USDC / LP) + ).to.be.reverted; await expect(liquidityHub.name()) .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); await expect(liquidityHub.symbol()) diff --git a/test/Rebalancer.ts b/test/Rebalancer.ts new file mode 100644 index 0000000..5335e99 --- /dev/null +++ b/test/Rebalancer.ts @@ -0,0 +1,236 @@ +import { + loadFixture, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import {expect} from "chai"; +import hre from "hardhat"; +import {AbiCoder} from "ethers"; +import { + getCreateAddress, getContractAt, deploy, + ZERO_ADDRESS, ZERO_BYTES32, toBytes32 +} from "./helpers"; +import { + ProviderSolidity as Provider, DomainSolidity as Domain, +} from "../scripts/helpers"; +import { + TestUSDC, TransparentUpgradeableProxy, ProxyAdmin, + TestLiquidityPool, Rebalancer, TestCCTPTokenMessenger, TestCCTPMessageTransmitter, +} from "../typechain-types"; + +const ALLOWED = true; +const DISALLOWED = false; + +describe("Rebalancer", function () { + const deployAll = async () => { + const [deployer, admin, rebalanceUser, user] = await hre.ethers.getSigners(); + + const DEFAULT_ADMIN_ROLE = ZERO_BYTES32; + const REBALANCER_ROLE = toBytes32("REBALANCER_ROLE"); + + const usdc = (await deploy("TestUSDC", deployer, {})) as TestUSDC; + const liquidityPool = (await deploy("TestLiquidityPool", deployer, {}, usdc.target)) as TestLiquidityPool; + const cctpTokenMessenger = (await deploy("TestCCTPTokenMessenger", deployer, {})) as TestCCTPTokenMessenger; + const cctpMessageTransmitter = ( + await deploy("TestCCTPMessageTransmitter", deployer, {}) + ) as TestCCTPMessageTransmitter; + + const USDC = 10n ** (await usdc.decimals()); + + const rebalancerImpl = ( + await deploy("Rebalancer", deployer, {}, + liquidityPool.target, cctpTokenMessenger.target, cctpMessageTransmitter.target + ) + ) as Rebalancer; + const rebalancerInit = (await rebalancerImpl.initialize.populateTransaction( + admin.address, rebalanceUser.address, [Domain.ETHEREUM, Domain.ARBITRUM_ONE], [Provider.CCTP, Provider.CCTP] + )).data; + const rebalancerProxy = (await deploy( + "TransparentUpgradeableProxy", deployer, {}, + rebalancerImpl.target, admin, rebalancerInit + )) as TransparentUpgradeableProxy; + const rebalancer = (await getContractAt("Rebalancer", rebalancerProxy.target, deployer)) as Rebalancer; + const rebalancerProxyAdminAddress = await getCreateAddress(rebalancerProxy, 1); + const rebalancerAdmin = (await getContractAt("ProxyAdmin", rebalancerProxyAdminAddress, admin)) as ProxyAdmin; + + await liquidityPool.grantRole(DEFAULT_ADMIN_ROLE, rebalancer.target); + + return { + deployer, admin, rebalanceUser, user, usdc, + USDC, liquidityPool, rebalancer, rebalancerProxy, rebalancerAdmin, + cctpTokenMessenger, cctpMessageTransmitter, REBALANCER_ROLE, DEFAULT_ADMIN_ROLE, + }; + }; + + it("Should have default values", async function () { + const {liquidityPool, rebalancer, usdc, REBALANCER_ROLE, DEFAULT_ADMIN_ROLE, + cctpTokenMessenger, cctpMessageTransmitter, admin, rebalanceUser, deployer, + } = await loadFixture(deployAll); + + expect(await rebalancer.LIQUIDITY_POOL()).to.equal(liquidityPool.target); + expect(await rebalancer.COLLATERAL()).to.equal(usdc.target); + expect(await rebalancer.CCTP_TOKEN_MESSENGER()).to.equal(cctpTokenMessenger.target); + expect(await rebalancer.CCTP_MESSAGE_TRANSMITTER()).to.equal(cctpMessageTransmitter.target); + expect(await rebalancer.REBALANCER_ROLE()).to.equal(REBALANCER_ROLE); + expect(await rebalancer.isRouteAllowed(Domain.ETHEREUM, Provider.CCTP)).to.be.true; + expect(await rebalancer.isRouteAllowed(Domain.AVALANCHE, Provider.CCTP)).to.be.false; + expect(await rebalancer.isRouteAllowed(Domain.ARBITRUM_ONE, Provider.CCTP)).to.be.true; + expect(await rebalancer.hasRole(DEFAULT_ADMIN_ROLE, admin.address)).to.be.true; + expect(await rebalancer.hasRole(DEFAULT_ADMIN_ROLE, deployer.address)).to.be.false; + expect(await rebalancer.hasRole(REBALANCER_ROLE, rebalanceUser.address)).to.be.true; + expect(await rebalancer.hasRole(REBALANCER_ROLE, deployer.address)).to.be.false; + expect(await rebalancer.domainCCTP(Domain.ETHEREUM)).to.equal(0n); + expect(await rebalancer.domainCCTP(Domain.AVALANCHE)).to.equal(1n); + expect(await rebalancer.domainCCTP(Domain.OP_MAINNET)).to.equal(2n); + expect(await rebalancer.domainCCTP(Domain.ARBITRUM_ONE)).to.equal(3n); + expect(await rebalancer.domainCCTP(Domain.BASE)).to.equal(6n); + expect(await rebalancer.domainCCTP(Domain.POLYGON_MAINNET)).to.equal(7n); + + await expect(rebalancer.connect(admin).initialize(admin.address, rebalanceUser.address, [], [])).to.be.reverted; + }); + + it("Should allow admin to enable routes", async function () { + const {rebalancer, usdc, USDC, admin, rebalanceUser, + liquidityPool + } = await loadFixture(deployAll); + + await usdc.transfer(liquidityPool.target, 10n * USDC); + await expect(rebalancer.connect(rebalanceUser).initiateRebalance(5n * USDC, Domain.AVALANCHE, Provider.CCTP, "0x")) + .to.be.revertedWithCustomError(rebalancer, "RouteDenied()"); + const tx = rebalancer.connect(admin).setRoute([Domain.AVALANCHE], [Provider.CCTP], ALLOWED); + await expect(tx) + .to.emit(rebalancer, "SetRoute") + .withArgs(Domain.AVALANCHE, Provider.CCTP, ALLOWED); + + expect(await rebalancer.isRouteAllowed(Domain.ETHEREUM, Provider.CCTP)).to.be.true; + expect(await rebalancer.isRouteAllowed(Domain.AVALANCHE, Provider.CCTP)).to.be.true; + expect(await rebalancer.isRouteAllowed(Domain.ARBITRUM_ONE, Provider.CCTP)).to.be.true; + await rebalancer.connect(rebalanceUser).initiateRebalance(5n * USDC, Domain.AVALANCHE, Provider.CCTP, "0x"); + }); + + it("Should allow admin to disable routes", async function () { + const {rebalancer, usdc, USDC, admin, rebalanceUser, liquidityPool} = await loadFixture(deployAll); + + await usdc.transfer(liquidityPool.target, 10n * USDC); + await rebalancer.connect(rebalanceUser).initiateRebalance(5n * USDC, Domain.ETHEREUM, Provider.CCTP, "0x"); + const tx = rebalancer.connect(admin).setRoute([Domain.ETHEREUM], [Provider.CCTP], DISALLOWED); + await expect(tx) + .to.emit(rebalancer, "SetRoute") + .withArgs(Domain.ETHEREUM, Provider.CCTP, DISALLOWED); + + expect(await rebalancer.isRouteAllowed(Domain.ETHEREUM, Provider.CCTP)).to.be.false; + expect(await rebalancer.isRouteAllowed(Domain.AVALANCHE, Provider.CCTP)).to.be.false; + expect(await rebalancer.isRouteAllowed(Domain.ARBITRUM_ONE, Provider.CCTP)).to.be.true; + await expect(rebalancer.connect(rebalanceUser).initiateRebalance(5n * USDC, Domain.ETHEREUM, Provider.CCTP, "0x")) + .to.be.revertedWithCustomError(rebalancer, "RouteDenied()"); + }); + + it("Should not allow others to enable routes", async function () { + const {rebalancer, rebalanceUser} = await loadFixture(deployAll); + + await expect(rebalancer.connect(rebalanceUser).setRoute([Domain.AVALANCHE], [Provider.CCTP], ALLOWED)) + .to.be.revertedWithCustomError(rebalancer, "AccessControlUnauthorizedAccount(address,bytes32)"); + }); + + it("Should not allow others to disable routes", async function () { + const {rebalancer, rebalanceUser} = await loadFixture(deployAll); + + await expect(rebalancer.connect(rebalanceUser).setRoute([Domain.ETHEREUM], [Provider.CCTP], DISALLOWED)) + .to.be.revertedWithCustomError(rebalancer, "AccessControlUnauthorizedAccount(address,bytes32)"); + }); + + it("Should allow rebalancer to initiate rebalance", async function () { + const {rebalancer, usdc, USDC, rebalanceUser, liquidityPool, + cctpTokenMessenger + } = await loadFixture(deployAll); + + await usdc.transfer(liquidityPool.target, 10n * USDC); + const tx = rebalancer.connect(rebalanceUser).initiateRebalance(4n * USDC, Domain.ETHEREUM, Provider.CCTP, "0x"); + await expect(tx) + .to.emit(rebalancer, "InitiateRebalance") + .withArgs(4n * USDC, Domain.ETHEREUM, Provider.CCTP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityPool.target, rebalancer.target, 4n * USDC); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(rebalancer.target, cctpTokenMessenger.target, 4n * USDC); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(cctpTokenMessenger.target, ZERO_ADDRESS, 4n * USDC); + + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(6n * USDC); + expect(await usdc.balanceOf(rebalancer.target)).to.equal(0n); + }); + + it("Should not allow others to initiate rebalance", async function () { + const {rebalancer, usdc, USDC, admin, liquidityPool} = await loadFixture(deployAll); + + await usdc.transfer(liquidityPool.target, 10n * USDC); + await expect(rebalancer.connect(admin).initiateRebalance(4n * USDC, Domain.ETHEREUM, Provider.CCTP, "0x")) + .to.be.revertedWithCustomError(rebalancer, "AccessControlUnauthorizedAccount(address,bytes32)"); + }); + + it("Should not allow rebalancer to initiate rebalance with 0 amount", async function () { + const {rebalancer, rebalanceUser, usdc, USDC, liquidityPool} = await loadFixture(deployAll); + + await usdc.transfer(liquidityPool.target, 10n * USDC); + await expect(rebalancer.connect(rebalanceUser).initiateRebalance(0n, Domain.ETHEREUM, Provider.CCTP, "0x")) + .to.be.revertedWithCustomError(rebalancer, "ZeroAmount()"); + }); + + it("Should not allow rebalancer to initiate rebalance with disabled route", async function () { + const {rebalancer, rebalanceUser, usdc, USDC, liquidityPool} = await loadFixture(deployAll); + + await usdc.transfer(liquidityPool.target, 10n * USDC); + await expect(rebalancer.connect(rebalanceUser).initiateRebalance(4n * USDC, Domain.AVALANCHE, Provider.CCTP, "0x")) + .to.be.revertedWithCustomError(rebalancer, "RouteDenied()"); + }); + + it("Should allow anyone to process rebalance", async function () { + const {rebalancer, usdc, USDC, liquidityPool, user} = await loadFixture(deployAll); + + const message = AbiCoder.defaultAbiCoder().encode( + ["address", "address", "uint256"], + [usdc.target, liquidityPool.target, 4n * USDC] + ); + const signature = AbiCoder.defaultAbiCoder().encode(["bool", "bool"], [true, true]); + const extraData = AbiCoder.defaultAbiCoder().encode(["bytes", "bytes"], [message, signature]); + const tx = rebalancer.connect(user).processRebalance(Provider.CCTP, extraData); + await expect(tx) + .to.emit(rebalancer, "ProcessRebalance") + .withArgs(Provider.CCTP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(ZERO_ADDRESS, liquidityPool.target, 4n * USDC); + await expect(tx) + .to.emit(liquidityPool, "Deposit"); + + expect(await usdc.balanceOf(liquidityPool.target)).to.equal(4n * USDC); + expect(await usdc.balanceOf(rebalancer.target)).to.equal(0n); + }); + + it("Should revert if CCTP receiveMessage reverts", async function () { + const {rebalancer, usdc, USDC, liquidityPool, user} = await loadFixture(deployAll); + + const message = AbiCoder.defaultAbiCoder().encode( + ["address", "address", "uint256"], + [usdc.target, liquidityPool.target, 4n * USDC] + ); + const signature = AbiCoder.defaultAbiCoder().encode(["bool", "bool"], [false, true]); + const extraData = AbiCoder.defaultAbiCoder().encode(["bytes", "bytes"], [message, signature]); + await expect(rebalancer.connect(user).processRebalance(Provider.CCTP, extraData)) + .to.be.reverted; + }); + + it("Should revert if CCTP receiveMessage returned false", async function () { + const {rebalancer, usdc, USDC, liquidityPool, user} = await loadFixture(deployAll); + + const message = AbiCoder.defaultAbiCoder().encode( + ["address", "address", "uint256"], + [usdc.target, liquidityPool.target, 4n * USDC] + ); + const signature = AbiCoder.defaultAbiCoder().encode(["bool", "bool"], [true, false]); + const extraData = AbiCoder.defaultAbiCoder().encode(["bytes", "bytes"], [message, signature]); + await expect(rebalancer.connect(user).processRebalance(Provider.CCTP, extraData)) + .to.be.revertedWithCustomError(rebalancer, "ProcessFailed()"); + }); +});