diff --git a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol index b654df9751..cd0f665838 100644 --- a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol +++ b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol @@ -78,6 +78,10 @@ abstract contract AbstractMessageIdAuthHook is message.destination() == destinationDomain, "AbstractMessageIdAuthHook: invalid destination domain" ); + require( + metadata.msgValue(0) < 2 ** 255, + "AbstractMessageIdAuthHook: msgValue must be less than 2 ** 255" + ); bytes memory payload = abi.encodeCall( AbstractMessageIdAuthorizedIsm.verifyMessageId, id diff --git a/solidity/contracts/test/ERC20Test.sol b/solidity/contracts/test/ERC20Test.sol index 33f6766800..b9bc42f1d9 100644 --- a/solidity/contracts/test/ERC20Test.sol +++ b/solidity/contracts/test/ERC20Test.sol @@ -19,4 +19,12 @@ contract ERC20Test is ERC20 { function decimals() public view override returns (uint8) { return _decimals; } + + function mint(uint256 amount) public { + _mint(msg.sender, amount); + } + + function mintTo(address account, uint256 amount) public { + _mint(account, amount); + } } diff --git a/solidity/contracts/test/ERC4626/ERC4626Test.sol b/solidity/contracts/test/ERC4626/ERC4626Test.sol new file mode 100644 index 0000000000..044429b539 --- /dev/null +++ b/solidity/contracts/test/ERC4626/ERC4626Test.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC20.sol"; + +contract ERC4626Test is ERC4626 { + constructor( + address _asset, + string memory _name, + string memory _symbol + ) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) {} +} diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol index 0979464d1c..00f0acd9a5 100644 --- a/solidity/contracts/token/HypERC20Collateral.sol +++ b/solidity/contracts/token/HypERC20Collateral.sol @@ -37,7 +37,7 @@ contract HypERC20Collateral is TokenRouter { */ function _transferFromSender( uint256 _amount - ) internal override returns (bytes memory) { + ) internal virtual override returns (bytes memory) { wrappedToken.safeTransferFrom(msg.sender, address(this), _amount); return bytes(""); // no metadata } diff --git a/solidity/contracts/token/HypERC20CollateralVaultDeposit.sol b/solidity/contracts/token/HypERC20CollateralVaultDeposit.sol new file mode 100644 index 0000000000..b4085a9b2c --- /dev/null +++ b/solidity/contracts/token/HypERC20CollateralVaultDeposit.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {HypERC20Collateral} from "./HypERC20Collateral.sol"; + +/** + * @title Hyperlane ERC20 Token Collateral with deposits collateral to a vault + * @author ltyu + */ +contract HypERC20CollateralVaultDeposit is HypERC20Collateral { + // Address of the ERC4626 compatible vault + ERC4626 public immutable vault; + + // Internal balance of total asset deposited + uint256 public assetDeposited; + + event ExcessSharesSwept(uint256 amount, uint256 assetsRedeemed); + + constructor( + ERC4626 _vault, + address _mailbox + ) HypERC20Collateral(_vault.asset(), _mailbox) { + vault = _vault; + wrappedToken.approve(address(vault), type(uint256).max); + } + + /** + * @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract, and deposit into vault + * @inheritdoc HypERC20Collateral + */ + function _transferFromSender( + uint256 _amount + ) internal override returns (bytes memory metadata) { + metadata = super._transferFromSender(_amount); + _depositIntoVault(_amount); + } + + /** + * @dev Deposits into the vault and increment assetDeposited + * @param _amount amount to deposit into vault + */ + function _depositIntoVault(uint256 _amount) internal { + assetDeposited += _amount; + vault.deposit(_amount, address(this)); + } + + /** + * @dev Transfers `_amount` of `wrappedToken` from this contract to `_recipient`, and withdraws from vault + * @inheritdoc HypERC20Collateral + */ + function _transferTo( + address _recipient, + uint256 _amount, + bytes calldata + ) internal virtual override { + _withdrawFromVault(_amount, _recipient); + } + + /** + * @dev Withdraws from the vault and decrement assetDeposited + * @param _amount amount to withdraw from vault + * @param _recipient address to deposit withdrawn underlying to + */ + function _withdrawFromVault(uint256 _amount, address _recipient) internal { + assetDeposited -= _amount; + vault.withdraw(_amount, _recipient, address(this)); + } + + /** + * @notice Allows the owner to redeem excess shares + */ + function sweep() external onlyOwner { + uint256 excessShares = vault.maxRedeem(address(this)) - + vault.convertToShares(assetDeposited); + uint256 assetsRedeemed = vault.redeem( + excessShares, + owner(), + address(this) + ); + emit ExcessSharesSwept(excessShares, assetsRedeemed); + } +} diff --git a/solidity/test/isms/OPStackIsm.t.sol b/solidity/test/isms/OPStackIsm.t.sol index 3066d7bda7..5649ec7426 100644 --- a/solidity/test/isms/OPStackIsm.t.sol +++ b/solidity/test/isms/OPStackIsm.t.sol @@ -199,7 +199,9 @@ contract OPStackIsmTest is Test { .overrideMsgValue(uint256(2 ** 255 + 1)); l1Mailbox.updateLatestDispatchedId(messageId); - vm.expectRevert("OPStackHook: msgValue must be less than 2 ** 255"); + vm.expectRevert( + "AbstractMessageIdAuthHook: msgValue must be less than 2 ** 255" + ); opHook.postDispatch(excessValueMetadata, encodedMessage); } diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol index 89d1563bf1..0b196f3c66 100644 --- a/solidity/test/token/HypERC20.t.sol +++ b/solidity/test/token/HypERC20.t.sol @@ -103,6 +103,20 @@ abstract contract HypTokenTest is Test { ); } + function _handleLocalTransfer(uint256 _transferAmount) internal { + vm.prank(address(localMailbox)); + localToken.handle( + DESTINATION, + address(remoteToken).addressToBytes32(), + abi.encodePacked(ALICE.addressToBytes32(), _transferAmount) + ); + } + + function _mintAndApprove(uint256 _amount, address _account) internal { + primaryToken.mint(_amount); + primaryToken.approve(_account, _amount); + } + function _setCustomGasConfig() internal { localToken.setHook(address(igp)); @@ -153,7 +167,7 @@ abstract contract HypTokenTest is Test { _performRemoteTransferAndGas(_msgValue, _amount, _gasOverhead); } - function testBenchmark_overheadGasUsage() public { + function testBenchmark_overheadGasUsage() public virtual { vm.prank(address(localMailbox)); uint256 gasBefore = gasleft(); diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol new file mode 100644 index 0000000000..bf01665366 --- /dev/null +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.13; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +import "forge-std/Test.sol"; +import {ERC4626Test} from "../../contracts/test/ERC4626/ERC4626Test.sol"; +import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; +import {HypTokenTest} from "./HypERC20.t.sol"; + +import {HypERC20CollateralVaultDeposit} from "../../contracts/token/HypERC20CollateralVaultDeposit.sol"; +import "../../contracts/test/ERC4626/ERC4626Test.sol"; + +contract HypERC20CollateralVaultDepositTest is HypTokenTest { + using TypeCasts for address; + uint256 constant DUST_AMOUNT = 1e11; + HypERC20CollateralVaultDeposit internal erc20CollateralVaultDeposit; + ERC4626Test vault; + + function setUp() public override { + super.setUp(); + vault = new ERC4626Test(address(primaryToken), "Regular Vault", "RV"); + + localToken = new HypERC20CollateralVaultDeposit( + vault, + address(localMailbox) + ); + erc20CollateralVaultDeposit = HypERC20CollateralVaultDeposit( + address(localToken) + ); + + erc20CollateralVaultDeposit.enrollRemoteRouter( + DESTINATION, + address(remoteToken).addressToBytes32() + ); + + remoteMailbox.setDefaultHook(address(noopHook)); + remoteMailbox.setRequiredHook(address(noopHook)); + primaryToken.transfer(ALICE, 1000e18); + _enrollRemoteTokenRouter(); + } + + function _transferRoundTripAndIncreaseYields( + uint256 transferAmount, + uint256 yieldAmount + ) internal { + // Transfer from Alice to Bob + vm.prank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + _performRemoteTransfer(0, transferAmount); + + // Increase vault balance, which will reduce share redeemed for the same amount + primaryToken.mintTo(address(vault), yieldAmount); + + // Transfer back from Bob to Alice + vm.prank(BOB); + remoteToken.transferRemote( + ORIGIN, + BOB.addressToBytes32(), + transferAmount + ); + } + + function testERC4626VaultDeposit_RemoteTransfer_deposits_intoVault( + uint256 transferAmount + ) public { + transferAmount = bound(transferAmount, 0, TOTAL_SUPPLY); + + vm.prank(ALICE); + _mintAndApprove(transferAmount, address(localToken)); + + // Check vault shares balance before and after transfer + assertEq(vault.maxRedeem(address(erc20CollateralVaultDeposit)), 0); + assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0); + + vm.prank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + _performRemoteTransfer(0, transferAmount); + + assertApproxEqAbs( + vault.maxRedeem(address(erc20CollateralVaultDeposit)), + transferAmount, + 1 + ); + assertEq(erc20CollateralVaultDeposit.assetDeposited(), transferAmount); + } + + function testERC4626VaultDeposit_RemoteTransfer_withdraws_fromVault( + uint256 transferAmount + ) public { + transferAmount = bound(transferAmount, 0, TOTAL_SUPPLY); + + vm.prank(ALICE); + _mintAndApprove(transferAmount, address(localToken)); + _transferRoundTripAndIncreaseYields(transferAmount, DUST_AMOUNT); + + // Check Alice's local token balance + uint256 prevBalance = localToken.balanceOf(ALICE); + _handleLocalTransfer(transferAmount); + + assertEq(localToken.balanceOf(ALICE), prevBalance + transferAmount); + assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0); + } + + function testERC4626VaultDeposit_RemoteTransfer_withdraws_lessShares( + uint256 rewardAmount + ) public { + // @dev a rewardAmount less than the DUST_AMOUNT will round down + rewardAmount = bound(rewardAmount, DUST_AMOUNT, TOTAL_SUPPLY); + + _transferRoundTripAndIncreaseYields(TRANSFER_AMT, rewardAmount); + + // Check Alice's local token balance + uint256 prevBalance = localToken.balanceOf(ALICE); + _handleLocalTransfer(TRANSFER_AMT); + assertEq(localToken.balanceOf(ALICE), prevBalance + TRANSFER_AMT); + + // Has leftover shares, but no assets deposited + assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0); + assertGt(vault.maxRedeem(address(erc20CollateralVaultDeposit)), 0); + } + + function testERC4626VaultDeposit_RemoteTransfer_sweep_revertNonOwner( + uint256 rewardAmount + ) public { + // @dev a rewardAmount less than the DUST_AMOUNT will round down + rewardAmount = bound(rewardAmount, DUST_AMOUNT, TOTAL_SUPPLY); + _transferRoundTripAndIncreaseYields(TRANSFER_AMT, rewardAmount); + + vm.startPrank(BOB); + vm.expectRevert(abi.encodePacked("Ownable: caller is not the owner")); + erc20CollateralVaultDeposit.sweep(); + vm.stopPrank(); + } + + function testERC4626VaultDeposit_RemoteTransfer_sweep_noExcessShares( + uint256 transferAmount + ) public { + testERC4626VaultDeposit_RemoteTransfer_deposits_intoVault( + transferAmount + ); + + uint256 ownerBalancePrev = primaryToken.balanceOf( + erc20CollateralVaultDeposit.owner() + ); + + erc20CollateralVaultDeposit.sweep(); + assertEq( + primaryToken.balanceOf(erc20CollateralVaultDeposit.owner()), + ownerBalancePrev + ); + } + + function testERC4626VaultDeposit_RemoteTransfer_sweep_excessShares12312( + uint256 rewardAmount + ) public { + // @dev a rewardAmount less than the DUST_AMOUNT will round down + rewardAmount = bound(rewardAmount, DUST_AMOUNT, TOTAL_SUPPLY); + + _transferRoundTripAndIncreaseYields(TRANSFER_AMT, rewardAmount); + _handleLocalTransfer(TRANSFER_AMT); + + uint256 ownerBalancePrev = primaryToken.balanceOf( + erc20CollateralVaultDeposit.owner() + ); + uint256 excessAmount = vault.maxRedeem( + address(erc20CollateralVaultDeposit) + ); + + erc20CollateralVaultDeposit.sweep(); + assertGt( + primaryToken.balanceOf(erc20CollateralVaultDeposit.owner()), + ownerBalancePrev + excessAmount + ); + } + + function testERC4626VaultDeposit_RemoteTransfer_sweep_excessSharesMultipleDeposit( + uint256 rewardAmount + ) public { + // @dev a rewardAmount less than the DUST_AMOUNT will round down + rewardAmount = bound(rewardAmount, DUST_AMOUNT, TOTAL_SUPPLY); + + _transferRoundTripAndIncreaseYields(TRANSFER_AMT, rewardAmount); + _handleLocalTransfer(TRANSFER_AMT); + + uint256 ownerBalancePrev = primaryToken.balanceOf( + erc20CollateralVaultDeposit.owner() + ); + uint256 excessAmount = vault.maxRedeem( + address(erc20CollateralVaultDeposit) + ); + + // Deposit again for Alice + vm.prank(ALICE); + primaryToken.approve(address(localToken), TRANSFER_AMT); + _performRemoteTransfer(0, TRANSFER_AMT); + + // Sweep and check + erc20CollateralVaultDeposit.sweep(); + assertGt( + primaryToken.balanceOf(erc20CollateralVaultDeposit.owner()), + ownerBalancePrev + excessAmount + ); + } + + function testBenchmark_overheadGasUsage() public override { + vm.prank(ALICE); + primaryToken.approve(address(localToken), TRANSFER_AMT); + _performRemoteTransfer(0, TRANSFER_AMT); + + vm.prank(address(localMailbox)); + + uint256 gasBefore = gasleft(); + localToken.handle( + DESTINATION, + address(remoteToken).addressToBytes32(), + abi.encodePacked(BOB.addressToBytes32(), TRANSFER_AMT) + ); + uint256 gasAfter = gasleft(); + console.log("Overhead gas usage: %d", gasBefore - gasAfter); + } +}