diff --git a/src/L1YearnEscrow.sol b/src/L1YearnEscrow.sol index 8455952..39b923c 100644 --- a/src/L1YearnEscrow.sol +++ b/src/L1YearnEscrow.sol @@ -114,7 +114,12 @@ contract L1YearnEscrow is L1Escrow { function _receiveTokens( uint256 amount ) internal virtual override whenNotPaused { - super._receiveTokens(amount); + originTokenAddress().safeTransferFrom( + msg.sender, + address(this), + amount + ); + VaultStorage storage $ = _getVaultStorage(); uint256 _minimumBuffer = $.minimumBuffer; // Deposit to the vault if above buffer @@ -122,9 +127,8 @@ contract L1YearnEscrow is L1Escrow { uint256 underlyingBalance = originTokenAddress().balanceOf( address(this) ); - if (underlyingBalance <= _minimumBuffer) { - return; - } + + if (underlyingBalance <= _minimumBuffer) return; unchecked { amount = underlyingBalance - _minimumBuffer; @@ -135,7 +139,8 @@ contract L1YearnEscrow is L1Escrow { } /** - * @dev Handle the transfer of the tokens + * @dev Handle the transfer of the tokens. Will send shares instead of + * the underlying asset if the vault is illiquid. * @param destinationAddress Address destination that will receive the tokens on the other network * @param amount Token amount */ @@ -143,29 +148,41 @@ contract L1YearnEscrow is L1Escrow { address destinationAddress, uint256 amount ) internal virtual override whenNotPaused { - VaultStorage storage $ = _getVaultStorage(); - - // Check if there is enough loose balance. - uint256 underlyingBalance = originTokenAddress().balanceOf( - address(this) - ); - if (underlyingBalance != 0) { - if (underlyingBalance >= amount) { - super._transferTokens(destinationAddress, amount); - return; - } + IERC20 originToken = originTokenAddress(); + + // Check if there is enough buffer. + uint256 underlyingBalance = originToken.balanceOf(address(this)); + if (underlyingBalance >= amount) { + // Only use buffer if it covers the full amount. + originToken.safeTransfer(destinationAddress, amount); + return; + } - uint256 maxWithdraw = $.vaultAddress.maxWithdraw(address(this)); - if (maxWithdraw < amount) { - super._transferTokens(destinationAddress, underlyingBalance); + // Check if the vault will allow for a full withdraw. + IVault _vault = _getVaultStorage().vaultAddress; + uint256 maxWithdraw = _vault.maxWithdraw(address(this)); + // If liquidity will not allow for a full withdraw. + if (amount > maxWithdraw) { + // First use any loose balance. + if (underlyingBalance != 0) { + originToken.safeTransfer(destinationAddress, underlyingBalance); unchecked { amount = amount - underlyingBalance; } } + + // Check again to account for if there was underlying + if (amount > maxWithdraw) { + // Send an equivalent amount of shares for the difference. + uint256 shares = _vault.convertToShares(amount - maxWithdraw); + _vault.transfer(destinationAddress, shares); + if (maxWithdraw == 0) return; + amount = maxWithdraw; + } } // Withdraw from vault to receiver. - $.vaultAddress.withdraw(amount, destinationAddress, address(this)); + _vault.withdraw(amount, destinationAddress, address(this)); } // **************************** @@ -175,15 +192,17 @@ contract L1YearnEscrow is L1Escrow { /** * @dev Escrow manager can withdraw the token backing * @param _recipient the recipient address - * @param _amount The amount of token + * @param _amount The amount of token in underlying */ function withdraw( address _recipient, uint256 _amount ) external virtual override onlyRole(ESCROW_MANAGER_ROLE) whenNotPaused { - VaultStorage storage $ = _getVaultStorage(); - uint256 shares = $.vaultAddress.convertToShares(_amount); - $.vaultAddress.transfer(_recipient, shares); + IVault _vault = _getVaultStorage().vaultAddress; + // Transfer the equivalent amount of vault shares + uint256 shares = _vault.convertToShares(_amount); + _vault.transfer(_recipient, shares); + emit Withdraw(_recipient, _amount); } @@ -201,10 +220,12 @@ contract L1YearnEscrow is L1Escrow { ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { VaultStorage storage $ = _getVaultStorage(); IVault oldVault = $.vaultAddress; + IERC20 originToken = originTokenAddress(); + // If re-initializing to a new vault address. if (address(oldVault) != address(0)) { // Lower allowance to 0 - originTokenAddress().forceApprove(address(oldVault), 0); + originToken.forceApprove(address(oldVault), 0); uint256 balance = oldVault.balanceOf(address(this)); // Withdraw the full balance of the current vault. @@ -216,10 +237,10 @@ contract L1YearnEscrow is L1Escrow { // Migrate to new vault if applicable if (_vaultAddress != address(0)) { // Max approve the new vault - originTokenAddress().forceApprove(_vaultAddress, 2 ** 256 - 1); + originToken.forceApprove(_vaultAddress, 2 ** 256 - 1); // Deposit any loose funds - uint256 balance = originTokenAddress().balanceOf(address(this)); + uint256 balance = originToken.balanceOf(address(this)); if (balance != 0) IVault(_vaultAddress).deposit(balance, address(this)); } diff --git a/test/L1Escrow.t.sol b/test/L1Escrow.t.sol index b9a503e..08599a4 100644 --- a/test/L1Escrow.t.sol +++ b/test/L1Escrow.t.sol @@ -288,6 +288,91 @@ contract EscrowTest is Setup { assertEq(vault.balanceOf(address(mockEscrow)), 0); } + function test_illiquidWithdraw(uint256 _amount) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + mockEscrow = deployMockL1Escrow(); + + // Simulate a bridge txn + mintAndBridge(mockEscrow, user, _amount); + + assertEq(vault.totalAssets(), _amount); + assertEq(asset.balanceOf(user), 0); + assertEq(asset.balanceOf(address(mockEscrow)), 0); + assertEq(vault.balanceOf(address(mockEscrow)), _amount); + + // send funds to a strategy + uint256 toLock = _amount / 2; + addStrategyAndDebt(vault, setUpStrategy(), toLock); + // And remove from queue + address[] memory queue = new address[](0); + vm.prank(governator); + vault.set_default_queue(queue); + + assertEq(vault.maxWithdraw(address(mockEscrow)), _amount - toLock); + + // Withdraw everything + bytes memory data = abi.encode(user, _amount); + vm.prank(address(polygonZkEVMBridge)); + mockEscrow.onMessageReceived(address(l2EscrowImpl), l2RollupID, data); + + // Should have sent the liquid balance and the rest in shares + assertEq(vault.totalAssets(), toLock); + assertEq(asset.balanceOf(user), _amount - toLock); + assertEq(vault.balanceOf(user), toLock); + assertEq(asset.balanceOf(address(mockEscrow)), 0); + assertEq(vault.balanceOf(address(mockEscrow)), 0); + } + + function test_illiquidWithdraw_withBuffer( + uint256 _amount, + uint256 _minimumBuffer + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + _minimumBuffer = bound(_minimumBuffer, 10, _amount / 2); + + mockEscrow = deployMockL1Escrow(); + + vm.prank(governator); + mockEscrow.updateMinimumBuffer(_minimumBuffer); + + // Simulate a bridge txn + mintAndBridge(mockEscrow, user, _amount); + + assertEq(vault.totalAssets(), _amount - _minimumBuffer); + assertEq(asset.balanceOf(user), 0); + assertEq(asset.balanceOf(address(mockEscrow)), _minimumBuffer); + assertEq( + vault.balanceOf(address(mockEscrow)), + _amount - _minimumBuffer + ); + + // send funds to a strategy + uint256 toLock = _amount / 2; + addStrategyAndDebt(vault, setUpStrategy(), toLock); + // And remove from queue + address[] memory queue = new address[](0); + vm.prank(governator); + vault.set_default_queue(queue); + + assertEq( + vault.maxWithdraw(address(mockEscrow)), + _amount - _minimumBuffer - toLock + ); + + // Withdraw everything + bytes memory data = abi.encode(user, _amount); + vm.prank(address(polygonZkEVMBridge)); + mockEscrow.onMessageReceived(address(l2EscrowImpl), l2RollupID, data); + + // Should have sent the liquid balance and the rest in shares + assertEq(vault.totalAssets(), toLock); + assertEq(asset.balanceOf(user), _amount - toLock); + assertEq(vault.balanceOf(user), toLock); + assertEq(asset.balanceOf(address(mockEscrow)), 0); + assertEq(vault.balanceOf(address(mockEscrow)), 0); + } + event BridgeEvent( uint8 leafType, uint32 originNetwork, diff --git a/test/mocks/MockStrategy.sol b/test/mocks/MockStrategy.sol deleted file mode 100644 index 3b569ba..0000000 --- a/test/mocks/MockStrategy.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity >=0.8.18; - -import {ERC4626Mock} from "@openzeppelin/contracts/mocks/token/ERC4626Mock.sol"; - -contract MockStrategy is ERC4626Mock { - constructor(address _asset) ERC4626Mock(_asset) {} -} diff --git a/test/mocks/MockTokenizedStrategy.sol b/test/mocks/MockTokenizedStrategy.sol index 6576e99..dd32754 100644 --- a/test/mocks/MockTokenizedStrategy.sol +++ b/test/mocks/MockTokenizedStrategy.sol @@ -5,9 +5,8 @@ import {BaseStrategy, ERC20} from "./BaseStrategy.sol"; contract MockTokenizedStrategy is BaseStrategy { constructor( - address _asset, - string memory _name - ) BaseStrategy(_asset, _name) {} + address _asset + ) BaseStrategy(_asset, "Mock Tokenized Strategy") {} function _deployFunds(uint256 _amount) internal virtual override {} @@ -22,10 +21,7 @@ contract MockTokenized is MockTokenizedStrategy { uint256 public loss; uint256 public limit; - constructor( - address _asset, - string memory _name - ) MockTokenizedStrategy(_asset, _name) {} + constructor(address _asset) MockTokenizedStrategy(_asset) {} function realizeLoss(uint256 _amount) external { asset.transfer(msg.sender, _amount); diff --git a/test/utils/Setup.sol b/test/utils/Setup.sol index 4e90007..aa05788 100644 --- a/test/utils/Setup.sol +++ b/test/utils/Setup.sol @@ -31,7 +31,7 @@ import {L2Escrow} from "@zkevm-stb/L2Escrow.sol"; import {L2Token} from "@zkevm-stb/L2Token.sol"; import {L2TokenConverter} from "@zkevm-stb/L2TokenConverter.sol"; -import {MockStrategy} from "../mocks/MockStrategy.sol"; +import {MockTokenizedStrategy} from "../mocks/MockTokenizedStrategy.sol"; contract Setup is ExtendedTest { using SafeERC20 for ERC20; @@ -234,7 +234,7 @@ contract Setup is ExtendedTest { function setUpStrategy() public returns (IStrategy) { // we save the strategy as a IStrategyInterface to give it the needed interface IStrategy _strategy = IStrategy( - address(new MockStrategy(address(asset))) + address(new MockTokenizedStrategy(address(asset))) ); // set keeper