From 56c8bd5c2954570579d50a03ae10539319d80267 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Wed, 19 Nov 2025 23:23:55 +0100 Subject: [PATCH 01/15] wip --- src/RescueStrategy.sol | 101 ++++++++++++++++++++++++++++++++++-- test/RescueStrategyTest.sol | 2 + 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 509abf2..8b77121 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -6,8 +6,10 @@ 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 {IEulerEarnFactory} from "./interfaces/IEulerEarnFactory.sol"; import {SafeERC20Permit2Lib} from "./libraries/SafeERC20Permit2Lib.sol"; import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "openzeppelin-contracts/utils/ReentrancyGuard.sol"; import {IBorrowing, IRiskManager} from "../lib/euler-vault-kit/src/EVault/IEVault.sol"; /* @@ -38,7 +40,7 @@ interface IFlashLoan { ) external; } -contract RescueStrategy { +contract RescueStrategy is IERC4626 { address public immutable rescueAccount; address public immutable earnVault; IERC20 internal immutable _asset; @@ -83,12 +85,23 @@ contract RescueStrategy { } // will revert user deposits - function maxDeposit(address) external view onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + function maxDeposit(address) external view returns (uint256) { + require(msg.sender != earnVault || rescueActive, "vault operations are paused"); return type(uint256).max; } // will revert user withdrawals - function maxWithdraw(address) external view onlyAllowedEarnVault onlyWhenRescueActive returns (uint256) { + function maxWithdraw(address) external view returns (uint256) { + if (!rescueActive && msg.sender == earnVault) { + // if reentrancy locked - earn is calling from `withdraw`, which shold be prevented + // if unlocked - let it through because `maxWithdrawFromStrategy` is called, and this + // function is relied upon by the Lens contract + (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeWithSignature("setFee(uint256)", uint256(0))); + require(!success, "expected revert"); // if not reentrancy lock, onlyOwner should revert + + if (bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) + revert("vault operations are paused"); + } return 0; } @@ -97,7 +110,8 @@ contract RescueStrategy { } // this reverts acceptCaps to prevent reusing the whitelisted strategy on other vaults - function balanceOf(address) external view onlyAllowedEarnVault returns (uint256) { + function balanceOf(address) external view returns (uint256) { + require(!IEulerEarnFactory(IEulerEarn(earnVault).creator()).isVault(msg.sender) || msg.sender == earnVault, "wrong vault"); return 0; } @@ -109,6 +123,85 @@ contract RescueStrategy { return amount; } + // ---------------- ERC4626 compatibility stubs -------------------- + + function symbol() external pure returns (string memory) { + return "RS"; + } + + function name() external pure returns (string memory) { + return "Rescue Strategy"; + } + + function decimals() external pure returns (uint8) { + return 18; + } + + function totalAssets() external pure returns (uint256) { + return 0; + } + + function totalSupply() external pure returns (uint256) { + return 0; + } + + function convertToShares(uint256) external pure returns (uint256) { + return 0; + } + + function convertToAssets(uint256) external pure returns (uint256 assets) { + return 0; + } + + function previewDeposit(uint256) external pure returns (uint256 shares) { + return 0; + } + + function maxMint(address) external pure returns (uint256 maxShares) { + return 0; + } + + function previewMint(uint256) external pure returns (uint256 assets) { + return 0; + } + + function previewWithdraw(uint256) external pure returns (uint256 shares) { + return 0; + } + + function maxRedeem(address) external pure returns (uint256 maxShares) { + return 0; + } + + function mint(uint256, address) external pure returns (uint256 assets) { + return 0; + } + + function redeem(uint256, address, address) external pure returns (uint256 assets) { + return 0; + } + + function allowance(address, address) external pure returns (uint256) { + return 0; + } + + function approve(address, uint256) external pure returns (bool) { + return true; + } + + function transfer(address, uint256) external pure returns (bool) { + return true; + } + + function transferFrom(address, address, uint256) external pure returns (bool) { + return true; + } + + function withdraw(uint256, address, address) external pure returns (uint256 shares) { + return 0; + } + + // ---------------- RESCUE FUNCTIONS -------------------- // alternative sources of flashloan diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index a297f14..be93c3c 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -99,6 +99,8 @@ contract RescuePOC is Test { vault.withdraw(0, user, user); vm.expectRevert("vault operations are paused"); vault.redeem(0, user, user); + + assertEq(vault.maxWithdrawFromStrategy(IERC4626(address(rescueStrategy))), 0); } function testRescue_rescueEulerBatch() public { From 7952638f079e09224519b5e8f9b5f9efba065df5 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Thu, 20 Nov 2025 18:48:03 +0100 Subject: [PATCH 02/15] clean up and tests --- .gitmodules | 3 +++ lib/euler-data-lenses | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/euler-data-lenses diff --git a/.gitmodules b/.gitmodules index 7f9e985..bbf3513 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/euler-vault-kit"] path = lib/euler-vault-kit url = https://github.com/euler-xyz/euler-vault-kit +[submodule "lib/euler-data-lenses"] + path = lib/euler-data-lenses + url = https://github.com/euler-xyz/euler-data-lenses diff --git a/lib/euler-data-lenses b/lib/euler-data-lenses new file mode 160000 index 0000000..b03cca6 --- /dev/null +++ b/lib/euler-data-lenses @@ -0,0 +1 @@ +Subproject commit b03cca64587c7c09552f10b318b0be0d7e7261d0 From 6bd35d90ec7f0cd94369fcaaa32e389167daaf0a Mon Sep 17 00:00:00 2001 From: dglowinski Date: Thu, 20 Nov 2025 18:48:23 +0100 Subject: [PATCH 03/15] clean up and tests --- foundry.lock | 17 ++++++++++ src/RescueStrategy.sol | 24 ++++++------- test/RescueStrategyTest.sol | 67 ++++++++++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 foundry.lock diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..3b488fc --- /dev/null +++ b/foundry.lock @@ -0,0 +1,17 @@ +{ + "lib/erc4626-tests": { + "rev": "232ff9ba8194e406967f52ecc5cb52ed764209e9" + }, + "lib/ethereum-vault-connector": { + "rev": "a7d3c29ef7e4964736e47675e0588630d6afbfd7" + }, + "lib/euler-vault-kit": { + "rev": "5b98b42048ba11ae82fb62dfec06d1010c8e41e6" + }, + "lib/forge-std": { + "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + }, + "lib/openzeppelin-contracts": { + "rev": "e4f70216d759d8e6a64144a9e1f7bbeed78e7079" + } +} \ No newline at end of file diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 8b77121..373691f 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.26; import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol"; +import {IERC20Metadata} from "openzeppelin-contracts/interfaces/IERC20Metadata.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"; @@ -86,21 +87,20 @@ contract RescueStrategy is IERC4626 { // will revert user deposits function maxDeposit(address) external view returns (uint256) { - require(msg.sender != earnVault || rescueActive, "vault operations are paused"); - return type(uint256).max; + require(msg.sender != earnVault || rescueActive, "vault operations are paused - maxDeposit"); + return msg.sender == earnVault ? type(uint256).max : 0; } // will revert user withdrawals function maxWithdraw(address) external view returns (uint256) { if (!rescueActive && msg.sender == earnVault) { // if reentrancy locked - earn is calling from `withdraw`, which shold be prevented - // if unlocked - let it through because `maxWithdrawFromStrategy` is called, and this - // function is relied upon by the Lens contract - (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeWithSignature("setFee(uint256)", uint256(0))); - require(!success, "expected revert"); // if not reentrancy lock, onlyOwner should revert + // if unlocked - let it through because `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract + (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeWithSignature("setFee(uint256)", 0)); + require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic if (bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) - revert("vault operations are paused"); + revert("vault operations are paused - maxWithdraw"); } return 0; } @@ -133,8 +133,8 @@ contract RescueStrategy is IERC4626 { return "Rescue Strategy"; } - function decimals() external pure returns (uint8) { - return 18; + function decimals() external view returns (uint8) { + return IERC20Metadata(address(_asset)).decimals(); } function totalAssets() external pure returns (uint256) { @@ -186,15 +186,15 @@ contract RescueStrategy is IERC4626 { } function approve(address, uint256) external pure returns (bool) { - return true; + return false; } function transfer(address, uint256) external pure returns (bool) { - return true; + return false; } function transferFrom(address, address, uint256) external pure returns (bool) { - return true; + return false; } function withdraw(uint256, address, address) external pure returns (uint256 shares) { diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index be93c3c..65ea99d 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -10,6 +10,7 @@ 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 {EulerEarnVaultLens as EarnIndexerLens} from "../lib/euler-data-lenses/src/EulerEarnLens.sol"; import "forge-std/Test.sol"; contract RescuePOC is Test { @@ -20,9 +21,13 @@ contract RescuePOC is Test { 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; + address constant EARN_LENS = 0xA09144BeAe23D8e7836Aeb0Fe17DD2647241A8bE; uint256 constant BLOCK_NUMBER = 23753054; IEulerEarn vault; + IEulerEarn otherVault; + + address indexerLens; string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); @@ -41,6 +46,7 @@ contract RescuePOC is Test { } vault = IEulerEarn(EARN_VAULT); + otherVault = IEulerEarn(OTHER_EARN_VAULT); // hyperithm euler usdc mainnet deal(vault.asset(), user, 100e18); vm.startPrank(user); @@ -48,6 +54,8 @@ contract RescuePOC is Test { IAllowanceTransfer(vault.permit2Address()).approve( vault.asset(), address(vault), type(uint160).max, type(uint48).max ); + + indexerLens = address(new EarnIndexerLens()); } function testRescue_assertRescueMode() public { @@ -91,13 +99,13 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.startPrank(user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxDeposit"); vault.deposit(10, user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxDeposit"); vault.mint(10, user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxWithdraw"); vault.withdraw(0, user, user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxWithdraw"); vault.redeem(0, user, user); assertEq(vault.maxWithdrawFromStrategy(IERC4626(address(rescueStrategy))), 0); @@ -205,13 +213,13 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.prank(user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxWithdraw"); vault.withdraw(1e6, user, user); deal(address(vault), rescueAccount, 1e6); vm.prank(rescueAccount); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxWithdraw"); vault.withdraw(1e6, rescueAccount, rescueAccount); } @@ -221,8 +229,6 @@ contract RescuePOC is Test { // 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); @@ -236,7 +242,7 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.startPrank(user); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("vault operations are paused - maxDeposit"); vault.deposit(10, user); vm.startPrank(vault.curator()); @@ -296,6 +302,49 @@ contract RescuePOC is Test { rescueStrategy.executeOperation(address(1), 1, 1, address(1), ""); } + function testRescue_callLenses() external { + _installRescueStrategy(); + + (bool success, bytes memory data) = EARN_LENS.call(abi.encodeWithSignature("getVaultInfoFull(address)", address(vault))); + assertTrue(success && data.length > 0); + + (success, data) = indexerLens.call(abi.encodeWithSignature("getVaultInfoFull(address)", address(vault))); + assertTrue(success && data.length > 0); + } + + function testRescue_maxWithdrawView() external { + _installRescueStrategy(); + vm.prank(user); + assertEq(rescueStrategy.maxWithdraw(user), 0); + + vm.startPrank(address(vault)); + assertEq(rescueStrategy.maxWithdraw(user), 0); + } + + function testRescue_maxDepositView() external { + _installRescueStrategy(); + vm.prank(user); + assertEq(rescueStrategy.maxDeposit(user), 0); + + vm.startPrank(address(vault)); + vm.expectRevert("vault operations are paused - maxDeposit"); + rescueStrategy.maxDeposit(user); + } + + function testRescue_balanceOfView() external { + _installRescueStrategy(); + vm.prank(user); + assertEq(rescueStrategy.balanceOf(user), 0); + + vm.startPrank(address(vault)); + assertEq(rescueStrategy.balanceOf(user), 0); + + vm.startPrank(address(otherVault)); + vm.expectRevert("wrong vault"); + rescueStrategy.balanceOf(user); + + } + function _installRescueStrategy() internal { // install perspective in earn factory which will allow custom strategies (use mock here) _installPerspective(); From 7d398b3ea1f90bd2998cd4a35b52afdb37416d70 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 13:21:38 +0100 Subject: [PATCH 04/15] revert mutating ERC4626 stubs --- src/RescueStrategy.sol | 43 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 373691f..7f3b684 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -137,7 +137,7 @@ contract RescueStrategy is IERC4626 { return IERC20Metadata(address(_asset)).decimals(); } - function totalAssets() external pure returns (uint256) { + function allowance(address, address) external pure returns (uint256) { return 0; } @@ -145,62 +145,61 @@ contract RescueStrategy is IERC4626 { return 0; } - function convertToShares(uint256) external pure returns (uint256) { - return 0; - } - - function convertToAssets(uint256) external pure returns (uint256 assets) { - return 0; - } - - function previewDeposit(uint256) external pure returns (uint256 shares) { + function totalAssets() external pure returns (uint256) { return 0; } - function maxMint(address) external pure returns (uint256 maxShares) { + function convertToShares(uint256) external pure returns (uint256) { return 0; } - function previewMint(uint256) external pure returns (uint256 assets) { + function convertToAssets(uint256) external pure returns (uint256) { return 0; } - function previewWithdraw(uint256) external pure returns (uint256 shares) { + function previewDeposit(uint256) external pure returns (uint256) { return 0; } - function maxRedeem(address) external pure returns (uint256 maxShares) { + function maxMint(address) external pure returns (uint256) { return 0; } - function mint(uint256, address) external pure returns (uint256 assets) { + function previewMint(uint256) external pure returns (uint256) { return 0; } - function redeem(uint256, address, address) external pure returns (uint256 assets) { + function previewWithdraw(uint256) external pure returns (uint256) { return 0; } - function allowance(address, address) external pure returns (uint256) { + function maxRedeem(address) external pure returns (uint256) { return 0; } function approve(address, uint256) external pure returns (bool) { - return false; + revert("not supported"); } function transfer(address, uint256) external pure returns (bool) { - return false; + revert("not supported"); } function transferFrom(address, address, uint256) external pure returns (bool) { - return false; + revert("not supported"); } - function withdraw(uint256, address, address) external pure returns (uint256 shares) { - return 0; + function mint(uint256, address) external pure returns (uint256) { + revert("not supported"); + } + + function redeem(uint256, address, address) external pure returns (uint256) { + revert("not supported"); } + function withdraw(uint256, address, address) external pure returns (uint256) { + revert("not supported"); + } // ---------------- RESCUE FUNCTIONS -------------------- From 9b60aa0a6b8791ac8b70edf1e1e2a6e92b9eab34 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 13:52:24 +0100 Subject: [PATCH 05/15] modify deposit behavior for consistency --- src/RescueStrategy.sol | 193 ++++++++++++++++++------------------ test/RescueStrategyTest.sol | 13 ++- 2 files changed, 107 insertions(+), 99 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 7f3b684..cbef1b6 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -66,11 +66,6 @@ contract RescueStrategy is IERC4626 { _; } - modifier onlyAllowedEarnVault() { - require(msg.sender == earnVault, "wrong vault"); - _; - } - event Rescued(address indexed vault, uint256 assets); constructor(address _rescueAccount, address _earnVault) { @@ -79,11 +74,7 @@ contract RescueStrategy is IERC4626 { _asset = IERC20(IEulerEarn(earnVault).asset()); } - // ---------------- VAULT INTERFACE -------------------- - - function asset() external view returns (address) { - return address(_asset); - } + // ---------------- RESCUE ENABLING BEHAVIOR -------------------- // will revert user deposits function maxDeposit(address) external view returns (uint256) { @@ -105,99 +96,23 @@ contract RescueStrategy is IERC4626 { 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 returns (uint256) { require(!IEulerEarnFactory(IEulerEarn(earnVault).creator()).isVault(msg.sender) || msg.sender == earnVault, "wrong vault"); 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; - } - - // ---------------- ERC4626 compatibility stubs -------------------- - - function symbol() external pure returns (string memory) { - return "RS"; - } - - function name() external pure returns (string memory) { - return "Rescue Strategy"; - } - - function decimals() external view returns (uint8) { - return IERC20Metadata(address(_asset)).decimals(); - } - - function allowance(address, address) external pure returns (uint256) { - return 0; - } - - function totalSupply() external pure returns (uint256) { - return 0; - } - - function totalAssets() external pure returns (uint256) { - return 0; - } - - function convertToShares(uint256) external pure returns (uint256) { - return 0; - } - - function convertToAssets(uint256) external pure returns (uint256) { - return 0; - } - - function previewDeposit(uint256) external pure returns (uint256) { - return 0; - } - - function maxMint(address) external pure returns (uint256) { - return 0; - } - - function previewMint(uint256) external pure returns (uint256) { - return 0; - } - - function previewWithdraw(uint256) external pure returns (uint256) { - return 0; - } - - function maxRedeem(address) external pure returns (uint256) { - return 0; - } - - function approve(address, uint256) external pure returns (bool) { - revert("not supported"); - } - - function transfer(address, uint256) external pure returns (bool) { - revert("not supported"); - } - - function transferFrom(address, address, uint256) external pure returns (bool) { - revert("not supported"); - } + function deposit(uint256 amount, address) external returns (uint256) { + if (msg.sender == earnVault) { + require(rescueActive, "only during rescue"); - function mint(uint256, address) external pure returns (uint256) { - revert("not supported"); - } + SafeERC20Permit2Lib.safeTransferFromWithPermit2( + _asset, msg.sender, address(this), amount, IEulerEarn(earnVault).permit2Address() + ); - function redeem(uint256, address, address) external pure returns (uint256) { - revert("not supported"); - } + return amount; + } - function withdraw(uint256, address, address) external pure returns (uint256) { revert("not supported"); } @@ -310,6 +225,92 @@ contract RescueStrategy is IERC4626 { return true; } + // ---------------- ERC4626 compatibility stubs -------------------- + + function symbol() external pure returns (string memory) { + return "RS"; + } + + function name() external pure returns (string memory) { + return "Rescue Strategy"; + } + + function decimals() external view returns (uint8) { + return IERC20Metadata(address(_asset)).decimals(); + } + + function allowance(address, address) external pure returns (uint256) { + return 0; + } + + function totalSupply() external pure returns (uint256) { + return 0; + } + + function asset() external view returns (address) { + return address(_asset); + } + + function totalAssets() external pure returns (uint256) { + return 0; + } + + function convertToShares(uint256) external pure returns (uint256) { + return 0; + } + + function convertToAssets(uint256) external pure returns (uint256) { + return 0; + } + + function previewDeposit(uint256) external pure returns (uint256) { + return 0; + } + + function maxMint(address) external pure returns (uint256) { + return 0; + } + + function previewMint(uint256) external pure returns (uint256) { + return 0; + } + + function previewWithdraw(uint256) external pure returns (uint256) { + return 0; + } + + function maxRedeem(address) external pure returns (uint256) { + return 0; + } + + function previewRedeem(uint256) external pure returns (uint256) { + return 0; + } + + function approve(address, uint256) external pure returns (bool) { + revert("not supported"); + } + + function transfer(address, uint256) external pure returns (bool) { + revert("not supported"); + } + + function transferFrom(address, address, uint256) external pure returns (bool) { + revert("not supported"); + } + + function mint(uint256, address) external pure returns (uint256) { + revert("not supported"); + } + + function redeem(uint256, address, address) external pure returns (uint256) { + revert("not supported"); + } + + function withdraw(uint256, address, address) external pure returns (uint256) { + revert("not supported"); + } + // ---------------- HELPERS AND INTERNAL -------------------- // The contract is not supposed to hold any value, but in case of any issues rescue account can exec arbitrary call @@ -318,10 +319,6 @@ contract RescueStrategy is IERC4626 { require(success, "call failed"); } - fallback() external { - revert("vault operations are paused"); - } - function _processFlashLoan(uint256 loanAmount, uint256 loops) internal { SafeERC20Permit2Lib.forceApproveMaxWithPermit2(_asset, earnVault, address(0)); diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index 65ea99d..507175c 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -134,7 +134,7 @@ contract RescuePOC is Test { console.log("Rescued", rescueOneLoop, IEulerEarn(vault.asset()).symbol()); console.log("Received shares", IERC4626(vault).balanceOf(rescueAccount)); - vm.revertTo(snapshot); + vm.revertToState(snapshot); loops = 2; rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); @@ -331,6 +331,17 @@ contract RescuePOC is Test { rescueStrategy.maxDeposit(user); } + function testRescue_deposit() external { + _installRescueStrategy(); + vm.prank(user); + vm.expectRevert("not supported"); + rescueStrategy.deposit(1, user); + + vm.prank(address(vault)); + vm.expectRevert("only during rescue"); + rescueStrategy.deposit(1, address(vault)); + } + function testRescue_balanceOfView() external { _installRescueStrategy(); vm.prank(user); From 4299f6b0d865bf1734e7f36fca40f787df355be0 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 13:53:31 +0100 Subject: [PATCH 06/15] fix typo --- src/RescueStrategy.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index cbef1b6..7edc483 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -85,7 +85,7 @@ contract RescueStrategy is IERC4626 { // will revert user withdrawals function maxWithdraw(address) external view returns (uint256) { if (!rescueActive && msg.sender == earnVault) { - // if reentrancy locked - earn is calling from `withdraw`, which shold be prevented + // if reentrancy locked - earn is calling from `withdraw`, which should be prevented // if unlocked - let it through because `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeWithSignature("setFee(uint256)", 0)); require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic From c748134c3130aa0d0ef86cbf7f3bdacc2fe8d876 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 13:55:35 +0100 Subject: [PATCH 07/15] clean up --- src/RescueStrategy.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 7edc483..1c40a72 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -6,7 +6,7 @@ import {IERC20Metadata} from "openzeppelin-contracts/interfaces/IERC20Metadata.s 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 {IEulerEarn, IEulerEarnBase} from "./interfaces/IEulerEarn.sol"; import {IEulerEarnFactory} from "./interfaces/IEulerEarnFactory.sol"; import {SafeERC20Permit2Lib} from "./libraries/SafeERC20Permit2Lib.sol"; import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; @@ -87,7 +87,7 @@ contract RescueStrategy is IERC4626 { if (!rescueActive && msg.sender == earnVault) { // if reentrancy locked - earn is calling from `withdraw`, which should be prevented // if unlocked - let it through because `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract - (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeWithSignature("setFee(uint256)", 0)); + (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeCall(IEulerEarnBase.setFee, (0))); require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic if (bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) From 021331b1c56661497155418bba1afbe754a607db Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 13:58:07 +0100 Subject: [PATCH 08/15] check revert msg length --- src/RescueStrategy.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 1c40a72..1d51975 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -90,7 +90,7 @@ contract RescueStrategy is IERC4626 { (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeCall(IEulerEarnBase.setFee, (0))); require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic - if (bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) + if (reason.length == 4 && bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) revert("vault operations are paused - maxWithdraw"); } return 0; From 4223cbc882981894bc9b25e9e5a01d9a6200e44c Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 14:08:22 +0100 Subject: [PATCH 09/15] adjust lest tests --- test/RescueStrategyTest.sol | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index 507175c..bcdf944 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -10,7 +10,7 @@ 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 {EulerEarnVaultLens as EarnIndexerLens} from "../lib/euler-data-lenses/src/EulerEarnLens.sol"; +import {EulerEarnVaultLens as EarnIndexerLens, EulerEarnVaultInfoFull} from "../lib/euler-data-lenses/src/EulerEarnLens.sol"; import "forge-std/Test.sol"; contract RescuePOC is Test { @@ -27,7 +27,7 @@ contract RescuePOC is Test { IEulerEarn vault; IEulerEarn otherVault; - address indexerLens; + EarnIndexerLens indexerLens; string FORK_RPC_URL = vm.envOr("FORK_RPC_URL_MAINNET", string("")); @@ -55,7 +55,7 @@ contract RescuePOC is Test { vault.asset(), address(vault), type(uint160).max, type(uint48).max ); - indexerLens = address(new EarnIndexerLens()); + indexerLens = new EarnIndexerLens(); } function testRescue_assertRescueMode() public { @@ -304,12 +304,15 @@ contract RescuePOC is Test { function testRescue_callLenses() external { _installRescueStrategy(); + // lens calls don't revert + // onchain lens (bool success, bytes memory data) = EARN_LENS.call(abi.encodeWithSignature("getVaultInfoFull(address)", address(vault))); assertTrue(success && data.length > 0); - (success, data) = indexerLens.call(abi.encodeWithSignature("getVaultInfoFull(address)", address(vault))); - assertTrue(success && data.length > 0); + // lens used in the indexer by setting the `code` in eth_call + EulerEarnVaultInfoFull memory lensData = indexerLens.getVaultInfoFull(address(vault)); + assertEq(lensData.vault, address(vault)); } function testRescue_maxWithdrawView() external { From c392b4de8d3c778b86394ad895f245e63a17c135 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 17:35:42 +0100 Subject: [PATCH 10/15] add EVault stubs --- src/RescueStrategy.sol | 326 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 293 insertions(+), 33 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 1d51975..ad6a174 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.26; import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol"; import {IERC20Metadata} from "openzeppelin-contracts/interfaces/IERC20Metadata.sol"; import {IERC4626} from "openzeppelin-contracts/interfaces/IERC4626.sol"; +import {IEVault} from "../lib/euler-vault-kit/src/EVault/IEVault.sol"; import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector.sol"; import {IEulerEarn, IEulerEarnBase} from "./interfaces/IEulerEarn.sol"; @@ -41,7 +42,7 @@ interface IFlashLoan { ) external; } -contract RescueStrategy is IERC4626 { +contract RescueStrategy is IEVault { address public immutable rescueAccount; address public immutable earnVault; IERC20 internal immutable _asset; @@ -113,7 +114,7 @@ contract RescueStrategy is IERC4626 { return amount; } - revert("not supported"); + _revertNotSupported(); } // ---------------- RESCUE FUNCTIONS -------------------- @@ -225,7 +226,48 @@ contract RescueStrategy is IERC4626 { return true; } - // ---------------- ERC4626 compatibility stubs -------------------- + // ---------------- 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"); + } + + 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"); + } + + function _revertNotSupported() internal pure { + revert("not supported"); + } + + // ---------------- EVault compatibility stubs -------------------- function symbol() external pure returns (string memory) { return "RS"; @@ -288,63 +330,281 @@ contract RescueStrategy is IERC4626 { } function approve(address, uint256) external pure returns (bool) { - revert("not supported"); + _revertNotSupported(); } function transfer(address, uint256) external pure returns (bool) { - revert("not supported"); + _revertNotSupported(); } function transferFrom(address, address, uint256) external pure returns (bool) { - revert("not supported"); + _revertNotSupported(); } function mint(uint256, address) external pure returns (uint256) { - revert("not supported"); + _revertNotSupported(); } function redeem(uint256, address, address) external pure returns (uint256) { - revert("not supported"); + _revertNotSupported(); } function withdraw(uint256, address, address) external pure returns (uint256) { - revert("not supported"); + _revertNotSupported(); } - // ---------------- HELPERS AND INTERNAL -------------------- + function transferFromMax(address, address) external pure returns (bool) { + _revertNotSupported(); + } - // 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"); + function accumulatedFees() external pure returns (uint256) { + return 0; } - function _processFlashLoan(uint256 loanAmount, uint256 loops) internal { - SafeERC20Permit2Lib.forceApproveMaxWithPermit2(_asset, earnVault, address(0)); + function accumulatedFeesAssets() external pure returns (uint256) { + return 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)); - } + function creator() external pure returns (address) { + return address(0); + } - // withdraw as much as possible to the receiver - uint256 rescuedAmount = IERC4626(earnVault).maxWithdraw(address(this)); - IERC4626(earnVault).withdraw(rescuedAmount, rescueAccount, address(this)); + function skim(uint256, address) external pure returns (uint256) { + _revertNotSupported(); + } - // send the remaining shares to the receiver - IERC4626(earnVault).transfer(rescueAccount, IERC4626(earnVault).balanceOf(address(this))); + function totalBorrows() external pure returns (uint256) { + return 0; + } - emit Rescued(address(earnVault), rescuedAmount); + function totalBorrowsExact() external pure returns (uint256) { + return 0; } - function _assertRescueMode() internal view { - IEulerEarn vault = IEulerEarn(earnVault); + function cash() external pure returns (uint256) { + return 0; + } - // 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"); + function debtOf(address) external pure returns (uint256) { + return 0; + } - // Must be first in withdraw queue (bank-run guard) - require(address(vault.withdrawQueue(0)) == address(this), "rescue: withdrawQueue[0] != rescue"); + function debtOfExact(address) external pure returns (uint256) { + return 0; + } + + function interestRate() external pure returns (uint256) { + return 0; + } + + function interestAccumulator() external pure returns (uint256) { + return 0; + } + + function dToken() external pure returns (address) { + return address(0); + } + + function borrow(uint256, address) external pure returns (uint256) { + _revertNotSupported(); } + + function repay(uint256, address) external pure returns (uint256) { + _revertNotSupported(); + } + + function repayWithShares(uint256, address) external pure returns (uint256, uint256) { + _revertNotSupported(); + } + + function pullDebt(uint256, address) external pure { + _revertNotSupported(); + } + + function flashLoan(uint256, bytes calldata) external pure { + _revertNotSupported(); + } + + function touch() external pure { + _revertNotSupported(); + } + + function checkLiquidation(address, address, address) external pure returns (uint256, uint256) { + return (0, 0); + } + + function liquidate(address, address, uint256, uint256) external pure { + _revertNotSupported(); + } + + function accountLiquidity(address, bool) external pure returns (uint256, uint256) { + return (0, 0); + } + + function accountLiquidityFull(address, bool) external pure returns (address[] memory c, uint256[] memory cv, uint256 lv) {} + + function disableController() external pure { + _revertNotSupported(); + } + + function checkAccountStatus(address, address[] calldata) external pure returns (bytes4) { + return bytes4(0); + } + + function checkVaultStatus() external pure returns (bytes4) { + return bytes4(0); + } + + function balanceTrackerAddress() external pure returns (address) { + return address(0); + } + + function balanceForwarderEnabled(address) external pure returns (bool) { + return false; + } + + function enableBalanceForwarder() external pure { + _revertNotSupported(); + } + + function disableBalanceForwarder() external pure { + _revertNotSupported(); + } + + function governorAdmin() external pure returns (address) { + return address(0); + } + + function feeReceiver() external pure returns (address) { + return address(0); + } + + function interestFee() external pure returns (uint16) { + return 0; + } + + function interestRateModel() external pure returns (address) { + return address(0); + } + + function protocolConfigAddress() external pure returns (address) { + return address(0); + } + + function protocolFeeShare() external pure returns (uint256) { + return 0; + } + + function protocolFeeReceiver() external pure returns (address) { + return address(0); + } + + function caps() external pure returns (uint16, uint16) { + return (0, 0); + } + + function LTVBorrow(address) external pure returns (uint16) { + return 0; + } + + function LTVLiquidation(address) external pure returns (uint16) { + return 0; + } + + function LTVFull(address collateral) external pure returns ( + uint16 borrowLTV, + uint16 liquidationLTV, + uint16 initialLiquidationLTV, + uint48 targetTimestamp, + uint32 rampDuration + ) {} + + function LTVList() external pure returns (address[] memory l) {} + + function maxLiquidationDiscount() external pure returns (uint16) { + return 0; + } + + function liquidationCoolOffTime() external pure returns (uint16) { + return 0; + } + + function hookConfig() external pure returns (address hookTarget, uint32 hookedOps) {} + + function configFlags() external pure returns (uint32) { + return 0; + } + + function EVC() external pure returns (address) { + return address(0); + } + + function unitOfAccount() external pure returns (address) { + return address(0); + } + + function oracle() external pure returns (address) { + return address(0); + } + + function permit2Address() external pure returns (address) { + return address(0); + } + + function convertFees() external pure { + _revertNotSupported(); + } + + function setGovernorAdmin(address) external pure { + _revertNotSupported(); + } + + function setFeeReceiver(address) external pure { + _revertNotSupported(); + } + + function setLTV(address, uint16, uint16, uint32) external pure { + _revertNotSupported(); + } + + function setMaxLiquidationDiscount(uint16) external pure { + _revertNotSupported(); + } + + function setLiquidationCoolOffTime(uint16) external pure { + _revertNotSupported(); + } + + function setInterestRateModel(address) external pure { + _revertNotSupported(); + } + + function setHookConfig(address, uint32) external pure { + _revertNotSupported(); + } + + function setConfigFlags(uint32) external pure { + _revertNotSupported(); + } + + function setCaps(uint16, uint16) external pure { + _revertNotSupported(); + } + + function setInterestFee(uint16) external pure { + _revertNotSupported(); + } + + function initialize(address) external pure { + _revertNotSupported(); + } + + function MODULE_INITIALIZE() external pure returns (address) { return address(0); } + function MODULE_TOKEN() external pure returns (address) { return address(0); } + function MODULE_VAULT() external pure returns (address) { return address(0); } + function MODULE_BORROWING() external pure returns (address) { return address(0); } + function MODULE_LIQUIDATION() external pure returns (address) { return address(0); } + function MODULE_RISKMANAGER() external pure returns (address) { return address(0); } + function MODULE_BALANCE_FORWARDER() external pure returns (address) { return address(0); } + function MODULE_GOVERNANCE() external pure returns (address) { return address(0); } } From 8021385dd48c7597e53ee7658e2f3c9dd86c325c Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 17:47:53 +0100 Subject: [PATCH 11/15] allow maxDeposit call on earn --- src/RescueStrategy.sol | 31 +++++++++++++++++-------------- test/RescueStrategyTest.sol | 25 ++++++++++++------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index ad6a174..2f77502 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -63,7 +63,20 @@ contract RescueStrategy is IEVault { } modifier onlyWhenRescueActive() { - require(rescueActive, "vault operations are paused"); + require(rescueActive, "unauthorized"); + _; + } + + modifier notEarnMutatingCall() { + if (!rescueActive && msg.sender == earnVault) { + // if reentrancy locked - earn is calling from `withdraw`, which should be prevented + // if unlocked - let it through because `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract + (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeCall(IEulerEarnBase.setFee, (0))); + require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic + + if (reason.length == 4 && bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) + revert("vault operations are paused"); + } _; } @@ -78,22 +91,12 @@ contract RescueStrategy is IEVault { // ---------------- RESCUE ENABLING BEHAVIOR -------------------- // will revert user deposits - function maxDeposit(address) external view returns (uint256) { - require(msg.sender != earnVault || rescueActive, "vault operations are paused - maxDeposit"); - return msg.sender == earnVault ? type(uint256).max : 0; + function maxDeposit(address) external view notEarnMutatingCall returns (uint256) { + return msg.sender == earnVault && rescueActive ? type(uint256).max : 0; } // will revert user withdrawals - function maxWithdraw(address) external view returns (uint256) { - if (!rescueActive && msg.sender == earnVault) { - // if reentrancy locked - earn is calling from `withdraw`, which should be prevented - // if unlocked - let it through because `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract - (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeCall(IEulerEarnBase.setFee, (0))); - require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic - - if (reason.length == 4 && bytes4(reason) == ReentrancyGuard.ReentrancyGuardReentrantCall.selector) - revert("vault operations are paused - maxWithdraw"); - } + function maxWithdraw(address) external view notEarnMutatingCall returns (uint256) { return 0; } diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index bcdf944..eb3ecdd 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -99,13 +99,13 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.startPrank(user); - vm.expectRevert("vault operations are paused - maxDeposit"); + vm.expectRevert("vault operations are paused"); vault.deposit(10, user); - vm.expectRevert("vault operations are paused - maxDeposit"); + vm.expectRevert("vault operations are paused"); vault.mint(10, user); - vm.expectRevert("vault operations are paused - maxWithdraw"); + vm.expectRevert("vault operations are paused"); vault.withdraw(0, user, user); - vm.expectRevert("vault operations are paused - maxWithdraw"); + vm.expectRevert("vault operations are paused"); vault.redeem(0, user, user); assertEq(vault.maxWithdrawFromStrategy(IERC4626(address(rescueStrategy))), 0); @@ -213,13 +213,13 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.prank(user); - vm.expectRevert("vault operations are paused - maxWithdraw"); + 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 - maxWithdraw"); + vm.expectRevert("vault operations are paused"); vault.withdraw(1e6, rescueAccount, rescueAccount); } @@ -242,7 +242,7 @@ contract RescuePOC is Test { _installRescueStrategy(); vm.startPrank(user); - vm.expectRevert("vault operations are paused - maxDeposit"); + vm.expectRevert("vault operations are paused"); vault.deposit(10, user); vm.startPrank(vault.curator()); @@ -292,13 +292,13 @@ contract RescuePOC is Test { function testRescue_flashloanCallbacks() external { _installRescueStrategy(); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("unauthorized"); rescueStrategy.onBatchLoan(1, 1); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("unauthorized"); rescueStrategy.onFlashLoan(""); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("unauthorized"); rescueStrategy.onMorphoFlashLoan(1, ""); - vm.expectRevert("vault operations are paused"); + vm.expectRevert("unauthorized"); rescueStrategy.executeOperation(address(1), 1, 1, address(1), ""); } @@ -330,8 +330,7 @@ contract RescuePOC is Test { assertEq(rescueStrategy.maxDeposit(user), 0); vm.startPrank(address(vault)); - vm.expectRevert("vault operations are paused - maxDeposit"); - rescueStrategy.maxDeposit(user); + assertEq(rescueStrategy.maxDeposit(user), 0); } function testRescue_deposit() external { From ac78df882decbd2a866b19b054024a278837c04e Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 17:49:13 +0100 Subject: [PATCH 12/15] update comment --- src/RescueStrategy.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 2f77502..30ffd9a 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -69,8 +69,8 @@ contract RescueStrategy is IEVault { modifier notEarnMutatingCall() { if (!rescueActive && msg.sender == earnVault) { - // if reentrancy locked - earn is calling from `withdraw`, which should be prevented - // if unlocked - let it through because `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract + // if reentrancy locked - earn is calling from `withdraw` or `deposit`, which should be prevented + // if unlocked - let it through because e.g. `maxWithdrawFromStrategy` is called, which is relied upon by the Lens contract (bool success, bytes memory reason) = earnVault.staticcall(abi.encodeCall(IEulerEarnBase.setFee, (0))); require(!success, "expected revert"); // if reentrancy was unlocked, attempt to set it will panic From 012e968ef88661301b8e2a2fec896ab6a970c460 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Fri, 21 Nov 2025 17:53:19 +0100 Subject: [PATCH 13/15] add evault lens test --- test/RescueStrategyTest.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index eb3ecdd..5c2101a 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -22,6 +22,7 @@ contract RescuePOC is Test { address constant FLASH_LOAN_SOURCE_EULER = 0x797DD80692c3b2dAdabCe8e30C07fDE5307D48a9; // Euler Prime - also a strategy in earn address constant FLASH_LOAN_SOURCE_AAVE = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; address constant EARN_LENS = 0xA09144BeAe23D8e7836Aeb0Fe17DD2647241A8bE; + address constant EVAULT_LENS = 0xc3c45633E45041BF3BE841f89d2cb51E2F657403; uint256 constant BLOCK_NUMBER = 23753054; IEulerEarn vault; @@ -313,6 +314,10 @@ contract RescuePOC is Test { // lens used in the indexer by setting the `code` in eth_call EulerEarnVaultInfoFull memory lensData = indexerLens.getVaultInfoFull(address(vault)); assertEq(lensData.vault, address(vault)); + + // evault lens + (success, data) = EVAULT_LENS.call(abi.encodeWithSignature("getVaultInfoFull(address)", address(rescueStrategy))); + assertTrue(success && data.length > 0); } function testRescue_maxWithdrawView() external { From 79ee5dd1da78dbe297cd18204cb4099be6baac3b Mon Sep 17 00:00:00 2001 From: dglowinski Date: Mon, 24 Nov 2025 12:49:13 +0100 Subject: [PATCH 14/15] audit fixes --- src/RescueStrategy.sol | 7 ++++--- test/RescueStrategyTest.sol | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/RescueStrategy.sol b/src/RescueStrategy.sol index 30ffd9a..32b18c5 100644 --- a/src/RescueStrategy.sol +++ b/src/RescueStrategy.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.26; import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol"; import {IERC20Metadata} from "openzeppelin-contracts/interfaces/IERC20Metadata.sol"; import {IERC4626} from "openzeppelin-contracts/interfaces/IERC4626.sol"; -import {IEVault} from "../lib/euler-vault-kit/src/EVault/IEVault.sol"; import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector.sol"; import {IEulerEarn, IEulerEarnBase} from "./interfaces/IEulerEarn.sol"; @@ -12,7 +11,7 @@ import {IEulerEarnFactory} from "./interfaces/IEulerEarnFactory.sol"; import {SafeERC20Permit2Lib} from "./libraries/SafeERC20Permit2Lib.sol"; import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "openzeppelin-contracts/utils/ReentrancyGuard.sol"; -import {IBorrowing, IRiskManager} from "../lib/euler-vault-kit/src/EVault/IEVault.sol"; +import {IEVault, IBorrowing, IRiskManager} from "../lib/euler-vault-kit/src/EVault/IEVault.sol"; /* Rescue procedure: @@ -45,6 +44,7 @@ interface IFlashLoan { contract RescueStrategy is IEVault { address public immutable rescueAccount; address public immutable earnVault; + address public immutable earnFactory; IERC20 internal immutable _asset; bool internal rescueActive; @@ -86,6 +86,7 @@ contract RescueStrategy is IEVault { rescueAccount = _rescueAccount; earnVault = _earnVault; _asset = IERC20(IEulerEarn(earnVault).asset()); + earnFactory = IEulerEarn(_earnVault).creator(); } // ---------------- RESCUE ENABLING BEHAVIOR -------------------- @@ -102,7 +103,7 @@ contract RescueStrategy is IEVault { // this reverts acceptCaps to prevent reusing the whitelisted strategy on other vaults function balanceOf(address) external view returns (uint256) { - require(!IEulerEarnFactory(IEulerEarn(earnVault).creator()).isVault(msg.sender) || msg.sender == earnVault, "wrong vault"); + require(!IEulerEarnFactory(earnFactory).isVault(msg.sender) || msg.sender == earnVault, "wrong vault"); return 0; } diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index 5c2101a..f704f62 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -112,9 +112,17 @@ contract RescuePOC is Test { assertEq(vault.maxWithdrawFromStrategy(IERC4626(address(rescueStrategy))), 0); } + mapping (address => uint256) strategyCaps; function testRescue_rescueEulerBatch() public { _installRescueStrategy(); + uint256 withdrawQueueLength = vault.withdrawQueueLength(); + + for (uint256 i = 0; i < withdrawQueueLength; i++) { + address strategy = address(vault.withdrawQueue(i)); + strategyCaps[strategy] = vault.config(vault.withdrawQueue(i)).cap; + } + uint256 amount = 100_000e6; uint256 loops = 1; uint256 snapshot = vm.snapshotState(); @@ -140,6 +148,15 @@ contract RescuePOC is Test { rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); assertEq(IERC20(vault.asset()).balanceOf(rescueAccount), rescueOneLoop * 2); + + // caps are unchanged on other strategies + + for (uint256 i = 0; i < withdrawQueueLength; i++) { + address strategy = address(vault.withdrawQueue(i)); + if (strategy != address(rescueStrategy)) { + assertEq(strategyCaps[strategy], vault.config(vault.withdrawQueue(i)).cap); + } + } } function testRescue_rescueMorpho() public { @@ -242,10 +259,25 @@ contract RescuePOC is Test { function testRescue_uninstall() public { _installRescueStrategy(); + // exchange rate should remain constant (besides rounding) + uint256 sharePriceBefore = vault.convertToAssets(1e18); + uint256 lostAssetsBefore = vault.lostAssets(); + vm.startPrank(user); vm.expectRevert("vault operations are paused"); vault.deposit(10, user); + // rescue + uint256 amount = vault.previewMint(vault.totalSupply()) * 10001 / 10000 ; + uint256 loops = 1; + + vm.startPrank(rescueAccount); + rescueStrategy.rescueMorpho(amount, loops, FLASH_LOAN_SOURCE_MORPHO); + + assertApproxEqAbs(sharePriceBefore, vault.convertToAssets(1e18), 1e5); + // all the deposited amount is counted as lost + assertEq(vault.lostAssets(), lostAssetsBefore + amount); + vm.startPrank(vault.curator()); IERC4626 id = IERC4626(address(rescueStrategy)); @@ -267,6 +299,8 @@ contract RescuePOC is Test { // the vault is functional + assertApproxEqAbs(sharePriceBefore, vault.convertToAssets(1e18), 1e5); + vm.startPrank(user); vault.deposit(10, user); uint256 balance = vault.balanceOf(user); @@ -277,6 +311,8 @@ contract RescuePOC is Test { assertEq(vault.balanceOf(user), balance); vault.withdraw(vault.maxWithdraw(user), user, user); assertEq(vault.balanceOf(user), 0); + + assertApproxEqAbs(sharePriceBefore, vault.convertToAssets(1e18), 1e6); } function testRescue_onlyRescueAccountCallFunc() external { From a2af356bdfaa642bbcd4dc3fe1a968240d619ffb Mon Sep 17 00:00:00 2001 From: dglowinski Date: Tue, 25 Nov 2025 20:56:57 +0100 Subject: [PATCH 15/15] add rescueEuler test --- test/RescueStrategyTest.sol | 45 +++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/test/RescueStrategyTest.sol b/test/RescueStrategyTest.sol index f704f62..2642071 100644 --- a/test/RescueStrategyTest.sol +++ b/test/RescueStrategyTest.sol @@ -19,7 +19,8 @@ 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_EULER = 0x9bD52F2805c6aF014132874124686e7b248c2Cbb; + address constant FLASH_LOAN_SOURCE_EULER_BATCH = 0x797DD80692c3b2dAdabCe8e30C07fDE5307D48a9; // Euler Prime - also a strategy in earn address constant FLASH_LOAN_SOURCE_AAVE = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; address constant EARN_LENS = 0xA09144BeAe23D8e7836Aeb0Fe17DD2647241A8bE; address constant EVAULT_LENS = 0xc3c45633E45041BF3BE841f89d2cb51E2F657403; @@ -67,7 +68,7 @@ contract RescuePOC is Test { vm.prank(rescueAccount); vm.expectRevert("rescue: supplyQueue len != 1"); - rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER_BATCH); IERC4626[] memory supplyQueue = new IERC4626[](1); supplyQueue[0] = vault.supplyQueue(0); @@ -77,7 +78,7 @@ contract RescuePOC is Test { vm.prank(rescueAccount); vm.expectRevert("rescue: supplyQueue[0] != rescue"); - rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER_BATCH); vm.prank(vault.curator()); vault.submitCap(id, type(uint184).max); @@ -93,7 +94,7 @@ contract RescuePOC is Test { vm.prank(rescueAccount); vm.expectRevert("rescue: withdrawQueue[0] != rescue"); - rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(1, 1, FLASH_LOAN_SOURCE_EULER_BATCH); } function testRescue_pauseForUsers() public { @@ -129,12 +130,12 @@ contract RescuePOC is Test { // only rescue account vm.prank(user); vm.expectRevert("unauthorized"); - rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER_BATCH); vm.startPrank(rescueAccount); vm.expectEmit(true, true, false, false); emit RescueStrategy.Rescued(address(vault), 0); - rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER_BATCH); assertGt(IERC20(vault.asset()).balanceOf(rescueAccount), 0); assertEq(IEVC(vault.EVC()).getControllers(address(rescueStrategy)).length, 0); @@ -146,7 +147,7 @@ contract RescuePOC is Test { vm.revertToState(snapshot); loops = 2; - rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER); + rescueStrategy.rescueEulerBatch(amount, loops, FLASH_LOAN_SOURCE_EULER_BATCH); assertEq(IERC20(vault.asset()).balanceOf(rescueAccount), rescueOneLoop * 2); // caps are unchanged on other strategies @@ -159,6 +160,36 @@ contract RescuePOC is Test { } } + function testRescue_rescueEuler() public { + _installRescueStrategy(); + + uint256 amount = 100_000e6; + uint256 loops = 1; + uint256 snapshot = vm.snapshotState(); + // only rescue account + vm.prank(user); + vm.expectRevert("unauthorized"); + rescueStrategy.rescueEuler(amount, loops, FLASH_LOAN_SOURCE_EULER); + + vm.startPrank(rescueAccount); + vm.expectEmit(true, true, false, false); + emit RescueStrategy.Rescued(address(vault), 0); + rescueStrategy.rescueEuler(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.rescueEuler(amount, loops, FLASH_LOAN_SOURCE_EULER); + assertEq(IERC20(vault.asset()).balanceOf(rescueAccount), rescueOneLoop * 2); + } + function testRescue_rescueMorpho() public { _installRescueStrategy();