diff --git a/.gitmodules b/.gitmodules index daa52ab6..c10805bf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,9 +19,6 @@ [submodule "lib/sdai"] path = lib/sdai url = https://github.com/makerdao/sdai -[submodule "lib/spark-address-registry"] - path = lib/spark-address-registry - url = https://github.com/marsfoundation/spark-address-registry [submodule "lib/erc20-helpers"] path = lib/erc20-helpers url = https://github.com/marsfoundation/erc20-helpers @@ -34,3 +31,6 @@ [submodule "lib/aave-v3-origin"] path = lib/aave-v3-origin url = https://github.com/aave-dao/aave-v3-origin +[submodule "lib/spark-address-registry"] + path = lib/spark-address-registry + url = https://github.com/sparkdotfi/spark-address-registry diff --git a/README.md b/README.md index 20df09a1..72019f1e 100644 --- a/README.md +++ b/README.md @@ -35,25 +35,12 @@ The diagram below provides and example of calling to mint USDS using the Sky all All contracts in this repo inherit and implement the AccessControl contract from OpenZeppelin to manage permissions. The following roles are defined: - `DEFAULT_ADMIN_ROLE`: The admin role is the role that can grant and revoke roles. Also used for general admin functions in all contracts. - `RELAYER`: Used for the ALM Planner offchain system. This address can call functions on `controller` contracts to perform actions on behalf of the `ALMProxy` contract. -- `FREEZER`: Allows an address with this role to freeze all actions on the `controller` contracts. This role is intended to be used in emergency situations. +- `FREEZER`: Allows an address with this role to remove a `RELAYER` that has been compromised. The intention of this is to have a backup `RELAYER` that the system can fall back to when the main one is removed. - `CONTROLLER`: Used for the `ALMProxy` contract. Only contracts with this role can call the `call` functions on the `ALMProxy` contract. Also used in the RateLimits contract, only this role can update rate limits. ## Controller Functionality -All functions below change the balance of funds in the ALMProxy contract and are only callable by the `RELAYER` role. - -- `ForeignController`: This contract currently implements logic to: - - Deposit and withdraw on EVM compliant L2 PSM3 contracts (see [spark-psm](https://github.com/marsfoundation/spark-psm) for implementation). - - Initiate a transfer of USDC to other domains using CCTP. - - Deposit, withdraw, and redeem from ERC4626 contracts. - - Deposit and withdraw from AAVE. -- `MainnetController`: This contract currently implements logic to: - - Mint and burn USDS. - - Deposit, withdraw, redeem from ERC4626 contracts. - - Deposit and withdraw from AAVE. - - Mint and burn USDe. - - Cooldown and unstake from sUSDe. - - Swap USDS to USDC and vice versa using the mainnet PSM. - - Transfer USDC to other domains using CCTP. +The `MainnetController` contains all logic necessary to interact with the Sky allocation system to mint and burn USDS, swap USDS to USDC in the PSM, as well as interact with mainnet external protocols and CCTP for bridging USDC. +The `ForeignController` contains all logic necessary to deposit, withdraw, and swap assets in L2 PSMs as well as interact with external protocols on L2s and CCTP for bridging USDC. ## Rate Limits @@ -79,8 +66,8 @@ Below are all stated trust assumptions for using this contract in production: - The `RELAYER` role is assumed to be able to be fully compromised by a malicious actor. **This should be a major consideration during auditing engagements.** - The logic in the smart contracts must prevent the movement of value anywhere outside of the ALM system of contracts. - Any action must be limited to "reasonable" slippage/losses/opportunity cost by rate limits. - - The `FREEZER` must be able to stop the compromised `RELAYER` from performing more harmful actions within the max rate limits by using the `freeze()` function. -- A compromised `RELAYER` can DOS Ethena unstaking, but this can be mitigated by freezing the Controller and reassigning the `RELAYER`. This is outlined in a test `test_compromisedRelayer_lockingFundsInEthenaSilo`. + - The `FREEZER` must be able to stop the compromised `RELAYER` from performing more harmful actions within the max rate limits by using the `removeRelayer` function. +- A compromised `RELAYER` can perform DOS attacks. These attacks along with their respective recovery procedures are outlined in the `Attacks.t.sol` test files. - Ethena USDe Mint/Burn is trusted to not honor requests with over 50bps slippage from a delegated signer. ## Operational Requirements @@ -100,9 +87,9 @@ forge test ``` ## Deployments -All commands to deploy: +All commands to deploy: - Either the full system or just the controller - - To mainnet or base + - To mainnet or base - For staging or production Can be found in the Makefile, with the nomenclature `make deploy---`. diff --git a/deploy/ForeignControllerInit.sol b/deploy/ForeignControllerInit.sol index bc69fa32..b25e7592 100644 --- a/deploy/ForeignControllerInit.sol +++ b/deploy/ForeignControllerInit.sol @@ -74,12 +74,12 @@ library ForeignControllerInit { ) internal { - _initController(controllerInst, configAddresses, checkAddresses, mintRecipients); - + _initController(controllerInst, configAddresses, checkAddresses, mintRecipients); + IALMProxy almProxy = IALMProxy(controllerInst.almProxy); IRateLimits rateLimits = IRateLimits(controllerInst.rateLimits); - require(configAddresses.oldController != address(0), "ForeignControllerInit/old-controller-zero-address"); + require(configAddresses.oldController != address(0), "ForeignControllerInit/old-controller-zero-address"); require(almProxy.hasRole(almProxy.CONTROLLER(), configAddresses.oldController), "ForeignControllerInit/old-controller-not-almProxy-controller"); require(rateLimits.hasRole(rateLimits.CONTROLLER(), configAddresses.oldController), "ForeignControllerInit/old-controller-not-rateLimits-controller"); @@ -98,7 +98,7 @@ library ForeignControllerInit { CheckAddressParams memory checkAddresses, MintRecipient[] memory mintRecipients ) - private + private { // Step 1: Perform controller sanity checks @@ -113,8 +113,6 @@ library ForeignControllerInit { require(address(newController.usdc()) == checkAddresses.usdc, "ForeignControllerInit/incorrect-usdc"); require(address(newController.cctp()) == checkAddresses.cctp, "ForeignControllerInit/incorrect-cctp"); - require(newController.active(), "ForeignControllerInit/controller-not-active"); - require(configAddresses.oldController != address(newController), "ForeignControllerInit/old-controller-is-new-controller"); // Step 2: Perform PSM sanity checks diff --git a/deploy/MainnetControllerInit.sol b/deploy/MainnetControllerInit.sol index e8e50df6..c630c4b3 100644 --- a/deploy/MainnetControllerInit.sol +++ b/deploy/MainnetControllerInit.sol @@ -55,7 +55,7 @@ library MainnetControllerInit { /**********************************************************************************************/ function initAlmSystem( - address vault, + address vault, address usds, ControllerInstance memory controllerInst, ConfigAddressParams memory configAddresses, @@ -89,12 +89,12 @@ library MainnetControllerInit { ) internal { - _initController(controllerInst, configAddresses, checkAddresses, mintRecipients); - + _initController(controllerInst, configAddresses, checkAddresses, mintRecipients); + IALMProxy almProxy = IALMProxy(controllerInst.almProxy); IRateLimits rateLimits = IRateLimits(controllerInst.rateLimits); - require(configAddresses.oldController != address(0), "MainnetControllerInit/old-controller-zero-address"); + require(configAddresses.oldController != address(0), "MainnetControllerInit/old-controller-zero-address"); require(almProxy.hasRole(almProxy.CONTROLLER(), configAddresses.oldController), "MainnetControllerInit/old-controller-not-almProxy-controller"); require(rateLimits.hasRole(rateLimits.CONTROLLER(), configAddresses.oldController), "MainnetControllerInit/old-controller-not-rateLimits-controller"); @@ -117,7 +117,7 @@ library MainnetControllerInit { CheckAddressParams memory checkAddresses, MintRecipient[] memory mintRecipients ) - private + private { // Step 1: Perform controller sanity checks @@ -134,7 +134,6 @@ library MainnetControllerInit { require(address(newController.cctp()) == checkAddresses.cctp, "MainnetControllerInit/incorrect-cctp"); require(newController.psmTo18ConversionFactor() == 1e12, "MainnetControllerInit/incorrect-psmTo18ConversionFactor"); - require(newController.active(), "MainnetControllerInit/controller-not-active"); require(configAddresses.oldController != address(newController), "MainnetControllerInit/old-controller-is-new-controller"); diff --git a/foundry.toml b/foundry.toml index d59ed990..3cbbdf2c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -9,7 +9,7 @@ fs_permissions = [ { access = "read", path = "./script/input/"}, { access = "read-write", path = "./script/output/"} ] -evm_version = 'shanghai' +evm_version = 'cancun' [fuzz] runs = 1000 diff --git a/lib/forge-std b/lib/forge-std index e4aef94c..bf909b22 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit e4aef94c1768803a16fe19f7ce8b65defd027cfd +Subproject commit bf909b22fa55e244796dfa920c9639fdffa1c545 diff --git a/lib/spark-address-registry b/lib/spark-address-registry index 0894d151..23eb2865 160000 --- a/lib/spark-address-registry +++ b/lib/spark-address-registry @@ -1 +1 @@ -Subproject commit 0894d151cab9cc50dcf49c4c32e6469b16b391a1 +Subproject commit 23eb286593ea7be1bcbb3f1a87fb9185e46e3285 diff --git a/script/staging/test/StagingDeployment.t.sol b/script/staging/test/StagingDeployment.t.sol index bce32dfb..ab1d8e2a 100644 --- a/script/staging/test/StagingDeployment.t.sol +++ b/script/staging/test/StagingDeployment.t.sol @@ -197,7 +197,7 @@ contract MainnetStagingDeploymentTests is StagingDeploymentTestBase { mainnetController.withdrawERC4626(Ethereum.SUSDS, 10e18); vm.stopPrank(); - assertEq(usds.balanceOf(address(almProxy)), startingBalance + 10e18); + assertEq(usds.balanceOf(address(almProxy)), startingBalance + 10e18); assertGe(IERC4626(Ethereum.SUSDS).balanceOf(address(almProxy)), 0); // Interest earned } @@ -214,7 +214,7 @@ contract MainnetStagingDeploymentTests is StagingDeploymentTestBase { assertGe(usds.balanceOf(address(almProxy)), startingBalance + 10e18); // Interest earned - assertEq(IERC4626(Ethereum.SUSDS).balanceOf(address(almProxy)), 0); + assertEq(IERC4626(Ethereum.SUSDS).balanceOf(address(almProxy)), 0); } function test_depositAndWithdrawUsdsFromAave() public { @@ -266,20 +266,20 @@ contract MainnetStagingDeploymentTests is StagingDeploymentTestBase { _simulateUsdeBurn(10e18 - 1); - assertEq(usdc.balanceOf(address(almProxy)), startingBalance + 10e6 - 1); // Rounding not captured - + assertEq(usdc.balanceOf(address(almProxy)), startingBalance + 10e6 - 1); // Rounding not captured + assertGe(IERC4626(Ethereum.SUSDE).balanceOf(address(almProxy)), 0); // Interest earned } function test_mintDepositCooldownSharesBurnUsde() public { - uint256 startingBalance = usdc.balanceOf(address(almProxy)); - vm.startPrank(relayerSafe); mainnetController.mintUSDS(10e18); mainnetController.swapUSDSToUSDC(10e6); mainnetController.prepareUSDeMint(10e6); vm.stopPrank(); + uint256 startingBalance = usdc.balanceOf(address(almProxy)); + _simulateUsdeMint(10e6); vm.startPrank(relayerSafe); @@ -288,23 +288,28 @@ contract MainnetStagingDeploymentTests is StagingDeploymentTestBase { uint256 usdeAmount = mainnetController.cooldownSharesSUSDe(IERC4626(Ethereum.SUSDE).balanceOf(address(almProxy))); skip(7 days); mainnetController.unstakeSUSDe(); - mainnetController.prepareUSDeBurn(usdeAmount); + + // Handle situation where usde balance of ALM Proxy is higher than max rate limit + uint256 maxBurnAmount = rateLimits.getCurrentRateLimit(mainnetController.LIMIT_USDE_BURN()); + uint256 burnAmount = usdeAmount > maxBurnAmount ? maxBurnAmount : usdeAmount; + mainnetController.prepareUSDeBurn(burnAmount); + vm.stopPrank(); - _simulateUsdeBurn(usdeAmount); + _simulateUsdeBurn(burnAmount); + + assertGe(usdc.balanceOf(address(almProxy)), startingBalance - 1); // Interest earned (rounding) - assertGe(usdc.balanceOf(address(almProxy)), startingBalance + 10e6 - 1); // Interest earned (rounding) - - assertEq(IERC4626(Ethereum.SUSDE).balanceOf(address(almProxy)), 0); + assertEq(IERC4626(Ethereum.SUSDE).balanceOf(address(almProxy)), 0); } /**********************************************************************************************/ /**** Helper functions ***/ /**********************************************************************************************/ - // NOTE: In reality these actions are performed by the signer submitting an order with an - // EIP712 signature which is verified by the ethenaMinter contract, - // minting/burning USDe into the ALMProxy. Also, for the purposes of this test, + // NOTE: In reality these actions are performed by the signer submitting an order with an + // EIP712 signature which is verified by the ethenaMinter contract, + // minting/burning USDe into the ALMProxy. Also, for the purposes of this test, // minting/burning is done 1:1 with USDC. // TODO: Try doing ethena minting with EIP-712 signatures (vm.sign) @@ -313,8 +318,8 @@ contract MainnetStagingDeploymentTests is StagingDeploymentTestBase { vm.prank(Ethereum.ETHENA_MINTER); usdc.transferFrom(address(almProxy), Ethereum.ETHENA_MINTER, amount); deal( - Ethereum.USDE, - address(almProxy), + Ethereum.USDE, + address(almProxy), IERC20(Ethereum.USDE).balanceOf(address(almProxy)) + amount * 1e12 ); } @@ -496,7 +501,7 @@ contract BaseStagingDeploymentTests is StagingDeploymentTestBase { assertGe(usdcBase.balanceOf(address(baseAlmProxy)), 10e6); // Interest earned - assertEq(IERC20(MORPHO_VAULT_USDC).balanceOf(address(baseAlmProxy)), 0); + assertEq(IERC20(MORPHO_VAULT_USDC).balanceOf(address(baseAlmProxy)), 0); baseController.transferUSDCToCCTP(1e6 - 1, CCTPForwarder.DOMAIN_ID_CIRCLE_ETHEREUM); // Account for potential rounding vm.stopPrank(); diff --git a/src/ForeignController.sol b/src/ForeignController.sol index 922bd3df..2a33a2af 100644 --- a/src/ForeignController.sol +++ b/src/ForeignController.sol @@ -7,6 +7,8 @@ import { IPool as IAavePool } from "aave-v3-origin/src/core/contracts/interfaces import { IERC20 } from "forge-std/interfaces/IERC20.sol"; import { IERC4626 } from "forge-std/interfaces/IERC4626.sol"; +import { IMetaMorpho, Id, MarketAllocation } from "metamorpho/interfaces/IMetaMorpho.sol"; + import { AccessControl } from "openzeppelin-contracts/contracts/access/AccessControl.sol"; import { IPSM3 } from "spark-psm/src/interfaces/IPSM3.sol"; @@ -35,11 +37,9 @@ contract ForeignController is AccessControl { uint256 usdcAmount ); - event Frozen(); - event MintRecipientSet(uint32 indexed destinationDomain, bytes32 mintRecipient); - event Reactivated(); + event RelayerRemoved(address indexed relayer); /**********************************************************************************************/ /*** State variables ***/ @@ -64,8 +64,6 @@ contract ForeignController is AccessControl { IERC20 public immutable usdc; - bool public active; - mapping(uint32 destinationDomain => bytes32 mintRecipient) public mintRecipients; /**********************************************************************************************/ @@ -87,19 +85,12 @@ contract ForeignController is AccessControl { psm = IPSM3(psm_); usdc = IERC20(usdc_); cctp = ICCTPLike(cctp_); - - active = true; } /**********************************************************************************************/ /*** Modifiers ***/ /**********************************************************************************************/ - modifier isActive { - require(active, "ForeignController/not-active"); - _; - } - modifier rateLimited(bytes32 key, uint256 amount) { rateLimits.triggerRateLimitDecrease(key, amount); _; @@ -110,12 +101,21 @@ contract ForeignController is AccessControl { _; } + modifier rateLimitExists(bytes32 key) { + require( + rateLimits.getRateLimitData(key).maxAmount > 0, + "ForeignController/invalid-action" + ); + _; + } + /**********************************************************************************************/ /*** Admin functions ***/ /**********************************************************************************************/ function setMintRecipient(uint32 destinationDomain, bytes32 mintRecipient) - external onlyRole(DEFAULT_ADMIN_ROLE) + external + onlyRole(DEFAULT_ADMIN_ROLE) { mintRecipients[destinationDomain] = mintRecipient; emit MintRecipientSet(destinationDomain, mintRecipient); @@ -125,14 +125,9 @@ contract ForeignController is AccessControl { /*** Freezer functions ***/ /**********************************************************************************************/ - function freeze() external onlyRole(FREEZER) { - active = false; - emit Frozen(); - } - - function reactivate() external onlyRole(DEFAULT_ADMIN_ROLE) { - active = true; - emit Reactivated(); + function removeRelayer(address relayer) external onlyRole(FREEZER) { + _revokeRole(RELAYER, relayer); + emit RelayerRemoved(relayer); } /**********************************************************************************************/ @@ -142,15 +137,11 @@ contract ForeignController is AccessControl { function depositPSM(address asset, uint256 amount) external onlyRole(RELAYER) - isActive rateLimitedAsset(LIMIT_PSM_DEPOSIT, asset, amount) returns (uint256 shares) { // Approve `asset` to PSM from the proxy (assumes the proxy has enough `asset`). - proxy.doCall( - asset, - abi.encodeCall(IERC20.approve, (address(psm), amount)) - ); + _approve(asset, address(psm), amount); // Deposit `amount` of `asset` in the PSM, decode the result to get `shares`. shares = abi.decode( @@ -167,7 +158,9 @@ contract ForeignController is AccessControl { // NOTE: !!! Rate limited at end of function !!! function withdrawPSM(address asset, uint256 maxAmount) - external onlyRole(RELAYER) isActive returns (uint256 assetsWithdrawn) + external + onlyRole(RELAYER) + returns (uint256 assetsWithdrawn) { // Withdraw up to `maxAmount` of `asset` in the PSM, decode the result // to get `assetsWithdrawn` (assumes the proxy has enough PSM shares). @@ -195,7 +188,6 @@ contract ForeignController is AccessControl { function transferUSDCToCCTP(uint256 usdcAmount, uint32 destinationDomain) external onlyRole(RELAYER) - isActive rateLimited(LIMIT_USDC_TO_CCTP, usdcAmount) rateLimited( RateLimitHelpers.makeDomainKey(LIMIT_USDC_TO_DOMAIN, destinationDomain), @@ -206,13 +198,10 @@ contract ForeignController is AccessControl { require(mintRecipient != 0, "ForeignController/domain-not-configured"); - // Approve USDC to CCTP from the proxy (assumes the proxy has enough USDC) - proxy.doCall( - address(usdc), - abi.encodeCall(usdc.approve, (address(cctp), usdcAmount)) - ); + // Approve USDC to CCTP from the proxy (assumes the proxy has enough USDC). + _approve(address(usdc), address(cctp), usdcAmount); - // If amount is larger than limit it must be split into multiple calls + // If amount is larger than limit it must be split into multiple calls. uint256 burnLimit = cctp.localMinter().burnLimitsPerMessage(address(usdc)); while (usdcAmount > burnLimit) { @@ -233,23 +222,16 @@ contract ForeignController is AccessControl { function depositERC4626(address token, uint256 amount) external onlyRole(RELAYER) - isActive - rateLimited( - RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, token), - amount - ) + rateLimitedAsset(LIMIT_4626_DEPOSIT, token, amount) returns (uint256 shares) { - // Note that whitelist is done by rate limits + // Note that whitelist is done by rate limits. IERC20 asset = IERC20(IERC4626(token).asset()); // Approve asset to token from the proxy (assumes the proxy has enough of the asset). - proxy.doCall( - address(asset), - abi.encodeCall(asset.approve, (token, amount)) - ); + _approve(address(asset), token, amount); - // Deposit asset into the token, proxy receives token shares, decode the resulting shares + // Deposit asset into the token, proxy receives token shares, decode the resulting shares. shares = abi.decode( proxy.doCall( token, @@ -262,11 +244,7 @@ contract ForeignController is AccessControl { function withdrawERC4626(address token, uint256 amount) external onlyRole(RELAYER) - isActive - rateLimited( - RateLimitHelpers.makeAssetKey(LIMIT_4626_WITHDRAW, token), - amount - ) + rateLimitedAsset(LIMIT_4626_WITHDRAW, token, amount) returns (uint256 shares) { // Withdraw asset from a token, decode resulting shares. @@ -282,7 +260,9 @@ contract ForeignController is AccessControl { // NOTE: !!! Rate limited at end of function !!! function redeemERC4626(address token, uint256 shares) - external onlyRole(RELAYER) isActive returns (uint256 assets) + external + onlyRole(RELAYER) + returns (uint256 assets) { // Redeem shares for assets from the token, decode the resulting assets. // Assumes proxy has adequate token shares. @@ -307,22 +287,15 @@ contract ForeignController is AccessControl { function depositAave(address aToken, uint256 amount) external onlyRole(RELAYER) - isActive - rateLimited( - RateLimitHelpers.makeAssetKey(LIMIT_AAVE_DEPOSIT, aToken), - amount - ) + rateLimitedAsset(LIMIT_AAVE_DEPOSIT, aToken, amount) { IERC20 underlying = IERC20(IATokenWithPool(aToken).UNDERLYING_ASSET_ADDRESS()); IAavePool pool = IAavePool(IATokenWithPool(aToken).POOL()); // Approve underlying to Aave pool from the proxy (assumes the proxy has enough underlying). - proxy.doCall( - address(underlying), - abi.encodeCall(underlying.approve, (address(pool), amount)) - ); + _approve(address(underlying), address(pool), amount); - // Deposit underlying into Aave pool, proxy receives aTokens + // Deposit underlying into Aave pool, proxy receives aTokens. proxy.doCall( address(pool), abi.encodeCall(pool.supply, (address(underlying), amount, address(proxy), 0)) @@ -331,7 +304,9 @@ contract ForeignController is AccessControl { // NOTE: !!! Rate limited at end of function !!! function withdrawAave(address aToken, uint256 amount) - external onlyRole(RELAYER) isActive returns (uint256 amountWithdrawn) + external + onlyRole(RELAYER) + returns (uint256 amountWithdrawn) { IAavePool pool = IAavePool(IATokenWithPool(aToken).POOL()); @@ -354,10 +329,52 @@ contract ForeignController is AccessControl { ); } + /**********************************************************************************************/ + /*** Relayer Morpho functions ***/ + /**********************************************************************************************/ + + function setSupplyQueueMorpho(address morphoVault, Id[] memory newSupplyQueue) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, morphoVault)) + { + proxy.doCall( + morphoVault, + abi.encodeCall(IMetaMorpho(morphoVault).setSupplyQueue, (newSupplyQueue)) + ); + } + + function updateWithdrawQueueMorpho(address morphoVault, uint256[] calldata indexes) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, morphoVault)) + { + proxy.doCall( + morphoVault, + abi.encodeCall(IMetaMorpho(morphoVault).updateWithdrawQueue, (indexes)) + ); + } + + function reallocateMorpho(address morphoVault, MarketAllocation[] calldata allocations) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, morphoVault)) + { + proxy.doCall( + morphoVault, + abi.encodeCall(IMetaMorpho(morphoVault).reallocate, (allocations)) + ); + } + + /**********************************************************************************************/ /*** Internal helper functions ***/ /**********************************************************************************************/ + function _approve(address token, address spender, uint256 amount) internal { + proxy.doCall(token, abi.encodeCall(IERC20.approve, (spender, amount))); + } + function _initiateCCTPTransfer( uint256 usdcAmount, uint32 destinationDomain, diff --git a/src/MainnetController.sol b/src/MainnetController.sol index 4fe4844c..38e4cd62 100644 --- a/src/MainnetController.sol +++ b/src/MainnetController.sol @@ -6,6 +6,9 @@ import { IPool as IAavePool } from "aave-v3-origin/src/core/contracts/interfaces import { IERC20 } from "forge-std/interfaces/IERC20.sol"; import { IERC4626 } from "forge-std/interfaces/IERC4626.sol"; +import { IERC7540 } from "forge-std/interfaces/IERC7540.sol"; + +import { IMetaMorpho, Id, MarketAllocation } from "metamorpho/interfaces/IMetaMorpho.sol"; import { AccessControl } from "openzeppelin-contracts/contracts/access/AccessControl.sol"; @@ -17,6 +20,15 @@ import { IRateLimits } from "./interfaces/IRateLimits.sol"; import { RateLimitHelpers } from "./RateLimitHelpers.sol"; +interface IATokenWithPool is IAToken { + function POOL() external view returns(address); +} + +interface IBuidlRedeemLike { + function asset() external view returns(address); + function redeem(uint256 usdcAmount) external; +} + interface IDaiUsdsLike { function dai() external view returns(address); function daiToUsds(address usr, uint256 wad) external; @@ -28,16 +40,18 @@ interface IEthenaMinterLike { function removeDelegatedSigner(address delegateSigner) external; } -interface ISUSDELike is IERC4626 { - function cooldownAssets(uint256 usdeAmount) external; - function cooldownShares(uint256 susdeAmount) external; - function unstake(address receiver) external; +interface ICentrifugeToken is IERC7540 { + function cancelDepositRequest(uint256 requestId, address controller) external; + function cancelRedeemRequest(uint256 requestId, address controller) external; + function claimCancelDepositRequest(uint256 requestId, address receiver, address controller) + external returns (uint256 assets); + function claimCancelRedeemRequest(uint256 requestId, address receiver, address controller) + external returns (uint256 shares); } -interface IVaultLike { - function buffer() external view returns(address); - function draw(uint256 usdsAmount) external; - function wipe(uint256 usdsAmount) external; +interface IMapleTokenLike is IERC4626 { + function requestRedeem(uint256 shares, address receiver) external; + function removeShares(uint256 shares, address receiver) external; } interface IPSMLike { @@ -48,8 +62,26 @@ interface IPSMLike { function to18ConversionFactor() external view returns (uint256); } -interface IATokenWithPool is IAToken { - function POOL() external view returns(address); +interface ISSRedemptionLike is IERC20 { + function calculateUsdcOut(uint256 ustbAmount) + external view returns (uint256 usdcOutAmount, uint256 usdPerUstbChainlinkRaw); + function redeem(uint256 ustbAmout) external; +} + +interface ISUSDELike is IERC4626 { + function cooldownAssets(uint256 usdeAmount) external; + function cooldownShares(uint256 susdeAmount) external; + function unstake(address receiver) external; +} + +interface IUSTBLike is IERC20 { + function subscribe(uint256 inAmount, address stablecoin) external; +} + +interface IVaultLike { + function buffer() external view returns(address); + function draw(uint256 usdsAmount) external; + function wipe(uint256 usdsAmount) external; } contract MainnetController is AccessControl { @@ -66,11 +98,9 @@ contract MainnetController is AccessControl { uint256 usdcAmount ); - event Frozen(); - event MintRecipientSet(uint32 indexed destinationDomain, bytes32 mintRecipient); - event Reactivated(); + event RelayerRemoved(address indexed relayer); /**********************************************************************************************/ /*** State variables ***/ @@ -79,38 +109,46 @@ contract MainnetController is AccessControl { bytes32 public constant FREEZER = keccak256("FREEZER"); bytes32 public constant RELAYER = keccak256("RELAYER"); - bytes32 public constant LIMIT_4626_DEPOSIT = keccak256("LIMIT_4626_DEPOSIT"); - bytes32 public constant LIMIT_4626_WITHDRAW = keccak256("LIMIT_4626_WITHDRAW"); - bytes32 public constant LIMIT_AAVE_DEPOSIT = keccak256("LIMIT_AAVE_DEPOSIT"); - bytes32 public constant LIMIT_AAVE_WITHDRAW = keccak256("LIMIT_AAVE_WITHDRAW"); - bytes32 public constant LIMIT_SUSDE_COOLDOWN = keccak256("LIMIT_SUSDE_COOLDOWN"); - bytes32 public constant LIMIT_USDC_TO_CCTP = keccak256("LIMIT_USDC_TO_CCTP"); - bytes32 public constant LIMIT_USDC_TO_DOMAIN = keccak256("LIMIT_USDC_TO_DOMAIN"); - bytes32 public constant LIMIT_USDE_BURN = keccak256("LIMIT_USDE_BURN"); - bytes32 public constant LIMIT_USDE_MINT = keccak256("LIMIT_USDE_MINT"); - bytes32 public constant LIMIT_USDS_MINT = keccak256("LIMIT_USDS_MINT"); - bytes32 public constant LIMIT_USDS_TO_USDC = keccak256("LIMIT_USDS_TO_USDC"); + bytes32 public constant LIMIT_4626_DEPOSIT = keccak256("LIMIT_4626_DEPOSIT"); + bytes32 public constant LIMIT_4626_WITHDRAW = keccak256("LIMIT_4626_WITHDRAW"); + bytes32 public constant LIMIT_7540_DEPOSIT = keccak256("LIMIT_7540_DEPOSIT"); + bytes32 public constant LIMIT_7540_REDEEM = keccak256("LIMIT_7540_REDEEM"); + bytes32 public constant LIMIT_AAVE_DEPOSIT = keccak256("LIMIT_AAVE_DEPOSIT"); + bytes32 public constant LIMIT_AAVE_WITHDRAW = keccak256("LIMIT_AAVE_WITHDRAW"); + bytes32 public constant LIMIT_ASSET_TRANSFER = keccak256("LIMIT_ASSET_TRANSFER"); + bytes32 public constant LIMIT_BUIDL_REDEEM_CIRCLE = keccak256("LIMIT_BUIDL_REDEEM_CIRCLE"); + bytes32 public constant LIMIT_MAPLE_REDEEM = keccak256("LIMIT_MAPLE_REDEEM"); + bytes32 public constant LIMIT_SUPERSTATE_REDEEM = keccak256("LIMIT_SUPERSTATE_REDEEM"); + bytes32 public constant LIMIT_SUPERSTATE_SUBSCRIBE = keccak256("LIMIT_SUPERSTATE_SUBSCRIBE"); + bytes32 public constant LIMIT_SUSDE_COOLDOWN = keccak256("LIMIT_SUSDE_COOLDOWN"); + bytes32 public constant LIMIT_USDC_TO_CCTP = keccak256("LIMIT_USDC_TO_CCTP"); + bytes32 public constant LIMIT_USDC_TO_DOMAIN = keccak256("LIMIT_USDC_TO_DOMAIN"); + bytes32 public constant LIMIT_USDE_BURN = keccak256("LIMIT_USDE_BURN"); + bytes32 public constant LIMIT_USDE_MINT = keccak256("LIMIT_USDE_MINT"); + bytes32 public constant LIMIT_USDS_MINT = keccak256("LIMIT_USDS_MINT"); + bytes32 public constant LIMIT_USDS_TO_USDC = keccak256("LIMIT_USDS_TO_USDC"); address public immutable buffer; IALMProxy public immutable proxy; + IBuidlRedeemLike public immutable buidlRedeem; ICCTPLike public immutable cctp; IDaiUsdsLike public immutable daiUsds; IEthenaMinterLike public immutable ethenaMinter; IPSMLike public immutable psm; IRateLimits public immutable rateLimits; + ISSRedemptionLike public immutable superstateRedemption; IVaultLike public immutable vault; IERC20 public immutable dai; IERC20 public immutable usds; IERC20 public immutable usde; IERC20 public immutable usdc; + IUSTBLike public immutable ustb; ISUSDELike public immutable susde; uint256 public immutable psmTo18ConversionFactor; - bool public active; - mapping(uint32 destinationDomain => bytes32 mintRecipient) public mintRecipients; /**********************************************************************************************/ @@ -136,30 +174,31 @@ contract MainnetController is AccessControl { daiUsds = IDaiUsdsLike(daiUsds_); cctp = ICCTPLike(cctp_); - ethenaMinter = IEthenaMinterLike(Ethereum.ETHENA_MINTER); + buidlRedeem = IBuidlRedeemLike(Ethereum.BUIDL_REDEEM); + ethenaMinter = IEthenaMinterLike(Ethereum.ETHENA_MINTER); + superstateRedemption = ISSRedemptionLike(Ethereum.SUPERSTATE_REDEMPTION); susde = ISUSDELike(Ethereum.SUSDE); + ustb = IUSTBLike(Ethereum.USTB); dai = IERC20(daiUsds.dai()); usdc = IERC20(psm.gem()); usds = IERC20(Ethereum.USDS); usde = IERC20(Ethereum.USDE); psmTo18ConversionFactor = psm.to18ConversionFactor(); - - active = true; } /**********************************************************************************************/ /*** Modifiers ***/ /**********************************************************************************************/ - modifier isActive { - require(active, "MainnetController/not-active"); + modifier rateLimited(bytes32 key, uint256 amount) { + rateLimits.triggerRateLimitDecrease(key, amount); _; } - modifier rateLimited(bytes32 key, uint256 amount) { - rateLimits.triggerRateLimitDecrease(key, amount); + modifier rateLimitedAsset(bytes32 key, address asset, uint256 amount) { + rateLimits.triggerRateLimitDecrease(RateLimitHelpers.makeAssetKey(key, asset), amount); _; } @@ -168,12 +207,21 @@ contract MainnetController is AccessControl { _; } + modifier rateLimitExists(bytes32 key) { + require( + rateLimits.getRateLimitData(key).maxAmount > 0, + "MainnetController/invalid-action" + ); + _; + } + /**********************************************************************************************/ /*** Admin functions ***/ /**********************************************************************************************/ function setMintRecipient(uint32 destinationDomain, bytes32 mintRecipient) - external onlyRole(DEFAULT_ADMIN_ROLE) + external + onlyRole(DEFAULT_ADMIN_ROLE) { mintRecipients[destinationDomain] = mintRecipient; emit MintRecipientSet(destinationDomain, mintRecipient); @@ -183,14 +231,9 @@ contract MainnetController is AccessControl { /*** Freezer functions ***/ /**********************************************************************************************/ - function freeze() external onlyRole(FREEZER) { - active = false; - emit Frozen(); - } - - function reactivate() external onlyRole(DEFAULT_ADMIN_ROLE) { - active = true; - emit Reactivated(); + function removeRelayer(address relayer) external onlyRole(FREEZER) { + _revokeRole(RELAYER, relayer); + emit RelayerRemoved(relayer); } /**********************************************************************************************/ @@ -198,7 +241,9 @@ contract MainnetController is AccessControl { /**********************************************************************************************/ function mintUSDS(uint256 usdsAmount) - external onlyRole(RELAYER) isActive rateLimited(LIMIT_USDS_MINT, usdsAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_USDS_MINT, usdsAmount) { // Mint USDS into the buffer proxy.doCall( @@ -214,7 +259,9 @@ contract MainnetController is AccessControl { } function burnUSDS(uint256 usdsAmount) - external onlyRole(RELAYER) isActive cancelRateLimit(LIMIT_USDS_MINT, usdsAmount) + external + onlyRole(RELAYER) + cancelRateLimit(LIMIT_USDS_MINT, usdsAmount) { // Transfer USDS from the proxy to the buffer proxy.doCall( @@ -230,27 +277,38 @@ contract MainnetController is AccessControl { } /**********************************************************************************************/ - /*** Relayer ERC4626 functions ***/ + /*** Relayer ERC20 functions ***/ /**********************************************************************************************/ - function depositERC4626(address token, uint256 amount) + function transferAsset(address asset, address destination, uint256 amount) external onlyRole(RELAYER) - isActive rateLimited( - RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, token), + RateLimitHelpers.makeAssetDestinationKey(LIMIT_ASSET_TRANSFER, asset, destination), amount ) + { + proxy.doCall( + asset, + abi.encodeCall(IERC20(asset).transfer, (destination, amount)) + ); + } + + /**********************************************************************************************/ + /*** Relayer ERC4626 functions ***/ + /**********************************************************************************************/ + + function depositERC4626(address token, uint256 amount) + external + onlyRole(RELAYER) + rateLimitedAsset(LIMIT_4626_DEPOSIT, token, amount) returns (uint256 shares) { // Note that whitelist is done by rate limits IERC20 asset = IERC20(IERC4626(token).asset()); // Approve asset to token from the proxy (assumes the proxy has enough of the asset). - proxy.doCall( - address(asset), - abi.encodeCall(asset.approve, (token, amount)) - ); + _approve(address(asset), token, amount); // Deposit asset into the token, proxy receives token shares, decode the resulting shares shares = abi.decode( @@ -265,11 +323,7 @@ contract MainnetController is AccessControl { function withdrawERC4626(address token, uint256 amount) external onlyRole(RELAYER) - isActive - rateLimited( - RateLimitHelpers.makeAssetKey(LIMIT_4626_WITHDRAW, token), - amount - ) + rateLimitedAsset(LIMIT_4626_WITHDRAW, token, amount) returns (uint256 shares) { // Withdraw asset from a token, decode resulting shares. @@ -285,7 +339,9 @@ contract MainnetController is AccessControl { // NOTE: !!! Rate limited at end of function !!! function redeemERC4626(address token, uint256 shares) - external onlyRole(RELAYER) isActive returns (uint256 assets) + external + onlyRole(RELAYER) + returns (uint256 assets) { // Redeem shares for assets from the token, decode the resulting assets. // Assumes proxy has adequate token shares. @@ -303,6 +359,138 @@ contract MainnetController is AccessControl { ); } + /**********************************************************************************************/ + /*** Relayer ERC7540 functions ***/ + /**********************************************************************************************/ + + function requestDepositERC7540(address token, uint256 amount) + external + onlyRole(RELAYER) + rateLimitedAsset(LIMIT_7540_DEPOSIT, token, amount) + { + // Note that whitelist is done by rate limits + IERC20 asset = IERC20(IERC7540(token).asset()); + + // Approve asset to vault from the proxy (assumes the proxy has enough of the asset). + _approve(address(asset), token, amount); + + // Submit deposit request by transferring assets + proxy.doCall( + token, + abi.encodeCall(IERC7540(token).requestDeposit, (amount, address(proxy), address(proxy))) + ); + } + + function claimDepositERC7540(address token) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_7540_DEPOSIT, token)) + { + uint256 shares = IERC7540(token).maxMint(address(proxy)); + + // Claim shares from the vault to the proxy + proxy.doCall( + token, + abi.encodeCall(IERC4626(token).mint, (shares, address(proxy))) + ); + } + + function requestRedeemERC7540(address token, uint256 shares) + external + onlyRole(RELAYER) + rateLimitedAsset( + LIMIT_7540_REDEEM, + token, + IERC7540(token).convertToAssets(shares) + ) + { + // Submit redeem request by transferring shares + proxy.doCall( + token, + abi.encodeCall(IERC7540(token).requestRedeem, (shares, address(proxy), address(proxy))) + ); + } + + function claimRedeemERC7540(address token) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_7540_REDEEM, token)) + { + uint256 assets = IERC7540(token).maxWithdraw(address(proxy)); + + // Claim assets from the vault to the proxy + proxy.doCall( + token, + abi.encodeCall(IERC7540(token).withdraw, (assets, address(proxy), address(proxy))) + ); + } + + /**********************************************************************************************/ + /*** Relayer Centrifuge functions ***/ + /**********************************************************************************************/ + + // NOTE: These cancelation methods are compatible with ERC-7887 + + uint256 CENTRIFUGE_REQUEST_ID = 0; + + function cancelCentrifugeDepositRequest(address token) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_7540_DEPOSIT, token)) + { + // NOTE: While the cancelation is pending, no new deposit request can be submitted + proxy.doCall( + token, + abi.encodeCall( + ICentrifugeToken(token).cancelDepositRequest, + (CENTRIFUGE_REQUEST_ID, address(proxy)) + ) + ); + } + + function claimCentrifugeCancelDepositRequest(address token) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_7540_DEPOSIT, token)) + { + proxy.doCall( + token, + abi.encodeCall( + ICentrifugeToken(token).claimCancelDepositRequest, + (CENTRIFUGE_REQUEST_ID, address(proxy), address(proxy)) + ) + ); + } + + function cancelCentrifugeRedeemRequest(address token) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_7540_REDEEM, token)) + { + // NOTE: While the cancelation is pending, no new redeem request can be submitted + proxy.doCall( + token, + abi.encodeCall( + ICentrifugeToken(token).cancelRedeemRequest, + (CENTRIFUGE_REQUEST_ID, address(proxy)) + ) + ); + } + + function claimCentrifugeCancelRedeemRequest(address token) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_7540_REDEEM, token)) + { + proxy.doCall( + token, + abi.encodeCall( + ICentrifugeToken(token).claimCancelRedeemRequest, + (CENTRIFUGE_REQUEST_ID, address(proxy), address(proxy)) + ) + ); + } + /**********************************************************************************************/ /*** Relayer Aave functions ***/ /**********************************************************************************************/ @@ -310,20 +498,13 @@ contract MainnetController is AccessControl { function depositAave(address aToken, uint256 amount) external onlyRole(RELAYER) - isActive - rateLimited( - RateLimitHelpers.makeAssetKey(LIMIT_AAVE_DEPOSIT, aToken), - amount - ) + rateLimitedAsset(LIMIT_AAVE_DEPOSIT, aToken, amount) { IERC20 underlying = IERC20(IATokenWithPool(aToken).UNDERLYING_ASSET_ADDRESS()); IAavePool pool = IAavePool(IATokenWithPool(aToken).POOL()); // Approve underlying to Aave pool from the proxy (assumes the proxy has enough underlying). - proxy.doCall( - address(underlying), - abi.encodeCall(underlying.approve, (address(pool), amount)) - ); + _approve(address(underlying), address(pool), amount); // Deposit underlying into Aave pool, proxy receives aTokens proxy.doCall( @@ -334,7 +515,9 @@ contract MainnetController is AccessControl { // NOTE: !!! Rate limited at end of function !!! function withdrawAave(address aToken, uint256 amount) - external onlyRole(RELAYER) isActive returns (uint256 amountWithdrawn) + external + onlyRole(RELAYER) + returns (uint256 amountWithdrawn) { IAavePool pool = IAavePool(IATokenWithPool(aToken).POOL()); @@ -357,18 +540,35 @@ contract MainnetController is AccessControl { ); } + /**********************************************************************************************/ + /*** Relayer BlackRock BUIDL functions ***/ + /**********************************************************************************************/ + + function redeemBUIDLCircleFacility(uint256 usdcAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_BUIDL_REDEEM_CIRCLE, usdcAmount) + { + _approve(address(buidlRedeem.asset()), address(buidlRedeem), usdcAmount); + + proxy.doCall( + address(buidlRedeem), + abi.encodeCall(buidlRedeem.redeem, (usdcAmount)) + ); + } + /**********************************************************************************************/ /*** Relayer Ethena functions ***/ /**********************************************************************************************/ - function setDelegatedSigner(address delegatedSigner) external onlyRole(RELAYER) isActive { + function setDelegatedSigner(address delegatedSigner) external onlyRole(RELAYER) { proxy.doCall( address(ethenaMinter), abi.encodeCall(ethenaMinter.setDelegatedSigner, (address(delegatedSigner))) ); } - function removeDelegatedSigner(address delegatedSigner) external onlyRole(RELAYER) isActive { + function removeDelegatedSigner(address delegatedSigner) external onlyRole(RELAYER) { proxy.doCall( address(ethenaMinter), abi.encodeCall(ethenaMinter.removeDelegatedSigner, (address(delegatedSigner))) @@ -377,25 +577,25 @@ contract MainnetController is AccessControl { // Note that Ethena's mint/redeem per-block limits include other users function prepareUSDeMint(uint256 usdcAmount) - external onlyRole(RELAYER) isActive rateLimited(LIMIT_USDE_MINT, usdcAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_USDE_MINT, usdcAmount) { - proxy.doCall( - address(usdc), - abi.encodeCall(usdc.approve, (address(ethenaMinter), usdcAmount)) - ); + _approve(address(usdc), address(ethenaMinter), usdcAmount); } function prepareUSDeBurn(uint256 usdeAmount) - external onlyRole(RELAYER) isActive rateLimited(LIMIT_USDE_BURN, usdeAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_USDE_BURN, usdeAmount) { - proxy.doCall( - address(usde), - abi.encodeCall(usde.approve, (address(ethenaMinter), usdeAmount)) - ); + _approve(address(usde), address(ethenaMinter), usdeAmount); } function cooldownAssetsSUSDe(uint256 usdeAmount) - external onlyRole(RELAYER) isActive rateLimited(LIMIT_SUSDE_COOLDOWN, usdeAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_SUSDE_COOLDOWN, usdeAmount) { proxy.doCall( address(susde), @@ -404,10 +604,9 @@ contract MainnetController is AccessControl { } // NOTE: !!! Rate limited at end of function !!! - function cooldownSharesSUSDe(uint256 susdeAmount) + function cooldownSharesSUSDe(uint256 susdeAmount) external - onlyRole(RELAYER) - isActive + onlyRole(RELAYER) returns (uint256 cooldownAmount) { cooldownAmount = abi.decode( @@ -421,13 +620,111 @@ contract MainnetController is AccessControl { rateLimits.triggerRateLimitDecrease(LIMIT_SUSDE_COOLDOWN, cooldownAmount); } - function unstakeSUSDe() external onlyRole(RELAYER) isActive { + function unstakeSUSDe() external onlyRole(RELAYER) { proxy.doCall( address(susde), abi.encodeCall(susde.unstake, (address(proxy))) ); } + /**********************************************************************************************/ + /*** Relayer Maple functions ***/ + /**********************************************************************************************/ + + function requestMapleRedemption(address mapleToken, uint256 shares) + external + onlyRole(RELAYER) + rateLimitedAsset( + LIMIT_MAPLE_REDEEM, + mapleToken, + IMapleTokenLike(mapleToken).convertToAssets(shares) + ) + { + proxy.doCall( + mapleToken, + abi.encodeCall(IMapleTokenLike(mapleToken).requestRedeem, (shares, address(proxy))) + ); + } + + function cancelMapleRedemption(address mapleToken, uint256 shares) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_MAPLE_REDEEM, mapleToken)) + { + proxy.doCall( + mapleToken, + abi.encodeCall(IMapleTokenLike(mapleToken).removeShares, (shares, address(proxy))) + ); + } + + /**********************************************************************************************/ + /*** Relayer Morpho functions ***/ + /**********************************************************************************************/ + + function setSupplyQueueMorpho(address morphoVault, Id[] memory newSupplyQueue) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, morphoVault)) + { + proxy.doCall( + morphoVault, + abi.encodeCall(IMetaMorpho(morphoVault).setSupplyQueue, (newSupplyQueue)) + ); + } + + function updateWithdrawQueueMorpho(address morphoVault, uint256[] calldata indexes) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, morphoVault)) + { + proxy.doCall( + morphoVault, + abi.encodeCall(IMetaMorpho(morphoVault).updateWithdrawQueue, (indexes)) + ); + } + + function reallocateMorpho(address morphoVault, MarketAllocation[] calldata allocations) + external + onlyRole(RELAYER) + rateLimitExists(RateLimitHelpers.makeAssetKey(LIMIT_4626_DEPOSIT, morphoVault)) + { + proxy.doCall( + morphoVault, + abi.encodeCall(IMetaMorpho(morphoVault).reallocate, (allocations)) + ); + } + + /**********************************************************************************************/ + /*** Relayer Superstate functions ***/ + /**********************************************************************************************/ + + function subscribeSuperstate(uint256 usdcAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_SUPERSTATE_SUBSCRIBE, usdcAmount) + { + _approve(address(usdc), address(ustb), usdcAmount); + + proxy.doCall( + address(ustb), + abi.encodeCall(ustb.subscribe, (usdcAmount, address(usdc))) + ); + } + + // NOTE: Rate limited outside of modifier because of tuple return + function redeemSuperstate(uint256 ustbAmount) external onlyRole(RELAYER) { + ( uint256 usdcAmount, ) = superstateRedemption.calculateUsdcOut(ustbAmount); + + rateLimits.triggerRateLimitDecrease(LIMIT_SUPERSTATE_REDEEM, usdcAmount); + + _approve(address(ustb), address(superstateRedemption), ustbAmount); + + proxy.doCall( + address(superstateRedemption), + abi.encodeCall(superstateRedemption.redeem, (ustbAmount)) + ); + } + /**********************************************************************************************/ /*** Relayer PSM functions ***/ /**********************************************************************************************/ @@ -435,15 +732,14 @@ contract MainnetController is AccessControl { // NOTE: The param `usdcAmount` is denominated in 1e6 precision to match how PSM uses // USDC precision for both `buyGemNoFee` and `sellGemNoFee` function swapUSDSToUSDC(uint256 usdcAmount) - external onlyRole(RELAYER) isActive rateLimited(LIMIT_USDS_TO_USDC, usdcAmount) + external + onlyRole(RELAYER) + rateLimited(LIMIT_USDS_TO_USDC, usdcAmount) { uint256 usdsAmount = usdcAmount * psmTo18ConversionFactor; // Approve USDS to DaiUsds migrator from the proxy (assumes the proxy has enough USDS) - proxy.doCall( - address(usds), - abi.encodeCall(usds.approve, (address(daiUsds), usdsAmount)) - ); + _approve(address(usds), address(daiUsds), usdsAmount); // Swap USDS to DAI 1:1 proxy.doCall( @@ -452,10 +748,7 @@ contract MainnetController is AccessControl { ); // Approve DAI to PSM from the proxy because conversion from USDS to DAI was 1:1 - proxy.doCall( - address(dai), - abi.encodeCall(dai.approve, (address(psm), usdsAmount)) - ); + _approve(address(dai), address(psm), usdsAmount); // Swap DAI to USDC through the PSM proxy.doCall( @@ -465,13 +758,12 @@ contract MainnetController is AccessControl { } function swapUSDCToUSDS(uint256 usdcAmount) - external onlyRole(RELAYER) isActive cancelRateLimit(LIMIT_USDS_TO_USDC, usdcAmount) + external + onlyRole(RELAYER) + cancelRateLimit(LIMIT_USDS_TO_USDC, usdcAmount) { // Approve USDC to PSM from the proxy (assumes the proxy has enough USDC) - proxy.doCall( - address(usdc), - abi.encodeCall(usdc.approve, (address(psm), usdcAmount)) - ); + _approve(address(usdc), address(psm), usdcAmount); // Max USDC that can be swapped to DAI in one call uint256 limit = dai.balanceOf(address(psm)) / psmTo18ConversionFactor; @@ -502,10 +794,7 @@ contract MainnetController is AccessControl { uint256 daiAmount = usdcAmount * psmTo18ConversionFactor; // Approve DAI to DaiUsds migrator from the proxy (assumes the proxy has enough DAI) - proxy.doCall( - address(dai), - abi.encodeCall(dai.approve, (address(daiUsds), daiAmount)) - ); + _approve(address(dai), address(daiUsds), daiAmount); // Swap DAI to USDS 1:1 proxy.doCall( @@ -521,7 +810,6 @@ contract MainnetController is AccessControl { function transferUSDCToCCTP(uint256 usdcAmount, uint32 destinationDomain) external onlyRole(RELAYER) - isActive rateLimited(LIMIT_USDC_TO_CCTP, usdcAmount) rateLimited( RateLimitHelpers.makeDomainKey(LIMIT_USDC_TO_DOMAIN, destinationDomain), @@ -533,10 +821,7 @@ contract MainnetController is AccessControl { require(mintRecipient != 0, "MainnetController/domain-not-configured"); // Approve USDC to CCTP from the proxy (assumes the proxy has enough USDC) - proxy.doCall( - address(usdc), - abi.encodeCall(usdc.approve, (address(cctp), usdcAmount)) - ); + _approve(address(usdc), address(cctp), usdcAmount); // If amount is larger than limit it must be split into multiple calls uint256 burnLimit = cctp.localMinter().burnLimitsPerMessage(address(usdc)); @@ -556,6 +841,10 @@ contract MainnetController is AccessControl { /*** Internal helper functions ***/ /**********************************************************************************************/ + function _approve(address token, address spender, uint256 amount) internal { + proxy.doCall(token, abi.encodeCall(IERC20.approve, (spender, amount))); + } + function _initiateCCTPTransfer( uint256 usdcAmount, uint32 destinationDomain, diff --git a/src/RateLimitHelpers.sol b/src/RateLimitHelpers.sol index 9e66d84a..ff39ea07 100644 --- a/src/RateLimitHelpers.sol +++ b/src/RateLimitHelpers.sol @@ -10,10 +10,18 @@ struct RateLimitData { library RateLimitHelpers { + error InvalidUnlimitedRateLimitSlope(string name); + error InvalidMaxAmountPrecision(string name); + error InvalidSlopePrecision(string name); + function makeAssetKey(bytes32 key, address asset) internal pure returns (bytes32) { return keccak256(abi.encode(key, asset)); } + function makeAssetDestinationKey(bytes32 key, address asset, address destination) internal pure returns (bytes32) { + return keccak256(abi.encode(key, asset, destination)); + } + function makeDomainKey(bytes32 key, uint32 domain) internal pure returns (bytes32) { return keccak256(abi.encode(key, domain)); } @@ -23,7 +31,7 @@ library RateLimitHelpers { maxAmount : type(uint256).max, slope : 0 }); - } + } function setRateLimitData( bytes32 key, @@ -36,20 +44,23 @@ library RateLimitHelpers { { // Handle setting an unlimited rate limit if (data.maxAmount == type(uint256).max) { - require( - data.slope == 0, - string(abi.encodePacked("RateLimitHelpers/invalid-rate-limit-", name)) - ); - } - else { - require( - data.maxAmount <= 1e12 * (10 ** decimals), - string(abi.encodePacked("RateLimitHelpers/invalid-max-amount-precision-", name)) - ); - require( - data.slope <= 1e12 * (10 ** decimals) / 1 hours, - string(abi.encodePacked("RateLimitHelpers/invalid-slope-precision-", name)) - ); + if (data.slope != 0) { + revert InvalidUnlimitedRateLimitSlope(name); + } + } else { + uint256 upperBound = 1e12 * (10 ** decimals); + uint256 lowerBound = 10 ** decimals; + + if (data.maxAmount > upperBound || data.maxAmount < lowerBound) { + revert InvalidMaxAmountPrecision(name); + } + + if ( + data.slope != 0 && + (data.slope > upperBound / 1 hours || data.slope < lowerBound / 1 hours) + ) { + revert InvalidSlopePrecision(name); + } } IRateLimits(rateLimits).setRateLimitData(key, data.maxAmount, data.slope); } diff --git a/src/RateLimits.sol b/src/RateLimits.sol index 832f030e..264bf3cb 100644 --- a/src/RateLimits.sol +++ b/src/RateLimits.sol @@ -84,7 +84,10 @@ contract RateLimits is IRateLimits, AccessControl { /**********************************************************************************************/ function triggerRateLimitDecrease(bytes32 key, uint256 amountToDecrease) - external override onlyRole(CONTROLLER) returns (uint256 newLimit) + external + override + onlyRole(CONTROLLER) + returns (uint256 newLimit) { RateLimitData storage d = _data[key]; uint256 maxAmount = d.maxAmount; @@ -103,7 +106,10 @@ contract RateLimits is IRateLimits, AccessControl { } function triggerRateLimitIncrease(bytes32 key, uint256 amountToIncrease) - external override onlyRole(CONTROLLER) returns (uint256 newLimit) + external + override + onlyRole(CONTROLLER) + returns (uint256 newLimit) { RateLimitData storage d = _data[key]; uint256 maxAmount = d.maxAmount; diff --git a/test/base-fork/Aave.t.sol b/test/base-fork/Aave.t.sol index b8cfc9e0..c07cb312 100644 --- a/test/base-fork/Aave.t.sol +++ b/test/base-fork/Aave.t.sol @@ -61,15 +61,6 @@ contract AaveV3BaseMarketDepositFailureTests is AaveV3BaseMarketTestBase { foreignController.depositAave(ATOKEN_USDC, 1_000_000e18); } - function test_depositAave_frozen() external { - vm.prank(freezer); - foreignController.freeze(); - - vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); - foreignController.depositAave(ATOKEN_USDC, 1_000_000e18); - } - function test_depositAave_zeroMaxAmount() external { vm.prank(relayer); vm.expectRevert("RateLimits/zero-maxAmount"); @@ -122,15 +113,6 @@ contract AaveV3BaseMarketWithdrawFailureTests is AaveV3BaseMarketTestBase { foreignController.withdrawAave(ATOKEN_USDC, 1_000_000e18); } - function test_withdrawAave_frozen() external { - vm.prank(freezer); - foreignController.freeze(); - - vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); - foreignController.withdrawAave(ATOKEN_USDC, 1_000_000e18); - } - function test_withdrawAave_zeroMaxAmount() external { // Longer setup because rate limit revert is at the end of the function vm.startPrank(Base.SPARK_EXECUTOR); diff --git a/test/base-fork/Deploy.t.sol b/test/base-fork/Deploy.t.sol index b12b8797..255062a7 100644 --- a/test/base-fork/Deploy.t.sol +++ b/test/base-fork/Deploy.t.sol @@ -55,8 +55,6 @@ contract ForeignControllerDeploySuccessTests is ForkTestBase { assertEq(address(controller.psm()), Base.PSM3); assertEq(address(controller.usdc()), Base.USDC); assertEq(address(controller.cctp()), Base.CCTP_TOKEN_MESSENGER); - - assertEq(controller.active(), true); } } diff --git a/test/base-fork/ForkTestBase.t.sol b/test/base-fork/ForkTestBase.t.sol index 3a6ea388..467ebee8 100644 --- a/test/base-fork/ForkTestBase.t.sol +++ b/test/base-fork/ForkTestBase.t.sol @@ -173,11 +173,11 @@ contract ForkTestBase is Test { ); // NOTE: Using minimal config for test base setup - RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(depositKey, address(usdcBase)), address(rateLimits), standardUsdcData, "usdcDepositData", 6); - RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(withdrawKey, address(usdcBase)), address(rateLimits), standardUsdcData, "usdcWithdrawData", 6); - RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(depositKey, address(usdsBase)), address(rateLimits), standardUsdsData, "usdsDepositData", 18); - RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(withdrawKey, address(usdsBase)), address(rateLimits), unlimitedData, "usdsWithdrawData", 18); - RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(depositKey, address(susdsBase)), address(rateLimits), standardUsdsData, "susdsDepositData", 18); + RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(depositKey, address(usdcBase)), address(rateLimits), standardUsdcData, "usdcDepositData", 6); + RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(withdrawKey, address(usdcBase)), address(rateLimits), standardUsdcData, "usdcWithdrawData", 6); + RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(depositKey, address(usdsBase)), address(rateLimits), standardUsdsData, "usdsDepositData", 18); + RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(withdrawKey, address(usdsBase)), address(rateLimits), unlimitedData, "usdsWithdrawData", 18); + RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(depositKey, address(susdsBase)), address(rateLimits), standardUsdsData, "susdsDepositData", 18); RateLimitHelpers.setRateLimitData(RateLimitHelpers.makeAssetKey(withdrawKey, address(susdsBase)), address(rateLimits), unlimitedData, "susdsWithdrawData", 18); RateLimitHelpers.setRateLimitData(foreignController.LIMIT_USDC_TO_CCTP(), address(rateLimits), standardUsdcData, "usdcToCctpData", 6); diff --git a/test/base-fork/InitAndUpgrade.t.sol b/test/base-fork/InitAndUpgrade.t.sol index a63bab43..dbb00b45 100644 --- a/test/base-fork/InitAndUpgrade.t.sol +++ b/test/base-fork/InitAndUpgrade.t.sol @@ -74,7 +74,7 @@ contract ForeignControllerInitAndUpgradeTestBase is ForkTestBase { contract ForeignControllerInitAndUpgradeFailureTest is ForeignControllerInitAndUpgradeTestBase { // NOTE: `initAlmSystem` and `upgradeController` are tested in the same contract because - // they both use _initController and have similar specific setups, so it + // they both use _initController and have similar specific setups, so it // less complex/repetitive to test them together. LibraryWrapper wrapper; @@ -82,7 +82,7 @@ contract ForeignControllerInitAndUpgradeFailureTest is ForeignControllerInitAndU ControllerInstance public controllerInst; address public mismatchAddress = makeAddr("mismatchAddress"); - + address public oldController; Init.ConfigAddressParams configAddresses; @@ -95,7 +95,7 @@ contract ForeignControllerInitAndUpgradeFailureTest is ForeignControllerInitAndU oldController = address(foreignController); // Cache for later testing // Deploy new controller against existing system - // NOTE: initAlmSystem will redundantly call rely and approve on already inited + // NOTE: initAlmSystem will redundantly call rely and approve on already inited // almProxy and rateLimits, this setup was chosen to easily test upgrade and init failures foreignController = ForeignController(ForeignControllerDeploy.deployController({ admin : Base.SPARK_EXECUTOR, @@ -199,18 +199,6 @@ contract ForeignControllerInitAndUpgradeFailureTest is ForeignControllerInitAndU _checkInitAndUpgradeFail(abi.encodePacked("ForeignControllerInit/incorrect-cctp")); } - function test_initAlmSystem_upgradeController_controllerInactive() external { - // Cheating to set this outside of init scripts so that the controller can be frozen - vm.startPrank(Base.SPARK_EXECUTOR); - foreignController.grantRole(FREEZER, freezer); - - vm.startPrank(freezer); - foreignController.freeze(); - vm.stopPrank(); - - _checkInitAndUpgradeFail(abi.encodePacked("ForeignControllerInit/controller-not-active")); - } - function test_initAlmSystem_upgradeController_oldControllerIsNewController() external { configAddresses.oldController = controllerInst.controller; _checkInitAndUpgradeFail(abi.encodePacked("ForeignControllerInit/old-controller-is-new-controller")); @@ -353,7 +341,7 @@ contract ForeignControllerInitAndUpgradeFailureTest is ForeignControllerInitAndU // Revoke the old controller address in ALM proxy vm.startPrank(Base.SPARK_EXECUTOR); almProxy.revokeRole(almProxy.CONTROLLER(), configAddresses.oldController); - vm.stopPrank(); + vm.stopPrank(); // Try to upgrade with the old controller address that is doesn't have the CONTROLLER role vm.expectRevert("ForeignControllerInit/old-controller-not-almProxy-controller"); diff --git a/test/base-fork/Morpho.t.sol b/test/base-fork/Morpho.t.sol index ddee575a..91f57f87 100644 --- a/test/base-fork/Morpho.t.sol +++ b/test/base-fork/Morpho.t.sol @@ -123,15 +123,6 @@ contract MorphoDepositFailureTests is MorphoBaseTest { foreignController.depositERC4626(MORPHO_VAULT_USDS, 1_000_000e18); } - function test_morpho_deposit_frozen() external { - vm.prank(freezer); - foreignController.freeze(); - - vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); - foreignController.depositERC4626(MORPHO_VAULT_USDS, 1_000_000e18); - } - function test_morpho_deposit_zeroMaxAmount() external { vm.prank(relayer); vm.expectRevert("RateLimits/zero-maxAmount"); @@ -205,15 +196,6 @@ contract MorphoWithdrawFailureTests is MorphoBaseTest { foreignController.withdrawERC4626(MORPHO_VAULT_USDS, 1_000_000e18); } - function test_morpho_withdraw_frozen() external { - vm.prank(freezer); - foreignController.freeze(); - - vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); - foreignController.withdrawERC4626(MORPHO_VAULT_USDS, 1_000_000e18); - } - function test_morpho_withdraw_zeroMaxAmount() external { vm.prank(relayer); vm.expectRevert("RateLimits/zero-maxAmount"); @@ -289,15 +271,6 @@ contract MorphoRedeemFailureTests is MorphoBaseTest { foreignController.redeemERC4626(MORPHO_VAULT_USDS, 1_000_000e18); } - function test_morpho_redeem_frozen() external { - vm.prank(freezer); - foreignController.freeze(); - - vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); - foreignController.redeemERC4626(MORPHO_VAULT_USDS, 1_000_000e18); - } - function test_morpho_redeem_zeroMaxAmount() external { // Longer setup because rate limit revert is at the end of the function vm.startPrank(Base.SPARK_EXECUTOR); diff --git a/test/base-fork/MorphoAllocations.t.sol b/test/base-fork/MorphoAllocations.t.sol new file mode 100644 index 00000000..6ebf91c1 --- /dev/null +++ b/test/base-fork/MorphoAllocations.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import { IERC4626 } from "forge-std/interfaces/IERC4626.sol"; + +import { IMetaMorpho, Id, MarketAllocation } from "metamorpho/interfaces/IMetaMorpho.sol"; + +import { MarketParamsLib } from "morpho-blue/src/libraries/MarketParamsLib.sol"; +import { IMorpho, MarketParams } from "morpho-blue/src/interfaces/IMorpho.sol"; + +import { RateLimitHelpers } from "../../src/RateLimitHelpers.sol"; + +import "./ForkTestBase.t.sol"; + +contract MorphoTestBase is ForkTestBase { + + address constant CBBTC = 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf; + address constant CBBTC_USDC_ORACLE = 0x663BECd10daE6C4A3Dcd89F1d76c1174199639B9; + address constant MORPHO_DEFAULT_IRM = 0x46415998764C29aB2a25CbeA6254146D50D22687; + + IMetaMorpho morphoVault = IMetaMorpho(Base.MORPHO_VAULT_SUSDC); + IMorpho morpho = IMorpho(Base.MORPHO); + + MarketParams usdcIdle = MarketParams({ + loanToken : Base.USDC, + collateralToken : address(0), + oracle : address(0), + irm : address(0), + lltv : 0 + }); + MarketParams usdcCBBTC = MarketParams({ + loanToken : Base.USDC, + collateralToken : CBBTC, + oracle : CBBTC_USDC_ORACLE, + irm : MORPHO_DEFAULT_IRM, + lltv : 0.86e18 + }); + + function setUp() public override { + super.setUp(); + + // Spell onboarding + vm.startPrank(Base.SPARK_EXECUTOR); + morphoVault.setIsAllocator(address(almProxy), true); + morphoVault.setIsAllocator(address(relayer), false); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + foreignController.LIMIT_4626_DEPOSIT(), + address(morphoVault) + ), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + } + + function _getBlock() internal pure override returns (uint256) { + return 25340000; // Jan 21, 2024 + } + + function positionShares(MarketParams memory marketParams) internal view returns (uint256) { + return morpho.position(MarketParamsLib.id(marketParams), address(morphoVault)).supplyShares; + } + + function positionAssets(MarketParams memory marketParams) internal view returns (uint256) { + return positionShares(marketParams) + * marketAssets(marketParams) + / morpho.market(MarketParamsLib.id(marketParams)).totalSupplyShares; + } + + function marketAssets(MarketParams memory marketParams) internal view returns (uint256) { + return morpho.market(MarketParamsLib.id(marketParams)).totalSupplyAssets; + } + +} + +contract MorphoSetSupplyQueueMorphoFailureTests is MorphoTestBase { + + function test_setSupplyQueueMorpho_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + foreignController.setSupplyQueueMorpho(address(morphoVault), new Id[](0)); + } + + function test_setSupplyQueueMorpho_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("ForeignController/invalid-action"); + foreignController.setSupplyQueueMorpho(makeAddr("fake-vault"), new Id[](0)); + } + +} + +contract MorphoSetSupplyQueueMorphoSuccessTests is MorphoTestBase { + + function test_setSupplyQueueMorpho() external { + // Switch order of existing markets + Id[] memory supplyQueueUSDC = new Id[](2); + supplyQueueUSDC[0] = MarketParamsLib.id(usdcIdle); + supplyQueueUSDC[1] = MarketParamsLib.id(usdcCBBTC); + + assertEq(morphoVault.supplyQueueLength(), 2); + + assertEq(Id.unwrap(morphoVault.supplyQueue(0)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); + assertEq(Id.unwrap(morphoVault.supplyQueue(1)), Id.unwrap(MarketParamsLib.id(usdcIdle))); + + vm.prank(relayer); + foreignController.setSupplyQueueMorpho(address(morphoVault), supplyQueueUSDC); + + assertEq(morphoVault.supplyQueueLength(), 2); + + assertEq(Id.unwrap(morphoVault.supplyQueue(0)), Id.unwrap(MarketParamsLib.id(usdcIdle))); + assertEq(Id.unwrap(morphoVault.supplyQueue(1)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); + } + +} + +contract MorphoUpdateWithdrawQueueMorphoFailureTests is MorphoTestBase { + + function test_updateWithdrawQueueMorpho_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + foreignController.updateWithdrawQueueMorpho(address(morphoVault), new uint256[](0)); + } + + function test_updateWithdrawQueueMorpho_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("ForeignController/invalid-action"); + foreignController.updateWithdrawQueueMorpho(makeAddr("fake-vault"), new uint256[](0)); + } + +} + +contract MorphoUpdateWithdrawQueueMorphoSuccessTests is MorphoTestBase { + + function test_updateWithdrawQueueMorpho() external { + // Switch order of existing markets + uint256[] memory withdrawQueueUsdc = new uint256[](2); + withdrawQueueUsdc[0] = 1; + withdrawQueueUsdc[1] = 0; + + assertEq(morphoVault.withdrawQueueLength(), 2); + + assertEq(Id.unwrap(morphoVault.withdrawQueue(0)), Id.unwrap(MarketParamsLib.id(usdcIdle))); + assertEq(Id.unwrap(morphoVault.withdrawQueue(1)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); + + vm.prank(relayer); + foreignController.updateWithdrawQueueMorpho(address(morphoVault), withdrawQueueUsdc); + + assertEq(morphoVault.withdrawQueueLength(), 2); + + assertEq(Id.unwrap(morphoVault.withdrawQueue(0)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); + assertEq(Id.unwrap(morphoVault.withdrawQueue(1)), Id.unwrap(MarketParamsLib.id(usdcIdle))); + } + +} + +contract MorphoReallocateMorphoFailureTests is MorphoTestBase { + + function test_reallocateMorpho_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + foreignController.reallocateMorpho(address(morphoVault), new MarketAllocation[](0)); + } + + function test_reallocateMorpho_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("ForeignController/invalid-action"); + foreignController.reallocateMorpho(makeAddr("fake-vault"), new MarketAllocation[](0)); + } + +} + +contract MorphoReallocateMorphoSuccessTests is MorphoTestBase { + + function test_reallocateMorpho() external { + vm.startPrank(Base.SPARK_EXECUTOR); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + foreignController.LIMIT_4626_DEPOSIT(), + address(morphoVault) + ), + 25_000_000e6, + uint256(5_000_000e6) / 1 days + ); + vm.stopPrank(); + + // Refresh markets so calculations don't include interest + vm.prank(relayer); + foreignController.depositERC4626(address(morphoVault), 0); + + uint256 positionCBBTC = positionAssets(usdcCBBTC); + uint256 positionIdle = positionAssets(usdcIdle); + + uint256 marketAssetsCBBTC = marketAssets(usdcCBBTC); + uint256 marketAssetsIdle = marketAssets(usdcIdle); + + assertEq(positionCBBTC, 12_128_319.737383e6); + assertEq(positionIdle, 0); + + assertEq(marketAssetsCBBTC, 56_494_357.047568e6); + assertEq(marketAssetsIdle, 5.205521e6); + + deal(Base.USDC, address(almProxy), 1_000_000e6); + vm.prank(relayer); + foreignController.depositERC4626(address(morphoVault), 1_000_000e6); + + assertEq(positionAssets(usdcCBBTC), positionCBBTC + 1_000_000e6); + assertEq(positionAssets(usdcIdle), 0); + + assertEq(marketAssets(usdcCBBTC), marketAssetsCBBTC + 1_000_000e6); + assertEq(marketAssets(usdcIdle), marketAssetsIdle); + + // Move new allocation into idle market + MarketAllocation[] memory reallocations = new MarketAllocation[](2); + reallocations[0] = MarketAllocation({ + marketParams : usdcCBBTC, + assets : positionCBBTC + }); + reallocations[1] = MarketAllocation({ + marketParams : usdcIdle, + assets : 1_000_000e6 + }); + + vm.prank(relayer); + foreignController.reallocateMorpho(address(morphoVault), reallocations); + + // NOTE: No interest is accrued because deposit coverered all markets and is atomic + assertEq(positionAssets(usdcCBBTC), positionCBBTC); + assertEq(positionAssets(usdcIdle), 1_000_000e6); + + assertEq(marketAssets(usdcCBBTC), marketAssetsCBBTC); + assertEq(marketAssets(usdcIdle), marketAssetsIdle + 1_000_000e6); + + // Move 400k back into CBBTC, note order has changed because of pulling from idle market + reallocations = new MarketAllocation[](2); + reallocations[0] = MarketAllocation({ + marketParams : usdcIdle, + assets : 600_000e6 + }); + reallocations[1] = MarketAllocation({ + marketParams : usdcCBBTC, + assets : positionCBBTC + 400_000e6 + }); + + vm.prank(relayer); + foreignController.reallocateMorpho(address(morphoVault), reallocations); + + assertEq(positionAssets(usdcCBBTC), positionCBBTC + 400_000e6); + assertEq(positionAssets(usdcIdle), 600_000e6); + + assertEq(marketAssets(usdcCBBTC), marketAssetsCBBTC + 400_000e6); + assertEq(marketAssets(usdcIdle), marketAssetsIdle + 600_000e6); + } + +} diff --git a/test/base-fork/PsmCalls.t.sol b/test/base-fork/PsmCalls.t.sol index f9146c6c..1ed24c25 100644 --- a/test/base-fork/PsmCalls.t.sol +++ b/test/base-fork/PsmCalls.t.sol @@ -48,30 +48,51 @@ contract ForeignControllerDepositPSMFailureTests is ForkTestBase { address(this), RELAYER )); - foreignController.depositPSM(address(usdsBase), 100e18); + foreignController.depositPSM(address(usdsBase), 1_000_000e18); } - function test_depositPSM_frozen() external { - vm.prank(freezer); - foreignController.freeze(); - + function test_depositPSM_zeroMaxAmount() external { vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); - foreignController.depositPSM(address(usdsBase), 100e18); + vm.expectRevert("RateLimits/zero-maxAmount"); + foreignController.depositPSM(makeAddr("fake-token"), 1_000_000e18); } -} + function test_depositPSM_usdcRateLimitedBoundary() external { + deal(address(usdcBase), address(almProxy), 5_000_000e6 + 1); -contract ForeignControllerDepositTests is ForeignControllerPSMSuccessTestBase { + vm.expectRevert("RateLimits/rate-limit-exceeded"); + vm.startPrank(relayer); + foreignController.depositPSM(address(usdcBase), 5_000_000e6 + 1); - function test_deposit_usds() external { - bytes32 key = foreignController.LIMIT_PSM_DEPOSIT(); + foreignController.depositPSM(address(usdcBase), 5_000_000e6); + } - // NOTE: USDS deposits are not going to be rate limited for launch - bytes32 assetKey = RateLimitHelpers.makeAssetKey(key, address(usdsBase)); + function test_depositPSM_usdsRateLimitedBoundary() external { + deal(address(usdsBase), address(almProxy), 5_000_000e18 + 1); - vm.prank(SPARK_EXECUTOR); - rateLimits.setUnlimitedRateLimitData(assetKey); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + vm.startPrank(relayer); + foreignController.depositPSM(address(usdsBase), 5_000_000e18 + 1); + + foreignController.depositPSM(address(usdsBase), 5_000_000e18); + } + + function test_depositPSM_susdsRateLimitedBoundary() external { + deal(address(susdsBase), address(almProxy), 5_000_000e18 + 1); + + vm.expectRevert("RateLimits/rate-limit-exceeded"); + vm.startPrank(relayer); + foreignController.depositPSM(address(susdsBase), 5_000_000e18 + 1); + + foreignController.depositPSM(address(susdsBase), 5_000_000e18); + } + +} + +contract ForeignControllerDepositPSMTests is ForeignControllerPSMSuccessTestBase { + + function test_depositPSM_depositUsds() external { + bytes32 key = foreignController.LIMIT_PSM_DEPOSIT(); deal(address(usdsBase), address(almProxy), 100e18); @@ -83,7 +104,7 @@ contract ForeignControllerDepositTests is ForeignControllerPSMSuccessTestBase { totalShares : 1e18, // From seeding USDS totalAssets : 1e18, // From seeding USDS rateLimitKey : key, - currentRateLimit : type(uint256).max + currentRateLimit : 5_000_000e18 }); vm.prank(relayer); @@ -99,11 +120,11 @@ contract ForeignControllerDepositTests is ForeignControllerPSMSuccessTestBase { totalShares : 101e18, totalAssets : 101e18, rateLimitKey : key, - currentRateLimit : type(uint256).max + currentRateLimit : 4_999_900e18 }); } - function test_deposit_usdc() external { + function test_depositPSM_depositUsdc() external { bytes32 key = foreignController.LIMIT_PSM_DEPOSIT(); deal(address(usdcBase), address(almProxy), 100e6); @@ -136,15 +157,9 @@ contract ForeignControllerDepositTests is ForeignControllerPSMSuccessTestBase { }); } - function test_deposit_susds() external { + function test_depositPSM_depositSUsds() external { bytes32 key = foreignController.LIMIT_PSM_DEPOSIT(); - // NOTE: sUSDS deposits are not going to be rate limited for launch - bytes32 assetKey = RateLimitHelpers.makeAssetKey(key, address(susdsBase)); - - vm.prank(SPARK_EXECUTOR); - rateLimits.setUnlimitedRateLimitData(assetKey); - deal(address(susdsBase), address(almProxy), 100e18); _assertState({ @@ -155,7 +170,7 @@ contract ForeignControllerDepositTests is ForeignControllerPSMSuccessTestBase { totalShares : 1e18, // From seeding USDS totalAssets : 1e18, // From seeding USDS rateLimitKey : key, - currentRateLimit : type(uint256).max + currentRateLimit : 5_000_000e18 }); vm.prank(relayer); @@ -171,7 +186,7 @@ contract ForeignControllerDepositTests is ForeignControllerPSMSuccessTestBase { totalShares : 1e18 + shares, totalAssets : 1e18 + shares, rateLimitKey : key, - currentRateLimit : type(uint256).max + currentRateLimit : 4_999_900e18 }); } @@ -188,31 +203,105 @@ contract ForeignControllerWithdrawPSMFailureTests is ForkTestBase { foreignController.withdrawPSM(address(usdsBase), 100e18); } - function test_withdrawPSM_frozen() external { - vm.prank(freezer); - foreignController.freeze(); + function test_withdrawPSM_usdcZeroMaxAmount() external { + bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); + bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(usdcBase)); + + vm.prank(SPARK_EXECUTOR); + rateLimits.setRateLimitData(withdrawAssetKey, 0, 0); vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); + vm.expectRevert("RateLimits/zero-maxAmount"); + foreignController.withdrawPSM(address(usdcBase), 100e18); + } + + function test_withdrawPSM_usdsZeroMaxAmount() external { + bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); + bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(usdsBase)); + + vm.prank(SPARK_EXECUTOR); + rateLimits.setRateLimitData(withdrawAssetKey, 0, 0); + + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); foreignController.withdrawPSM(address(usdsBase), 100e18); } -} + function test_withdrawPSM_susdsZeroMaxAmount() external { + bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); + bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(susdsBase)); + + vm.prank(SPARK_EXECUTOR); + rateLimits.setRateLimitData(withdrawAssetKey, 0, 0); -contract ForeignControllerWithdrawTests is ForeignControllerPSMSuccessTestBase { + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + foreignController.withdrawPSM(address(susdsBase), 100e18); + } + + function test_withdrawPSM_usdcRateLimitedBoundary() external { + bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); + bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(usdcBase)); + + vm.prank(SPARK_EXECUTOR); + rateLimits.setRateLimitData(withdrawAssetKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); - function test_withdraw_usds() external { - bytes32 depositKey = foreignController.LIMIT_PSM_DEPOSIT(); - bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); + deal(address(usdcBase), address(almProxy), 1_000_000e6 + 1); - // NOTE: USDS deposits and withdrawals are not going to be rate limited for launch - bytes32 depositAssetKey = RateLimitHelpers.makeAssetKey(depositKey, address(usdsBase)); + vm.startPrank(relayer); + foreignController.depositPSM(address(usdcBase), 1_000_000e6 + 1); + + vm.expectRevert("RateLimits/rate-limit-exceeded"); + foreignController.withdrawPSM(address(usdcBase), 1_000_000e6 + 1); + + foreignController.withdrawPSM(address(usdcBase), 1_000_000e6); + } + + function test_withdrawPSM_usdsRateLimitedBoundary() external { + bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(usdsBase)); - vm.startPrank(SPARK_EXECUTOR); - rateLimits.setUnlimitedRateLimitData(depositAssetKey); - rateLimits.setUnlimitedRateLimitData(withdrawAssetKey); - vm.stopPrank(); + vm.prank(SPARK_EXECUTOR); + rateLimits.setRateLimitData(withdrawAssetKey, 1_000_000e18, uint256(1_000_000e18) / 1 days); + + deal(address(usdsBase), address(almProxy), 1_000_000e18 + 1); + + vm.startPrank(relayer); + foreignController.depositPSM(address(usdsBase), 1_000_000e18 + 1); + + vm.expectRevert("RateLimits/rate-limit-exceeded"); + foreignController.withdrawPSM(address(usdsBase), 1_000_000e18 + 1); + + foreignController.withdrawPSM(address(usdsBase), 1_000_000e18); + } + + function test_withdrawPSM_susdsRateLimitedBoundary() external { + bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); + bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(susdsBase)); + + vm.prank(SPARK_EXECUTOR); + rateLimits.setRateLimitData(withdrawAssetKey, 1_000_000e18, uint256(1_000_000e18) / 1 days); + + // NOTE: Need an extra wei because of rounding on conversion + deal(address(susdsBase), address(almProxy), 1_000_000e18 + 2); + + vm.startPrank(relayer); + foreignController.depositPSM(address(susdsBase), 1_000_000e18 + 2); + + vm.expectRevert("RateLimits/rate-limit-exceeded"); + foreignController.withdrawPSM(address(susdsBase), 1_000_000e18 + 1); + + uint256 withdrawn = foreignController.withdrawPSM(address(susdsBase), 1_000_000e18); + + assertEq(withdrawn, 1_000_000e18); + } + +} + +contract ForeignControllerWithdrawPSMTests is ForeignControllerPSMSuccessTestBase { + + function test_withdrawPSM_withdrawUsds() external { + bytes32 key = foreignController.LIMIT_PSM_WITHDRAW(); deal(address(usdsBase), address(almProxy), 100e18); vm.prank(relayer); @@ -225,7 +314,7 @@ contract ForeignControllerWithdrawTests is ForeignControllerPSMSuccessTestBase { proxyShares : 100e18, totalShares : 101e18, totalAssets : 101e18, - rateLimitKey : withdrawKey, + rateLimitKey : key, currentRateLimit : type(uint256).max }); @@ -241,12 +330,12 @@ contract ForeignControllerWithdrawTests is ForeignControllerPSMSuccessTestBase { proxyShares : 0, totalShares : 1e18, // From seeding USDS totalAssets : 1e18, // From seeding USDS - rateLimitKey : withdrawKey, + rateLimitKey : key, currentRateLimit : type(uint256).max }); } - function test_withdraw_usdc() external { + function test_withdrawPSM_withdrawUsdc() external { bytes32 key = foreignController.LIMIT_PSM_WITHDRAW(); deal(address(usdcBase), address(almProxy), 100e6); @@ -281,18 +370,8 @@ contract ForeignControllerWithdrawTests is ForeignControllerPSMSuccessTestBase { }); } - function test_withdraw_susds() external { - bytes32 depositKey = foreignController.LIMIT_PSM_DEPOSIT(); - bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); - - // NOTE: sUSDS deposits and withdrawals are not going to be rate limited for launch - bytes32 depositAssetKey = RateLimitHelpers.makeAssetKey(depositKey, address(susdsBase)); - bytes32 withdrawAssetKey = RateLimitHelpers.makeAssetKey(withdrawKey, address(susdsBase)); - - vm.startPrank(SPARK_EXECUTOR); - rateLimits.setUnlimitedRateLimitData(depositAssetKey); - rateLimits.setUnlimitedRateLimitData(withdrawAssetKey); - vm.stopPrank(); + function test_withdrawPSM_withdrawSUsds() external { + bytes32 key = foreignController.LIMIT_PSM_WITHDRAW(); deal(address(susdsBase), address(almProxy), 100e18); vm.prank(relayer); @@ -307,7 +386,7 @@ contract ForeignControllerWithdrawTests is ForeignControllerPSMSuccessTestBase { proxyShares : shares, totalShares : 1e18 + shares, totalAssets : 1e18 + shares, - rateLimitKey : withdrawKey, + rateLimitKey : key, currentRateLimit : type(uint256).max }); @@ -323,7 +402,7 @@ contract ForeignControllerWithdrawTests is ForeignControllerPSMSuccessTestBase { proxyShares : 0, totalShares : 1e18, // From seeding USDS totalAssets : 1e18 + 1, // From seeding USDS, rounding - rateLimitKey : withdrawKey, + rateLimitKey : key, currentRateLimit : type(uint256).max }); } diff --git a/test/mainnet-fork/4626Calls.t.sol b/test/mainnet-fork/4626Calls.t.sol index d607f821..d5985e9c 100644 --- a/test/mainnet-fork/4626Calls.t.sol +++ b/test/mainnet-fork/4626Calls.t.sol @@ -54,15 +54,6 @@ contract MainnetControllerDepositERC4626FailureTests is SUSDSTestBase { mainnetController.depositERC4626(address(susds), 1e18); } - function test_depositERC4626_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.depositERC4626(address(susds), 1e18); - } - function test_depositERC4626_zeroMaxAmount() external { vm.prank(relayer); vm.expectRevert("RateLimits/zero-maxAmount"); @@ -132,15 +123,6 @@ contract MainnetControllerWithdrawERC4626FailureTests is SUSDSTestBase { mainnetController.withdrawERC4626(address(susds), 1e18); } - function test_withdrawERC4626_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.withdrawERC4626(address(susds), 1e18); - } - function test_withdrawERC4626_zeroMaxAmount() external { vm.prank(relayer); vm.expectRevert("RateLimits/zero-maxAmount"); @@ -215,15 +197,6 @@ contract MainnetControllerRedeemERC4626FailureTests is SUSDSTestBase { mainnetController.redeemERC4626(address(susds), 1e18); } - function test_redeemERC4626_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.redeemERC4626(address(susds), 1e18); - } - function test_redeemERC4626_zeroMaxAmount() external { // Longer setup because rate limit revert is at the end of the function vm.startPrank(Ethereum.SPARK_PROXY); diff --git a/test/mainnet-fork/Aave.t.sol b/test/mainnet-fork/Aave.t.sol index e369d66c..8f3769ff 100644 --- a/test/mainnet-fork/Aave.t.sol +++ b/test/mainnet-fork/Aave.t.sol @@ -80,15 +80,6 @@ contract AaveV3MainMarketDepositFailureTests is AaveV3MainMarketBaseTest { mainnetController.depositAave(ATOKEN_USDS, 1_000_000e18); } - function test_depositAave_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.depositAave(ATOKEN_USDS, 1_000_000e18); - } - function test_depositAave_zeroMaxAmount() external { vm.prank(relayer); vm.expectRevert("RateLimits/zero-maxAmount"); @@ -170,15 +161,6 @@ contract AaveV3MainMarketWithdrawFailureTests is AaveV3MainMarketBaseTest { mainnetController.withdrawAave(ATOKEN_USDS, 1_000_000e18); } - function test_withdrawAave_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.withdrawAave(ATOKEN_USDS, 1_000_000e18); - } - function test_withdrawAave_zeroMaxAmount() external { // Longer setup because rate limit revert is at the end of the function vm.startPrank(Ethereum.SPARK_PROXY); diff --git a/test/mainnet-fork/Attacks.t.sol b/test/mainnet-fork/Attacks.t.sol index 8ed28386..bcbf7c65 100644 --- a/test/mainnet-fork/Attacks.t.sol +++ b/test/mainnet-fork/Attacks.t.sol @@ -3,21 +3,44 @@ pragma solidity >=0.8.0; import "./ForkTestBase.t.sol"; -contract CompromisedRelayerTests is ForkTestBase { +import { MainnetControllerBUIDLTestBase } from "./Buidl.t.sol"; +import { MainnetControllerEthenaE2ETests } from "./Ethena.t.sol"; +import { MapleTestBase } from "./Maple.t.sol"; - address newRelayer = makeAddr("newRelayer"); - bytes32 key; +import { Id, MarketParamsLib, MorphoTestBase, MarketAllocation } from "./MorphoAllocations.t.sol"; - function setUp() public override { - super.setUp(); +import { IMapleTokenLike } from "../../src/MainnetController.sol"; - key = mainnetController.LIMIT_SUSDE_COOLDOWN(); +interface IBuidlLike is IERC20 { + function issueTokens(address to, uint256 amount) external; +} - vm.prank(SPARK_PROXY); - rateLimits.setRateLimitData(key, 5_000_000e18, uint256(1_000_000e18) / 4 hours); - } +interface IMapleTokenExtended is IMapleTokenLike { + function manager() external view returns (address); +} + +interface IPermissionManagerLike { + function admin() external view returns (address); + function setLenderAllowlist( + address poolManager_, + address[] calldata lenders_, + bool[] calldata booleans_ + ) external; +} + +interface IPoolManagerLike { + function withdrawalManager() external view returns (address); + function poolDelegate() external view returns (address); +} - function test_compromisedRelayer_lockingFundsInEthenaSilo() external { +interface IWhitelistLike { + function addWallet(address account, string memory id) external; + function registerInvestor(string memory id, string memory collisionHash) external; +} + +contract EthenaAttackTests is MainnetControllerEthenaE2ETests { + + function test_attack_compromisedRelayer_lockingFundsInEthenaSilo() external { deal(address(susde), address(almProxy), 1_000_000e18); address silo = susde.silo(); @@ -30,33 +53,21 @@ contract CompromisedRelayerTests is ForkTestBase { skip(7 days); // Relayer is now compromised and wants to lock funds in the silo - vm.prank(relayer); mainnetController.cooldownAssetsSUSDe(1); - // Relayer cannot withdraw when they want to + // Real relayer cannot withdraw when they want to vm.prank(relayer); vm.expectRevert(abi.encodeWithSignature("InvalidCooldown()")); mainnetController.unstakeSUSDe(); + // Frezer can remove the compromised relayer and fallback to the governance relayer vm.prank(freezer); - mainnetController.freeze(); + mainnetController.removeRelayer(relayer); skip(7 days); - // Compromised relayer cannot perform attack - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.cooldownAssetsSUSDe(1); - - // Action taken through spell to grant access to safe new relayer, and reactivates the system - vm.startPrank(SPARK_PROXY); - mainnetController.grantRole(mainnetController.RELAYER(), newRelayer); - mainnetController.revokeRole(mainnetController.RELAYER(), relayer); - mainnetController.reactivate(); - vm.stopPrank(); - - // Compromised relayer cannot perform attack on unfrozen system + // Compromised relayer cannot perform attack anymore vm.prank(relayer); vm.expectRevert(abi.encodeWithSignature( "AccessControlUnauthorizedAccount(address,bytes32)", @@ -69,8 +80,8 @@ contract CompromisedRelayerTests is ForkTestBase { assertEq(usde.balanceOf(address(almProxy)), 0); assertEq(usde.balanceOf(silo), startingSiloBalance + 1_000_000e18 + 1); // 1 wei deposit as well - // New relayer can unstake the funds - vm.prank(newRelayer); + // Backstop relayer can unstake the funds + vm.prank(backstopRelayer); mainnetController.unstakeSUSDe(); assertEq(usde.balanceOf(address(almProxy)), 1_000_000e18 + 1); @@ -78,3 +89,244 @@ contract CompromisedRelayerTests is ForkTestBase { } } + +contract MapleAttackTests is MapleTestBase { + + function test_attack_compromisedRelayer_delayRequestMapleRedemption() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + mainnetController.depositERC4626(address(syrup), 1_000_000e6); + + // Malicious relayer delays the request for redemption for 1m + // because new requests can't be fulfilled until the previous is fulfilled or cancelled + vm.prank(relayer); + mainnetController.requestMapleRedemption(address(syrup), 1); + + // Cannot process request + vm.prank(relayer); + vm.expectRevert("WM:AS:IN_QUEUE"); + mainnetController.requestMapleRedemption(address(syrup), 500_000e6); + + // Frezer can remove the compromised relayer and fallback to the governance relayer + vm.prank(freezer); + mainnetController.removeRelayer(relayer); + + // Compromised relayer cannot perform attack anymore + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + relayer, + RELAYER + )); + mainnetController.requestMapleRedemption(address(syrup), 1); + + // Governance relayer can cancel and submit the real request + vm.startPrank(backstopRelayer); + mainnetController.cancelMapleRedemption(address(syrup), 1); + mainnetController.requestMapleRedemption(address(syrup), 500_000e6); + vm.stopPrank(); + } + +} + +contract BUIDLAttackTests is MainnetControllerBUIDLTestBase { + + address admin = 0xe01605f6b6dC593b7d2917F4a0940db2A625b09e; + + IBuidlLike buidl = IBuidlLike(0x7712c34205737192402172409a8F7ccef8aA2AEc); + IWhitelistLike whitelist = IWhitelistLike(0x0Dac900f26DE70336f2320F7CcEDeE70fF6A1a5B); + + + uint256 internal speedup = 10; + + function setUp() public virtual override { + super.setUp(); + + vm.label(address(0x0A65a40a4B2F64D3445A628aBcFC8128625483A4), "LOCK_MANAGER"); + vm.label(address(0x1dc378568cefD4596C5F9f9A14256D8250b56369), "COMPLIANCE_CONFIGURATION_SERVICE"); + vm.label(address(0x07A1EBFb9a9A421249DDC71Bddb8860cc077E3a9), "COMPLIANCE_SERVICE"); + + bytes32 depositKey = RateLimitHelpers.makeAssetDestinationKey( + mainnetController.LIMIT_ASSET_TRANSFER(), address(usdc), address(buidlDeposit) + ); + + bytes32 redeemKey = mainnetController.LIMIT_BUIDL_REDEEM_CIRCLE(); + + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(depositKey, 2_000_000e6, uint256(2_000_000e6) / 1 days); + rateLimits.setRateLimitData(redeemKey, 2_000_000e6, uint256(2_000_000e6) / 1 days); + vm.stopPrank(); + + vm.startPrank(admin); + whitelist.registerInvestor("spark-almProxy", "collisionHash"); + whitelist.addWallet(address(almProxy), "spark-almProxy"); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 2_000_000e6); + + // Step 1: Deposit into BUIDL + vm.prank(relayer); + mainnetController.transferAsset(address(usdc), buidlDeposit, 1_000_000e6); + + // Step 2: BUIDL gets minted into proxy + assertEq(buidl.balanceOf(address(almProxy)), 0); + + vm.prank(admin); + buidl.issueTokens(address(almProxy), 1_000_000e6); + + assertEq(buidl.balanceOf(address(almProxy)), 1_000_000e6); + + // Step 3: Malicious relayer spams transfers & redemptions + // Every iteration uses a cold `sload` so need at most 30e6 / 2100 = 14_286 + // The iterations gas cost is roughly linear per iteration, to speed up the test we scale + // down both iterations and gas used. + for (uint256 i; i < 10_000 / speedup; i++) { + vm.prank(relayer); + mainnetController.transferAsset(address(usdc), buidlDeposit, 1e6); + vm.prank(admin); + buidl.issueTokens(address(almProxy), 1e6); + } + + // Skip time lock + skip(24 hours); + } + + // Run test in its own transaction so the sloads are cold + function test_attack_issuanceDos() public { + // Step 4: Redeem non-malicious BUIDL after timelock is passed + vm.startPrank(relayer); + vm.expectRevert("SafeERC20: low-level call failed"); + mainnetController.redeemBUIDLCircleFacility{gas: 30e6 / speedup}(1_000_000e6); + vm.stopPrank(); + } +} + +contract MorphoAttackTests is MorphoTestBase { + + function test_attack_compromisedRelayer_setSupplyQueue() external { + Id[] memory supplyQueueUSDC = new Id[](2); + supplyQueueUSDC[0] = MarketParamsLib.id(market1); + supplyQueueUSDC[1] = MarketParamsLib.id(market2); + + // No supply queue to start, but caps are above zero + assertEq(morphoVault.supplyQueueLength(), 0); + + vm.prank(relayer); + mainnetController.setSupplyQueueMorpho(address(morphoVault), supplyQueueUSDC); + + assertEq(morphoVault.supplyQueueLength(), 2); + + assertEq(Id.unwrap(morphoVault.supplyQueue(0)), Id.unwrap(MarketParamsLib.id(market1))); + assertEq(Id.unwrap(morphoVault.supplyQueue(1)), Id.unwrap(MarketParamsLib.id(market2))); + + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_4626_DEPOSIT(), + address(morphoVault) + ), + 25_000_000e18, + uint256(5_000_000e18) / 1 days + ); + vm.stopPrank(); + + deal(address(dai), address(almProxy), 1_000_000e18); + + // Able to deposit + vm.prank(relayer); + mainnetController.depositERC4626(address(morphoVault), 500_000e18); + + Id[] memory emptySupplyQueue = new Id[](0); + + // Malicious relayer empties the supply queue + vm.prank(relayer); + mainnetController.setSupplyQueueMorpho(address(morphoVault), emptySupplyQueue); + + // DOS deposits into morpho vault + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSignature("AllCapsReached()")); + mainnetController.depositERC4626(address(morphoVault), 500_000e18); + + // Frezer can remove the compromised relayer and fallback to the governance relayer + vm.prank(freezer); + mainnetController.removeRelayer(relayer); + + // Compromised relayer can no longer perform the attack + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + relayer, + RELAYER + )); + mainnetController.setSupplyQueueMorpho(address(morphoVault), emptySupplyQueue); + + // Backstop relayer can restore original supply queue + vm.prank(backstopRelayer); + mainnetController.setSupplyQueueMorpho(address(morphoVault), supplyQueueUSDC); + + // Deposit works again + vm.prank(backstopRelayer); + mainnetController.depositERC4626(address(morphoVault), 500_000e18); + } + + function test_attack_compromisedRelayer_reallocateMorpho() public { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_4626_DEPOSIT(), + address(morphoVault) + ), + 25_000_000e6, + uint256(5_000_000e6) / 1 days + ); + vm.stopPrank(); + + uint256 market1Position = positionAssets(market1); + uint256 market2Position = positionAssets(market2); + + // Move 1m from market1 to market2 + MarketAllocation[] memory reallocations = new MarketAllocation[](2); + reallocations[0] = MarketAllocation({ + marketParams : market1, + assets : market1Position - 1_000_000e18 + }); + reallocations[1] = MarketAllocation({ + marketParams : market2, + assets : type(uint256).max + }); + + // Malicious relayer reallocates freely + vm.prank(relayer); + mainnetController.reallocateMorpho(address(morphoVault), reallocations); + + // Frezer can remove the compromised relayer and fallback to the governance relayer + vm.prank(freezer); + mainnetController.removeRelayer(relayer); + + // Compromised relayer can no longer perform the attack + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + relayer, + RELAYER + )); + mainnetController.reallocateMorpho(address(morphoVault), reallocations); + + market1Position = positionAssets(market1); + market2Position = positionAssets(market2); + + // Backstop relayer can restore original allocations + reallocations[0] = MarketAllocation({ + marketParams : market2, + assets : market2Position - 1_000_000e18 + }); + reallocations[1] = MarketAllocation({ + marketParams : market1, + assets : type(uint256).max + }); + vm.prank(backstopRelayer); + mainnetController.reallocateMorpho(address(morphoVault), reallocations); + } + +} diff --git a/test/mainnet-fork/Buidl.t.sol b/test/mainnet-fork/Buidl.t.sol new file mode 100644 index 00000000..359baf4a --- /dev/null +++ b/test/mainnet-fork/Buidl.t.sol @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import "./ForkTestBase.t.sol"; + +interface IWhitelistLike { + function addWallet(address account, string memory id) external; + function registerInvestor(string memory id, string memory collisionHash) external; +} + +interface IBuidlLike is IERC20 { + function issueTokens(address to, uint256 amount) external; +} + +contract MainnetControllerBUIDLTestBase is ForkTestBase { + + address buidlDeposit = makeAddr("buidlDeposit"); + +} + +contract MainnetControllerDepositBUIDLFailureTests is MainnetControllerBUIDLTestBase { + + function test_transferAsset_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.transferAsset(address(usdc), buidlDeposit, 1_000_000e6); + } + + function test_transferAsset_zeroMaxAmount() external { + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.transferAsset(address(usdc), buidlDeposit, 0); + } + + function test_transferAsset_rateLimitsBoundary() external { + bytes32 key = RateLimitHelpers.makeAssetDestinationKey( + mainnetController.LIMIT_ASSET_TRANSFER(), + address(usdc), + address(buidlDeposit) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.transferAsset(address(usdc), buidlDeposit, 1_000_000e6 + 1); + + mainnetController.transferAsset(address(usdc), buidlDeposit, 1_000_000e6); + } + +} + +contract MainnetControllerDepositBUIDLSuccessTests is MainnetControllerBUIDLTestBase { + + function test_transferAsset() external { + bytes32 key = RateLimitHelpers.makeAssetDestinationKey( + mainnetController.LIMIT_ASSET_TRANSFER(), + address(usdc), + address(buidlDeposit) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(key), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(buidlDeposit), 0); + + vm.prank(relayer); + mainnetController.transferAsset(address(usdc), buidlDeposit, 1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(key), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(buidlDeposit), 1_000_000e6); + } + +} + +contract MainnetControllerRedeemBUIDLFailureTests is MainnetControllerBUIDLTestBase { + + address admin = 0xe01605f6b6dC593b7d2917F4a0940db2A625b09e; + + IWhitelistLike whitelist = IWhitelistLike(0x0Dac900f26DE70336f2320F7CcEDeE70fF6A1a5B); + + IBuidlLike buidl = IBuidlLike(0x7712c34205737192402172409a8F7ccef8aA2AEc); + + function test_redeemBUIDLCircleFacility_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + } + + function test_redeemBUIDLCircleFacility_zeroMaxAmount() external { + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + } + + function test_redeemBUIDLCircleFacility_rateLimitsBoundary() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + mainnetController.LIMIT_BUIDL_REDEEM_CIRCLE(), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + + // Set up success case + vm.startPrank(admin); + whitelist.registerInvestor("spark-almProxy", "collisionHash"); + whitelist.addWallet(address(almProxy), "spark-almProxy"); + buidl.issueTokens(address(almProxy), 1_000_000e6); + vm.stopPrank(); + + skip(25 hours); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6 + 1); + + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + } + +} + +contract MainnetControllerRedeemBUIDLSuccessTests is MainnetControllerBUIDLTestBase { + + address admin = 0xe01605f6b6dC593b7d2917F4a0940db2A625b09e; + + IWhitelistLike whitelist = IWhitelistLike(0x0Dac900f26DE70336f2320F7CcEDeE70fF6A1a5B); + + IBuidlLike buidl = IBuidlLike(0x7712c34205737192402172409a8F7ccef8aA2AEc); + + function setUp() override public { + super.setUp(); + + vm.startPrank(admin); + whitelist.registerInvestor("spark-almProxy", "collisionHash"); + whitelist.addWallet(address(almProxy), "spark-almProxy"); + vm.stopPrank(); + } + + function test_redeemBUIDLCircleFacility() public { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + mainnetController.LIMIT_BUIDL_REDEEM_CIRCLE(), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + + vm.startPrank(admin); + buidl.issueTokens(address(almProxy), 1_000_000e6); + vm.stopPrank(); + + skip(25 hours); + + assertEq(buidl.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(address(almProxy)), 0); + + vm.startPrank(relayer); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + + assertEq(buidl.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + } + + function test_redeemBUIDLCircleFacility_timelockReset() public { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + mainnetController.LIMIT_BUIDL_REDEEM_CIRCLE(), + 2_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + + vm.prank(admin); + buidl.issueTokens(address(almProxy), 1_000_000e6); + + skip(24 hours); + + uint256 snapshot = vm.snapshot(); + + // Can redeem after 24 hours + vm.prank(relayer); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + + vm.revertTo(snapshot); + + vm.prank(admin); + buidl.issueTokens(address(almProxy), 1); + + // Redeem of original 1_000_000e6 can still succeed + vm.prank(relayer); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + + // Redeem of new 1 can fail + vm.expectRevert("Under lock-up"); + vm.prank(relayer); + mainnetController.redeemBUIDLCircleFacility(1); + + vm.revertTo(snapshot); + + vm.prank(admin); + buidl.issueTokens(address(almProxy), 1); + + // Redeem of amount over original 1_000_000e6 will revert + vm.prank(relayer); + vm.expectRevert("Under lock-up"); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6 + 1); + } + +} + +contract MainnetControllerDepositRedeemBUIDLE2ESuccessTests is MainnetControllerBUIDLTestBase { + + address admin = 0xe01605f6b6dC593b7d2917F4a0940db2A625b09e; + + IWhitelistLike whitelist = IWhitelistLike(0x0Dac900f26DE70336f2320F7CcEDeE70fF6A1a5B); + + IBuidlLike buidl = IBuidlLike(0x7712c34205737192402172409a8F7ccef8aA2AEc); + + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(admin); + whitelist.registerInvestor("spark-almProxy", "collisionHash"); + whitelist.addWallet(address(almProxy), "spark-almProxy"); + vm.stopPrank(); + } + + function test_e2e_redeemBUIDLCircleFacility() public { + bytes32 depositKey = RateLimitHelpers.makeAssetDestinationKey( + mainnetController.LIMIT_ASSET_TRANSFER(), + address(usdc), + address(buidlDeposit) + ); + + bytes32 redeemKey = mainnetController.LIMIT_BUIDL_REDEEM_CIRCLE(); + + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(depositKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); + rateLimits.setRateLimitData(redeemKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + // Step 1: Deposit into BUIDL + + assertEq(rateLimits.getCurrentRateLimit(depositKey), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(buidlDeposit), 0); + + vm.prank(relayer); + mainnetController.transferAsset(address(usdc), buidlDeposit, 1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(depositKey), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(buidlDeposit), 1_000_000e6); + + // Step 2: BUIDL gets minted into proxy + + assertEq(buidl.balanceOf(address(almProxy)), 0); + + vm.startPrank(admin); + buidl.issueTokens(address(almProxy), 1_000_000e6); + vm.stopPrank(); + + assertEq(buidl.balanceOf(address(almProxy)), 1_000_000e6); + + // Step 3: Demostrate BUIDL can't be redeemed for 24 hours + + skip(24 hours - 1 seconds); + + vm.startPrank(relayer); + vm.expectRevert("Under lock-up"); + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + + skip(1 seconds); + + // Step 4: Redeem BUIDL after timelock is passed + + assertEq(rateLimits.getCurrentRateLimit(redeemKey), 1_000_000e6); + + assertEq(buidl.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(address(almProxy)), 0); + + mainnetController.redeemBUIDLCircleFacility(1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(redeemKey), 0); + + assertEq(buidl.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + } + +} diff --git a/test/mainnet-fork/CCTPCalls.t.sol b/test/mainnet-fork/CCTPCalls.t.sol index bf8b2e9b..79a2ac02 100644 --- a/test/mainnet-fork/CCTPCalls.t.sol +++ b/test/mainnet-fork/CCTPCalls.t.sol @@ -37,12 +37,30 @@ contract MainnetControllerTransferUSDCToCCTPFailureTests is ForkTestBase { mainnetController.transferUSDCToCCTP(1e6, CCTPForwarder.DOMAIN_ID_CIRCLE_BASE); } - function test_transferUSDCToCCTP_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_tranferUSDCToCCTP_zeroMaxAmountDomain() external { + vm.startPrank(SPARK_PROXY); + rateLimits.setRateLimitData( + RateLimitHelpers.makeDomainKey( + mainnetController.LIMIT_USDC_TO_DOMAIN(), + CCTPForwarder.DOMAIN_ID_CIRCLE_BASE + ), + 0, + 0 + ); + vm.stopPrank(); + vm.expectRevert("RateLimits/zero-maxAmount"); + vm.prank(relayer); + mainnetController.transferUSDCToCCTP(1e6, CCTPForwarder.DOMAIN_ID_CIRCLE_BASE); + } + + function test_tranferUSDCToCCTP_zeroMaxAmountCCTP() external { + vm.startPrank(SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDC_TO_CCTP(), 0, 0); + vm.stopPrank(); + + vm.expectRevert("RateLimits/zero-maxAmount"); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); mainnetController.transferUSDCToCCTP(1e6, CCTPForwarder.DOMAIN_ID_CIRCLE_BASE); } @@ -249,19 +267,6 @@ contract BaseChainUSDCToCCTPTestBase is ForkTestBase { slope : uint256(1_000_000e6) / 4 hours }); - RateLimitData memory standardUsdsData = RateLimitData({ - maxAmount : 5_000_000e18, - slope : uint256(1_000_000e18) / 4 hours - }); - - RateLimitData memory unlimitedData = RateLimitData({ - maxAmount : type(uint256).max, - slope : 0 - }); - - bytes32 depositKey = foreignController.LIMIT_PSM_DEPOSIT(); - bytes32 withdrawKey = foreignController.LIMIT_PSM_WITHDRAW(); - bytes32 domainKeyEthereum = RateLimitHelpers.makeDomainKey( foreignController.LIMIT_USDC_TO_DOMAIN(), CCTPForwarder.DOMAIN_ID_CIRCLE_ETHEREUM @@ -305,12 +310,30 @@ contract ForeignControllerTransferUSDCToCCTPFailureTests is BaseChainUSDCToCCTPT foreignController.transferUSDCToCCTP(1e6, CCTPForwarder.DOMAIN_ID_CIRCLE_ETHEREUM); } - function test_transferUSDCToCCTP_frozen() external { - vm.prank(freezer); - foreignController.freeze(); + function test_tranferUSDCToCCTP_zeroMaxAmountDomain() external { + vm.startPrank(SPARK_EXECUTOR); + foreignRateLimits.setRateLimitData( + RateLimitHelpers.makeDomainKey( + foreignController.LIMIT_USDC_TO_DOMAIN(), + CCTPForwarder.DOMAIN_ID_CIRCLE_ETHEREUM + ), + 0, + 0 + ); + vm.stopPrank(); + + vm.expectRevert("RateLimits/zero-maxAmount"); + vm.prank(relayer); + foreignController.transferUSDCToCCTP(1e6, CCTPForwarder.DOMAIN_ID_CIRCLE_ETHEREUM); + } + + function test_tranferUSDCToCCTP_zeroMaxAmountCCTP() external { + vm.startPrank(SPARK_EXECUTOR); + foreignRateLimits.setRateLimitData(foreignController.LIMIT_USDC_TO_CCTP(), 0, 0); + vm.stopPrank(); + vm.expectRevert("RateLimits/zero-maxAmount"); vm.prank(relayer); - vm.expectRevert("ForeignController/not-active"); foreignController.transferUSDCToCCTP(1e6, CCTPForwarder.DOMAIN_ID_CIRCLE_ETHEREUM); } diff --git a/test/mainnet-fork/Centrifuge.t.sol b/test/mainnet-fork/Centrifuge.t.sol new file mode 100644 index 00000000..37d117c0 --- /dev/null +++ b/test/mainnet-fork/Centrifuge.t.sol @@ -0,0 +1,873 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import "./ForkTestBase.t.sol"; + +import {IERC7540} from "forge-std/interfaces/IERC7540.sol"; + +interface IRestrictionManager { + function updateMember(address token, address user, uint64 validUntil) external; +} + +interface IInvestmentManager { + function fulfillCancelDepositRequest( + uint64 poolId, + bytes16 trancheId, + address user, + uint128 assetId, + uint128 assets, + uint128 fulfillment + ) external; + function fulfillCancelRedeemRequest( + uint64 poolId, + bytes16 trancheId, + address user, + uint128 assetId, + uint128 shares + ) external; + function fulfillDepositRequest( + uint64 poolId, + bytes16 trancheId, + address user, + uint128 assetId, + uint128 assets, + uint128 shares + ) external; + function fulfillRedeemRequest( + uint64 poolId, + bytes16 trancheId, + address user, + uint128 assetId, + uint128 assets, + uint128 shares + ) external; + +} + +interface IERC20Mintable is IERC20 { + function mint(address to, uint256 amount) external; +} + +interface ICentrifugeToken is IERC7540 { + function claimableCancelDepositRequest(uint256 requestId, address controller) + external view returns (uint256 claimableAssets); + function claimableCancelRedeemRequest(uint256 requestId, address controller) + external view returns (uint256 claimableShares); + function pendingCancelDepositRequest(uint256 requestId, address controller) + external view returns (bool isPending); + function pendingCancelRedeemRequest(uint256 requestId, address controller) + external view returns (bool isPending); +} + +contract CentrifugeTestBase is ForkTestBase { + + address constant ESCROW = 0x0000000005F458Fd6ba9EEb5f365D83b7dA913dD; + address constant INVESTMENT_MANAGER = 0xE79f06573d6aF1B66166A926483ba00924285d20; + address constant JTREASURY_RESTRICTION_MANAGER = 0x4737C3f62Cc265e786b280153fC666cEA2fBc0c0; + address constant JTREASURY_TOKEN = 0x8c213ee79581Ff4984583C6a801e5263418C4b86; + address constant JTREASURY_VAULT_USDC = 0x1d01Ef1997d44206d839b78bA6813f60F1B3A970; + address constant ROOT = 0x0C1fDfd6a1331a875EA013F3897fc8a76ada5DfC; + + bytes16 constant JTREASURY_TRANCHE_ID = 0x97aa65f23e7be09fcd62d0554d2e9273; + uint128 constant USDC_ASSET_ID = 242333941209166991950178742833476896417; + uint64 constant JTREASURY_POOL_ID = 4139607887; + + // Requests for Centrifuge pools are non-fungible and all have ID = 0 + uint256 constant REQUEST_ID = 0; + + IInvestmentManager investmentManager = IInvestmentManager(INVESTMENT_MANAGER); + IRestrictionManager restrictionManager = IRestrictionManager(JTREASURY_RESTRICTION_MANAGER); + + ICentrifugeToken jTreasuryVault = ICentrifugeToken(JTREASURY_VAULT_USDC); + IERC20Mintable jTreasuryToken = IERC20Mintable(JTREASURY_TOKEN); + + function _getBlock() internal pure override returns (uint256) { + return 21570000; // Jan 7, 2024 + } + +} + +contract MainnetControllerRequestDepositERC7540FailureTests is CentrifugeTestBase { + + function test_requestDepositERC7540_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + } + + function test_requestDepositERC7540_zeroMaxAmount() external { + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + } + + function test_requestDepositERC7540_rateLimitBoundary() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_DEPOSIT(), + address(jTreasuryVault) + ), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.prank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6 + 1); + + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + } +} + +contract MainnetControllerRequestDepositERC7540SuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.prank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_DEPOSIT(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_requestDepositERC7540() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(key), 1_000_000e6); + + assertEq(usdc.allowance(address(almProxy), address(jTreasuryVault)), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(ESCROW), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + + vm.prank(relayer); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(key), 0); + + assertEq(usdc.allowance(address(almProxy), address(jTreasuryVault)), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(ESCROW), 1_000_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + } + +} + +contract MainnetControllerClaimDepositERC7540FailureTests is CentrifugeTestBase { + + function test_claimDepositERC7540_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.claimDepositERC7540(address(jTreasuryVault)); + } + + function test_claimDepositERC7540_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.claimDepositERC7540(makeAddr("fake-vault")); + } + +} + +contract MainnetControllerClaimDepositERC7540SuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.prank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_DEPOSIT(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_500_000e6, uint256(1_500_000e6) / 1 days); + } + + function test_claimDepositERC7540_singleRequest() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + + // Request deposit into JTRSY by supplying USDC + vm.prank(relayer); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + + uint256 totalSupply = jTreasuryToken.totalSupply(); + + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + + // Fulfill request at price 2.0 + vm.prank(ROOT); + investmentManager.fulfillDepositRequest( + JTREASURY_POOL_ID, + JTREASURY_TRANCHE_ID, + address(almProxy), + USDC_ASSET_ID, + 1_000_000e6, + 500_000e6 + ); + + assertEq(jTreasuryToken.totalSupply(), totalSupply + 500_000e6); + assertEq(jTreasuryToken.balanceOf(ESCROW), 500_000e6); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + + // Claim shares + vm.prank(relayer); + mainnetController.claimDepositERC7540(address(jTreasuryVault)); + + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 500_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + } + + + function test_claimDepositERC7540_multipleRequests() external { + deal(address(usdc), address(almProxy), 1_500_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + + // Request deposit into JTRSY by supplying USDC + vm.prank(relayer); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + + uint256 totalSupply = jTreasuryToken.totalSupply(); + + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + + // Request another deposit into JTRSY by supplying more USDC + vm.prank(relayer); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 500_000e6); + + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_500_000e6); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + + // Fulfill both requests at price 2.0 + vm.prank(ROOT); + investmentManager.fulfillDepositRequest( + JTREASURY_POOL_ID, + JTREASURY_TRANCHE_ID, + address(almProxy), + USDC_ASSET_ID, + 1_500_000e6, + 750_000e6 + ); + + assertEq(jTreasuryToken.totalSupply(), totalSupply + 750_000e6); + assertEq(jTreasuryToken.balanceOf(ESCROW), 750_000e6); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 1_500_000e6); + + // Claim shares + vm.prank(relayer); + mainnetController.claimDepositERC7540(address(jTreasuryVault)); + + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 750_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableDepositRequest(REQUEST_ID, address(almProxy)), 0); + } + +} + +contract MainnetControllerCancelCentrifugeDepositFailureTests is CentrifugeTestBase { + + function test_cancelCentrifugeDepositRequest_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.cancelCentrifugeDepositRequest(address(jTreasuryVault)); + } + + function test_cancelCentrifugeDepositRequest_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.cancelCentrifugeDepositRequest(makeAddr("fake-vault")); + } + +} + +contract MainnetControllerCancelCentrifugeDepositSuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.prank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_DEPOSIT(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_cancelCentrifugeDepositRequest() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.pendingCancelDepositRequest(REQUEST_ID, address(almProxy)), false); + + vm.prank(relayer); + mainnetController.cancelCentrifugeDepositRequest(address(jTreasuryVault)); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.pendingCancelDepositRequest(REQUEST_ID, address(almProxy)), true); + } + +} + +contract MainnetControllerClaimCentrifugeCancelDepositFailureTests is CentrifugeTestBase { + + function test_claimCentrifugeCancelDepositRequest_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.claimCentrifugeCancelDepositRequest(address(jTreasuryVault)); + } + + function test_claimCentrifugeCancelDepositRequest_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.claimCentrifugeCancelDepositRequest(makeAddr("fake-vault")); + } + +} + +contract MainnetControllerClaimCentrifugeCancelDepositSuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.prank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_DEPOSIT(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_claimCentrifugeCancelDepositRequest() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(ESCROW), 0); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.pendingCancelDepositRequest(REQUEST_ID, address(almProxy)), false); + assertEq(jTreasuryVault.claimableCancelDepositRequest(REQUEST_ID, address(almProxy)), 0); + + vm.startPrank(relayer); + mainnetController.requestDepositERC7540(address(jTreasuryVault), 1_000_000e6); + mainnetController.cancelCentrifugeDepositRequest(address(jTreasuryVault)); + vm.stopPrank(); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(ESCROW), 1_000_000e6); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.pendingCancelDepositRequest(REQUEST_ID, address(almProxy)), true); + assertEq(jTreasuryVault.claimableCancelDepositRequest(REQUEST_ID, address(almProxy)), 0); + + // Fulfill cancelation request + vm.prank(ROOT); + investmentManager.fulfillCancelDepositRequest( + JTREASURY_POOL_ID, + JTREASURY_TRANCHE_ID, + address(almProxy), + USDC_ASSET_ID, + 1_000_000e6, + 1_000_000e6 + ); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.pendingCancelDepositRequest(REQUEST_ID, address(almProxy)), false); + assertEq(jTreasuryVault.claimableCancelDepositRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + + vm.prank(relayer); + mainnetController.claimCentrifugeCancelDepositRequest(address(jTreasuryVault)); + + assertEq(jTreasuryVault.pendingDepositRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.pendingCancelDepositRequest(REQUEST_ID, address(almProxy)), false); + assertEq(jTreasuryVault.claimableCancelDepositRequest(REQUEST_ID, address(almProxy)), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(ESCROW), 0); + } + +} + +contract MainnetControllerRequestRedeemERC7540FailureTests is CentrifugeTestBase { + + function test_requestRedeemERC7540_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), 1_000_000e6); + } + + function test_requestRedeemERC7540_zeroMaxAmount() external { + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), 1_000_000e6); + } + + function test_requestRedeemERC7540_rateLimitsBoundary() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_REDEEM(), + address(jTreasuryVault) + ), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + + vm.startPrank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + jTreasuryToken.mint(address(almProxy), 1_000_000e6); + vm.stopPrank(); + + uint256 overBoundaryShares = jTreasuryVault.convertToShares(1_000_000e6 + 2); + uint256 atBoundaryShares = jTreasuryVault.convertToShares(1_000_000e6 + 1); + + assertEq(jTreasuryVault.convertToAssets(overBoundaryShares), 1_000_000e6 + 1); + assertEq(jTreasuryVault.convertToAssets(atBoundaryShares), 1_000_000e6); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), overBoundaryShares); + + mainnetController.requestRedeemERC7540(address(jTreasuryVault), atBoundaryShares); + } +} + +contract MainnetControllerRequestRedeemERC7540SuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.startPrank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + vm.stopPrank(); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_REDEEM(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_requestRedeemERC7540() external { + uint256 shares = jTreasuryVault.convertToShares(1_000_000e6); + + assertEq(shares, 951_771.227025e6); + + vm.prank(ROOT); + jTreasuryToken.mint(address(almProxy), shares); + + assertEq(rateLimits.getCurrentRateLimit(key), 1_000_000e6); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), shares); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + vm.prank(relayer); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), shares); + + assertEq(rateLimits.getCurrentRateLimit(key), 1); // Rounding + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + assertEq(jTreasuryToken.balanceOf(ESCROW), shares); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), shares); + } + +} + +contract MainnetControllerClaimRedeemERC7540FailureTests is CentrifugeTestBase { + + function test_claimRedeemERC7540_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.claimRedeemERC7540(address(jTreasuryVault)); + } + + function test_claimRedeemERC7540_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.claimRedeemERC7540(makeAddr("fake-vault")); + } + +} + +contract MainnetControllerClaimRedeemERC7540SuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.startPrank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + vm.stopPrank(); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_REDEEM(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 2_000_000e6, uint256(2_000_000e6) / 1 days); + } + + function test_claimRedeemERC7540_singleRequest() external { + vm.prank(ROOT); + jTreasuryToken.mint(address(almProxy), 1_000_000e6); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + // Request JTRSY redemption + vm.prank(relayer); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), 1_000_000e6); + + uint256 totalSupply = jTreasuryToken.totalSupply(); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + assertEq(jTreasuryToken.balanceOf(ESCROW), 1_000_000e6); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + // Fulfill request at price 2.0 + deal(address(usdc), ESCROW, 2_000_000e6); + vm.prank(ROOT); + investmentManager.fulfillRedeemRequest( + JTREASURY_POOL_ID, + JTREASURY_TRANCHE_ID, + address(almProxy), + USDC_ASSET_ID, + 2_000_000e6, + 1_000_000e6 + ); + + assertEq(jTreasuryToken.totalSupply(), totalSupply - 1_000_000e6); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + + assertEq(usdc.balanceOf(ESCROW), 2_000_000e6); + assertEq(usdc.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + + // Claim assets + vm.prank(relayer); + mainnetController.claimRedeemERC7540(address(jTreasuryVault)); + + assertEq(usdc.balanceOf(ESCROW), 0); + assertEq(usdc.balanceOf(address(almProxy)), 2_000_000e6); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + } + + function test_claimRedeemERC7540_multipleRequests() external { + vm.prank(ROOT); + jTreasuryToken.mint(address(almProxy), 1_500_000e6); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 1_500_000e6); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + // Request JTRSY redemption + vm.prank(relayer); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), 1_000_000e6); + + uint256 totalSupply = jTreasuryToken.totalSupply(); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 500_000e6); + assertEq(jTreasuryToken.balanceOf(ESCROW), 1_000_000e6); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 1_000_000e6); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + // Request another JTRSY redemption + vm.prank(relayer); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), 500_000e6); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + assertEq(jTreasuryToken.balanceOf(ESCROW), 1_500_000e6); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 1_500_000e6); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + // Fulfill both requests at price 2.0 + deal(address(usdc), ESCROW, 3_000_000e6); + vm.prank(ROOT); + investmentManager.fulfillRedeemRequest( + JTREASURY_POOL_ID, + JTREASURY_TRANCHE_ID, + address(almProxy), + USDC_ASSET_ID, + 3_000_000e6, + 1_500_000e6 + ); + + assertEq(jTreasuryToken.totalSupply(), totalSupply - 1_500_000e6); + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + + assertEq(usdc.balanceOf(ESCROW), 3_000_000e6); + assertEq(usdc.balanceOf(address(almProxy)), 0); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 1_500_000e6); + + // Claim assets + vm.prank(relayer); + mainnetController.claimRedeemERC7540(address(jTreasuryVault)); + + assertEq(usdc.balanceOf(ESCROW), 0); + assertEq(usdc.balanceOf(address(almProxy)), 3_000_000e6); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.claimableRedeemRequest(REQUEST_ID, address(almProxy)), 0); + } + +} + +contract MainnetControllerCancelCentrifugeRedeemRequestFailureTests is CentrifugeTestBase { + + function test_cancelCentrifugeRedeemRequest_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.cancelCentrifugeRedeemRequest(address(jTreasuryVault)); + } + + function test_cancelCentrifugeRedeemRequest_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.cancelCentrifugeRedeemRequest(makeAddr("fake-vault")); + } + +} + +contract MainnetControllerCancelCentrifugeRedeemRequestSuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.startPrank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + vm.stopPrank(); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_REDEEM(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_cancelCentrifugeRedeemRequest() external { + uint256 shares = jTreasuryVault.convertToShares(1_000_000e6); + + vm.prank(ROOT); + jTreasuryToken.mint(address(almProxy), shares); + + vm.prank(relayer); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), shares); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), shares); + assertEq(jTreasuryVault.pendingCancelRedeemRequest(REQUEST_ID, address(almProxy)), false); + + vm.prank(relayer); + mainnetController.cancelCentrifugeRedeemRequest(address(jTreasuryVault)); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), shares); + assertEq(jTreasuryVault.pendingCancelRedeemRequest(REQUEST_ID, address(almProxy)), true); + } + +} + +contract MainnetControllerClaimCentrifugeCancelRedeemRequestFailureTests is CentrifugeTestBase { + + function test_claimCentrifugeCancelRedeemRequest_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.claimCentrifugeCancelRedeemRequest(address(jTreasuryVault)); + } + + function test_claimCentrifugeCancelRedeemRequest_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.claimCentrifugeCancelRedeemRequest(makeAddr("fake-vault")); + } + +} + +contract MainnetControllerClaimCentrifugeCancelRedeemRequestSuccessTests is CentrifugeTestBase { + + bytes32 key; + + function setUp() public override { + super.setUp(); + + vm.startPrank(ROOT); + restrictionManager.updateMember(address(jTreasuryToken), address(almProxy), type(uint64).max); + vm.stopPrank(); + + key = RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_7540_REDEEM(), + address(jTreasuryVault) + ); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_claimCentrifugeCancelRedeemRequest() external { + uint256 shares = jTreasuryVault.convertToShares(1_000_000e6); + + vm.prank(ROOT); + jTreasuryToken.mint(address(almProxy), shares); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), shares); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.pendingCancelRedeemRequest(REQUEST_ID, address(almProxy)), false); + assertEq(jTreasuryVault.claimableCancelRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + vm.startPrank(relayer); + mainnetController.requestRedeemERC7540(address(jTreasuryVault), shares); + mainnetController.cancelCentrifugeRedeemRequest(address(jTreasuryVault)); + vm.stopPrank(); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), 0); + assertEq(jTreasuryToken.balanceOf(ESCROW), shares); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), shares); + assertEq(jTreasuryVault.pendingCancelRedeemRequest(REQUEST_ID, address(almProxy)), true); + assertEq(jTreasuryVault.claimableCancelRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + // Fulfill cancelation request + vm.prank(ROOT); + investmentManager.fulfillCancelRedeemRequest( + JTREASURY_POOL_ID, + JTREASURY_TRANCHE_ID, + address(almProxy), + USDC_ASSET_ID, + uint128(shares) + ); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.pendingCancelRedeemRequest(REQUEST_ID, address(almProxy)), false); + assertEq(jTreasuryVault.claimableCancelRedeemRequest(REQUEST_ID, address(almProxy)), shares); + + vm.prank(relayer); + mainnetController.claimCentrifugeCancelRedeemRequest(address(jTreasuryVault)); + + assertEq(jTreasuryVault.pendingRedeemRequest(REQUEST_ID, address(almProxy)), 0); + assertEq(jTreasuryVault.pendingCancelRedeemRequest(REQUEST_ID, address(almProxy)), false); + assertEq(jTreasuryVault.claimableCancelRedeemRequest(REQUEST_ID, address(almProxy)), 0); + + assertEq(jTreasuryToken.balanceOf(address(almProxy)), shares); + assertEq(jTreasuryToken.balanceOf(ESCROW), 0); + } + +} diff --git a/test/mainnet-fork/Deploy.t.sol b/test/mainnet-fork/Deploy.t.sol index 1c407cd9..ea57fd5f 100644 --- a/test/mainnet-fork/Deploy.t.sol +++ b/test/mainnet-fork/Deploy.t.sol @@ -67,7 +67,6 @@ contract MainnetControllerDeploySuccessTests is ForkTestBase { assertEq(address(controller.usde()), Ethereum.USDE); assertEq(controller.psmTo18ConversionFactor(), 1e12); - assertEq(controller.active(), true); } } diff --git a/test/mainnet-fork/Ethena.t.sol b/test/mainnet-fork/Ethena.t.sol index b5fbedc7..6e7994b8 100644 --- a/test/mainnet-fork/Ethena.t.sol +++ b/test/mainnet-fork/Ethena.t.sol @@ -25,15 +25,6 @@ contract MainnetControllerSetDelegatedSignerFailureTests is EthenaTestBase { mainnetController.setDelegatedSigner(makeAddr("signer")); } - function test_setDelegatedSigner_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.setDelegatedSigner(makeAddr("signer")); - } - } contract MainnetControllerSetDelegatedSignerSuccessTests is EthenaTestBase { @@ -68,15 +59,6 @@ contract MainnetControllerRemoveDelegatedSignerFailureTests is EthenaTestBase { mainnetController.removeDelegatedSigner(makeAddr("signer")); } - function test_removeDelegatedSigner_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.removeDelegatedSigner(makeAddr("signer")); - } - } contract MainnetControllerRemoveDelegatedSignerSuccessTests is EthenaTestBase { @@ -114,13 +96,14 @@ contract MainnetControllerPrepareUSDeMintFailureTests is EthenaTestBase { mainnetController.prepareUSDeMint(100); } - function test_prepareUSDeMint_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_prepareUSDeMint_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDE_MINT(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.prepareUSDeMint(100); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.prepareUSDeMint(1e18); } function test_prepareUSDeMint_rateLimitBoundary() external { @@ -195,13 +178,14 @@ contract MainnetControllerPrepareUSDeBurnFailureTests is EthenaTestBase { mainnetController.prepareUSDeBurn(100); } - function test_prepareUSDeBurn_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_prepareUSDeBurn_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDE_BURN(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.prepareUSDeBurn(100); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.prepareUSDeBurn(1e18); } function test_prepareUSDeBurn_rateLimitBoundary() external { @@ -276,12 +260,13 @@ contract MainnetControllerCooldownAssetsSUSDeFailureTests is EthenaTestBase { mainnetController.cooldownAssetsSUSDe(100e18); } - function test_cooldownAssetsSUSDe_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_cooldownAssetsSUSDe_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_SUSDE_COOLDOWN(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); + vm.expectRevert("RateLimits/zero-maxAmount"); mainnetController.cooldownAssetsSUSDe(100e18); } @@ -384,13 +369,16 @@ contract MainnetControllerCooldownSharesSUSDeFailureTests is EthenaTestBase { mainnetController.cooldownSharesSUSDe(100); } - function test_cooldownSharesSUSDe_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_cooldownSharesSUSDe_zeroMaxAmount() external { + deal(address(susde), address(almProxy), 100e18); // To get past call + + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_SUSDE_COOLDOWN(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.cooldownSharesSUSDe(100); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.cooldownSharesSUSDe(100e18); } function test_cooldownSharesSUSDe_rateLimitBoundary() external { @@ -511,15 +499,6 @@ contract MainnetControllerUnstakeSUSDeFailureTests is EthenaTestBase { mainnetController.unstakeSUSDe(); } - function test_unstakeSUSDe_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); - - vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); - mainnetController.unstakeSUSDe(); - } - function test_unstakeSUSDe_cooldownBoundary() external { // Exchange rate greater than 1:1 deal(address(susde), address(almProxy), 100e18); diff --git a/test/mainnet-fork/ForkTestBase.t.sol b/test/mainnet-fork/ForkTestBase.t.sol index 40aaf085..bb2387cc 100644 --- a/test/mainnet-fork/ForkTestBase.t.sol +++ b/test/mainnet-fork/ForkTestBase.t.sol @@ -56,6 +56,19 @@ interface IPSMLike { function rush() external view returns (uint256); } +interface ISSTokenLike is IERC20 { + function calculateSuperstateTokenOut(uint256 amountIn, address stablecoin) + external view returns ( + uint256 superstateTokenOutAmount, + uint256 stablcoinInAmountAfterFee, + uint256 feeOnStablecoinInAmount + ); + function mint(address to, uint256 amount) external; + function burn(address src, uint256 amount) external; + function owner() external view returns (address); + function supportedStablecoins(address stablecoin) external view returns (address sweepDestination, uint256 fee); +} + interface IVaultLike { function rely(address) external; function wards(address) external returns (uint256); @@ -81,6 +94,8 @@ contract ForkTestBase is DssTest { address freezer = Ethereum.ALM_FREEZER; address relayer = Ethereum.ALM_RELAYER; + address backstopRelayer = makeAddr("backstopRelayer"); // TODO: Replace with real backstop + bytes32 CONTROLLER; bytes32 FREEZER; bytes32 RELAYER; @@ -104,7 +119,9 @@ contract ForkTestBase is DssTest { IERC20 constant usds = IERC20(Ethereum.USDS); ISUsds constant susds = ISUsds(Ethereum.SUSDS); - ISUSDELike constant susde = ISUSDELike(Ethereum.SUSDE); + ISSTokenLike constant uscc = ISSTokenLike(Ethereum.USCC); + ISSTokenLike constant ustb = ISSTokenLike(Ethereum.USTB); + ISUSDELike constant susde = ISUSDELike(Ethereum.SUSDE); IPSMLike constant psm = IPSMLike(PSM); @@ -152,7 +169,7 @@ contract ForkTestBase is DssTest { /*** Step 1: Set up environment, cast addresses ***/ - source = getChain("mainnet").createSelectFork(_getBlock()); + source = getChain("mainnet").createSelectFork(_getBlock()); dss = MCD.loadFromChainlog(LOG); @@ -216,7 +233,7 @@ contract ForkTestBase is DssTest { FREEZER = mainnetController.FREEZER(); RELAYER = mainnetController.RELAYER(); - Init.ConfigAddressParams memory configAddresses + Init.ConfigAddressParams memory configAddresses = Init.ConfigAddressParams({ freezer : freezer, relayer : relayer, @@ -259,6 +276,8 @@ contract ForkTestBase is DssTest { mintRecipients ); + mainnetController.grantRole(mainnetController.RELAYER(), backstopRelayer); + RateLimitData memory standardUsdsData = RateLimitData({ maxAmount : 5_000_000e18, slope : uint256(1_000_000e18) / 4 hours diff --git a/test/mainnet-fork/InitAndUpgrade.t.sol b/test/mainnet-fork/InitAndUpgrade.t.sol index fa66b794..efa41ebc 100644 --- a/test/mainnet-fork/InitAndUpgrade.t.sol +++ b/test/mainnet-fork/InitAndUpgrade.t.sol @@ -14,7 +14,7 @@ import { MainnetControllerInit as Init } from "../../deploy/MainnetControllerIni contract LibraryWrapper { function initAlmSystem( - address vault, + address vault, address usds, ControllerInstance memory controllerInst, Init.ConfigAddressParams memory configAddresses, @@ -81,7 +81,7 @@ contract MainnetControllerInitAndUpgradeTestBase is ForkTestBase { contract MainnetControllerInitAndUpgradeFailureTest is MainnetControllerInitAndUpgradeTestBase { // NOTE: `initAlmSystem` and `upgradeController` are tested in the same contract because - // they both use _initController and have similar specific setups, so it + // they both use _initController and have similar specific setups, so it // less complex/repetitive to test them together. LibraryWrapper wrapper; @@ -101,7 +101,7 @@ contract MainnetControllerInitAndUpgradeFailureTest is MainnetControllerInitAndU oldController = address(mainnetController); // Cache for later testing - // NOTE: initAlmSystem will redundantly call rely and approve on already inited + // NOTE: initAlmSystem will redundantly call rely and approve on already inited // almProxy and rateLimits, this setup was chosen to easily test upgrade and init failures. // It also should be noted that the almProxy and rateLimits that are being used in initAlmSystem // are already deployed. This is technically possible to do and works in the same way, it was @@ -218,18 +218,6 @@ contract MainnetControllerInitAndUpgradeFailureTest is MainnetControllerInitAndU _checkInitAndUpgradeFail(abi.encodePacked("MainnetControllerInit/incorrect-cctp")); } - function test_initAlmSystem_upgradeController_controllerInactive() external { - // Cheating to set this outside of init scripts so that the controller can be frozen - vm.startPrank(SPARK_PROXY); - mainnetController.grantRole(FREEZER, freezer); - - vm.startPrank(freezer); - mainnetController.freeze(); - vm.stopPrank(); - - _checkInitAndUpgradeFail(abi.encodePacked("MainnetControllerInit/controller-not-active")); - } - function test_initAlmSystem_upgradeController_oldControllerIsNewController() external { configAddresses.oldController = controllerInst.controller; _checkInitAndUpgradeFail(abi.encodePacked("MainnetControllerInit/old-controller-is-new-controller")); @@ -257,7 +245,7 @@ contract MainnetControllerInitAndUpgradeFailureTest is MainnetControllerInitAndU // Revoke the old controller address in ALM proxy vm.startPrank(SPARK_PROXY); almProxy.revokeRole(almProxy.CONTROLLER(), configAddresses.oldController); - vm.stopPrank(); + vm.stopPrank(); // Try to upgrade with the old controller address that is doesn't have the CONTROLLER role vm.expectRevert("MainnetControllerInit/old-controller-not-almProxy-controller"); diff --git a/test/mainnet-fork/Maple.t.sol b/test/mainnet-fork/Maple.t.sol new file mode 100644 index 00000000..3e162f42 --- /dev/null +++ b/test/mainnet-fork/Maple.t.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import "./ForkTestBase.t.sol"; + +import { IMapleTokenLike } from "../../src/MainnetController.sol"; + +interface IPermissionManagerLike { + function admin() external view returns (address); + function setLenderAllowlist( + address poolManager_, + address[] calldata lenders_, + bool[] calldata booleans_ + ) external; +} + +interface IMapleTokenExtended is IMapleTokenLike { + function manager() external view returns (address); +} + +interface IPoolManagerLike { + function withdrawalManager() external view returns (address); + function poolDelegate() external view returns (address); +} + +interface IWithdrawalManagerLike { + function processRedemptions(uint256 maxSharesToProcess) external; +} + +contract MapleTestBase is ForkTestBase { + + IMapleTokenExtended constant syrup = IMapleTokenExtended(0x80ac24aA929eaF5013f6436cdA2a7ba190f5Cc0b); + + IPermissionManagerLike constant permissionManager + = IPermissionManagerLike(0xBe10aDcE8B6E3E02Db384E7FaDA5395DD113D8b3); + + uint256 SYRUP_CONVERTED_ASSETS; + uint256 SYRUP_CONVERTED_SHARES; + + uint256 USDC_BAL_SYRUP; + + uint256 SYRUP_TOTAL_ASSETS; + uint256 SYRUP_TOTAL_SUPPLY; + + bytes32 depositKey; + bytes32 redeemKey; + + function setUp() override public { + super.setUp(); + + depositKey = RateLimitHelpers.makeAssetKey(mainnetController.LIMIT_4626_DEPOSIT(), address(syrup)); + redeemKey = RateLimitHelpers.makeAssetKey(mainnetController.LIMIT_MAPLE_REDEEM(), address(syrup)); + + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(depositKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); + rateLimits.setRateLimitData(redeemKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); + vm.stopPrank(); + + // Maple onboarding process + address[] memory lenders = new address[](1); + bool[] memory booleans = new bool[](1); + + lenders[0] = address(almProxy); + booleans[0] = true; + + vm.startPrank(permissionManager.admin()); + permissionManager.setLenderAllowlist( + syrup.manager(), + lenders, + booleans + ); + vm.stopPrank(); + + SYRUP_CONVERTED_ASSETS = syrup.convertToAssets(1_000_000e6); + SYRUP_CONVERTED_SHARES = syrup.convertToShares(1_000_000e6); + + SYRUP_TOTAL_ASSETS = syrup.totalAssets(); + SYRUP_TOTAL_SUPPLY = syrup.totalSupply(); + + USDC_BAL_SYRUP = usdc.balanceOf(address(syrup)); + + assertEq(SYRUP_CONVERTED_ASSETS, 1_066_100.425881e6); + assertEq(SYRUP_CONVERTED_SHARES, 937_997.936895e6); + + assertEq(SYRUP_TOTAL_ASSETS, 59_578_045.544596e6); + assertEq(SYRUP_TOTAL_SUPPLY, 55_884_083.805100e6); + } + + function _getBlock() internal pure override returns (uint256) { + return 21570000; // Jan 7, 2024 + } + +} + +contract MainnetControllerDepositERC4626MapleFailureTests is MapleTestBase { + + function test_depositERC4626_maple_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.depositERC4626(address(syrup), 1_000_000e6); + } + + function test_depositERC4626_maple_zeroMaxAmount() external { + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(depositKey, 0, 0); + + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.depositERC4626(address(syrup), 1_000_000e6); + } + + function test_depositERC4626_maple_rateLimitBoundary() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.depositERC4626(address(syrup), 1_000_000e6 + 1); + + mainnetController.depositERC4626(address(syrup), 1_000_000e6); + } + +} + +contract MainnetControllerDepositERC4626Tests is MapleTestBase { + + function test_depositERC4626_maple() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(address(mainnetController)), 0); + assertEq(usdc.balanceOf(address(syrup)), USDC_BAL_SYRUP); + + assertEq(usdc.allowance(address(almProxy), address(syrup)), 0); + + assertEq(syrup.totalSupply(), SYRUP_TOTAL_SUPPLY); + assertEq(syrup.totalAssets(), SYRUP_TOTAL_ASSETS); + assertEq(syrup.balanceOf(address(almProxy)), 0); + + vm.prank(relayer); + uint256 shares = mainnetController.depositERC4626(address(syrup), 1_000_000e6); + + assertEq(shares, SYRUP_CONVERTED_SHARES); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(address(mainnetController)), 0); + assertEq(usdc.balanceOf(address(syrup)), USDC_BAL_SYRUP + 1_000_000e6); + + assertEq(usdc.allowance(address(almProxy), address(syrup)), 0); + + assertEq(syrup.totalSupply(), SYRUP_TOTAL_SUPPLY + shares); + assertEq(syrup.totalAssets(), SYRUP_TOTAL_ASSETS + 1_000_000e6); + assertEq(syrup.balanceOf(address(almProxy)), shares); + } + +} + +contract MainnetControllerRequestMapleRedemptionFailureTests is MapleTestBase { + + function test_requestMapleRedemption_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.requestMapleRedemption(address(syrup), 1_000_000e6); + } + + function test_requestMapleRedemption_zeroMaxAmount() external { + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(redeemKey, 0, 0); + + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.requestMapleRedemption(address(syrup), 1_000_000e6); + } + + function test_requestMapleRedemption_rateLimitBoundary() external { + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(depositKey, 5_000_000e6, uint256(1_000_000e6) / 1 days); + + deal(address(usdc), address(almProxy), 5_000_000e6); + + vm.prank(relayer); + mainnetController.depositERC4626(address(syrup), 5_000_000e6); + + uint256 overBoundaryShares = syrup.convertToShares(1_000_000e6 + 2); // Rounding + uint256 atBoundaryShares = syrup.convertToShares(1_000_000e6 + 1); // Rounding + + assertEq(syrup.convertToAssets(overBoundaryShares), 1_000_000e6 + 1); + assertEq(syrup.convertToAssets(atBoundaryShares), 1_000_000e6); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.requestMapleRedemption(address(syrup), overBoundaryShares); + + mainnetController.requestMapleRedemption(address(syrup), atBoundaryShares); + } + +} + +contract MainnetControllerRequestMapleRedemptionSuccessTests is MapleTestBase { + + function test_requestMapleRedemption() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + uint256 proxyShares = mainnetController.depositERC4626(address(syrup), 1_000_000e6); + + address withdrawalManager = IPoolManagerLike(syrup.manager()).withdrawalManager(); + + uint256 totalEscrowedShares = syrup.balanceOf(withdrawalManager); + + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares); + assertEq(syrup.balanceOf(address(almProxy)), proxyShares); + assertEq(syrup.allowance(address(almProxy), withdrawalManager), 0); + + vm.prank(relayer); + mainnetController.requestMapleRedemption(address(syrup), proxyShares); + + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares + proxyShares); + assertEq(syrup.balanceOf(address(almProxy)), 0); + assertEq(syrup.allowance(address(almProxy), withdrawalManager), 0); + } +} + +contract MainnetControllerCancelMapleRedemptionFailureTests is MapleTestBase { + + function test_cancelMapleRedemption_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.cancelMapleRedemption(address(syrup), 1_000_000e6); + } + + function test_cancelMapleRedemption_invalidMapleToken() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.cancelMapleRedemption(makeAddr("fake-syrup"), 1_000_000e6); + } + +} + +contract MainnetControllerCancelMapleRedemptionSuccessTests is MapleTestBase { + + function test_cancelMapleRedemption() public { + address withdrawalManager = IPoolManagerLike(syrup.manager()).withdrawalManager(); + + uint256 totalEscrowedShares = syrup.balanceOf(withdrawalManager); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.startPrank(relayer); + uint256 proxyShares = mainnetController.depositERC4626(address(syrup), 1_000_000e6); + + mainnetController.requestMapleRedemption(address(syrup), proxyShares); + + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares + proxyShares); + assertEq(syrup.balanceOf(address(almProxy)), 0); + + mainnetController.cancelMapleRedemption(address(syrup), proxyShares); + + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares); + assertEq(syrup.balanceOf(address(almProxy)), proxyShares); + } + +} + +contract MainnetControllerMapleE2ETests is MapleTestBase { + + function test_e2e_mapleDepositAndRedeem() external { + // Increase withdraw rate limit so interest can be accrued + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(redeemKey, 2_000_000e6, uint256(1_000_000e6) / 1 days); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + // --- Step 1: Deposit USDC into Maple --- + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(address(mainnetController)), 0); + assertEq(usdc.balanceOf(address(syrup)), USDC_BAL_SYRUP); + + assertEq(usdc.allowance(address(almProxy), address(syrup)), 0); + + assertEq(syrup.totalSupply(), SYRUP_TOTAL_SUPPLY); + assertEq(syrup.totalAssets(), SYRUP_TOTAL_ASSETS); + assertEq(syrup.balanceOf(address(almProxy)), 0); + + vm.prank(relayer); + uint256 proxyShares = mainnetController.depositERC4626(address(syrup), 1_000_000e6); + + assertEq(proxyShares, SYRUP_CONVERTED_SHARES); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(address(mainnetController)), 0); + assertEq(usdc.balanceOf(address(syrup)), USDC_BAL_SYRUP + 1_000_000e6); + + assertEq(usdc.allowance(address(almProxy), address(syrup)), 0); + + assertEq(syrup.totalSupply(), SYRUP_TOTAL_SUPPLY + proxyShares); + assertEq(syrup.totalAssets(), SYRUP_TOTAL_ASSETS + 1_000_000e6); + assertEq(syrup.balanceOf(address(almProxy)), SYRUP_CONVERTED_SHARES); + + // --- Step 2: Request Redeem --- + + skip(1 days); // Warp to accrue interest + + address withdrawalManager = IPoolManagerLike(syrup.manager()).withdrawalManager(); + + uint256 totalEscrowedShares = syrup.balanceOf(withdrawalManager); + + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares); + assertEq(syrup.balanceOf(address(almProxy)), proxyShares); + assertEq(syrup.allowance(address(almProxy), withdrawalManager), 0); + + vm.prank(relayer); + mainnetController.requestMapleRedemption(address(syrup), proxyShares); + + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares + proxyShares); + assertEq(syrup.balanceOf(address(almProxy)), 0); + assertEq(syrup.allowance(address(almProxy), withdrawalManager), 0); + + // --- Step 3: Fulfill Redeem (done by Maple) --- + + skip(1 days); // Warp to accrue more interest + + uint256 totalAssets = syrup.totalAssets(); + uint256 withdrawAssets = syrup.convertToAssets(proxyShares); + uint256 usdcPoolBal = usdc.balanceOf(address(syrup)); + + assertGt(totalAssets, SYRUP_TOTAL_ASSETS + 1_000_000e6); // Interest accrued + + assertEq(withdrawAssets, 1_000_423.216342e6); // Interest accrued + + assertEq(syrup.totalSupply(), SYRUP_TOTAL_SUPPLY + proxyShares); + assertEq(syrup.totalAssets(), totalAssets); + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares + proxyShares); + + assertEq(usdc.balanceOf(address(syrup)), usdcPoolBal); + assertEq(usdc.balanceOf(address(almProxy)), 0); + + // NOTE: `proxyShares` can be used in this case because almProxy is the only account using the + // `withdrawalManager` at this fork block. Usually `proccessRedemptions` requires + // `maxSharesToProcess` to include the shares of all accounts ahead of almProxy in + // queue plus almProxy's shares. + vm.prank(IPoolManagerLike(syrup.manager()).poolDelegate()); + IWithdrawalManagerLike(withdrawalManager).processRedemptions(proxyShares); + + assertEq(syrup.totalSupply(), SYRUP_TOTAL_SUPPLY); + assertEq(syrup.totalAssets(), totalAssets - withdrawAssets); + assertEq(syrup.balanceOf(address(withdrawalManager)), totalEscrowedShares); + + assertEq(usdc.balanceOf(address(syrup)), usdcPoolBal - withdrawAssets); + assertEq(usdc.balanceOf(address(almProxy)), withdrawAssets); + } +} diff --git a/test/mainnet-fork/MorphoAllocations.t.sol b/test/mainnet-fork/MorphoAllocations.t.sol new file mode 100644 index 00000000..dc2bb992 --- /dev/null +++ b/test/mainnet-fork/MorphoAllocations.t.sol @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import { IERC4626 } from "forge-std/interfaces/IERC4626.sol"; + +import { IMetaMorpho, Id, MarketAllocation } from "metamorpho/interfaces/IMetaMorpho.sol"; + +import { MarketParamsLib } from "morpho-blue/src/libraries/MarketParamsLib.sol"; +import { IMorpho, MarketParams } from "morpho-blue/src/interfaces/IMorpho.sol"; + +import { RateLimitHelpers } from "../../src/RateLimitHelpers.sol"; + +import "./ForkTestBase.t.sol"; + +contract MorphoTestBase is ForkTestBase { + + address internal constant PT_SUSDE_27MAR2025_PRICE_FEED = 0x38d130cEe60CDa080A3b3aC94C79c34B6Fc919A7; + address internal constant PT_SUSDE_27MAR2025 = 0xE00bd3Df25fb187d6ABBB620b3dfd19839947b81; + address internal constant PT_SUSDE_29MAY2025_PRICE_FEED = 0xE84f7e0a890e5e57d0beEa2c8716dDf0c9846B4A; + address internal constant PT_SUSDE_29MAY2025 = 0xb7de5dFCb74d25c2f21841fbd6230355C50d9308; + + IMetaMorpho morphoVault = IMetaMorpho(Ethereum.MORPHO_VAULT_DAI_1); + IMorpho morpho = IMorpho(Ethereum.MORPHO); + + // Using March and May 2025 sUSDe PT markets for testing + MarketParams market1 = MarketParams({ + loanToken : Ethereum.DAI, + collateralToken : PT_SUSDE_27MAR2025, + oracle : PT_SUSDE_27MAR2025_PRICE_FEED, + irm : Ethereum.MORPHO_DEFAULT_IRM, + lltv : 0.915e18 + }); + MarketParams market2 = MarketParams({ + loanToken : Ethereum.DAI, + collateralToken : PT_SUSDE_29MAY2025, + oracle : PT_SUSDE_29MAY2025_PRICE_FEED, + irm : Ethereum.MORPHO_DEFAULT_IRM, + lltv : 0.915e18 + }); + + function setUp() public override { + super.setUp(); + + // Spell onboarding (Ability to deposit necessary to onboard a vault for allocations) + vm.startPrank(Ethereum.SPARK_PROXY); + morphoVault.setIsAllocator(address(almProxy), true); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_4626_DEPOSIT(), + address(morphoVault) + ), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + vm.stopPrank(); + } + + function _getBlock() internal pure override returns (uint256) { + return 21680000; // Jan 22, 2024 + } + + function positionShares(MarketParams memory marketParams) internal view returns (uint256) { + return morpho.position(MarketParamsLib.id(marketParams), address(morphoVault)).supplyShares; + } + + function positionAssets(MarketParams memory marketParams) internal view returns (uint256) { + return positionShares(marketParams) + * marketAssets(marketParams) + / morpho.market(MarketParamsLib.id(marketParams)).totalSupplyShares; + } + + function marketAssets(MarketParams memory marketParams) internal view returns (uint256) { + return morpho.market(MarketParamsLib.id(marketParams)).totalSupplyAssets; + } + +} + +contract MorphoSetSupplyQueueMorphoFailureTests is MorphoTestBase { + + function test_setSupplyQueueMorpho_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.setSupplyQueueMorpho(address(morphoVault), new Id[](0)); + } + + function test_setSupplyQueueMorpho_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.setSupplyQueueMorpho(makeAddr("fake-vault"), new Id[](0)); + } + +} + +contract MorphoSetSupplyQueueMorphoSuccessTests is MorphoTestBase { + + function test_setSupplyQueueMorpho() external { + // Switch order of existing markets + Id[] memory supplyQueueUSDC = new Id[](2); + supplyQueueUSDC[0] = MarketParamsLib.id(market1); + supplyQueueUSDC[1] = MarketParamsLib.id(market2); + + // No supply queue to start, but caps are above zero + assertEq(morphoVault.supplyQueueLength(), 0); + + vm.prank(relayer); + mainnetController.setSupplyQueueMorpho(address(morphoVault), supplyQueueUSDC); + + assertEq(morphoVault.supplyQueueLength(), 2); + + assertEq(Id.unwrap(morphoVault.supplyQueue(0)), Id.unwrap(MarketParamsLib.id(market1))); + assertEq(Id.unwrap(morphoVault.supplyQueue(1)), Id.unwrap(MarketParamsLib.id(market2))); + } + +} + +contract MorphoUpdateWithdrawQueueMorphoFailureTests is MorphoTestBase { + + function test_updateWithdrawQueueMorpho_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.updateWithdrawQueueMorpho(address(morphoVault), new uint256[](0)); + } + + function test_updateWithdrawQueueMorpho_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.updateWithdrawQueueMorpho(makeAddr("fake-vault"), new uint256[](0)); + } + +} + +contract MorphoUpdateWithdrawQueueMorphoSuccessTests is MorphoTestBase { + + function test_updateWithdrawQueueMorpho() external { + // Switch order of existing markets + uint256[] memory newWithdrawQueueUsdc = new uint256[](14); + Id[] memory startingWithdrawQueue = new Id[](14); + + // Set all markets in same order then adjust + for (uint256 i = 0; i < 14; i++) { + newWithdrawQueueUsdc[i] = i; + startingWithdrawQueue[i] = morphoVault.withdrawQueue(i); + } + + assertEq(morphoVault.withdrawQueueLength(), 14); + + assertEq(Id.unwrap(morphoVault.withdrawQueue(11)), Id.unwrap(MarketParamsLib.id(market1))); + assertEq(Id.unwrap(morphoVault.withdrawQueue(13)), Id.unwrap(MarketParamsLib.id(market2))); + + // Switch order of market1 and market2 + newWithdrawQueueUsdc[11] = 13; + newWithdrawQueueUsdc[13] = 11; + + vm.prank(relayer); + mainnetController.updateWithdrawQueueMorpho(address(morphoVault), newWithdrawQueueUsdc); + + assertEq(morphoVault.withdrawQueueLength(), 14); + + assertEq(Id.unwrap(morphoVault.withdrawQueue(11)), Id.unwrap(MarketParamsLib.id(market2))); + assertEq(Id.unwrap(morphoVault.withdrawQueue(13)), Id.unwrap(MarketParamsLib.id(market1))); + + // Ensure the rest is kept in order + for (uint256 i = 0; i < 14; i++) { + if (i == 11 || i == 13) continue; + assertEq(Id.unwrap(morphoVault.withdrawQueue(i)), Id.unwrap(startingWithdrawQueue[i])); + } + } + +} + +contract MorphoReallocateMorphoFailureTests is MorphoTestBase { + + function test_reallocateMorpho_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.reallocateMorpho(address(morphoVault), new MarketAllocation[](0)); + } + + function test_reallocateMorpho_invalidVault() external { + vm.prank(relayer); + vm.expectRevert("MainnetController/invalid-action"); + mainnetController.reallocateMorpho(makeAddr("fake-vault"), new MarketAllocation[](0)); + } + +} + +contract MorphoReallocateMorphoSuccessTests is MorphoTestBase { + + function test_reallocateMorpho() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetKey( + mainnetController.LIMIT_4626_DEPOSIT(), + address(morphoVault) + ), + 25_000_000e6, + uint256(5_000_000e6) / 1 days + ); + vm.stopPrank(); + + // Refresh markets so calculations don't include interest + vm.prank(relayer); + mainnetController.depositERC4626(address(morphoVault), 0); + + uint256 market1Position = positionAssets(market1); + uint256 market2Position = positionAssets(market2); + + uint256 market1Assets = marketAssets(market1); + uint256 market2Assets = marketAssets(market2); + + assertEq(market1Position, 356_456_521.341763767525558015e18); + assertEq(market2Position, 50_038_784.076802509703226888e18); + + assertEq(market1Assets, 390_003_166.284505547080982600e18); + assertEq(market2Assets, 50_038_786.142322219196324919e18); + + // Move 1m from market1 to market2 + MarketAllocation[] memory reallocations = new MarketAllocation[](2); + reallocations[0] = MarketAllocation({ + marketParams : market1, + assets : market1Position - 1_000_000e18 + }); + reallocations[1] = MarketAllocation({ + marketParams : market2, + assets : type(uint256).max + }); + + vm.prank(relayer); + mainnetController.reallocateMorpho(address(morphoVault), reallocations); + + uint256 positionInterest = 9_803.525491215426215841e18; + uint256 market1Interest = 223.610631168657703153e18; + uint256 market2Interest = 9_803.525797810824423503e18; // Slightly higher than position from external liquidity + + // Interest from position1 moves as well, resulting position is as specified + assertEq(positionAssets(market1), market1Position - 1_000_000e18); + assertEq(positionAssets(market2), market2Position + 1_000_000e18 + positionInterest); + + assertEq(marketAssets(market1), market1Assets - 1_000_000e18 + market1Interest); + assertEq(marketAssets(market2), market2Assets + 1_000_000e18 + market2Interest); + + // Overwrite values for simpler assertions + market1Position = positionAssets(market1); + market2Position = positionAssets(market2); + market1Assets = marketAssets(market1); + market2Assets = marketAssets(market2); + + // Move another 500k from market1 to market2 + reallocations = new MarketAllocation[](2); + reallocations[0] = MarketAllocation({ + marketParams : market1, + assets : market1Position - 500_000e18 + }); + reallocations[1] = MarketAllocation({ + marketParams : market2, + assets : market2Position + 500_000e18 + }); + + vm.prank(relayer); + mainnetController.reallocateMorpho(address(morphoVault), reallocations); + + // No new interest has been accounted for so values are exact + assertEq(positionAssets(market1), market1Position - 500_000e18); + assertEq(positionAssets(market2), market2Position + 500_000e18); + + assertEq(marketAssets(market1), market1Assets - 500_000e18); + assertEq(marketAssets(market2), market2Assets + 500_000e18); + } + +} diff --git a/test/mainnet-fork/PsmCalls.t.sol b/test/mainnet-fork/PsmCalls.t.sol index 7e1ac198..8a708bd7 100644 --- a/test/mainnet-fork/PsmCalls.t.sol +++ b/test/mainnet-fork/PsmCalls.t.sol @@ -19,15 +19,27 @@ contract MainnetControllerSwapUSDSToUSDCFailureTests is ForkTestBase { mainnetController.swapUSDSToUSDC(1e6); } - function test_swapUSDSToUSDC_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_swapUSDSToUSDC_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDS_TO_USDC(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); + vm.expectRevert("RateLimits/zero-maxAmount"); mainnetController.swapUSDSToUSDC(1e6); } + function test_swapUSDSToUSDC_rateLimitBoundary() external { + deal(address(usds), address(almProxy), 10_000_000e18); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.swapUSDSToUSDC(5_000_000e6 + 1); + + vm.prank(relayer); + mainnetController.swapUSDSToUSDC(5_000_000e6); + } + } contract MainnetControllerSwapUSDSToUSDCTests is ForkTestBase { @@ -123,12 +135,13 @@ contract MainnetControllerSwapUSDCToUSDSFailureTests is ForkTestBase { mainnetController.swapUSDCToUSDS(1e6); } - function test_swapUSDCToUSDS_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_swapUSDCToUSDS_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDS_TO_USDC(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); + vm.expectRevert("RateLimits/zero-maxAmount"); mainnetController.swapUSDCToUSDS(1e6); } diff --git a/test/mainnet-fork/Superstate.t.sol b/test/mainnet-fork/Superstate.t.sol new file mode 100644 index 00000000..32db2f78 --- /dev/null +++ b/test/mainnet-fork/Superstate.t.sol @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import "./ForkTestBase.t.sol"; + +interface IAllowlistV2Like { + function owner() external view returns (address); + function setEntityIdForAddress(uint256 entityId, address account) external; + function setEntityAllowedForFund(uint256 entityId, string memory fundSymbol, bool isAllowed) external; +} + +interface ISSRedemptionLike { + function calculateUsdcOut(uint256 ustbAmount) external view returns (uint256 usdcOutAmount, uint256 usdPerUstbChainlinkRaw); + function calculateUstbIn(uint256 usdcOutAmount) external view returns (uint256 ustbInAmount, uint256 usdPerUstbChainlinkRaw); +} + +contract SuperstateTestBase is ForkTestBase { + + IAllowlistV2Like allowlist = IAllowlistV2Like(0x02f1fA8B196d21c7b733EB2700B825611d8A38E5); + + function _getBlock() internal pure override returns (uint256) { + return 21570000; // Jan 7, 2024 + } + +} + +contract MainnetControllerSubscribeSuperstateFailureTests is SuperstateTestBase { + + function test_subscribeSuperstate_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.subscribeSuperstate(1_000_000e6); + } + + function test_subscribeSuperstate_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_SUPERSTATE_SUBSCRIBE(), 0, 0); + vm.stopPrank(); + + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.subscribeSuperstate(1_000_000e6); + } + + function test_subscribeSuperstate_rateLimitBoundary() external { + deal(address(usdc), address(almProxy), 5_000_000e6); + + bytes32 key = mainnetController.LIMIT_SUPERSTATE_SUBSCRIBE(); + + vm.startPrank(allowlist.owner()); + allowlist.setEntityIdForAddress(1, address(almProxy)); + allowlist.setEntityAllowedForFund(1, "USTB", true); + vm.stopPrank(); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + + vm.startPrank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.subscribeSuperstate(1_000_000e6 + 1); + + mainnetController.subscribeSuperstate(1_000_000e6); + } + +} + +contract MainnetControllerSubscribeSuperstateSuccessTests is SuperstateTestBase { + + address sweepDestination; + + bytes32 key; + + function setUp() public override { + super.setUp(); + + ( sweepDestination, ) = ustb.supportedStablecoins(address(usdc)); + + vm.startPrank(allowlist.owner()); + allowlist.setEntityIdForAddress(1, address(almProxy)); + allowlist.setEntityAllowedForFund(1, "USTB", true); + vm.stopPrank(); + + key = mainnetController.LIMIT_SUPERSTATE_SUBSCRIBE(); + + vm.prank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(key, 1_000_000e6, uint256(1_000_000e6) / 1 days); + } + + function test_subscribeSuperstate() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(key), 1_000_000e6); + + assertEq(usdc.allowance(address(almProxy), address(ustb)), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(sweepDestination), 0); + + assertEq(ustb.balanceOf(address(almProxy)), 0); + + assertEq(rateLimits.getCurrentRateLimit(key), 1_000_000e6); + + uint256 totalSupply = ustb.totalSupply(); + + ( uint256 expectedUstb, uint256 stablecoinInAmountAfterFee, uint256 feeOnStablecoinInAmount ) + = ustb.calculateSuperstateTokenOut(1_000_000e6, address(usdc)); + + assertEq(expectedUstb, 95_027.920628e6); + assertEq(stablecoinInAmountAfterFee, 1_000_000e6); + assertEq(feeOnStablecoinInAmount, 0); + + vm.prank(relayer); + mainnetController.subscribeSuperstate(1_000_000e6); + + assertEq(rateLimits.getCurrentRateLimit(key), 0); + + assertEq(usdc.allowance(address(almProxy), sweepDestination), 0); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(sweepDestination), 1_000_000e6); + + assertEq(ustb.balanceOf(address(almProxy)), expectedUstb); + assertEq(ustb.totalSupply(), totalSupply + expectedUstb); + + assertEq(rateLimits.getCurrentRateLimit(key), 0); + } + +} + +contract MainnetControllerRedeemSuperstateFailureTests is SuperstateTestBase { + + function test_redeemSuperstate_notRelayer() external { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + RELAYER + )); + mainnetController.redeemSuperstate(1_000_000e6); + } + + function test_redeemSuperstate_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_SUPERSTATE_REDEEM(), 0, 0); + vm.stopPrank(); + + vm.prank(relayer); + vm.expectRevert("RateLimits/zero-maxAmount"); + mainnetController.redeemSuperstate(1_000_000e6); + } + + function test_redeemSuperstate_rateLimitBoundary() external { + deal(address(usdc), address(almProxy), 5_000_000e6); + + ISSRedemptionLike redemption = ISSRedemptionLike(address(mainnetController.superstateRedemption())); + + vm.startPrank(allowlist.owner()); + allowlist.setEntityIdForAddress(1, address(almProxy)); + allowlist.setEntityAllowedForFund(1, "USTB", true); + vm.stopPrank(); + + bytes32 subscribeKey = mainnetController.LIMIT_SUPERSTATE_SUBSCRIBE(); + bytes32 redeemKey = mainnetController.LIMIT_SUPERSTATE_REDEEM(); + + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(subscribeKey, 5_000_000e6, uint256(5_000_000e6) / 1 days); + rateLimits.setRateLimitData(redeemKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); + vm.stopPrank(); + + vm.startPrank(relayer); + mainnetController.subscribeSuperstate(5_000_000e6); + + ( uint256 overBoundaryAmount, ) = redemption.calculateUstbIn(1_000_000e6 - 5); + ( uint256 atBoundaryAmount, ) = redemption.calculateUstbIn(1_000_000e6 - 6); + + ( uint256 overBoundaryUsdc, ) = redemption.calculateUsdcOut(overBoundaryAmount); + ( uint256 atBoundaryUsdc, ) = redemption.calculateUsdcOut(atBoundaryAmount); + + assertEq(overBoundaryUsdc, 1_000_000e6 + 5); // Smallest boundary difference with rounding + assertEq(atBoundaryUsdc, 1_000_000e6 - 6); // Smallest boundary difference with rounding + + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.redeemSuperstate(overBoundaryAmount); + + mainnetController.redeemSuperstate(atBoundaryAmount); + } + +} + +contract MainnetControllerRedeemSuperstateSuccessTests is SuperstateTestBase { + + bytes32 redeemKey; + + function setUp() public override { + super.setUp(); + + vm.startPrank(allowlist.owner()); + allowlist.setEntityIdForAddress(1, address(almProxy)); + allowlist.setEntityAllowedForFund(1, "USTB", true); + vm.stopPrank(); + + bytes32 subscribeKey = mainnetController.LIMIT_SUPERSTATE_SUBSCRIBE(); + + redeemKey = mainnetController.LIMIT_SUPERSTATE_REDEEM(); + + // Set redeem key higher to earn interest + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(subscribeKey, 1_000_000e6, uint256(1_000_000e6) / 1 days); + rateLimits.setRateLimitData(redeemKey, 2_000_000e6, uint256(1_000_000e6) / 1 days); + vm.stopPrank(); + } + + + function test_redeemSuperstate() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + ( uint256 mintedUstb,, ) = ustb.calculateSuperstateTokenOut(1_000_000e6, address(usdc)); + + assertEq(mintedUstb, 95_027.920628e6); + + vm.startPrank(relayer); + mainnetController.subscribeSuperstate(1_000_000e6); + + address redemption = address(mainnetController.superstateRedemption()); + uint256 totalSupply = ustb.totalSupply(); + uint256 usdcLiquidity = usdc.balanceOf(redemption); + + assertEq(usdcLiquidity, 9_999_003e6); + + assertEq(ustb.allowance(address(almProxy), redemption), 0); + assertEq(ustb.balanceOf(address(almProxy)), mintedUstb); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(redemption), usdcLiquidity); + + assertEq(rateLimits.getCurrentRateLimit(redeemKey), 2_000_000e6); + + skip(1 days); // Warp to accrue interest + + mainnetController.redeemSuperstate(mintedUstb); + + uint256 interestEarned = 122.776068e6; + + assertEq(ustb.allowance(address(almProxy), redemption), 0); + assertEq(ustb.balanceOf(address(almProxy)), 0); + assertEq(ustb.totalSupply(), totalSupply - mintedUstb); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6 + interestEarned); + assertEq(usdc.balanceOf(redemption), usdcLiquidity - (1_000_000e6 + interestEarned)); + + assertEq(rateLimits.getCurrentRateLimit(redeemKey), 2_000_000e6 - (1_000_000e6 + interestEarned)); + } + +} + +contract MainnetControllerSuperstateE2ETests is SuperstateTestBase { + + address usccDepositAddress = makeAddr("usccDepositAddress"); + + function setUp() public override { + super.setUp(); + + vm.startPrank(Ethereum.SPARK_PROXY); + + // Rate limit to transfer USDC to USCC deposit addressx to mint USCC + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetDestinationKey( + mainnetController.LIMIT_ASSET_TRANSFER(), + address(usdc), + address(usccDepositAddress) + ), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + + // Rate limit to transfer USCC to USCC to burn USCC for USDC + rateLimits.setRateLimitData( + RateLimitHelpers.makeAssetDestinationKey( + mainnetController.LIMIT_ASSET_TRANSFER(), + address(uscc), + address(uscc) + ), + 1_000_000e6, + uint256(1_000_000e6) / 1 days + ); + + vm.stopPrank(); + + // Allowlist for USCC to be transferred to almProxy + vm.startPrank(allowlist.owner()); + allowlist.setEntityIdForAddress(1, address(almProxy)); + allowlist.setEntityAllowedForFund(1, "USCC", true); + vm.stopPrank(); + } + + function test_e2e_superstateUSCCFullFlow() external { + deal(address(usdc), address(almProxy), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(usccDepositAddress), 0); + + // Step 1: Transfer USDC to USCC deposit address to trigger minting USCC + + vm.prank(relayer); + mainnetController.transferAsset(address(usdc), address(usccDepositAddress), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 0); + assertEq(usdc.balanceOf(usccDepositAddress), 1_000_000e6); + + assertEq(uscc.balanceOf(address(almProxy)), 0); + + uint256 totalSupply = uscc.totalSupply(); + + // Step 2: Superstate owner mints USCC to the ALM Proxy + + // Mint hardcoded amount because conversions don't work yet + vm.prank(uscc.owner()); + uscc.mint(address(almProxy), 900_000e6); + + assertEq(uscc.balanceOf(address(almProxy)), 900_000e6); + assertEq(uscc.balanceOf(address(uscc)), 0); + assertEq(uscc.totalSupply(), totalSupply + 900_000e6); + + // Step 3: Transfer USCC to USCC to trigger burning USCC for USDC + + vm.prank(relayer); + mainnetController.transferAsset(address(uscc), address(uscc), 900_000e6); + + assertEq(uscc.balanceOf(address(almProxy)), 0); + assertEq(uscc.balanceOf(address(uscc)), 0); + assertEq(uscc.totalSupply(), totalSupply); // USCC is burned on transfer + + // Step 4: Superstate owner transfers USDC to the ALM Proxy, returning to starting state + + vm.prank(usccDepositAddress); + usdc.transfer(address(almProxy), 1_000_000e6); + + assertEq(usdc.balanceOf(address(almProxy)), 1_000_000e6); + assertEq(usdc.balanceOf(usccDepositAddress), 0); + } + +} diff --git a/test/mainnet-fork/VaultCalls.t.sol b/test/mainnet-fork/VaultCalls.t.sol index 2b8cfb4e..519f5b5f 100644 --- a/test/mainnet-fork/VaultCalls.t.sol +++ b/test/mainnet-fork/VaultCalls.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.21; import "./ForkTestBase.t.sol"; -contract MainnetControllerMintUSDSTests is ForkTestBase { +contract MainnetControllerMintUSDSFailureTests is ForkTestBase { function test_mintUSDS_notRelayer() external { vm.expectRevert(abi.encodeWithSignature( @@ -14,15 +14,29 @@ contract MainnetControllerMintUSDSTests is ForkTestBase { mainnetController.mintUSDS(1e18); } - function test_mintUSDS_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_mintUSDS_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDS_MINT(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); + vm.expectRevert("RateLimits/zero-maxAmount"); mainnetController.mintUSDS(1e18); } + function test_mintUSDS_rateLimitBoundary() external { + vm.startPrank(relayer); + + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.mintUSDS(5_000_000e18 + 1); + + mainnetController.mintUSDS(5_000_000e18); + } + +} + +contract MainnetControllerMintUSDSSuccessTests is ForkTestBase { + function test_mintUSDS() external { ( uint256 ink, uint256 art ) = dss.vat.urns(ilk, vault); ( uint256 Art,,,, ) = dss.vat.ilks(ilk); @@ -82,7 +96,7 @@ contract MainnetControllerMintUSDSTests is ForkTestBase { } -contract MainnetControllerBurnUSDSTests is ForkTestBase { +contract MainnetControllerBurnUSDSFailureTests is ForkTestBase { function test_burnUSDS_notRelayer() external { vm.expectRevert(abi.encodeWithSignature( @@ -93,15 +107,20 @@ contract MainnetControllerBurnUSDSTests is ForkTestBase { mainnetController.burnUSDS(1e18); } - function test_burnUSDS_frozen() external { - vm.prank(freezer); - mainnetController.freeze(); + function test_burnUSDS_zeroMaxAmount() external { + vm.startPrank(Ethereum.SPARK_PROXY); + rateLimits.setRateLimitData(mainnetController.LIMIT_USDS_MINT(), 0, 0); + vm.stopPrank(); vm.prank(relayer); - vm.expectRevert("MainnetController/not-active"); + vm.expectRevert("RateLimits/zero-maxAmount"); mainnetController.burnUSDS(1e18); } +} + +contract MainnetControllerBurnUSDSSuccessTests is ForkTestBase { + function test_burnUSDS() external { // Setup vm.prank(relayer); diff --git a/test/unit/controllers/Constructor.t.sol b/test/unit/controllers/Constructor.t.sol index 33f96f15..a73644f6 100644 --- a/test/unit/controllers/Constructor.t.sol +++ b/test/unit/controllers/Constructor.t.sol @@ -42,8 +42,6 @@ contract MainnetControllerConstructorTests is UnitTestBase { assertEq(mainnetController.psmTo18ConversionFactor(), psm.to18ConversionFactor()); assertEq(mainnetController.psmTo18ConversionFactor(), 1e12); - - assertEq(mainnetController.active(), true); } } @@ -73,8 +71,6 @@ contract ForeignControllerConstructorTests is UnitTestBase { assertEq(address(foreignController.psm()), psm); assertEq(address(foreignController.usdc()), usdc); // asset1 param in MockPSM3 assertEq(address(foreignController.cctp()), cctp); - - assertEq(foreignController.active(), true); } } diff --git a/test/unit/controllers/Freeze.t.sol b/test/unit/controllers/Freeze.t.sol deleted file mode 100644 index c1206c2e..00000000 --- a/test/unit/controllers/Freeze.t.sol +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.21; - -import { MainnetController } from "../../../src/MainnetController.sol"; -import { ForeignController } from "../../../src/ForeignController.sol"; - -import { MockDaiUsds } from "../mocks/MockDaiUsds.sol"; -import { MockPSM } from "../mocks/MockPSM.sol"; -import { MockPSM3 } from "../mocks/MockPSM3.sol"; -import { MockVault } from "../mocks/MockVault.sol"; - -import "../UnitTestBase.t.sol"; - -interface IBaseControllerLike { - function active() external view returns (bool); - function grantRole(bytes32 role, address account) external; - function freeze() external; - function reactivate() external; -} - -contract ControllerTestBase is UnitTestBase { - - IBaseControllerLike controller; - - function setUp() public virtual { - MockDaiUsds daiUsds = new MockDaiUsds(makeAddr("dai")); - MockPSM psm = new MockPSM(makeAddr("usdc")); - MockVault vault = new MockVault(makeAddr("buffer")); - - // Default to mainnet controller for tests and override with foreign controller - controller = IBaseControllerLike(address(new MainnetController( - admin, - makeAddr("almProxy"), - makeAddr("rateLimits"), - address(vault), - address(psm), - address(daiUsds), - makeAddr("cctp") - ))); - - _setRoles(); - } - - function _setRoles() internal { - // Done with spell by pause proxy - vm.startPrank(admin); - - controller.grantRole(FREEZER, freezer); - controller.grantRole(RELAYER, relayer); - - vm.stopPrank(); - } - -} - -contract ControllerFreezeTests is ControllerTestBase { - - event Frozen(); - - function test_freeze_unauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - address(this), - FREEZER - )); - controller.freeze(); - - vm.prank(admin); - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - admin, - FREEZER - )); - controller.freeze(); - } - - function test_freeze() public { - assertEq(controller.active(), true); - - vm.prank(freezer); - controller.freeze(); - - assertEq(controller.active(), false); - - vm.prank(freezer); - vm.expectEmit(address(controller)); - emit Frozen(); - controller.freeze(); - - assertEq(controller.active(), false); - } - -} - -contract ControllerReactivateTests is ControllerTestBase { - - event Reactivated(); - - function test_reactivate_unauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - address(this), - DEFAULT_ADMIN_ROLE - )); - controller.reactivate(); - - vm.prank(freezer); - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - freezer, - DEFAULT_ADMIN_ROLE - )); - controller.reactivate(); - } - - function test_reactivate() public { - vm.prank(freezer); - controller.freeze(); - - assertEq(controller.active(), false); - - vm.prank(admin); - controller.reactivate(); - - assertEq(controller.active(), true); - - vm.prank(admin); - vm.expectEmit(address(controller)); - emit Reactivated(); - controller.reactivate(); - - assertEq(controller.active(), true); - } - -} - -contract ForeignControllerFreezeTest is ControllerFreezeTests { - - address usds = makeAddr("usds"); - address usdc = makeAddr("usdc"); - address susds = makeAddr("susds"); - - // Override setUp to run the same tests against the foreign controller - function setUp() public override { - MockPSM3 psm3 = new MockPSM3(usds, usdc, susds); - - controller = IBaseControllerLike(address(new ForeignController( - admin, - makeAddr("almProxy"), - makeAddr("rateLimits"), - address(psm3), - usdc, - makeAddr("cctp") - ))); - - _setRoles(); - } - -} - -contract ForeignControllerReactivateTest is ControllerReactivateTests { - - address usds = makeAddr("usds"); - address usdc = makeAddr("usdc"); - address susds = makeAddr("susds"); - - // Override setUp to run the same tests against the foreign controller - function setUp() public override { - MockPSM3 psm3 = new MockPSM3(usds, usdc, susds); - - controller = IBaseControllerLike(address(new ForeignController( - admin, - makeAddr("almProxy"), - makeAddr("rateLimits"), - address(psm3), - usdc, - makeAddr("cctp") - ))); - - _setRoles(); - } - -} diff --git a/test/unit/controllers/Freezer.t.sol b/test/unit/controllers/Freezer.t.sol new file mode 100644 index 00000000..5aec69bb --- /dev/null +++ b/test/unit/controllers/Freezer.t.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import { MainnetController } from "../../../src/MainnetController.sol"; +import { ForeignController } from "../../../src/ForeignController.sol"; + +import { MockDaiUsds } from "../mocks/MockDaiUsds.sol"; +import { MockPSM } from "../mocks/MockPSM.sol"; +import { MockPSM3 } from "../mocks/MockPSM3.sol"; +import { MockVault } from "../mocks/MockVault.sol"; + +import "../UnitTestBase.t.sol"; + +contract MainnetControllerRemoveRelayerTests is UnitTestBase { + + MainnetController controller; + + address relayer1 = makeAddr("relayer1"); + address relayer2 = makeAddr("relayer2"); + + event RelayerRemoved(address indexed relayer); + + function setUp() public virtual { + MockDaiUsds daiUsds = new MockDaiUsds(makeAddr("dai")); + MockPSM psm = new MockPSM(makeAddr("usdc")); + MockVault vault = new MockVault(makeAddr("buffer")); + + controller = new MainnetController( + admin, + makeAddr("almProxy"), + makeAddr("rateLimits"), + address(vault), + address(psm), + address(daiUsds), + makeAddr("cctp") + ); + + vm.startPrank(admin); + + controller.grantRole(FREEZER, freezer); + controller.grantRole(RELAYER, relayer1); + controller.grantRole(RELAYER, relayer2); + + vm.stopPrank(); + } + + function test_removeRelayer_unauthorizedAccount() public { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + FREEZER + )); + controller.removeRelayer(relayer); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + admin, + FREEZER + )); + controller.removeRelayer(relayer); + } + + function test_removeRelayer() public { + assertEq(controller.hasRole(RELAYER, relayer1), true); + assertEq(controller.hasRole(RELAYER, relayer2), true); + + vm.prank(freezer); + vm.expectEmit(address(controller)); + emit RelayerRemoved(relayer1); + controller.removeRelayer(relayer1); + + assertEq(controller.hasRole(RELAYER, relayer1), false); + assertEq(controller.hasRole(RELAYER, relayer2), true); + + vm.prank(freezer); + vm.expectEmit(address(controller)); + emit RelayerRemoved(relayer2); + controller.removeRelayer(relayer2); + + assertEq(controller.hasRole(RELAYER, relayer1), false); + assertEq(controller.hasRole(RELAYER, relayer2), false); + } + +} + +contract ForeignControllerRemoveRelayerTests is UnitTestBase { + + ForeignController controller; + + address relayer1 = makeAddr("relayer1"); + address relayer2 = makeAddr("relayer2"); + address susds = makeAddr("susds"); + address usdc = makeAddr("usdc"); + address usds = makeAddr("usds"); + + event RelayerRemoved(address indexed relayer); + + function setUp() public { + MockPSM3 psm3 = new MockPSM3(usds, usdc, susds); + + controller = new ForeignController( + admin, + makeAddr("almProxy"), + makeAddr("rateLimits"), + address(psm3), + usdc, + makeAddr("cctp") + ); + + vm.startPrank(admin); + + controller.grantRole(FREEZER, freezer); + controller.grantRole(RELAYER, relayer1); + controller.grantRole(RELAYER, relayer2); + + vm.stopPrank(); + } + + function test_removeRelayer_unauthorizedAccount() public { + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + address(this), + FREEZER + )); + controller.removeRelayer(relayer); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSignature( + "AccessControlUnauthorizedAccount(address,bytes32)", + admin, + FREEZER + )); + controller.removeRelayer(relayer); + } + + function test_removeRelayer() public { + assertEq(controller.hasRole(RELAYER, relayer1), true); + assertEq(controller.hasRole(RELAYER, relayer2), true); + + vm.prank(freezer); + vm.expectEmit(address(controller)); + emit RelayerRemoved(relayer1); + controller.removeRelayer(relayer1); + + assertEq(controller.hasRole(RELAYER, relayer1), false); + assertEq(controller.hasRole(RELAYER, relayer2), true); + + vm.prank(freezer); + vm.expectEmit(address(controller)); + emit RelayerRemoved(relayer2); + controller.removeRelayer(relayer2); + + assertEq(controller.hasRole(RELAYER, relayer1), false); + assertEq(controller.hasRole(RELAYER, relayer2), false); + } + +} diff --git a/test/unit/deployments/Deploy.t.sol b/test/unit/deployments/Deploy.t.sol index 213bec8c..ae4da60b 100644 --- a/test/unit/deployments/Deploy.t.sol +++ b/test/unit/deployments/Deploy.t.sol @@ -38,8 +38,6 @@ contract ForeignControllerDeployTests is UnitTestBase { assertEq(address(controller.psm()), psm); assertEq(address(controller.usdc()), usdc); assertEq(address(controller.cctp()), cctp); - - assertEq(controller.active(), true); } function test_deployFull() public { @@ -64,8 +62,6 @@ contract ForeignControllerDeployTests is UnitTestBase { assertEq(address(controller.psm()), psm); assertEq(address(controller.usdc()), usdc); assertEq(address(controller.cctp()), cctp); - - assertEq(controller.active(), true); } } @@ -118,7 +114,6 @@ contract MainnetControllerDeployTests is UnitTestBase { assertEq(address(controller.usdc()), makeAddr("usdc")); // Gem param in MockPSM assertEq(controller.psmTo18ConversionFactor(), 1e12); - assertEq(controller.active(), true); } function test_deployFull() public { @@ -158,7 +153,6 @@ contract MainnetControllerDeployTests is UnitTestBase { assertEq(address(controller.usdc()), makeAddr("usdc")); // Gem param in MockPSM assertEq(controller.psmTo18ConversionFactor(), 1e12); - assertEq(controller.active(), true); } } diff --git a/test/unit/rate-limits/RateLimitHelpers.t.sol b/test/unit/rate-limits/RateLimitHelpers.t.sol new file mode 100644 index 00000000..a2ff89d3 --- /dev/null +++ b/test/unit/rate-limits/RateLimitHelpers.t.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import "../UnitTestBase.t.sol"; + +import { RateLimits, IRateLimits } from "../../../src/RateLimits.sol"; +import { RateLimitHelpers, RateLimitData } from "../../../src/RateLimitHelpers.sol"; + +contract RateLimitHelpersWrapper { + + function makeAssetKey(bytes32 key, address asset) public pure returns (bytes32) { + return RateLimitHelpers.makeAssetKey(key, asset); + } + + function makeAssetDestinationKey(bytes32 key, address asset, address destination) public pure returns (bytes32) { + return RateLimitHelpers.makeAssetDestinationKey(key, asset, destination); + } + + function makeDomainKey(bytes32 key, uint32 domain) public pure returns (bytes32) { + return RateLimitHelpers.makeDomainKey(key, domain); + } + + function unlimitedRateLimit() public pure returns (RateLimitData memory) { + return RateLimitHelpers.unlimitedRateLimit(); + } + + function setRateLimitData( + bytes32 key, + address rateLimits, + RateLimitData memory data, + string memory name, + uint256 decimals + ) + public + { + RateLimitHelpers.setRateLimitData(key, rateLimits, data, name, decimals); + } +} + +contract RateLimitHelpersTestBase is UnitTestBase { + + bytes32 constant KEY = "KEY"; + string constant NAME = "NAME"; + + address controller = makeAddr("controller"); + + RateLimits rateLimits; + RateLimitHelpersWrapper wrapper; + + function setUp() public { + // Set wrapper as admin so it can set rate limits + wrapper = new RateLimitHelpersWrapper(); + rateLimits = new RateLimits(address(wrapper)); + } + + function _assertLimitData( + bytes32 key, + uint256 maxAmount, + uint256 slope, + uint256 lastAmount, + uint256 lastUpdated + ) + internal view + { + IRateLimits.RateLimitData memory d = rateLimits.getRateLimitData(key); + + assertEq(d.maxAmount, maxAmount); + assertEq(d.slope, slope); + assertEq(d.lastAmount, lastAmount); + assertEq(d.lastUpdated, lastUpdated); + } + +} + +contract RateLimitHelpersPureFunctionTests is RateLimitHelpersTestBase { + + function test_makeAssetKey() public { + assertEq( + wrapper.makeAssetKey(KEY, address(this)), + keccak256(abi.encode(KEY, address(this))) + ); + } + + function test_makeAssetDestinationKey() public { + assertEq( + wrapper.makeAssetDestinationKey(KEY, address(this), address(0)), + keccak256(abi.encode(KEY, address(this), address(0))) + ); + } + + function test_makeDomainKey() public { + assertEq( + wrapper.makeDomainKey(KEY, 123), + keccak256(abi.encode(KEY, 123)) + ); + } + + function test_unlimitedRateLimit() public { + RateLimitData memory data = wrapper.unlimitedRateLimit(); + + assertEq(data.maxAmount, type(uint256).max); + assertEq(data.slope, 0); + } + +} + +contract RateLimitHelpersSetRateLimitDataFailureTests is RateLimitHelpersTestBase { + + function test_setRateLimitData_unlimitedWithNonZeroSlope() external { + RateLimitData memory data = RateLimitData({ + maxAmount : type(uint256).max, + slope : 1 + }); + + vm.expectRevert(abi.encodeWithSignature( + "InvalidUnlimitedRateLimitSlope(string)", + NAME + )); + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + } + + function test_setRateLimitData_maxAmountUpperBoundBoundary() external { + // Set 1e18 precision value on a 6 decimal token + RateLimitData memory data = RateLimitData({ + maxAmount : 1e18 + 1, + slope : 0 + }); + + vm.expectRevert(abi.encodeWithSignature( + "InvalidMaxAmountPrecision(string)", + NAME + )); + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 6); + + data.maxAmount = 1e18; + + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 6); + } + + function test_setRateLimitData_maxAmountLowerBoundBoundary() external { + // Set 1e6 precision value on a 18 decimal token + RateLimitData memory data = RateLimitData({ + maxAmount : 1_000_000_000_000e6 - 1, + slope : 0 + }); + + vm.expectRevert(abi.encodeWithSignature( + "InvalidMaxAmountPrecision(string)", + NAME + )); + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + + data.maxAmount = 1_000_000_000_000e6; + + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + } + + function test_setRateLimitData_slopeUpperBoundBoundary() external { + // Set 1e18 precision value on a 6 decimal token + RateLimitData memory data = RateLimitData({ + maxAmount : 100e6, + slope : uint256(1e18) / 1 hours + 1 + }); + + vm.expectRevert(abi.encodeWithSignature( + "InvalidSlopePrecision(string)", + NAME + )); + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 6); + + data.slope = uint256(1e18) / 1 hours; + + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 6); + } + + function test_setRateLimitData_slopeLowerBoundBoundary() external { + // Set 1e6 precision value on a 18 decimal token + RateLimitData memory data = RateLimitData({ + maxAmount : 100e18, + slope : uint256(1_000_000_000_000e6) / 1 hours - 1 + }); + + vm.expectRevert(abi.encodeWithSignature( + "InvalidSlopePrecision(string)", + NAME + )); + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + + data.slope = uint256(1_000_000_000_000e6) / 1 hours; + + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + } + +} + +contract RateLimitHelpersSetRateLimitDataSuccessTests is RateLimitHelpersTestBase { + + function test_setRateLimitData_unlimited() external { + RateLimitData memory data = RateLimitData({ + maxAmount : type(uint256).max, + slope : 0 + }); + + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + + _assertLimitData(KEY, type(uint256).max, 0, type(uint256).max, block.timestamp); + } + + function test_setRateLimitData() external { + RateLimitData memory data = RateLimitData({ + maxAmount : 100e18, + slope : uint256(1e18) / 1 hours + }); + + wrapper.setRateLimitData(KEY, address(rateLimits), data, NAME, 18); + + _assertLimitData(KEY, 100e18, uint256(1e18) / 1 hours, 100e18, block.timestamp); + } + +}