diff --git a/foundry.toml b/foundry.toml index 189a8a3..2074255 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,7 +4,6 @@ out = "out" libs = ["lib"] solc = "0.8.26" -via_ir = true optimizer = true optimizer_runs = 200 evm_version = "cancun" \ No newline at end of file diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol new file mode 100644 index 0000000..509abf2 --- /dev/null +++ b/src/RescueStrategy.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.26; + +import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol"; +import {IERC4626} from "openzeppelin-contracts/interfaces/IERC4626.sol"; +import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; +import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector.sol"; +import {IEulerEarn} from "./interfaces/IEulerEarn.sol"; +import {SafeERC20Permit2Lib} from "./libraries/SafeERC20Permit2Lib.sol"; +import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import {IBorrowing, IRiskManager} from "../lib/euler-vault-kit/src/EVault/IEVault.sol"; + +/* + Rescue procedure: + - Euler installs a perspective in the earn factory which allows adding custom strategies + - RescueStrategy contracts are deployed for each earn vault to rescue. + Immutable params: + o Rescue account: is allowed to call the rescue functions and receives rescued assets and shares + o Earn vault: the strategy can only work with the specified vault. If another vault tries to enable it, it will revert on `acceptCap` + - Euler registers the strategies in the perspective + - Curator installs the strategy with unlimited cap (submit/acceptCap) + - Curator sets the new strategy as the only one in supply queue and moves it to the front of withdraw queue + o at this stage the regular users can't deposit or withdraw from earn + - Rescue account calls one of the `rescueX` functions (for Euler, Morpho or Aave flash loan sources), specifying the asset amount to flashloan + o flash loan is used to create earn vault shares, it just passes through earn vault back to the rescue strategy where it is repaid + o the shares are used to withdraw as much as possible from the underlying strategies to the rescue account +*/ + +interface IFlashLoan { + function flashLoan(uint256, bytes memory) external; + function flashLoan(address, uint256, bytes memory) external; + function flashLoanSimple( + address receiverAddress, + address asset, + uint256 amount, + bytes calldata params, + uint16 referralCode + ) external; +} + +contract RescueStrategy { + address public immutable rescueAccount; + address public immutable earnVault; + IERC20 internal immutable _asset; + + bool internal rescueActive; + + modifier onlyRescueAccount() { + require(msg.sender == rescueAccount, "unauthorized"); + _; + } + + modifier rescueLock() { + require(!rescueActive, "rescue ongoing"); + _assertRescueMode(); + rescueActive = true; + _; + rescueActive = false; + } + + modifier onlyWhenRescueActive() { + require(rescueActive, "vault operations are paused"); + _; + } + + modifier onlyAllowedEarnVault() { + require(msg.sender == earnVault, "wrong vault"); + _; + } + + event Rescued(address indexed vault, uint256 assets); + + constructor(address _rescueAccount, address _earnVault) { + rescueAccount = _rescueAccount; + earnVault = _earnVault; + _asset = IERC20(IEulerEarn(earnVault).asset()); + } + + // ---------------- VAULT INTERFACE -------------------- + + function asset() external view returns (address) { + return address(_asset); + } + + // will revert user deposits + function maxDeposit(address) external view onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + return type(uint256).max; + } + + // will revert user withdrawals + function maxWithdraw(address) external view onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + return 0; + } + + function previewRedeem(uint256) external pure returns (uint256) { + return 0; + } + + // this reverts acceptCaps to prevent reusing the whitelisted strategy on other vaults + function balanceOf(address) external view onlyAllowedEarnVault returns (uint256) { + return 0; + } + + function deposit(uint256 amount, address) external onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + SafeERC20Permit2Lib.safeTransferFromWithPermit2( + _asset, msg.sender, address(this), amount, IEulerEarn(earnVault).permit2Address() + ); + + return amount; + } + + // ---------------- RESCUE FUNCTIONS -------------------- + + // alternative sources of flashloan + function rescueEuler(uint256 loanAmount, uint256 loops, address flashLoanVault) + external + onlyRescueAccount + rescueLock + { + bytes memory data = abi.encode(loanAmount, loops, flashLoanVault); + IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); + } + + // alternative sources of flashloan + function rescueEulerBatch(uint256 loanAmount, uint256 loops, address flashLoanVault) + external + onlyRescueAccount + rescueLock + { + address evc = EVCUtil(earnVault).EVC(); + + SafeERC20.forceApprove(_asset, flashLoanVault, loanAmount); + + IEVC.BatchItem[] memory batchItems = new IEVC.BatchItem[](5); + batchItems[0] = IEVC.BatchItem({ + targetContract: evc, + onBehalfOfAccount: address(0), + value: 0, + data: abi.encodeCall(IEVC.enableController, (address(this), flashLoanVault)) + }); + batchItems[1] = IEVC.BatchItem({ + targetContract: flashLoanVault, + onBehalfOfAccount: address(this), + value: 0, + data: abi.encodeCall(IBorrowing.borrow, (loanAmount, address(this))) + }); + batchItems[2] = IEVC.BatchItem({ + targetContract: address(this), + onBehalfOfAccount: address(this), + value: 0, + data: abi.encodeCall(this.onBatchLoan, (loanAmount, loops)) + }); + batchItems[3] = IEVC.BatchItem({ + targetContract: flashLoanVault, + onBehalfOfAccount: address(this), + value: 0, + data: abi.encodeCall(IBorrowing.repay, (loanAmount, address(this))) + }); + batchItems[4] = IEVC.BatchItem({ + targetContract: flashLoanVault, + onBehalfOfAccount: address(this), + value: 0, + data: abi.encodeCall(IRiskManager.disableController, ()) + }); + + IEVC(evc).batch(batchItems); + } + + function rescueAave(uint256 loanAmount, uint256 loops, address pool, address feeProvider) + external + onlyRescueAccount + rescueLock + { + bytes memory data = abi.encode(loops, feeProvider); + IFlashLoan(pool).flashLoanSimple(address(this), address(_asset), loanAmount, data, 0); + } + + // alternative sources of flashloan + function rescueMorpho(uint256 loanAmount, uint256 loops, address morpho) external onlyRescueAccount rescueLock { + IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, abi.encode(loops)); + } + + // ---------------- FLASHLOAN CALLBACKS -------------------- + + function onBatchLoan(uint256 loanAmount, uint256 loops) external onlyWhenRescueActive { + _processFlashLoan(loanAmount, loops); + } + + function onFlashLoan(bytes memory data) external onlyWhenRescueActive { + (uint256 loanAmount, uint256 loops, address flashLoanSource) = abi.decode(data, (uint256, uint256, address)); + + _processFlashLoan(loanAmount, loops); + + // repay the flashloan + SafeERC20.safeTransfer(_asset, flashLoanSource, loanAmount); + } + + function onMorphoFlashLoan(uint256 amount, bytes memory data) external onlyWhenRescueActive { + uint256 loops = abi.decode(data, (uint256)); + + _processFlashLoan(amount, loops); + + SafeERC20.forceApprove(_asset, msg.sender, amount); + } + + // aave callback + function executeOperation(address, uint256 amount, uint256 premium, address, bytes calldata data) + external + onlyWhenRescueActive + returns (bool) + { + (uint256 loops, address feeProvider) = abi.decode(data, (uint256, address)); + SafeERC20.safeTransferFrom(_asset, feeProvider, address(this), premium); + + _processFlashLoan(amount, loops); + + SafeERC20.forceApprove(_asset, msg.sender, amount + premium); + return true; + } + + // ---------------- HELPERS AND INTERNAL -------------------- + + // The contract is not supposed to hold any value, but in case of any issues rescue account can exec arbitrary call + function call(address target, bytes memory payload) external onlyRescueAccount { + (bool success,) = target.call(payload); + require(success, "call failed"); + } + + fallback() external { + revert("vault operations are paused"); + } + + function _processFlashLoan(uint256 loanAmount, uint256 loops) internal { + SafeERC20Permit2Lib.forceApproveMaxWithPermit2(_asset, earnVault, address(0)); + + // deposit to earn, create shares. Assets will come back here if the strategy is first in supply queue + for (uint256 i = 0; i < loops; i++) { + IERC4626(earnVault).deposit(loanAmount, address(this)); + } + + // withdraw as much as possible to the receiver + uint256 rescuedAmount = IERC4626(earnVault).maxWithdraw(address(this)); + IERC4626(earnVault).withdraw(rescuedAmount, rescueAccount, address(this)); + + // send the remaining shares to the receiver + IERC4626(earnVault).transfer(rescueAccount, IERC4626(earnVault).balanceOf(address(this))); + + emit Rescued(address(earnVault), rescuedAmount); + } + + function _assertRescueMode() internal view { + IEulerEarn vault = IEulerEarn(earnVault); + + // Must be the ONLY supply target + require(vault.supplyQueueLength() == 1, "rescue: supplyQueue len != 1"); + require(address(vault.supplyQueue(0)) == address(this), "rescue: supplyQueue[0] != rescue"); + + // Must be first in withdraw queue (bank-run guard) + require(address(vault.withdrawQueue(0)) == address(this), "rescue: withdrawQueue[0] != rescue"); + } +} diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol new file mode 100644 index 0000000..a297f14 --- /dev/null +++ b/test/RescueStrategyTest.sol @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.26; + +import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol"; +import {IERC4626} from "openzeppelin-contracts/interfaces/IERC4626.sol"; +import {IEulerEarn} from "../src/interfaces/IEulerEarn.sol"; +import {IEulerEarnFactory} from "../src/interfaces/IEulerEarnFactory.sol"; +import {Ownable} from "openzeppelin-contracts/access/Ownable.sol"; +import {IAllowanceTransfer} from "../src/interfaces/IAllowanceTransfer.sol"; +import {EnumerableSet} from "openzeppelin-contracts/utils/structs/EnumerableSet.sol"; +import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector.sol"; +import {RescueStrategy} from "../src/RescueStrategy.sol"; +import "forge-std/Test.sol"; + +contract RescuePOC is Test { + // the earn vault to rescue: + address constant EARN_VAULT = 0x3B4802FDb0E5d74aA37d58FD77d63e93d4f9A4AF; // https://app.euler.finance/earn/0x3B4802FDb0E5d74aA37d58FD77d63e93d4f9A4AF?network=ethereum + + address constant OTHER_EARN_VAULT = 0x3cd3718f8f047aA32F775E2cb4245A164E1C99fB; // https://app.euler.finance/earn/0x3cd3718f8f047aA32F775E2cb4245A164E1C99fB?network=ethereum + address constant FLASH_LOAN_SOURCE_MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + address constant FLASH_LOAN_SOURCE_EULER = 0x797DD80692c3b2dAdabCe8e30C07fDE5307D48a9; // Euler Prime - also a strategy in earn + address constant FLASH_LOAN_SOURCE_AAVE = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; + uint256 constant BLOCK_NUMBER = 23753054; + + IEulerEarn vault; + + string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); + + uint256 fork; + + address rescueAccount = makeAddr("rescueAccount"); + address user = makeAddr("user"); + RescueStrategy rescueStrategy; + + function setUp() public { + require(bytes(FORK_RPC_URL).length != 0, "No FORK_RPC_URL env found"); + + fork = vm.createSelectFork(FORK_RPC_URL); + if (BLOCK_NUMBER > 0) { + vm.rollFork(BLOCK_NUMBER); + } + + vault = IEulerEarn(EARN_VAULT); + + deal(vault.asset(), user, 100e18); + vm.startPrank(user); + IERC20(vault.asset()).approve(vault.permit2Address(), type(uint256).max); + IAllowanceTransfer(vault.permit2Address()).approve( + vault.asset(), address(vault), type(uint160).max, type(uint48).max + ); + } + + function testRescue_assertRescueMode() public { + _installPerspective(); + + rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); + IERC4626 id = IERC4626(address(rescueStrategy)); + + vm.prank(rescueAccount); + vm.expectRevert("rescue: supplyQueue len != 1"); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); + + IERC4626[] memory supplyQueue = new IERC4626[](1); + supplyQueue[0] = vault.supplyQueue(0); + + vm.prank(vault.curator()); + vault.setSupplyQueue(supplyQueue); + + vm.prank(rescueAccount); + vm.expectRevert("rescue: supplyQueue[0] != rescue"); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); + + vm.prank(vault.curator()); + vault.submitCap(id, type(uint184).max); + + skip(vault.timelock()); + + vm.prank(vault.curator()); + vault.acceptCap(id); + supplyQueue[0] = id; + + vm.prank(vault.curator()); + vault.setSupplyQueue(supplyQueue); + + vm.prank(rescueAccount); + vm.expectRevert("rescue: withdrawQueue[0] != rescue"); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); + } + + function testRescue_pauseForUsers() public { + _installRescueStrategy(); + + vm.startPrank(user); + vm.expectRevert("vault operations are paused"); + vault.deposit(10, user); + vm.expectRevert("vault operations are paused"); + vault.mint(10, user); + vm.expectRevert("vault operations are paused"); + vault.withdraw(0, user, user); + vm.expectRevert("vault operations are paused"); + vault.redeem(0, user, user); + } + + function testRescue_rescueEulerBatch() public { + _installRescueStrategy(); + + uint256 amount = 100_000e6; + uint256 loops = 1; + uint256 snapshot = vm.snapshotState(); + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); + + vm.startPrank(rescueAccount); + vm.expectEmit(true, true, false, false); + emit RescueStrategy.Rescued(address(vault), 0); + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); + + assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); + assertEq(IEVC(vault.EVC()).getControllers(address(rescueStrategy)).length, 0); + uint256 rescueOneLoop = IERC20(vault.asset()).balanceOf(rescueAccount); + + console.log("Rescued", rescueOneLoop, IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); + + vm.revertTo(snapshot); + loops = 2; + + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); + assertEq(IERC20(vault.asset()).balanceOf(rescueAccount), rescueOneLoop * 2); + } + + function testRescue_rescueMorpho() public { + _installRescueStrategy(); + + // create shares equal total supply + extra + uint256 amount = vault.previewMint(vault.totalSupply()) * 10001 / 10000 / 2; + uint256 loops = 2; + + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + + vm.startPrank(rescueAccount); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + + assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); + + console.log("Rescued", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); + } + + function testRescue_rescueAave() public { + _installRescueStrategy(); + + uint256 amount = 5_000_000e6; + uint256 loops = 1; + address feeProvider = makeAddr("feeProvider"); + address asset = vault.asset(); + + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE, feeProvider); + + vm.prank(rescueAccount); + vm.expectRevert("ERC20: transfer amount exceeds allowance"); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE, feeProvider); + + deal(asset, feeProvider, amount * 5 / 10000); + vm.prank(feeProvider); + IERC20(asset).approve(address(rescueStrategy), type(uint256).max); + + vm.prank(rescueAccount); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE, feeProvider); + + assertGt(IERC20(asset).balanceOf(rescueAccount), 0); + + console.log("Rescued", IERC20(asset).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); + } + + function testRescue_rescueMultipleMorpho() public { + _installRescueStrategy(); + + uint256 amount = 1000000000000; + uint256 loops = 1; + + vm.startPrank(rescueAccount); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + + assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); + + console.log("Rescued", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); + } + + function testRescue_rescueAccountCantWithdrawOutsideRescue() public { + _installRescueStrategy(); + + vm.prank(user); + vm.expectRevert("vault operations are paused"); + vault.withdraw(1e6, user, user); + + deal(address(vault), rescueAccount, 1e6); + + vm.prank(rescueAccount); + vm.expectRevert("vault operations are paused"); + vault.withdraw(1e6, rescueAccount, rescueAccount); + } + + function testRescue_cantBeReused() public { + rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); + + // install perspective in earn factory which will allow custom strategies + _installPerspective(); + + IEulerEarn otherVault = IEulerEarn(OTHER_EARN_VAULT); // hyperithm euler usdc mainnet + + vm.startPrank(otherVault.curator()); + + otherVault.submitCap(IERC4626(address(rescueStrategy)), type(uint184).max); + skip(vault.timelock()); + + vm.expectRevert("wrong vault"); + otherVault.acceptCap(IERC4626(address(rescueStrategy))); + } + + function testRescue_uninstall() public { + _installRescueStrategy(); + + vm.startPrank(user); + vm.expectRevert("vault operations are paused"); + vault.deposit(10, user); + + vm.startPrank(vault.curator()); + + IERC4626 id = IERC4626(address(rescueStrategy)); + vault.submitCap(id, 0); + + uint256 withdrawQueueLength = vault.withdrawQueueLength(); + uint256[] memory newIndexes = new uint256[](withdrawQueueLength - 1); + newIndexes[0] = withdrawQueueLength - 1; + + for (uint256 i = 1; i < withdrawQueueLength; i++) { + newIndexes[i - 1] = i; + } + + vault.updateWithdrawQueue(newIndexes); + + IERC4626[] memory supplyQueue = new IERC4626[](1); + supplyQueue[0] = vault.withdrawQueue(0); + vault.setSupplyQueue(supplyQueue); + + // the vault is functional + + vm.startPrank(user); + vault.deposit(10, user); + uint256 balance = vault.balanceOf(user); + assertGt(balance, 0); + vault.mint(10, user); + assertEq(vault.balanceOf(user), balance + 10); + vault.redeem(10, user, user); + assertEq(vault.balanceOf(user), balance); + vault.withdraw(vault.maxWithdraw(user), user, user); + assertEq(vault.balanceOf(user), 0); + } + + function testRescue_onlyRescueAccountCallFunc() external { + _installRescueStrategy(); + + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.call(address(0), ""); + + vm.prank(rescueAccount); + rescueStrategy.call(address(0), ""); + } + + function testRescue_flashloanCallbacks() external { + _installRescueStrategy(); + + vm.expectRevert("vault operations are paused"); + rescueStrategy.onBatchLoan(1, 1); + vm.expectRevert("vault operations are paused"); + rescueStrategy.onFlashLoan(""); + vm.expectRevert("vault operations are paused"); + rescueStrategy.onMorphoFlashLoan(1, ""); + vm.expectRevert("vault operations are paused"); + rescueStrategy.executeOperation(address(1), 1, 1, address(1), ""); + } + + function _installRescueStrategy() internal { + // install perspective in earn factory which will allow custom strategies (use mock here) + _installPerspective(); + + // deploy strategy, set a cap for it and put in in the supply and withdraw queues + rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); + + vm.startPrank(vault.curator()); + + IERC4626 id = IERC4626(address(rescueStrategy)); + + vault.submitCap(id, type(uint184).max); + + skip(vault.timelock()); + + vault.acceptCap(id); + + IERC4626[] memory supplyQueue = new IERC4626[](1); + supplyQueue[0] = id; + + vault.setSupplyQueue(supplyQueue); + + // move the new strategy to the front of the queue + uint256 withdrawQueueLength = vault.withdrawQueueLength(); + uint256[] memory newIndexes = new uint256[](withdrawQueueLength); + newIndexes[0] = withdrawQueueLength - 1; + + for (uint256 i = 1; i < withdrawQueueLength; i++) { + newIndexes[i] = i - 1; + } + + vault.updateWithdrawQueue(newIndexes); + + vm.stopPrank(); + } + + function _installPerspective() internal { + vm.startPrank(Ownable(vault.creator()).owner()); + + IEulerEarnFactory factory = IEulerEarnFactory(vault.creator()); + factory.setPerspective(address(new MockPerspective())); + + vm.stopPrank(); + } +} + +contract MockPerspective { + function isVerified(address) external pure returns (bool) { + return true; + } +}