From d79f634377e23ba4cb0ee7d7131288735f3f191b Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 17:08:49 +0100 Subject: [PATCH 01/18] add rescue strategy --- foundry.toml | 1 - src/RescueStrategy.sol | 133 +++++++++++++++++++++++ test/RescueStrategyTest.sol | 206 ++++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 src/RescueStrategy.sol create mode 100644 test/RescueStrategyTest.sol 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..90fb0e7 --- /dev/null +++ b/src/RescueStrategy.sol @@ -0,0 +1,133 @@ +// 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 "./interfaces/IEulerEarn.sol"; +import {SafeERC20Permit2Lib} from "./libraries/SafeERC20Permit2Lib.sol"; +import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IFlashLoan { + function flashLoan(uint256, bytes memory) external; + function flashLoan(address, uint256, bytes memory) external; +} + +contract RescueStrategy { + address immutable public rescueAccount; + address immutable public earnVault; + IERC20 immutable internal _asset; + address immutable public fundsReceiver; + + modifier onlyRescueAccount() { + require(tx.origin == rescueAccount, "vault operations are paused"); + _; + } + + modifier onlyAllowedEarnVault() { + require(msg.sender == earnVault, "wrong vault"); + _; + } + + constructor(address _rescueAccount, address _earnVault, address _fundsReceiver) { + rescueAccount = _rescueAccount; + earnVault = _earnVault; + fundsReceiver = _fundsReceiver; + _asset = IERC20(IEulerEarn(earnVault).asset()); + SafeERC20Permit2Lib.forceApproveMaxWithPermit2( + _asset, + rescueAccount, + address(0) + ); + } + + function asset() onlyAllowedEarnVault external view returns(address) { + return address(_asset); + } + + // will revert user deposits + function maxDeposit(address) onlyAllowedEarnVault onlyRescueAccount external view returns (uint256) { + return type(uint256).max; + } + + // will revert user withdrawals + function maxWithdraw(address) onlyAllowedEarnVault onlyRescueAccount external view returns (uint256) { + return 0; + } + + function previewRedeem(uint256) onlyAllowedEarnVault external view returns (uint256) { + return 0; + } + + function balanceOf(address) onlyAllowedEarnVault external view returns (uint256) { + return 0; + } + + function deposit(uint256 amount, address) onlyAllowedEarnVault onlyRescueAccount external returns (uint256) { + SafeERC20Permit2Lib.safeTransferFromWithPermit2( + _asset, + msg.sender, + address(this), + amount, + IEulerEarn(earnVault).permit2Address() + ); + + return amount; + } + + function rescueEuler(uint256 loanAmount, address flashLoanVault) onlyRescueAccount external { + bytes memory data = abi.encode(loanAmount, flashLoanVault); + IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); + } + + function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount external { + bytes memory data = abi.encode(loanAmount, morpho); + IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, data); + } + + function onFlashLoan(bytes memory data) external { + (uint256 loanAmount, address flashLoanSource) = abi.decode(data, (uint256, address)); + + _processFlashLoan(loanAmount); + + // repay the flashloan + SafeERC20.safeTransfer( + _asset, + flashLoanSource, + loanAmount + ); + } + + function onMorphoFlashLoan(uint256, bytes memory data) external { + (uint256 loanAmount, address flashLoanSource) = abi.decode(data, (uint256, address)); + + _processFlashLoan(loanAmount); + + SafeERC20.forceApprove(_asset, flashLoanSource, loanAmount); + } + + function call(address target, bytes memory payload) onlyRescueAccount external { + (bool success,) = target.call(payload); + require(success, "call failed"); + } + + fallback() external { + revert("vault operations are paused"); + } + + function _processFlashLoan(uint256 loanAmount) internal { + SafeERC20Permit2Lib.forceApproveMaxWithPermit2( + _asset, + earnVault, + address(0) + ); + + // deposit to earn. All assets should be allocated to rescue strategy, which returns them to the executor + IERC4626(earnVault).deposit(loanAmount, address(this)); + + // withdraw as much as possible to the owner + IERC4626(earnVault).withdraw(IERC4626(earnVault).maxWithdraw(address(this)), fundsReceiver, address(this)); + + // send the remaining shares to the owner + IERC4626(earnVault).transfer(fundsReceiver, IERC4626(earnVault).balanceOf(address(this))); + } +} diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol new file mode 100644 index 0000000..926fca4 --- /dev/null +++ b/test/RescueStrategyTest.sol @@ -0,0 +1,206 @@ +// 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 {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 = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; // morpho + address constant RESCUE_EOA = address(10000); + address constant FUNDS_RECEIVER = address(20000); + + IEulerEarn vault; + + string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); + uint256 BLOCK_NUMBER = vm.envOr("FORK_BLOCK_NUMBER", uint256(0)); + + uint256 fork; + + address user = makeAddr("user"); + RescueStrategy rescueStrategy; + + function setUp() public { + require(bytes(FORK_RPC_URL).length != 0, "No FORK_RPC_URL env found"); + require(RESCUE_EOA != address(0), "No RESCUE_EOA env found"); + require(EARN_VAULT != address(0), "No EARN_VAULT env found"); + require(FLASH_LOAN_SOURCE != address(0), "No FLASH_LOAN_SOURCE env found"); + require(FUNDS_RECEIVER != address(0), "No FUNDS_RECEIVER 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_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_rescueOneGo() public { + _installRescueStrategy(); + + // create shares equal total supply + extra + uint256 amount = vault.previewMint(vault.totalSupply()) * 10001 / 10000; + + vm.startPrank(RESCUE_EOA, RESCUE_EOA); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); + + assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); + + console.log("Rescued", IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); + } + + function testRescue_rescueMultiple() public { + _installRescueStrategy(); + + uint256 amount = 1000000000000; + + vm.startPrank(RESCUE_EOA, RESCUE_EOA); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); + + assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); + + console.log("Rescued", IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); + } + + function testRescue_cantBeReused() public { + rescueStrategy = new RescueStrategy(RESCUE_EOA, address(vault), FUNDS_RECEIVER); + + // 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()); + + vm.expectRevert("wrong vault"); + otherVault.submitCap(IERC4626(address(rescueStrategy)), type(uint184).max); + } + + 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 _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(RESCUE_EOA, address(vault), FUNDS_RECEIVER); + + 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; + } +} + From 3525fd908763e97ce7475ad4814b7d44947221dd Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 17:12:41 +0100 Subject: [PATCH 02/18] add docs --- src/RescueStrategy.sol | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 90fb0e7..bedfbfe 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -7,6 +7,28 @@ import {IEulerEarn} from "./interfaces/IEulerEarn.sol"; import {SafeERC20Permit2Lib} from "./libraries/SafeERC20Permit2Lib.sol"; import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.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 EOA: is only allowed to call the rescue function directly or through a multisig (tx.origin is checked). + It should be a throw-away EOA just for the purpose of rescue, because of tx.origin use. + o funds receiver: will receive rescued assets and left over shares (see below) + o earn vault: the strategy can only work with the specified vault. If another vault tries to enable it, it will revert on `submitCap` + - 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 EOA calls one of the `rescueX` funcitons (for Euler or Morpho 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 funds receiver + o remaining shares are returned to the funds receiver + o the rescue function can be called multiple times + o the rescue EOA can also withdraw shares at any time, as long as it is tx.origin + (so can also initiate withdrawal if funds receiver is a multisig) +*/ + interface IFlashLoan { function flashLoan(uint256, bytes memory) external; function flashLoan(address, uint256, bytes memory) external; From 0e6ecbbcf674a42e6ac1bd7907cd2c6302dd5953 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 17:19:02 +0100 Subject: [PATCH 03/18] add comment --- src/RescueStrategy.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index bedfbfe..cc4ad18 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -62,6 +62,7 @@ contract RescueStrategy { ); } + // this reverts submitCaps to prevent reusing the whitelisted strategy on other vaults function asset() onlyAllowedEarnVault external view returns(address) { return address(_asset); } From 57e209aea70ab3cda28a55c6249324520c5e032a Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 17:21:45 +0100 Subject: [PATCH 04/18] update comment --- src/RescueStrategy.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index cc4ad18..090d185 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -144,13 +144,13 @@ contract RescueStrategy { address(0) ); - // deposit to earn. All assets should be allocated to rescue strategy, which returns them to the executor + // deposit to earn, create shares. Assets will come back here if the strategy is first in supply queue IERC4626(earnVault).deposit(loanAmount, address(this)); - // withdraw as much as possible to the owner + // withdraw as much as possible to the receiver IERC4626(earnVault).withdraw(IERC4626(earnVault).maxWithdraw(address(this)), fundsReceiver, address(this)); - // send the remaining shares to the owner + // send the remaining shares to the receiver IERC4626(earnVault).transfer(fundsReceiver, IERC4626(earnVault).balanceOf(address(this))); } } From d375dcca907c43cb101500b1deb8c415c8944b51 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 17:32:12 +0100 Subject: [PATCH 05/18] simplify on morpho flashloan --- src/RescueStrategy.sol | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 090d185..195bc4f 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -103,8 +103,7 @@ contract RescueStrategy { } function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount external { - bytes memory data = abi.encode(loanAmount, morpho); - IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, data); + IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, ""); } function onFlashLoan(bytes memory data) external { @@ -120,12 +119,10 @@ contract RescueStrategy { ); } - function onMorphoFlashLoan(uint256, bytes memory data) external { - (uint256 loanAmount, address flashLoanSource) = abi.decode(data, (uint256, address)); - - _processFlashLoan(loanAmount); + function onMorphoFlashLoan(uint256 amount, bytes memory) external { + _processFlashLoan(amount); - SafeERC20.forceApprove(_asset, flashLoanSource, loanAmount); + SafeERC20.forceApprove(_asset, msg.sender, amount); } function call(address target, bytes memory payload) onlyRescueAccount external { From bc9afa0db50f9fd3f609fda2baaab3048bda251b Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 17:49:33 +0100 Subject: [PATCH 06/18] add test --- test/RescueStrategyTest.sol | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index 926fca4..65c137b 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -98,6 +98,21 @@ contract RescuePOC is Test { console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); } + function testRescue_rescueEOACanWithdrawAnyTime() public { + _installRescueStrategy(); + + vm.prank(user); + vm.expectRevert("vault operations are paused"); + vault.withdraw(1e6, user, user); + + deal(address(vault), RESCUE_EOA, 1e6); + + vm.prank(RESCUE_EOA, RESCUE_EOA); + vault.withdraw(1e6, RESCUE_EOA, RESCUE_EOA); + + assertEq(IERC20(vault.asset()).balanceOf(RESCUE_EOA), 1e6); + } + function testRescue_cantBeReused() public { rescueStrategy = new RescueStrategy(RESCUE_EOA, address(vault), FUNDS_RECEIVER); From 051723b7ddafcd33761e209be44c74e0185750a5 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 7 Nov 2025 20:14:02 +0100 Subject: [PATCH 07/18] add comments --- src/RescueStrategy.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 195bc4f..ffa7023 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -97,11 +97,13 @@ contract RescueStrategy { return amount; } + // alternative sources of flashloan function rescueEuler(uint256 loanAmount, address flashLoanVault) onlyRescueAccount external { bytes memory data = abi.encode(loanAmount, flashLoanVault); IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); } + // alternative sources of flashloan function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount external { IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, ""); } @@ -125,6 +127,7 @@ contract RescueStrategy { SafeERC20.forceApprove(_asset, msg.sender, amount); } + // 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) onlyRescueAccount external { (bool success,) = target.call(payload); require(success, "call failed"); From 3d8d08e411ebd9fd9146587fc17b4076e46821ce Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sat, 8 Nov 2025 09:20:36 +0100 Subject: [PATCH 08/18] add batch borrow loan source --- src/RescueStrategy.sol | 48 +++++++++++++++++++++++++++++++++++++ test/RescueStrategyTest.sol | 37 ++++++++++++++++++---------- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index ffa7023..c364c2b 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -3,9 +3,12 @@ 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: @@ -103,11 +106,56 @@ contract RescueStrategy { IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); } + // alternative sources of flashloan + function rescueEulerBatch(uint256 loanAmount, address flashLoanVault) onlyRescueAccount external { + 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)) + }); + 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); + } + // alternative sources of flashloan function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount external { IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, ""); } + function onBatchLoan(uint256 loanAmount) external { + _processFlashLoan(loanAmount); + } + function onFlashLoan(bytes memory data) external { (uint256 loanAmount, address flashLoanSource) = abi.decode(data, (uint256, address)); diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index 65c137b..e1ff16a 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -8,6 +8,7 @@ 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"; @@ -17,14 +18,15 @@ contract RescuePOC is Test { 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 = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; // morpho + address constant FLASH_LOAN_SOURCE_MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + address constant FLASH_LOAN_SOURCE_EULER = 0x797DD80692c3b2dAdabCe8e30C07fDE5307D48a9; // Euler Prime - also a strategy in earn address constant RESCUE_EOA = address(10000); address constant FUNDS_RECEIVER = address(20000); + uint256 constant BLOCK_NUMBER = 23753054; IEulerEarn vault; string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); - uint256 BLOCK_NUMBER = vm.envOr("FORK_BLOCK_NUMBER", uint256(0)); uint256 fork; @@ -33,10 +35,6 @@ contract RescuePOC is Test { function setUp() public { require(bytes(FORK_RPC_URL).length != 0, "No FORK_RPC_URL env found"); - require(RESCUE_EOA != address(0), "No RESCUE_EOA env found"); - require(EARN_VAULT != address(0), "No EARN_VAULT env found"); - require(FLASH_LOAN_SOURCE != address(0), "No FLASH_LOAN_SOURCE env found"); - require(FUNDS_RECEIVER != address(0), "No FUNDS_RECEIVER env found"); fork = vm.createSelectFork(FORK_RPC_URL); if (BLOCK_NUMBER > 0) { @@ -67,14 +65,29 @@ contract RescuePOC is Test { vault.redeem(0, user, user); } - function testRescue_rescueOneGo() public { + function testRescue_rescueEulerBatch() public { + _installRescueStrategy(); + + uint256 amount = 100_000e6; + + vm.startPrank(RESCUE_EOA, RESCUE_EOA); + rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_EULER); + + assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); + assertEq(IEVC(vault.EVC()).getControllers(address(rescueStrategy)).length, 0); + + console.log("Rescued", IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); + } + + function testRescue_rescueOneGoMorpho() public { _installRescueStrategy(); // create shares equal total supply + extra uint256 amount = vault.previewMint(vault.totalSupply()) * 10001 / 10000; vm.startPrank(RESCUE_EOA, RESCUE_EOA); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); @@ -82,15 +95,15 @@ contract RescuePOC is Test { console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); } - function testRescue_rescueMultiple() public { + function testRescue_rescueMultipleMorpho() public { _installRescueStrategy(); uint256 amount = 1000000000000; vm.startPrank(RESCUE_EOA, RESCUE_EOA); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); From 5270ee23c736e34fb8591d08941197b25b2e045f Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sat, 8 Nov 2025 10:33:34 +0100 Subject: [PATCH 09/18] free up assets call for FE --- src/RescueStrategy.sol | 4 ++-- test/RescueStrategyTest.sol | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index c364c2b..2d2d50b 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -65,8 +65,7 @@ contract RescueStrategy { ); } - // this reverts submitCaps to prevent reusing the whitelisted strategy on other vaults - function asset() onlyAllowedEarnVault external view returns(address) { + function asset() external view returns(address) { return address(_asset); } @@ -84,6 +83,7 @@ contract RescueStrategy { return 0; } + // this reverts acceptCaps to prevent reusing the whitelisted strategy on other vaults function balanceOf(address) onlyAllowedEarnVault external view returns (uint256) { return 0; } diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index e1ff16a..e26d9d6 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -136,8 +136,11 @@ contract RescuePOC is Test { vm.startPrank(otherVault.curator()); - vm.expectRevert("wrong vault"); otherVault.submitCap(IERC4626(address(rescueStrategy)), type(uint184).max); + skip(vault.timelock()); + + vm.expectRevert("wrong vault"); + otherVault.acceptCap(IERC4626(address(rescueStrategy))); } function testRescue_uninstall() public { From d1b0ddf6b74feb4c1143403bc3eaab99e0c3f9a7 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 09:02:36 +0100 Subject: [PATCH 10/18] remove tx.origin check, add rescueActive flag --- src/RescueStrategy.sol | 51 ++++++++++++++----------- test/RescueStrategyTest.sol | 76 ++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 45 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 2d2d50b..1fd8aaa 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -41,28 +41,35 @@ contract RescueStrategy { address immutable public rescueAccount; address immutable public earnVault; IERC20 immutable internal _asset; - address immutable public fundsReceiver; - modifier onlyRescueAccount() { - require(tx.origin == rescueAccount, "vault operations are paused"); + bool internal rescueActive; + + modifier onlyRescueAccount() { + require(msg.sender == rescueAccount, "unauthorized"); + _; + } + + modifier rescueLock() { + require(!rescueActive, "rescue ongoing"); + rescueActive = true; _; + rescueActive = false; } + modifier onlyWhenRescueActive() { + require(rescueActive, "vault operations are paused"); + _; + } + modifier onlyAllowedEarnVault() { require(msg.sender == earnVault, "wrong vault"); _; } - constructor(address _rescueAccount, address _earnVault, address _fundsReceiver) { + constructor(address _rescueAccount, address _earnVault) { rescueAccount = _rescueAccount; earnVault = _earnVault; - fundsReceiver = _fundsReceiver; _asset = IERC20(IEulerEarn(earnVault).asset()); - SafeERC20Permit2Lib.forceApproveMaxWithPermit2( - _asset, - rescueAccount, - address(0) - ); } function asset() external view returns(address) { @@ -70,16 +77,16 @@ contract RescueStrategy { } // will revert user deposits - function maxDeposit(address) onlyAllowedEarnVault onlyRescueAccount external view returns (uint256) { + function maxDeposit(address) onlyAllowedEarnVault onlyWhenRescueActive external view returns (uint256) { return type(uint256).max; } // will revert user withdrawals - function maxWithdraw(address) onlyAllowedEarnVault onlyRescueAccount external view returns (uint256) { + function maxWithdraw(address) onlyAllowedEarnVault onlyWhenRescueActive external view returns (uint256) { return 0; } - function previewRedeem(uint256) onlyAllowedEarnVault external view returns (uint256) { + function previewRedeem(uint256) external pure returns (uint256) { return 0; } @@ -88,7 +95,7 @@ contract RescueStrategy { return 0; } - function deposit(uint256 amount, address) onlyAllowedEarnVault onlyRescueAccount external returns (uint256) { + function deposit(uint256 amount, address) onlyAllowedEarnVault onlyWhenRescueActive external returns (uint256) { SafeERC20Permit2Lib.safeTransferFromWithPermit2( _asset, msg.sender, @@ -101,13 +108,13 @@ contract RescueStrategy { } // alternative sources of flashloan - function rescueEuler(uint256 loanAmount, address flashLoanVault) onlyRescueAccount external { + function rescueEuler(uint256 loanAmount, address flashLoanVault) onlyRescueAccount rescueLock external { bytes memory data = abi.encode(loanAmount, flashLoanVault); IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); } // alternative sources of flashloan - function rescueEulerBatch(uint256 loanAmount, address flashLoanVault) onlyRescueAccount external { + function rescueEulerBatch(uint256 loanAmount, address flashLoanVault) onlyRescueAccount rescueLock external { address evc = EVCUtil(earnVault).EVC(); SafeERC20.forceApprove(_asset, flashLoanVault, loanAmount); @@ -148,15 +155,15 @@ contract RescueStrategy { } // alternative sources of flashloan - function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount external { + function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount rescueLock external { IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, ""); } - function onBatchLoan(uint256 loanAmount) external { + function onBatchLoan(uint256 loanAmount) onlyWhenRescueActive external { _processFlashLoan(loanAmount); } - function onFlashLoan(bytes memory data) external { + function onFlashLoan(bytes memory data) onlyWhenRescueActive external { (uint256 loanAmount, address flashLoanSource) = abi.decode(data, (uint256, address)); _processFlashLoan(loanAmount); @@ -169,7 +176,7 @@ contract RescueStrategy { ); } - function onMorphoFlashLoan(uint256 amount, bytes memory) external { + function onMorphoFlashLoan(uint256 amount, bytes memory) onlyWhenRescueActive external { _processFlashLoan(amount); SafeERC20.forceApprove(_asset, msg.sender, amount); @@ -196,9 +203,9 @@ contract RescueStrategy { IERC4626(earnVault).deposit(loanAmount, address(this)); // withdraw as much as possible to the receiver - IERC4626(earnVault).withdraw(IERC4626(earnVault).maxWithdraw(address(this)), fundsReceiver, address(this)); + IERC4626(earnVault).withdraw(IERC4626(earnVault).maxWithdraw(address(this)), rescueAccount, address(this)); // send the remaining shares to the receiver - IERC4626(earnVault).transfer(fundsReceiver, IERC4626(earnVault).balanceOf(address(this))); + IERC4626(earnVault).transfer(rescueAccount, IERC4626(earnVault).balanceOf(address(this))); } } diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index e26d9d6..a5ab8e7 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -20,8 +20,6 @@ contract RescuePOC is Test { 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 RESCUE_EOA = address(10000); - address constant FUNDS_RECEIVER = address(20000); uint256 constant BLOCK_NUMBER = 23753054; IEulerEarn vault; @@ -30,6 +28,7 @@ contract RescuePOC is Test { uint256 fork; + address rescueAccount = makeAddr("rescueAccount"); address user = makeAddr("user"); RescueStrategy rescueStrategy; @@ -70,29 +69,39 @@ contract RescuePOC is Test { uint256 amount = 100_000e6; - vm.startPrank(RESCUE_EOA, RESCUE_EOA); + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_EULER); + + vm.startPrank(rescueAccount); rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_EULER); - assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); + assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); assertEq(IEVC(vault.EVC()).getControllers(address(rescueStrategy)).length, 0); - console.log("Rescued", IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), IEulerEarn(vault.asset()).symbol()); - console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); + console.log("Rescued", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); } - function testRescue_rescueOneGoMorpho() public { + function testRescue_rescueMorpho() public { _installRescueStrategy(); // create shares equal total supply + extra uint256 amount = vault.previewMint(vault.totalSupply()) * 10001 / 10000; - vm.startPrank(RESCUE_EOA, RESCUE_EOA); + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_MORPHO); + + vm.startPrank(rescueAccount); rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); - assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); + assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); - console.log("Rescued", IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), IEulerEarn(vault.asset()).symbol()); - console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); + console.log("Rescued", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); } function testRescue_rescueMultipleMorpho() public { @@ -100,34 +109,33 @@ contract RescuePOC is Test { uint256 amount = 1000000000000; - vm.startPrank(RESCUE_EOA, RESCUE_EOA); + vm.startPrank(rescueAccount); rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); - assertGt(IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), 0); + assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); - console.log("Rescued", IERC20(vault.asset()).balanceOf(FUNDS_RECEIVER), IEulerEarn(vault.asset()).symbol()); - console.log("Received shares", IERC4626(vault).balanceOf(FUNDS_RECEIVER)); + console.log("Rescued", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); } - function testRescue_rescueEOACanWithdrawAnyTime() public { + function testRescue_rescueAccountCantWithdrawOutsideRescue() public { _installRescueStrategy(); vm.prank(user); vm.expectRevert("vault operations are paused"); vault.withdraw(1e6, user, user); - deal(address(vault), RESCUE_EOA, 1e6); - - vm.prank(RESCUE_EOA, RESCUE_EOA); - vault.withdraw(1e6, RESCUE_EOA, RESCUE_EOA); + deal(address(vault), rescueAccount, 1e6); - assertEq(IERC20(vault.asset()).balanceOf(RESCUE_EOA), 1e6); + vm.prank(rescueAccount); + vm.expectRevert("vault operations are paused"); + vault.withdraw(1e6, rescueAccount, rescueAccount); } function testRescue_cantBeReused() public { - rescueStrategy = new RescueStrategy(RESCUE_EOA, address(vault), FUNDS_RECEIVER); + rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); // install perspective in earn factory which will allow custom strategies _installPerspective(); @@ -183,12 +191,34 @@ contract RescuePOC is Test { 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); + vm.expectRevert("vault operations are paused"); + rescueStrategy.onFlashLoan(""); + vm.expectRevert("vault operations are paused"); + rescueStrategy.onMorphoFlashLoan(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(RESCUE_EOA, address(vault), FUNDS_RECEIVER); + rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); vm.startPrank(vault.curator()); From b6225bab0cd06b77f1d9b70fc8e104e02c920c35 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 09:09:38 +0100 Subject: [PATCH 11/18] add Rescue event --- src/RescueStrategy.sol | 7 ++++++- test/RescueStrategyTest.sol | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 1fd8aaa..d577004 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -66,6 +66,8 @@ contract RescueStrategy { _; } + event Rescued(address indexed vault, uint256 assets); + constructor(address _rescueAccount, address _earnVault) { rescueAccount = _rescueAccount; earnVault = _earnVault; @@ -203,9 +205,12 @@ contract RescueStrategy { IERC4626(earnVault).deposit(loanAmount, address(this)); // withdraw as much as possible to the receiver - IERC4626(earnVault).withdraw(IERC4626(earnVault).maxWithdraw(address(this)), rescueAccount, address(this)); + 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); } } diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index a5ab8e7..0ce4cb2 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -75,6 +75,8 @@ contract RescuePOC is Test { rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_EULER); vm.startPrank(rescueAccount); + vm.expectEmit(true, true, false, false); + emit RescueStrategy.Rescued(address(vault), 0); rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_EULER); assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); From 180c1e89714b6d259e190f81749f9cf21c4fc3fe Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 09:23:03 +0100 Subject: [PATCH 12/18] add assertRescueMode --- src/RescueStrategy.sol | 12 ++++++++++++ test/RescueStrategyTest.sol | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index d577004..4e3dc4f 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -51,6 +51,7 @@ contract RescueStrategy { modifier rescueLock() { require(!rescueActive, "rescue ongoing"); + _assertRescueMode(); rescueActive = true; _; rescueActive = false; @@ -213,4 +214,15 @@ contract RescueStrategy { 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 index 0ce4cb2..bc3ea77 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -50,6 +50,43 @@ contract RescuePOC is Test { ); } + 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, 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, 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, FLASH_LOAN_SOURCE_EULER); + } + function testRescue_pauseForUsers() public { _installRescueStrategy(); From b0b207e0c402e786e9312e676b490ac8e94a8488 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 09:26:45 +0100 Subject: [PATCH 13/18] update comment --- src/RescueStrategy.sol | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 4e3dc4f..954be09 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -15,21 +15,15 @@ import {IBorrowing, IRiskManager} from "../lib/euler-vault-kit/src/EVault/IEVaul - 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 EOA: is only allowed to call the rescue function directly or through a multisig (tx.origin is checked). - It should be a throw-away EOA just for the purpose of rescue, because of tx.origin use. - o funds receiver: will receive rescued assets and left over shares (see below) - o earn vault: the strategy can only work with the specified vault. If another vault tries to enable it, it will revert on `submitCap` + 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 EOA calls one of the `rescueX` funcitons (for Euler or Morpho flash loan sources), specifying the asset amount to flashloan + - 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 funds receiver - o remaining shares are returned to the funds receiver - o the rescue function can be called multiple times - o the rescue EOA can also withdraw shares at any time, as long as it is tx.origin - (so can also initiate withdrawal if funds receiver is a multisig) + o the shares are used to withdraw as much as possible from the underlying strategies to the rescue account */ interface IFlashLoan { From f2753e76182601aeb7b28ff5efac928fa48509b6 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 13:42:36 +0100 Subject: [PATCH 14/18] add deposit loops --- src/RescueStrategy.sol | 32 +++++++++++++++++-------------- test/RescueStrategyTest.sol | 38 +++++++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 954be09..63f7944 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -105,13 +105,13 @@ contract RescueStrategy { } // alternative sources of flashloan - function rescueEuler(uint256 loanAmount, address flashLoanVault) onlyRescueAccount rescueLock external { - bytes memory data = abi.encode(loanAmount, flashLoanVault); + function rescueEuler(uint256 loanAmount, uint256 loops, address flashLoanVault) onlyRescueAccount rescueLock external { + bytes memory data = abi.encode(loanAmount, loops, flashLoanVault); IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); } // alternative sources of flashloan - function rescueEulerBatch(uint256 loanAmount, address flashLoanVault) onlyRescueAccount rescueLock external { + function rescueEulerBatch(uint256 loanAmount, uint256 loops, address flashLoanVault) onlyRescueAccount rescueLock external { address evc = EVCUtil(earnVault).EVC(); SafeERC20.forceApprove(_asset, flashLoanVault, loanAmount); @@ -133,7 +133,7 @@ contract RescueStrategy { targetContract: address(this), onBehalfOfAccount: address(this), value: 0, - data: abi.encodeCall(this.onBatchLoan, (loanAmount)) + data: abi.encodeCall(this.onBatchLoan, (loanAmount, loops)) }); batchItems[3] = IEVC.BatchItem({ targetContract: flashLoanVault, @@ -152,18 +152,18 @@ contract RescueStrategy { } // alternative sources of flashloan - function rescueMorpho(uint256 loanAmount, address morpho) onlyRescueAccount rescueLock external { - IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, ""); + function rescueMorpho(uint256 loanAmount, uint256 loops, address morpho) onlyRescueAccount rescueLock external { + IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, abi.encode(loops)); } - function onBatchLoan(uint256 loanAmount) onlyWhenRescueActive external { - _processFlashLoan(loanAmount); + function onBatchLoan(uint256 loanAmount, uint256 loops) onlyWhenRescueActive external { + _processFlashLoan(loanAmount, loops); } function onFlashLoan(bytes memory data) onlyWhenRescueActive external { - (uint256 loanAmount, address flashLoanSource) = abi.decode(data, (uint256, address)); + (uint256 loanAmount, uint256 loops, address flashLoanSource) = abi.decode(data, (uint256, uint256, address)); - _processFlashLoan(loanAmount); + _processFlashLoan(loanAmount, loops); // repay the flashloan SafeERC20.safeTransfer( @@ -173,8 +173,10 @@ contract RescueStrategy { ); } - function onMorphoFlashLoan(uint256 amount, bytes memory) onlyWhenRescueActive external { - _processFlashLoan(amount); + function onMorphoFlashLoan(uint256 amount, bytes memory data) onlyWhenRescueActive external { + uint256 loops = abi.decode(data, (uint256)); + + _processFlashLoan(amount, loops); SafeERC20.forceApprove(_asset, msg.sender, amount); } @@ -189,7 +191,7 @@ contract RescueStrategy { revert("vault operations are paused"); } - function _processFlashLoan(uint256 loanAmount) internal { + function _processFlashLoan(uint256 loanAmount, uint256 loops) internal { SafeERC20Permit2Lib.forceApproveMaxWithPermit2( _asset, earnVault, @@ -197,7 +199,9 @@ contract RescueStrategy { ); // deposit to earn, create shares. Assets will come back here if the strategy is first in supply queue - IERC4626(earnVault).deposit(loanAmount, address(this)); + 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)); diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index bc3ea77..f797a7a 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -58,7 +58,7 @@ contract RescuePOC is Test { vm.prank(rescueAccount); vm.expectRevert("rescue: supplyQueue len != 1"); - rescueStrategy.rescueEulerBatch(1, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); IERC4626[] memory supplyQueue = new IERC4626[](1); supplyQueue[0] = vault.supplyQueue(0); @@ -68,7 +68,7 @@ contract RescuePOC is Test { vm.prank(rescueAccount); vm.expectRevert("rescue: supplyQueue[0] != rescue"); - rescueStrategy.rescueEulerBatch(1, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); vm.prank(vault.curator()); vault.submitCap(id, type(uint184).max); @@ -84,7 +84,7 @@ contract RescuePOC is Test { vm.prank(rescueAccount); vm.expectRevert("rescue: withdrawQueue[0] != rescue"); - rescueStrategy.rescueEulerBatch(1, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); } function testRescue_pauseForUsers() public { @@ -105,37 +105,46 @@ contract RescuePOC is Test { _installRescueStrategy(); uint256 amount = 100_000e6; - + uint256 loops = 1; + uint256 snapshot = vm.snapshotState(); // only rescue account vm.prank(user); vm.expectRevert("unauthorized"); - rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_EULER); + 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, FLASH_LOAN_SOURCE_EULER); + 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", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + 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; + uint256 amount = vault.previewMint(vault.totalSupply()) * 10001 / 10000 / 2; + uint256 loops = 2; // only rescue account vm.prank(user); vm.expectRevert("unauthorized"); - rescueStrategy.rescueEulerBatch(amount, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_MORPHO); vm.startPrank(rescueAccount); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); @@ -147,11 +156,12 @@ contract RescuePOC is Test { _installRescueStrategy(); uint256 amount = 1000000000000; + uint256 loops = 1; vm.startPrank(rescueAccount); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); - rescueStrategy.rescueMorpho(amount, FLASH_LOAN_SOURCE_MORPHO); + 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); @@ -245,7 +255,7 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.expectRevert("vault operations are paused"); - rescueStrategy.onBatchLoan(1); + rescueStrategy.onBatchLoan(1, 1); vm.expectRevert("vault operations are paused"); rescueStrategy.onFlashLoan(""); vm.expectRevert("vault operations are paused"); From 2e8b1c487a1ba34486d7e7404943b71a7b9407dd Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 14:30:17 +0100 Subject: [PATCH 15/18] add aave flashloan source --- src/RescueStrategy.sol | 38 +++++++++++++++++++++++++++++++++++++ test/RescueStrategyTest.sol | 27 +++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 63f7944..bb8841e 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -29,8 +29,16 @@ import {IBorrowing, IRiskManager} from "../lib/euler-vault-kit/src/EVault/IEVaul 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 immutable public rescueAccount; address immutable public earnVault; @@ -69,6 +77,8 @@ contract RescueStrategy { _asset = IERC20(IEulerEarn(earnVault).asset()); } + // ---------------- VAULT INTERFACE -------------------- + function asset() external view returns(address) { return address(_asset); } @@ -104,6 +114,8 @@ contract RescueStrategy { return amount; } + // ---------------- RESCUE FUNCTIONS -------------------- + // alternative sources of flashloan function rescueEuler(uint256 loanAmount, uint256 loops, address flashLoanVault) onlyRescueAccount rescueLock external { bytes memory data = abi.encode(loanAmount, loops, flashLoanVault); @@ -151,11 +163,18 @@ contract RescueStrategy { IEVC(evc).batch(batchItems); } + function rescueAave(uint256 loanAmount, uint256 loops, address pool) onlyRescueAccount rescueLock external { + bytes memory data = abi.encode(loops); + IFlashLoan(pool).flashLoanSimple(address(this), address(_asset), loanAmount, data, 0); + } + // alternative sources of flashloan function rescueMorpho(uint256 loanAmount, uint256 loops, address morpho) onlyRescueAccount rescueLock external { IFlashLoan(morpho).flashLoan(address(_asset), loanAmount, abi.encode(loops)); } + // ---------------- FLASHLOAN CALLBACKS -------------------- + function onBatchLoan(uint256 loanAmount, uint256 loops) onlyWhenRescueActive external { _processFlashLoan(loanAmount, loops); } @@ -181,6 +200,25 @@ contract RescueStrategy { SafeERC20.forceApprove(_asset, msg.sender, amount); } + // aave callback + function executeOperation( + address, + uint256 amount, + uint256 premium, + address, + bytes calldata data + ) external returns (bool) { + require(_asset.balanceOf(address(this)) >= amount + premium, "insufficient funds to repay flashloan"); + uint256 loops = abi.decode(data, (uint256)); + + _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) onlyRescueAccount external { (bool success,) = target.call(payload); diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index f797a7a..c0c3d93 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -20,6 +20,7 @@ contract RescuePOC is Test { 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; @@ -141,7 +142,7 @@ contract RescuePOC is Test { // only rescue account vm.prank(user); vm.expectRevert("unauthorized"); - rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); vm.startPrank(rescueAccount); rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); @@ -152,6 +153,30 @@ contract RescuePOC is Test { console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); } + function testRescue_rescueAave() public { + _installRescueStrategy(); + + uint256 amount = 5_000_000e6; + uint256 loops = 1; + + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE); + + vm.startPrank(rescueAccount); + vm.expectRevert("insufficient funds to repay flashloan"); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE); + + deal(vault.asset(), address(rescueStrategy), amount * 5 / 10000); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE); + + 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_rescueMultipleMorpho() public { _installRescueStrategy(); From 2f356155aaefdf53a65b44f8c4672320686a4d01 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Sun, 9 Nov 2025 15:47:21 +0100 Subject: [PATCH 16/18] fix aave callback --- src/RescueStrategy.sol | 2 +- test/RescueStrategyTest.sol | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index bb8841e..49489e9 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -207,7 +207,7 @@ contract RescueStrategy { uint256 premium, address, bytes calldata data - ) external returns (bool) { + ) onlyWhenRescueActive external returns (bool) { require(_asset.balanceOf(address(this)) >= amount + premium, "insufficient funds to repay flashloan"); uint256 loops = abi.decode(data, (uint256)); diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index c0c3d93..c5aea6f 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -285,6 +285,8 @@ contract RescuePOC is Test { 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 { From 8858d7a64982c01da9eb01bb819a9bd57966cbd9 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Mon, 10 Nov 2025 10:37:57 +0100 Subject: [PATCH 17/18] pull aave fee --- src/RescueStrategy.sol | 8 ++++---- test/RescueStrategyTest.sol | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 49489e9..2cb6cba 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -163,8 +163,8 @@ contract RescueStrategy { IEVC(evc).batch(batchItems); } - function rescueAave(uint256 loanAmount, uint256 loops, address pool) onlyRescueAccount rescueLock external { - bytes memory data = abi.encode(loops); + function rescueAave(uint256 loanAmount, uint256 loops, address pool, address feeProvider) onlyRescueAccount rescueLock external { + bytes memory data = abi.encode(loops, feeProvider); IFlashLoan(pool).flashLoanSimple(address(this), address(_asset), loanAmount, data, 0); } @@ -208,8 +208,8 @@ contract RescueStrategy { address, bytes calldata data ) onlyWhenRescueActive external returns (bool) { - require(_asset.balanceOf(address(this)) >= amount + premium, "insufficient funds to repay flashloan"); - uint256 loops = abi.decode(data, (uint256)); + (uint256 loops, address feeProvider) = abi.decode(data, (uint256, address)); + SafeERC20.safeTransferFrom(_asset, feeProvider, address(this), premium); _processFlashLoan(amount, loops); diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index c5aea6f..f1c73e4 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -158,22 +158,28 @@ contract RescuePOC is Test { 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); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE, feeProvider); - vm.startPrank(rescueAccount); - vm.expectRevert("insufficient funds to repay flashloan"); - rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE); + vm.prank(rescueAccount); + vm.expectRevert("ERC20: transfer amount exceeds allowance"); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE, feeProvider); - deal(vault.asset(), address(rescueStrategy), amount * 5 / 10000); - rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE); + deal(asset, feeProvider, amount * 5 / 10000); + vm.prank(feeProvider); + IERC20(asset).approve(address(rescueStrategy), type(uint256).max); - assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); + vm.prank(rescueAccount); + rescueStrategy.rescueAave(amount, loops, FLASH_LOAN_SOURCE_AAVE, feeProvider); - console.log("Rescued", IERC20(vault.asset()).balanceOf(rescueAccount), IEulerEarn(vault.asset()).symbol()); + 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)); } From 31035713ae55b867e2bd6debc812c83e0095e661 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Mon, 10 Nov 2025 11:00:41 +0100 Subject: [PATCH 18/18] format --- src/RescueStrategy.sol | 163 ++++++++++++++++----------------- test/RescueStrategyTest.sol | 178 ++++++++++++++++++------------------ 2 files changed, 168 insertions(+), 173 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 2cb6cba..509abf2 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -38,11 +38,10 @@ interface IFlashLoan { ) external; } - contract RescueStrategy { - address immutable public rescueAccount; - address immutable public earnVault; - IERC20 immutable internal _asset; + address public immutable rescueAccount; + address public immutable earnVault; + IERC20 internal immutable _asset; bool internal rescueActive; @@ -51,13 +50,13 @@ contract RescueStrategy { _; } - modifier rescueLock() { + modifier rescueLock() { require(!rescueActive, "rescue ongoing"); _assertRescueMode(); rescueActive = true; - _; + _; rescueActive = false; - } + } modifier onlyWhenRescueActive() { require(rescueActive, "vault operations are paused"); @@ -71,59 +70,63 @@ contract RescueStrategy { event Rescued(address indexed vault, uint256 assets); - constructor(address _rescueAccount, address _earnVault) { - rescueAccount = _rescueAccount; - earnVault = _earnVault; - _asset = IERC20(IEulerEarn(earnVault).asset()); - } + constructor(address _rescueAccount, address _earnVault) { + rescueAccount = _rescueAccount; + earnVault = _earnVault; + _asset = IERC20(IEulerEarn(earnVault).asset()); + } // ---------------- VAULT INTERFACE -------------------- - function asset() external view returns(address) { + function asset() external view returns (address) { return address(_asset); } // will revert user deposits - function maxDeposit(address) onlyAllowedEarnVault onlyWhenRescueActive external view returns (uint256) { - return type(uint256).max; - } + function maxDeposit(address) external view onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + return type(uint256).max; + } // will revert user withdrawals - function maxWithdraw(address) onlyAllowedEarnVault onlyWhenRescueActive external view returns (uint256) { - return 0; - } + function maxWithdraw(address) external view onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + return 0; + } - function previewRedeem(uint256) external pure 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) onlyAllowedEarnVault external view returns (uint256) { - return 0; - } - - function deposit(uint256 amount, address) onlyAllowedEarnVault onlyWhenRescueActive external returns (uint256) { - SafeERC20Permit2Lib.safeTransferFromWithPermit2( - _asset, - msg.sender, - address(this), - amount, - IEulerEarn(earnVault).permit2Address() - ); + 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) onlyRescueAccount rescueLock external { + function rescueEuler(uint256 loanAmount, uint256 loops, address flashLoanVault) + external + onlyRescueAccount + rescueLock + { bytes memory data = abi.encode(loanAmount, loops, flashLoanVault); - IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); - } + IFlashLoan(flashLoanVault).flashLoan(loanAmount, data); + } // alternative sources of flashloan - function rescueEulerBatch(uint256 loanAmount, uint256 loops, address flashLoanVault) onlyRescueAccount rescueLock external { + function rescueEulerBatch(uint256 loanAmount, uint256 loops, address flashLoanVault) + external + onlyRescueAccount + rescueLock + { address evc = EVCUtil(earnVault).EVC(); SafeERC20.forceApprove(_asset, flashLoanVault, loanAmount); @@ -161,53 +164,51 @@ contract RescueStrategy { }); IEVC(evc).batch(batchItems); - } + } - function rescueAave(uint256 loanAmount, uint256 loops, address pool, address feeProvider) onlyRescueAccount rescueLock external { + 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); - } + IFlashLoan(pool).flashLoanSimple(address(this), address(_asset), loanAmount, data, 0); + } // alternative sources of flashloan - function rescueMorpho(uint256 loanAmount, uint256 loops, address morpho) onlyRescueAccount rescueLock external { + 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) onlyWhenRescueActive external { - _processFlashLoan(loanAmount, loops); - } + function onBatchLoan(uint256 loanAmount, uint256 loops) external onlyWhenRescueActive { + _processFlashLoan(loanAmount, loops); + } - function onFlashLoan(bytes memory data) onlyWhenRescueActive external { + function onFlashLoan(bytes memory data) external onlyWhenRescueActive { (uint256 loanAmount, uint256 loops, address flashLoanSource) = abi.decode(data, (uint256, uint256, address)); - _processFlashLoan(loanAmount, loops); + _processFlashLoan(loanAmount, loops); // repay the flashloan - SafeERC20.safeTransfer( - _asset, - flashLoanSource, - loanAmount - ); - } - - function onMorphoFlashLoan(uint256 amount, bytes memory data) onlyWhenRescueActive external { + SafeERC20.safeTransfer(_asset, flashLoanSource, loanAmount); + } + + function onMorphoFlashLoan(uint256 amount, bytes memory data) external onlyWhenRescueActive { uint256 loops = abi.decode(data, (uint256)); - _processFlashLoan(amount, loops); + _processFlashLoan(amount, loops); SafeERC20.forceApprove(_asset, msg.sender, amount); - } + } // aave callback - function executeOperation( - address, - uint256 amount, - uint256 premium, - address, - bytes calldata data - ) onlyWhenRescueActive external returns (bool) { + 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); @@ -220,29 +221,25 @@ contract RescueStrategy { // ---------------- 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) onlyRescueAccount external { - (bool success,) = target.call(payload); - require(success, "call failed"); - } + 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"); - } + 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++) { + 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)); + uint256 rescuedAmount = IERC4626(earnVault).maxWithdraw(address(this)); IERC4626(earnVault).withdraw(rescuedAmount, rescueAccount, address(this)); // send the remaining shares to the receiver diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index f1c73e4..a297f14 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -12,10 +12,9 @@ import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector. 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 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; @@ -23,38 +22,38 @@ contract RescuePOC is Test { address constant FLASH_LOAN_SOURCE_AAVE = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; uint256 constant BLOCK_NUMBER = 23753054; - IEulerEarn vault; + IEulerEarn vault; - string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); + string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); - uint256 fork; + uint256 fork; address rescueAccount = makeAddr("rescueAccount"); - address user = makeAddr("user"); + address user = makeAddr("user"); RescueStrategy rescueStrategy; - function setUp() public { - require(bytes(FORK_RPC_URL).length != 0, "No FORK_RPC_URL env found"); + 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); - } + fork = vm.createSelectFork(FORK_RPC_URL); + if (BLOCK_NUMBER > 0) { + vm.rollFork(BLOCK_NUMBER); + } - vault = IEulerEarn(EARN_VAULT); + vault = IEulerEarn(EARN_VAULT); - deal(vault.asset(), user, 100e18); - vm.startPrank(user); - IERC20(vault.asset()).approve(vault.permit2Address(), type(uint256).max); + 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(); + _installPerspective(); - rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); + rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); IERC4626 id = IERC4626(address(rescueStrategy)); vm.prank(rescueAccount); @@ -62,45 +61,45 @@ contract RescuePOC is Test { rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); IERC4626[] memory supplyQueue = new IERC4626[](1); - supplyQueue[0] = vault.supplyQueue(0); + supplyQueue[0] = vault.supplyQueue(0); - vm.prank(vault.curator()); + 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); + vault.submitCap(id, type(uint184).max); - skip(vault.timelock()); + skip(vault.timelock()); vm.prank(vault.curator()); - vault.acceptCap(id); - supplyQueue[0] = id; + vault.acceptCap(id); + supplyQueue[0] = id; vm.prank(vault.curator()); - vault.setSupplyQueue(supplyQueue); + 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(); + 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); - } + 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(); @@ -204,7 +203,7 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.prank(user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused"); vault.withdraw(1e6, user, user); deal(address(vault), rescueAccount, 1e6); @@ -217,14 +216,14 @@ contract RescuePOC is Test { function testRescue_cantBeReused() public { rescueStrategy = new RescueStrategy(rescueAccount, address(vault)); - // install perspective in earn factory which will allow custom strategies - _installPerspective(); + // 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()); + vm.startPrank(otherVault.curator()); - otherVault.submitCap(IERC4626(address(rescueStrategy)), type(uint184).max); + otherVault.submitCap(IERC4626(address(rescueStrategy)), type(uint184).max); skip(vault.timelock()); vm.expectRevert("wrong vault"); @@ -235,23 +234,23 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.startPrank(user); - vm.expectRevert("vault operations are paused"); - vault.deposit(10, 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; - } + uint256 withdrawQueueLength = vault.withdrawQueueLength(); + uint256[] memory newIndexes = new uint256[](withdrawQueueLength - 1); + newIndexes[0] = withdrawQueueLength - 1; - vault.updateWithdrawQueue(newIndexes); + 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); @@ -260,14 +259,14 @@ contract RescuePOC is Test { // the vault is functional vm.startPrank(user); - vault.deposit(10, user); + vault.deposit(10, user); uint256 balance = vault.balanceOf(user); assertGt(balance, 0); - vault.mint(10, user); + vault.mint(10, user); assertEq(vault.balanceOf(user), balance + 10); - vault.redeem(10, user, user); + vault.redeem(10, user, user); assertEq(vault.balanceOf(user), balance); - vault.withdraw(vault.maxWithdraw(user), user, user); + vault.withdraw(vault.maxWithdraw(user), user, user); assertEq(vault.balanceOf(user), 0); } @@ -292,58 +291,57 @@ contract RescuePOC is Test { vm.expectRevert("vault operations are paused"); rescueStrategy.onMorphoFlashLoan(1, ""); vm.expectRevert("vault operations are paused"); - rescueStrategy.executeOperation(address(1), 1, 1, address(1), ""); + 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(); + 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)); + // 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()); + vm.startPrank(vault.curator()); + + IERC4626 id = IERC4626(address(rescueStrategy)); - IERC4626 id = IERC4626(address(rescueStrategy)); + vault.submitCap(id, type(uint184).max); - vault.submitCap(id, type(uint184).max); + skip(vault.timelock()); - skip(vault.timelock()); + vault.acceptCap(id); - vault.acceptCap(id); + IERC4626[] memory supplyQueue = new IERC4626[](1); + supplyQueue[0] = id; - IERC4626[] memory supplyQueue = new IERC4626[](1); - supplyQueue[0] = id; + vault.setSupplyQueue(supplyQueue); - 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; - // 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; - } + for (uint256 i = 1; i < withdrawQueueLength; i++) { + newIndexes[i] = i - 1; + } - vault.updateWithdrawQueue(newIndexes); + vault.updateWithdrawQueue(newIndexes); vm.stopPrank(); - } + } - function _installPerspective() internal { - vm.startPrank(Ownable(vault.creator()).owner()); + function _installPerspective() internal { + vm.startPrank(Ownable(vault.creator()).owner()); - IEulerEarnFactory factory = IEulerEarnFactory(vault.creator()); - factory.setPerspective(address(new MockPerspective())); + IEulerEarnFactory factory = IEulerEarnFactory(vault.creator()); + factory.setPerspective(address(new MockPerspective())); - vm.stopPrank(); - } + vm.stopPrank(); + } } contract MockPerspective { - function isVerified(address) external pure returns(bool) { + function isVerified(address) external pure returns (bool) { return true; } } -