diff --git a/src/utils/VaultRouter.sol b/src/utils/VaultRouter.sol index 61f1cf2e..d7dd413c 100644 --- a/src/utils/VaultRouter.sol +++ b/src/utils/VaultRouter.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.25; import {IERC4626, IERC20} from "openzeppelin-contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; import {ICurveGauge} from "src/interfaces/external/curve/ICurveGauge.sol"; +import {IERC7540Redeem} from "ERC-7540/interfaces/IERC7540.sol"; /** * @title VaultRouter @@ -97,7 +98,7 @@ contract VaultRouter { error ArrayMismatch(); // Vault Receiver Amount mapping(address => mapping(address => uint256)) public requestShares; - // Asset Receiver Amount + // Vault Receiver Amount mapping(address => mapping(address => uint256)) public claimableAssets; function unstakeAndRequestWithdrawal( @@ -119,6 +120,7 @@ contract VaultRouter { uint256 shares ) external { IERC20(vault).safeTransferFrom(msg.sender, address(this), shares); + _requestWithdrawal(vault, receiver, shares); } @@ -128,6 +130,12 @@ contract VaultRouter { uint256 shares ) internal { requestShares[vault][receiver] += shares; + + // allow vault to pull shares + IERC20(vault).safeIncreaseAllowance(vault, shares); + + // request redeem - send shares to vault + IERC7540Redeem(vault).requestRedeem(shares, receiver, address(this)); emit WithdrawalRequested( vault, @@ -138,15 +146,18 @@ contract VaultRouter { ); } - function claimWithdrawal(address asset, address receiver) external { - uint256 amount = claimableAssets[asset][receiver]; - claimableAssets[asset][receiver] = 0; + // anyone can claim for a receiver + function claimWithdrawal(address vault, address receiver) external { + uint256 amount = claimableAssets[vault][receiver]; + claimableAssets[vault][receiver] = 0; - IERC20(asset).safeTransfer(receiver, amount); + // claim asset with receiver shares + IERC4626(vault).withdraw(amount, receiver, receiver); - emit WithdrawalClaimed(asset, receiver, amount); + emit WithdrawalClaimed(vault, receiver, amount); } + // anyone can fullfil a withdrawal for a receiver function fullfillWithdrawal( address vault, address receiver, @@ -180,22 +191,29 @@ contract VaultRouter { ) internal { requestShares[vault][receiver] -= shares; - uint256 assetAmount = IERC4626(vault).redeem( - shares, - address(this), - address(this) - ); + // fulfill redeem of pending shares for receiver + uint256 assetAmount = IERC7540Redeem(vault).fulfillRedeem(shares, receiver); - claimableAssets[address(asset)][receiver] += assetAmount; + // assets are claimable now + claimableAssets[vault][receiver] += assetAmount; emit WithdrawalFullfilled(vault, address(asset), receiver, shares); } - function cancelRequest(address vault, uint256 shares) external { - requestShares[vault][msg.sender] -= shares; + error ZeroRequestShares(); + + // only receiver is able to cancel a request + function cancelRequest(address vault) external { + uint256 sharesCancelled = requestShares[vault][msg.sender]; + + if(sharesCancelled == 0) + revert ZeroRequestShares(); + + requestShares[vault][msg.sender] = 0; - IERC20(vault).safeTransfer(msg.sender, shares); + // cancel request and receive pending shares back + IERC7540Redeem(vault).cancelRedeemRequest(msg.sender); - emit WithdrawalCancelled(vault, msg.sender, shares); + emit WithdrawalCancelled(vault, msg.sender, sharesCancelled); } } diff --git a/test/mocks/MockERC7540.sol b/test/mocks/MockERC7540.sol new file mode 100644 index 00000000..bd9853fe --- /dev/null +++ b/test/mocks/MockERC7540.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0 +// Docgen-SOLC: 0.8.15 +pragma solidity ^0.8.15; + +import {IERC4626, IERC20, IERC20Metadata} from "openzeppelin-contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {ERC4626Upgradeable, ERC20Upgradeable as ERC20} from "openzeppelin-contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "openzeppelin-contracts/utils/math/Math.sol"; +import {Pausable} from "src/utils/Pausable.sol"; +import {IERC7540Redeem} from "ERC-7540/interfaces/IERC7540.sol"; + +contract MockERC7540 is ERC4626Upgradeable, IERC7540Redeem, Pausable { + using SafeERC20 for IERC20; + using Math for uint256; + + uint256 public beforeWithdrawHookCalledCounter = 0; + uint256 public afterDepositHookCalledCounter = 0; + + uint8 internal _decimals; + uint8 public constant decimalOffset = 0; + + /*////////////////////////////////////////////////////////////// + IMMUTABLES + //////////////////////////////////////////////////////////////*/ + + function initialize( + IERC20 _asset, + string memory, + string memory + ) external initializer { + __ERC4626_init(IERC20Metadata(address(_asset))); + _decimals = IERC20Metadata(address(_asset)).decimals() + decimalOffset; + } + + /*////////////////////////////////////////////////////////////// + GENERAL VIEWS + //////////////////////////////////////////////////////////////*/ + + function decimals() public view override returns (uint8) { + return _decimals; + } + + /*////////////////////////////////////////////////////////////// + ACCOUNTING LOGIC + //////////////////////////////////////////////////////////////*/ + + function _convertToShares( + uint256 assets, + Math.Rounding rounding + ) internal view override returns (uint256 shares) { + return + assets.mulDiv( + totalSupply() + 10 ** decimalOffset, + totalAssets() + 1, + rounding + ); + } + + function _convertToAssets( + uint256 shares, + Math.Rounding rounding + ) internal view override returns (uint256) { + return + shares.mulDiv( + totalAssets() + 1, + totalSupply() + 10 ** decimalOffset, + rounding + ); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HOOKS LOGIC + //////////////////////////////////////////////////////////////*/ + + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal override { + IERC20(asset()).safeTransferFrom(caller, address(this), assets); + _mint(receiver, shares); + + afterDepositHookCalledCounter++; + + emit Deposit(caller, receiver, assets, shares); + } + + function withdraw( + uint256 assets, + address receiver, + address owner + ) public override returns (uint256 shares) { + shares = convertToShares(assets); + + _burn(address(this), shares); + + IERC20(asset()).safeTransfer(receiver, assets); + + emit Withdraw(msg.sender, receiver, owner, assets, shares); + } + + /*////////////////////////////////////////////////////////////// + ASYNC WITHDRAW MOCK LOGIC + //////////////////////////////////////////////////////////////*/ + + mapping (address => uint256) public requestedShares; + mapping (address => uint256) public claimableAssets; + + function requestRedeem( + uint256 shares, + address controller, + address owner + ) external returns (uint256 requestId) { + requestedShares[controller] += shares; + + // Transfer shares from owner to vault (these will be burned on withdrawal) + IERC20(address(this)).safeTransferFrom(owner, address(this), shares); + } + + function fulfillRedeem( + uint256 shares, + address controller + ) external returns (uint256) { + uint256 assets = convertToAssets(shares); + claimableAssets[controller] = assets; + + return assets; + } + + function cancelRedeemRequest(address controller) external { + uint256 shares = requestedShares[controller]; + requestedShares[controller] = 0; + + // Transfer the pending shares back to the receiver + IERC20(address(this)).safeTransfer(controller, shares); + } + + function pendingRedeemRequest(uint256 requestId, address controller) + external + view + returns (uint256 pendingShares) { + pendingShares = requestedShares[controller]; + } + + function claimableRedeemRequest(uint256 requestId, address controller) + external + view + returns (uint256 claimableShares) { + claimableShares = convertToShares(claimableAssets[controller]); + } + + /*////////////////////////////////////////////////////////////// + PAUSABLE LOGIC + //////////////////////////////////////////////////////////////*/ + + function pause() public override { + _pause(); + } + + function unpause() public override { + _unpause(); + } +} \ No newline at end of file diff --git a/test/utils/VaultRouter.t.sol b/test/utils/VaultRouter.t.sol index aa3ba80e..f949fcb1 100644 --- a/test/utils/VaultRouter.t.sol +++ b/test/utils/VaultRouter.t.sol @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {VaultRouter} from "src/utils/VaultRouter.sol"; import {MockERC20, ERC20} from "../mocks/MockERC20.sol"; import {MockERC4626} from "../mocks/MockERC4626.sol"; +import {MockERC7540} from "../mocks/MockERC7540.sol"; import {MockGauge} from "../mocks/MockGauge.sol"; contract MaliciousGauge is ERC20 { @@ -22,14 +23,14 @@ contract MaliciousGauge is ERC20 { contract VaultRouterTest is Test { VaultRouter public router; MockERC20 public asset; - MockERC4626 public vault; + MockERC7540 public vault; MockGauge public gauge; address public user = address(0x1); function setUp() public { router = new VaultRouter(); asset = new MockERC20("Test Asset", "TAST", 18); - vault = new MockERC4626(); + vault = new MockERC7540(); vault.initialize(asset, "Test Vault", "vTAST"); gauge = new MockGauge(address(vault)); @@ -148,7 +149,6 @@ contract VaultRouterTest is Test { vm.startPrank(user); asset.approve(address(vault), amount); vault.deposit(amount, user); - vault.approve(address(router), amount); router.requestWithdrawal(address(vault), user, amount); vm.stopPrank(); @@ -156,6 +156,21 @@ contract VaultRouterTest is Test { assertEq(router.requestShares(address(vault), user), amount); } + function test__requestWithdrawal_different_receiver() public { + uint256 amount = 100e18; + address receiver = address(0x1234); + + vm.startPrank(user); + asset.approve(address(vault), amount); + vault.deposit(amount, user); + vault.approve(address(router), amount); + router.requestWithdrawal(address(vault), receiver, amount); + vm.stopPrank(); + + assertEq(router.requestShares(address(vault), receiver), amount); + assertEq(router.requestShares(address(vault), user), 0); + } + function test__unstakeAndRequestWithdrawal() public { // First, deposit and stake test__depositAndStake(); @@ -185,12 +200,12 @@ contract VaultRouterTest is Test { test__requestWithdrawal(); uint256 amount = 100e18; - + router.fullfillWithdrawal(address(vault), user, amount); assertEq(router.requestShares(address(vault), user), 0); - assertEq(asset.balanceOf(address(router)), 100e18); - assertEq(router.claimableAssets(address(asset), user), 100e18); + // assertEq(asset.balanceOf(address(router)), 100e18, "lil"); + assertEq(router.claimableAssets(address(vault), user), 100e18); } function test__fullfillWithdrawals_MultipleReceivers() public { @@ -245,11 +260,11 @@ contract VaultRouterTest is Test { assertEq(router.requestShares(address(vault), user2), 0); assertEq(router.requestShares(address(vault), user3), 0); - assertEq(asset.balanceOf(address(router)), 450e18); + assertEq(asset.balanceOf(address(vault)), 450e18); - assertEq(router.claimableAssets(address(asset), user), 100e18); - assertEq(router.claimableAssets(address(asset), user2), 150e18); - assertEq(router.claimableAssets(address(asset), user3), 200e18); + assertEq(router.claimableAssets(address(vault), user), 100e18); + assertEq(router.claimableAssets(address(vault), user2), 150e18); + assertEq(router.claimableAssets(address(vault), user3), 200e18); } function test__fullfillWithdrawals_array_mismatch() public { @@ -264,19 +279,43 @@ contract VaultRouterTest is Test { router.fullfillWithdrawals(address(vault), receivers, amounts); } - /*////////////////////////////////////////////////////////////// - CLAIM WITHDRAWAL - //////////////////////////////////////////////////////////////*/ + // /*////////////////////////////////////////////////////////////// + // CLAIM WITHDRAWAL + // //////////////////////////////////////////////////////////////*/ + function test__claimWithdrawal_different_receiver() public { + test__requestWithdrawal_different_receiver(); + + address receiver = address(0x1234); + uint256 amount = 100e18; + + // fulfill + router.fullfillWithdrawal(address(vault), receiver, amount); + + assertEq(router.claimableAssets(address(vault), user), 0); + assertEq(router.claimableAssets(address(vault), receiver), amount); + + // claim + vm.prank(user); + router.claimWithdrawal(address(vault), receiver); + + assertEq(asset.balanceOf(receiver), amount); + assertEq(asset.balanceOf(user), 0); + + assertEq(router.claimableAssets(address(vault), receiver), 0); + assertEq(router.claimableAssets(address(vault), user), 0); + + assertEq(asset.balanceOf(address(router)), 0); + } function test__claimWithdrawal() public { // First, request and fulfill withdrawal test__fullfillWithdrawal(); vm.prank(user); - router.claimWithdrawal(address(asset), user); + router.claimWithdrawal(address(vault), user); assertEq(asset.balanceOf(user), 100e18); - assertEq(router.claimableAssets(address(asset), user), 0); + assertEq(router.claimableAssets(address(vault), user), 0); assertEq(asset.balanceOf(address(router)), 0); } @@ -284,37 +323,59 @@ contract VaultRouterTest is Test { // First, request and fulfill withdrawal test__fullfillWithdrawal(); - router.claimWithdrawal(address(asset), user); + router.claimWithdrawal(address(vault), user); assertEq(asset.balanceOf(user), 100e18); - assertEq(router.claimableAssets(address(asset), user), 0); + assertEq(router.claimableAssets(address(vault), user), 0); assertEq(asset.balanceOf(address(router)), 0); } - /*////////////////////////////////////////////////////////////// - CANCEL REQUEST - //////////////////////////////////////////////////////////////*/ + // /*////////////////////////////////////////////////////////////// + // CANCEL REQUEST + // //////////////////////////////////////////////////////////////*/ function test__cancelRequest() public { // First, request withdrawal test__requestWithdrawal(); - - uint256 amount = 100e18; + assertEq(ERC20(address(vault)).balanceOf(user), 0); vm.prank(user); - router.cancelRequest(address(vault), amount); + router.cancelRequest(address(vault)); + assertEq(ERC20(address(vault)).balanceOf(user), 100e18); assertEq(router.requestShares(address(vault), user), 0); } - function test__cancelRequest_insufficient_shares() public { - test__requestWithdrawal(); + function test__cancelRequest_fail() public { + // First, request withdrawal + test__requestWithdrawal_different_receiver(); - uint256 cancelAmount = 1000e18; + vm.startPrank(user); + vm.expectRevert(); // Zero request shares + router.cancelRequest(address(vault)); + vm.stopPrank(); + } + + function test__cancelRequest_different_receiver() public { + // First, request withdrawal + test__requestWithdrawal_different_receiver(); + + address receiver = address(0x1234); + + assertEq(ERC20(address(vault)).balanceOf(receiver), 0); + vm.prank(receiver); + router.cancelRequest(address(vault)); + + assertEq(ERC20(address(vault)).balanceOf(receiver), 100e18); + assertEq(router.requestShares(address(vault), receiver), 0); + } + + + function test__cancelRequest_insufficient_shares() public { vm.startPrank(user); vm.expectRevert(); // panic: underflow - router.cancelRequest(address(vault), cancelAmount); + router.cancelRequest(address(vault)); vm.stopPrank(); } }