diff --git a/src/ERC20WrapperBundler.sol b/src/ERC20WrapperBundler.sol new file mode 100644 index 00000000..2066a2fd --- /dev/null +++ b/src/ERC20WrapperBundler.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.21; + +import {ErrorsLib} from "./libraries/ErrorsLib.sol"; +import {Math} from "../lib/morpho-utils/src/math/Math.sol"; +import {SafeTransferLib, ERC20} from "../lib/solmate/src/utils/SafeTransferLib.sol"; + +import {BaseBundler} from "./BaseBundler.sol"; +import {ERC20Wrapper} from "../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Wrapper.sol"; + +/// @title ERC20WrapperBundler +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Enables the wrapping and unwrapping of ERC20 tokens. The largest usecase is to wrap permissionless tokens to +/// their permissioned counterparts and access permissioned markets on Morpho Blue. Permissioned tokens can be built +/// using: https://github.com/morpho-org/erc20-permissioned +abstract contract ERC20WrapperBundler is BaseBundler { + using SafeTransferLib for ERC20; + + /* WRAPPER ACTIONS */ + + /// @notice Deposits underlying tokens and mints the corresponding amount of wrapped tokens to the initiator. + /// @dev Wraps tokens on behalf of the initiator to make sure they are able to receive and transfer wrapped tokens. + /// @dev Wrapped tokens must be transferred to the bundler afterwards to perform additional actions. + /// @dev Initiator must have previously transferred their tokens to the bundler. + /// @dev Assumes that `wrapper` implements the `ERC20Wrapper` interface. + /// @param wrapper The address of the ERC20 wrapper contract. + /// @param amount The amount of underlying tokens to deposit. Pass `type(uint256).max` to deposit the bundler's + /// balance. + function erc20WrapperDepositFor(address wrapper, uint256 amount) external protected { + ERC20 underlying = ERC20(address(ERC20Wrapper(wrapper).underlying())); + + amount = Math.min(amount, underlying.balanceOf(address(this))); + + require(amount != 0, ErrorsLib.ZERO_AMOUNT); + + _approveMaxTo(address(underlying), wrapper); + ERC20Wrapper(wrapper).depositFor(initiator(), amount); + } + + /// @notice Burns a number of wrapped tokens and withdraws the corresponding number of underlying tokens. + /// @dev Initiator must have previously transferred their wrapped tokens to the bundler. + /// @dev Assumes that `wrapper` implements the `ERC20Wrapper` interface. + /// @param wrapper The address of the ERC20 wrapper contract. + /// @param account The address receiving the underlying tokens. + /// @param amount The amount of wrapped tokens to burn. Pass `type(uint256).max` to burn the bundler's balance. + function erc20WrapperWithdrawTo(address wrapper, address account, uint256 amount) external protected { + require(account != address(0), ErrorsLib.ZERO_ADDRESS); + + amount = Math.min(amount, ERC20(wrapper).balanceOf(address(this))); + + require(amount != 0, ErrorsLib.ZERO_AMOUNT); + + ERC20Wrapper(wrapper).withdrawTo(account, amount); + } +} diff --git a/src/ethereum/EthereumBundler.sol b/src/ethereum/EthereumBundler.sol index ab0ad421..367d99ba 100644 --- a/src/ethereum/EthereumBundler.sol +++ b/src/ethereum/EthereumBundler.sol @@ -12,6 +12,7 @@ import {WNativeBundler} from "../WNativeBundler.sol"; import {EthereumStEthBundler} from "./EthereumStEthBundler.sol"; import {UrdBundler} from "../UrdBundler.sol"; import {MorphoBundler} from "../MorphoBundler.sol"; +import {ERC20WrapperBundler} from "../ERC20WrapperBundler.sol"; /// @title EthereumBundler /// @author Morpho Labs @@ -25,7 +26,8 @@ contract EthereumBundler is WNativeBundler, EthereumStEthBundler, UrdBundler, - MorphoBundler + MorphoBundler, + ERC20WrapperBundler { /* CONSTRUCTOR */ diff --git a/src/mocks/ERC20WrapperMock.sol b/src/mocks/ERC20WrapperMock.sol new file mode 100644 index 00000000..484af4f4 --- /dev/null +++ b/src/mocks/ERC20WrapperMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import { + IERC20, + ERC20Wrapper, + ERC20 +} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Wrapper.sol"; + +contract ERC20WrapperMock is ERC20Wrapper { + constructor(IERC20 token, string memory _name, string memory _symbol) ERC20Wrapper(token) ERC20(_name, _symbol) {} + + function setBalance(address account, uint256 amount) external { + _burn(account, balanceOf(account)); + _mint(account, amount); + } +} diff --git a/src/mocks/bundlers/ERC20WrapperBundlerMock.sol b/src/mocks/bundlers/ERC20WrapperBundlerMock.sol new file mode 100644 index 00000000..d2b18e81 --- /dev/null +++ b/src/mocks/bundlers/ERC20WrapperBundlerMock.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "../../TransferBundler.sol"; +import {ERC20WrapperBundler} from "../../ERC20WrapperBundler.sol"; + +contract ERC20WrapperBundlerMock is ERC20WrapperBundler, TransferBundler {} diff --git a/test/forge/ERC20WrapperBundlerLocalTest.sol b/test/forge/ERC20WrapperBundlerLocalTest.sol new file mode 100644 index 00000000..09aa15b0 --- /dev/null +++ b/test/forge/ERC20WrapperBundlerLocalTest.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {ErrorsLib} from "../../src/libraries/ErrorsLib.sol"; + +import {ERC20WrapperBundlerMock} from "../../src/mocks/bundlers/ERC20WrapperBundlerMock.sol"; +import {ERC20WrapperMock} from "../../src/mocks/ERC20WrapperMock.sol"; + +import "./helpers/LocalTest.sol"; + +contract ERC20WrapperBundlerBundlerLocalTest is LocalTest { + ERC20WrapperMock internal loanWrapper; + + function setUp() public override { + super.setUp(); + + bundler = new ERC20WrapperBundlerMock(); + + loanWrapper = new ERC20WrapperMock(loanToken, "Wrapped Loan Token", "WLT"); + } + + function testErc20WrapperDepositFor(uint256 amount) public { + amount = bound(amount, MIN_AMOUNT, MAX_AMOUNT); + + bundle.push(_erc20WrapperDepositFor(address(loanWrapper), amount)); + + loanToken.setBalance(address(bundler), amount); + + vm.prank(RECEIVER); + bundler.multicall(bundle); + + assertEq(loanToken.balanceOf(address(bundler)), 0, "loan.balanceOf(bundler)"); + assertEq(loanWrapper.balanceOf(RECEIVER), amount, "loanWrapper.balanceOf(RECEIVER)"); + } + + function testErc20WrapperDepositForZeroAmount() public { + bundle.push(_erc20WrapperDepositFor(address(loanWrapper), 0)); + + vm.expectRevert(bytes(ErrorsLib.ZERO_AMOUNT)); + bundler.multicall(bundle); + } + + function testErc20WrapperWithdrawTo(uint256 amount) public { + amount = bound(amount, MIN_AMOUNT, MAX_AMOUNT); + + loanWrapper.setBalance(address(bundler), amount); + loanToken.setBalance(address(loanWrapper), amount); + + bundle.push(_erc20WrapperWithdrawTo(address(loanWrapper), RECEIVER, amount)); + + bundler.multicall(bundle); + + assertEq(loanWrapper.balanceOf(address(bundler)), 0, "loanWrapper.balanceOf(bundler)"); + assertEq(loanToken.balanceOf(RECEIVER), amount, "loan.balanceOf(RECEIVER)"); + } + + function testErc20WrapperWithdrawToAll(uint256 amount, uint256 inputAmount) public { + amount = bound(amount, MIN_AMOUNT, MAX_AMOUNT); + inputAmount = bound(inputAmount, amount, type(uint256).max); + + loanWrapper.setBalance(address(bundler), amount); + loanToken.setBalance(address(loanWrapper), amount); + + bundle.push(_erc20WrapperWithdrawTo(address(loanWrapper), RECEIVER, inputAmount)); + + bundler.multicall(bundle); + + assertEq(loanWrapper.balanceOf(address(bundler)), 0, "loanWrapper.balanceOf(bundler)"); + assertEq(loanToken.balanceOf(RECEIVER), amount, "loan.balanceOf(RECEIVER)"); + } + + function testErc20WrapperWithdrawToAccountZeroAddress(uint256 amount) public { + amount = bound(amount, MIN_AMOUNT, MAX_AMOUNT); + + bundle.push(_erc20WrapperWithdrawTo(address(loanWrapper), address(0), amount)); + + vm.expectRevert(bytes(ErrorsLib.ZERO_ADDRESS)); + bundler.multicall(bundle); + } + + function testErc20WrapperWithdrawToZeroAmount() public { + bundle.push(_erc20WrapperWithdrawTo(address(loanWrapper), RECEIVER, 0)); + + vm.expectRevert(bytes(ErrorsLib.ZERO_AMOUNT)); + bundler.multicall(bundle); + } + + function testErc20WrapperDepositForUninitiated(uint256 amount) public { + amount = bound(amount, MIN_AMOUNT, MAX_AMOUNT); + + vm.expectRevert(bytes(ErrorsLib.UNINITIATED)); + ERC20WrapperBundler(address(bundler)).erc20WrapperDepositFor(address(loanWrapper), amount); + } + + function testErc20WrapperWithdrawToUninitiated(uint256 amount) public { + amount = bound(amount, MIN_AMOUNT, MAX_AMOUNT); + + vm.expectRevert(bytes(ErrorsLib.UNINITIATED)); + ERC20WrapperBundler(address(bundler)).erc20WrapperWithdrawTo(address(loanWrapper), RECEIVER, amount); + } +} diff --git a/test/forge/helpers/BaseTest.sol b/test/forge/helpers/BaseTest.sol index 0b7dbf1b..fa385fe9 100644 --- a/test/forge/helpers/BaseTest.sol +++ b/test/forge/helpers/BaseTest.sol @@ -31,6 +31,7 @@ import {TransferBundler} from "../../../src/TransferBundler.sol"; import {ERC4626Bundler} from "../../../src/ERC4626Bundler.sol"; import {UrdBundler} from "../../../src/UrdBundler.sol"; import {MorphoBundler} from "../../../src/MorphoBundler.sol"; +import {ERC20WrapperBundler} from "../../../src/ERC20WrapperBundler.sol"; import "../../../lib/forge-std/src/Test.sol"; import "../../../lib/forge-std/src/console2.sol"; @@ -114,6 +115,20 @@ abstract contract BaseTest is Test { return abi.encodeCall(TransferBundler.erc20TransferFrom, (asset, amount)); } + /* ERC20 WRAPPER ACTIONS */ + + function _erc20WrapperDepositFor(address asset, uint256 amount) internal pure returns (bytes memory) { + return abi.encodeCall(ERC20WrapperBundler.erc20WrapperDepositFor, (asset, amount)); + } + + function _erc20WrapperWithdrawTo(address asset, address account, uint256 amount) + internal + pure + returns (bytes memory) + { + return abi.encodeCall(ERC20WrapperBundler.erc20WrapperWithdrawTo, (asset, account, amount)); + } + /* ERC4626 ACTIONS */ function _erc4626Mint(address vault, uint256 shares, uint256 maxAssets, address receiver)