diff --git a/.changeset/rich-donkeys-visit.md b/.changeset/rich-donkeys-visit.md new file mode 100644 index 000000000..6e149c5cb --- /dev/null +++ b/.changeset/rich-donkeys-visit.md @@ -0,0 +1,6 @@ +--- +'@hyperlane-xyz/sdk': minor +'@hyperlane-xyz/core': minor +--- + +Added yield route with yield going to message recipient. diff --git a/solidity/contracts/mock/MockERC4626YieldSharing.sol b/solidity/contracts/mock/MockERC4626YieldSharing.sol new file mode 100644 index 000000000..50a0c51ab --- /dev/null +++ b/solidity/contracts/mock/MockERC4626YieldSharing.sol @@ -0,0 +1,80 @@ +// 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"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title MockERC4626YieldSharing + * @dev Mock ERC4626 vault for testing yield sharing with the owner of the vault + * @dev This is a simplified version of the Aave v3 vault here + * https://github.com/aave/Aave-Vault/blob/main/src/ATokenVault.sol + */ +contract MockERC4626YieldSharing is ERC4626, Ownable { + using Math for uint256; + + uint256 public constant SCALE = 1e18; + uint256 public fee; + uint256 public accumulatedFees; + uint256 public lastVaultBalance; + + constructor( + address _asset, + string memory _name, + string memory _symbol, + uint256 _initialFee + ) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) { + fee = _initialFee; + } + + function setFee(uint256 newFee) external onlyOwner { + require(newFee <= SCALE, "Fee too high"); + fee = newFee; + } + + function _accrueYield() internal { + uint256 newVaultBalance = IERC20(asset()).balanceOf(address(this)); + if (newVaultBalance > lastVaultBalance) { + uint256 newYield = newVaultBalance - lastVaultBalance; + uint256 newFees = newYield.mulDiv(fee, SCALE, Math.Rounding.Down); + accumulatedFees += newFees; + lastVaultBalance = newVaultBalance; + } + } + + function deposit( + uint256 assets, + address receiver + ) public override returns (uint256) { + lastVaultBalance += assets; + return super.deposit(assets, receiver); + } + + function redeem( + uint256 shares, + address receiver, + address owner + ) public override returns (uint256) { + _accrueYield(); + return super.redeem(shares, receiver, owner); + } + + function getClaimableFees() public view returns (uint256) { + uint256 newVaultBalance = IERC20(asset()).balanceOf(address(this)); + + if (newVaultBalance <= lastVaultBalance) { + return accumulatedFees; + } + + uint256 newYield = newVaultBalance - lastVaultBalance; + uint256 newFees = newYield.mulDiv(fee, SCALE, Math.Rounding.Down); + + return accumulatedFees + newFees; + } + + function totalAssets() public view override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)) - getClaimableFees(); + } +} diff --git a/solidity/contracts/test/ERC20Test.sol b/solidity/contracts/test/ERC20Test.sol index a825c8d13..9cba050c1 100644 --- a/solidity/contracts/test/ERC20Test.sol +++ b/solidity/contracts/test/ERC20Test.sol @@ -31,6 +31,10 @@ contract ERC20Test is ERC20 { function mintTo(address account, uint256 amount) public { _mint(account, amount); } + + function burnFrom(address account, uint256 amount) public { + _burn(account, amount); + } } contract FiatTokenTest is ERC20Test, IFiatToken { diff --git a/solidity/contracts/test/ERC4626/ERC4626Test.sol b/solidity/contracts/test/ERC4626/ERC4626Test.sol index 044429b53..ddc9a2f88 100644 --- a/solidity/contracts/test/ERC4626/ERC4626Test.sol +++ b/solidity/contracts/test/ERC4626/ERC4626Test.sol @@ -1,5 +1,6 @@ // 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"; diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol new file mode 100644 index 000000000..2252696fa --- /dev/null +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {IXERC20} from "../interfaces/IXERC20.sol"; +import {HypERC20} from "../HypERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Message} from "../../libs/Message.sol"; +import {TokenMessage} from "../libs/TokenMessage.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; + +/** + * @title Hyperlane ERC20 Rebasing Token + * @author Abacus Works + */ +contract HypERC4626 is HypERC20 { + using Math for uint256; + using Message for bytes; + using TokenMessage for bytes; + + uint256 public constant PRECISION = 1e10; + uint32 public immutable collateralDomain; + uint256 public exchangeRate; // 1e10 + + constructor( + uint8 _decimals, + address _mailbox, + uint32 _collateralDomain + ) HypERC20(_decimals, _mailbox) { + collateralDomain = _collateralDomain; + exchangeRate = 1e10; + _disableInitializers(); + } + + /// Override to send shares instead of assets from synthetic + /// @inheritdoc TokenRouter + function _transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amountOrId, + uint256 _value, + bytes memory _hookMetadata, + address _hook + ) internal virtual override returns (bytes32 messageId) { + uint256 _shares = assetsToShares(_amountOrId); + _transferFromSender(_shares); + bytes memory _tokenMessage = TokenMessage.format( + _recipient, + _shares, + bytes("") + ); + + messageId = _Router_dispatch( + _destination, + _value, + _tokenMessage, + _hookMetadata, + _hook + ); + + emit SentTransferRemote(_destination, _recipient, _amountOrId); + } + + function _handle( + uint32 _origin, + bytes32 _sender, + bytes calldata _message + ) internal virtual override { + if (_origin == collateralDomain) { + exchangeRate = abi.decode(_message.metadata(), (uint256)); + } + super._handle(_origin, _sender, _message); + } + + // Override to send shares locally instead of assets + function transfer( + address to, + uint256 amount + ) public virtual override returns (bool) { + address owner = _msgSender(); + _transfer(owner, to, assetsToShares(amount)); + return true; + } + + function shareBalanceOf(address account) public view returns (uint256) { + return super.balanceOf(account); + } + + function balanceOf( + address account + ) public view virtual override returns (uint256) { + uint256 _balance = super.balanceOf(account); + return sharesToAssets(_balance); + } + + function assetsToShares(uint256 _amount) public view returns (uint256) { + return _amount.mulDiv(PRECISION, exchangeRate); + } + + function sharesToAssets(uint256 _shares) public view returns (uint256) { + return _shares.mulDiv(exchangeRate, PRECISION); + } +} diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol new file mode 100644 index 000000000..8a084134c --- /dev/null +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; + +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {TokenMessage} from "../libs/TokenMessage.sol"; +import {HypERC20Collateral} from "../HypERC20Collateral.sol"; +import {TypeCasts} from "../../libs/TypeCasts.sol"; + +/** + * @title Hyperlane ERC4626 Token Collateral with deposits collateral to a vault + * @author Abacus Works + */ +contract HypERC4626Collateral is HypERC20Collateral { + using TypeCasts for address; + using TokenMessage for bytes; + using Math for uint256; + + // Address of the ERC4626 compatible vault + ERC4626 public immutable vault; + uint256 public constant PRECISION = 1e10; + bytes32 public constant NULL_RECIPIENT = + 0x0000000000000000000000000000000000000000000000000000000000000001; + + constructor( + ERC4626 _vault, + address _mailbox + ) HypERC20Collateral(_vault.asset(), _mailbox) { + vault = _vault; + } + + function initialize( + address _hook, + address _interchainSecurityModule, + address _owner + ) public override initializer { + _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); + } + + function _transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount, + uint256 _value, + bytes memory _hookMetadata, + address _hook + ) internal virtual override returns (bytes32 messageId) { + // Can't override _transferFromSender only because we need to pass shares in the token message + _transferFromSender(_amount); + uint256 _shares = _depositIntoVault(_amount); + uint256 _exchangeRate = PRECISION.mulDiv( + vault.totalAssets(), + vault.totalSupply(), + Math.Rounding.Down + ); + bytes memory _tokenMetadata = abi.encode(_exchangeRate); + + bytes memory _tokenMessage = TokenMessage.format( + _recipient, + _shares, + _tokenMetadata + ); + + messageId = _Router_dispatch( + _destination, + _value, + _tokenMessage, + _hookMetadata, + _hook + ); + + emit SentTransferRemote(_destination, _recipient, _shares); + } + + /** + * @dev Deposits into the vault and increment assetDeposited + * @param _amount amount to deposit into vault + */ + function _depositIntoVault(uint256 _amount) internal returns (uint256) { + wrappedToken.approve(address(vault), _amount); + return 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 { + // withdraw with the specified amount of shares + vault.redeem(_amount, _recipient, address(this)); + } + + /** + * @dev Update the exchange rate on the synthetic token by accounting for additional yield accrued to the underlying vault + * @param _destinationDomain domain of the vault + */ + function rebase(uint32 _destinationDomain) public payable { + // force a rebase with an empty transfer to 0x1 + _transferRemote( + _destinationDomain, + NULL_RECIPIENT, + 0, + msg.value, + bytes(""), + address(0) + ); + } +} diff --git a/solidity/contracts/token/extensions/HypERC20CollateralVaultDeposit.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol similarity index 96% rename from solidity/contracts/token/extensions/HypERC20CollateralVaultDeposit.sol rename to solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol index 27fe09dc0..1d4d64b0b 100644 --- a/solidity/contracts/token/extensions/HypERC20CollateralVaultDeposit.sol +++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol @@ -1,13 +1,14 @@ // 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 + * @title Hyperlane ERC20 Token Collateral with deposits collateral to a vault, the yield goes to the owner * @author ltyu */ -contract HypERC20CollateralVaultDeposit is HypERC20Collateral { +contract HypERC4626OwnerCollateral is HypERC20Collateral { // Address of the ERC4626 compatible vault ERC4626 public immutable vault; diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol index 9191d3225..ce50c611e 100644 --- a/solidity/test/token/HypERC20.t.sol +++ b/solidity/test/token/HypERC20.t.sol @@ -18,13 +18,14 @@ import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transpa import {Mailbox} from "../../contracts/Mailbox.sol"; import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; -import {TestMailbox} from "../../contracts/test/TestMailbox.sol"; +import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; import {XERC20LockboxTest, XERC20Test, FiatTokenTest, ERC20Test} from "../../contracts/test/ERC20Test.sol"; import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol"; import {TestInterchainGasPaymaster} from "../../contracts/test/TestInterchainGasPaymaster.sol"; import {GasRouter} from "../../contracts/client/GasRouter.sol"; import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol"; +import {Router} from "../../contracts/client/Router.sol"; import {HypERC20} from "../../contracts/token/HypERC20.sol"; import {HypERC20Collateral} from "../../contracts/token/HypERC20Collateral.sol"; import {HypXERC20Lockbox} from "../../contracts/token/extensions/HypXERC20Lockbox.sol"; @@ -53,13 +54,15 @@ abstract contract HypTokenTest is Test { string internal constant SYMBOL = "HYP"; address internal constant ALICE = address(0x1); address internal constant BOB = address(0x2); + address internal constant CAROL = address(0x3); + address internal constant DANIEL = address(0x4); address internal constant PROXY_ADMIN = address(0x37); ERC20Test internal primaryToken; TokenRouter internal localToken; HypERC20 internal remoteToken; - TestMailbox internal localMailbox; - TestMailbox internal remoteMailbox; + MockMailbox internal localMailbox; + MockMailbox internal remoteMailbox; TestPostDispatchHook internal noopHook; TestInterchainGasPaymaster internal igp; @@ -76,14 +79,18 @@ abstract contract HypTokenTest is Test { ); function setUp() public virtual { - localMailbox = new TestMailbox(ORIGIN); - remoteMailbox = new TestMailbox(DESTINATION); + localMailbox = new MockMailbox(ORIGIN); + remoteMailbox = new MockMailbox(DESTINATION); + localMailbox.addRemoteMailbox(DESTINATION, remoteMailbox); + remoteMailbox.addRemoteMailbox(ORIGIN, localMailbox); primaryToken = new ERC20Test(NAME, SYMBOL, TOTAL_SUPPLY, DECIMALS); noopHook = new TestPostDispatchHook(); localMailbox.setDefaultHook(address(noopHook)); localMailbox.setRequiredHook(address(noopHook)); + remoteMailbox.setDefaultHook(address(noopHook)); + remoteMailbox.setRequiredHook(address(noopHook)); REQUIRED_VALUE = noopHook.quoteDispatch("", ""); @@ -113,6 +120,13 @@ abstract contract HypTokenTest is Test { vm.deal(ALICE, 125000); } + function _enrollLocalTokenRouter() internal { + localToken.enrollRemoteRouter( + DESTINATION, + address(remoteToken).addressToBytes32() + ); + } + function _enrollRemoteTokenRouter() internal { remoteToken.enrollRemoteRouter( ORIGIN, @@ -120,7 +134,34 @@ abstract contract HypTokenTest is Test { ); } - function _expectRemoteBalance(address _user, uint256 _balance) internal { + function _connectRouters( + uint32[] memory _domains, + bytes32[] memory _addresses + ) internal { + uint256 n = _domains.length; + for (uint256 i = 0; i < n; i++) { + uint32[] memory complementDomains = new uint32[](n - 1); + bytes32[] memory complementAddresses = new bytes32[](n - 1); + + uint256 j = 0; + for (uint256 k = 0; k < n; k++) { + if (k != i) { + complementDomains[j] = _domains[k]; + complementAddresses[j] = _addresses[k]; + j++; + } + } + + // Enroll complement routers into the current router, Routers - router_i + Router(TypeCasts.bytes32ToAddress(_addresses[i])) + .enrollRemoteRouters(complementDomains, complementAddresses); + } + } + + function _expectRemoteBalance( + address _user, + uint256 _balance + ) internal view { assertEq(remoteToken.balanceOf(_user), _balance); } @@ -218,7 +259,7 @@ abstract contract HypTokenTest is Test { function testTransfer_withHookSpecified( uint256 fee, bytes calldata metadata - ) public { + ) public virtual { TestPostDispatchHook hook = new TestPostDispatchHook(); hook.setFee(fee); @@ -249,6 +290,7 @@ abstract contract HypTokenTest is Test { contract HypERC20Test is HypTokenTest { using TypeCasts for address; + HypERC20 internal erc20Token; function setUp() public override { @@ -292,11 +334,11 @@ contract HypERC20Test is HypTokenTest { ); } - function testTotalSupply() public { + function testTotalSupply() public view { assertEq(erc20Token.totalSupply(), TOTAL_SUPPLY); } - function testDecimals() public { + function testDecimals() public view { assertEq(erc20Token.decimals(), DECIMALS); } @@ -341,6 +383,7 @@ contract HypERC20Test is HypTokenTest { contract HypERC20CollateralTest is HypTokenTest { using TypeCasts for address; + HypERC20Collateral internal erc20Collateral; function setUp() public override { @@ -398,6 +441,7 @@ contract HypERC20CollateralTest is HypTokenTest { contract HypXERC20Test is HypTokenTest { using TypeCasts for address; + HypXERC20 internal xerc20Collateral; function setUp() public override { @@ -446,6 +490,7 @@ contract HypXERC20Test is HypTokenTest { contract HypXERC20LockboxTest is HypTokenTest { using TypeCasts for address; + HypXERC20Lockbox internal xerc20Lockbox; function setUp() public override { @@ -520,6 +565,7 @@ contract HypXERC20LockboxTest is HypTokenTest { contract HypFiatTokenTest is HypTokenTest { using TypeCasts for address; + HypFiatToken internal fiatToken; function setUp() public override { @@ -574,6 +620,7 @@ contract HypFiatTokenTest is HypTokenTest { contract HypNativeTest is HypTokenTest { using TypeCasts for address; + HypNative internal nativeToken; function setUp() public override { diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol index d71af8d56..3dba941b3 100644 --- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol +++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol @@ -20,20 +20,21 @@ 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/extensions/HypERC20CollateralVaultDeposit.sol"; +import {HypERC4626OwnerCollateral} from "../../contracts/token/extensions/HypERC4626OwnerCollateral.sol"; import "../../contracts/test/ERC4626/ERC4626Test.sol"; -contract HypERC20CollateralVaultDepositTest is HypTokenTest { +contract HypERC4626OwnerCollateralTest is HypTokenTest { using TypeCasts for address; + uint256 constant DUST_AMOUNT = 1e11; - HypERC20CollateralVaultDeposit internal erc20CollateralVaultDeposit; + HypERC4626OwnerCollateral internal erc20CollateralVaultDeposit; ERC4626Test vault; function setUp() public override { super.setUp(); vault = new ERC4626Test(address(primaryToken), "Regular Vault", "RV"); - HypERC20CollateralVaultDeposit implementation = new HypERC20CollateralVaultDeposit( + HypERC4626OwnerCollateral implementation = new HypERC4626OwnerCollateral( vault, address(localMailbox) ); @@ -41,14 +42,14 @@ contract HypERC20CollateralVaultDepositTest is HypTokenTest { address(implementation), PROXY_ADMIN, abi.encodeWithSelector( - HypERC20CollateralVaultDeposit.initialize.selector, + HypERC4626OwnerCollateral.initialize.selector, address(address(noopHook)), address(igp), address(this) ) ); - localToken = HypERC20CollateralVaultDeposit(address(proxy)); - erc20CollateralVaultDeposit = HypERC20CollateralVaultDeposit( + localToken = HypERC4626OwnerCollateral(address(proxy)); + erc20CollateralVaultDeposit = HypERC4626OwnerCollateral( address(localToken) ); diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol new file mode 100644 index 000000000..d09e0aae6 --- /dev/null +++ b/solidity/test/token/HypERC4626Test.t.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.13; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +import "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {ERC4626Test} from "../../contracts/test/ERC4626/ERC4626Test.sol"; +import {MockERC4626YieldSharing} from "../../contracts/mock/MockERC4626YieldSharing.sol"; +import {TypeCasts} from "../../contracts/libs/TypeCasts.sol"; +import {HypTokenTest} from "./HypERC20.t.sol"; +import {MockMailbox} from "../../contracts/mock/MockMailbox.sol"; +import {HypERC20} from "../../contracts/token/HypERC20.sol"; +import {HypERC4626Collateral} from "../../contracts/token/extensions/HypERC4626Collateral.sol"; +import {HypERC4626} from "../../contracts/token/extensions/HypERC4626.sol"; +import "../../contracts/test/ERC4626/ERC4626Test.sol"; + +contract HypERC4626CollateralTest is HypTokenTest { + using TypeCasts for address; + + uint32 internal constant PEER_DESTINATION = 13; + uint256 constant YIELD = 5e18; + uint256 constant YIELD_FEES = 1e17; // 10% of yield goes to the vault owner + uint256 internal transferAmount = 100e18; + HypERC4626Collateral internal rebasingCollateral; + MockERC4626YieldSharing vault; + + MockMailbox internal peerMailbox; // mailbox for second synthetic token + HypERC20 internal peerToken; + + HypERC4626Collateral localRebasingToken; + HypERC4626 remoteRebasingToken; + HypERC4626 peerRebasingToken; + + function setUp() public override { + super.setUp(); + + // multi-synthetic setup + peerMailbox = new MockMailbox(PEER_DESTINATION); + localMailbox.addRemoteMailbox(PEER_DESTINATION, peerMailbox); + remoteMailbox.addRemoteMailbox(PEER_DESTINATION, peerMailbox); + peerMailbox.addRemoteMailbox(DESTINATION, remoteMailbox); + peerMailbox.addRemoteMailbox(ORIGIN, localMailbox); + peerMailbox.setDefaultHook(address(noopHook)); + peerMailbox.setRequiredHook(address(noopHook)); + + vm.prank(DANIEL); // daniel will be the owner of the vault and accrue yield fees + vault = new MockERC4626YieldSharing( + address(primaryToken), + "Regular Vault", + "RV", + YIELD_FEES + ); + + HypERC4626Collateral implementation = new HypERC4626Collateral( + vault, + address(localMailbox) + ); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN, + abi.encodeWithSelector( + HypERC4626Collateral.initialize.selector, + address(address(noopHook)), + address(0x0), + address(this) + ) + ); + localToken = HypERC4626Collateral(address(proxy)); + + remoteToken = new HypERC4626( + primaryToken.decimals(), + address(remoteMailbox), + localToken.localDomain() + ); + peerToken = new HypERC4626( + primaryToken.decimals(), + address(peerMailbox), + localToken.localDomain() + ); + + localRebasingToken = HypERC4626Collateral(address(proxy)); + remoteRebasingToken = HypERC4626(address(remoteToken)); + peerRebasingToken = HypERC4626(address(peerToken)); + + primaryToken.transfer(ALICE, 1000e18); + + uint32[] memory domains = new uint32[](3); + domains[0] = ORIGIN; + domains[1] = DESTINATION; + domains[2] = PEER_DESTINATION; + + bytes32[] memory addresses = new bytes32[](3); + addresses[0] = address(localToken).addressToBytes32(); + addresses[1] = address(remoteToken).addressToBytes32(); + addresses[2] = address(peerToken).addressToBytes32(); + _connectRouters(domains, addresses); + } + + function test_collateralDomain() public view { + assertEq( + remoteRebasingToken.collateralDomain(), + localToken.localDomain() + ); + } + + function testRemoteTransfer_rebaseAfter() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + localRebasingToken.rebase(DESTINATION); + remoteMailbox.processNextInboundMessage(); + assertEq( + remoteToken.balanceOf(BOB), + transferAmount + _discountedYield() + ); + } + + function testRebaseWithTransfer() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + _performRemoteTransferWithoutExpectation(0, transferAmount); + + // max 1bp diff + assertApproxEqRelDecimal( + remoteToken.balanceOf(BOB), + 2 * transferAmount + _discountedYield(), + 1e14, + 0 + ); + } + + function testSyntheticTransfers_withRebase() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + _performRemoteTransferWithoutExpectation(0, transferAmount); + + vm.prank(BOB); + remoteToken.transfer(CAROL, transferAmount); // transfer ~100e18 equivalent to CAROL + + // max 1bp diff + assertApproxEqRelDecimal( + remoteToken.balanceOf(BOB), + transferAmount + _discountedYield(), + 1e14, + 0 + ); + assertApproxEqRelDecimal( + remoteToken.balanceOf(CAROL), + transferAmount, + 1e14, + 0 + ); + } + + function testWithdrawalWithoutYield() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + vm.prank(BOB); + remoteToken.transferRemote{value: 0}( + ORIGIN, + BOB.addressToBytes32(), + transferAmount + ); + localMailbox.processNextInboundMessage(); + assertEq(primaryToken.balanceOf(BOB), transferAmount); + } + + function testWithdrawalWithYield() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + vm.prank(BOB); + remoteToken.transferRemote{value: 0}( + ORIGIN, + BOB.addressToBytes32(), + transferAmount + ); + + localMailbox.processNextInboundMessage(); + + uint256 _bobBal = primaryToken.balanceOf(BOB); + uint256 _expectedBal = transferAmount + _discountedYield(); + + // BOB gets the yield even though it didn't rebase + assertApproxEqRelDecimal(_bobBal, _expectedBal, 1e14, 0); + assertTrue(_bobBal < _expectedBal, "Transfer remote should round down"); + + assertEq(vault.accumulatedFees(), YIELD / 10); + } + + function testWithdrawalAfterYield() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + localRebasingToken.rebase(DESTINATION); + remoteMailbox.processNextInboundMessage(); + + // Use balance here since it might be off by <1bp + uint256 bobsBalance = remoteToken.balanceOf(BOB); + vm.prank(BOB); + remoteToken.transferRemote{value: 0}( + ORIGIN, + BOB.addressToBytes32(), + bobsBalance + ); + localMailbox.processNextInboundMessage(); + assertApproxEqRelDecimal( + primaryToken.balanceOf(BOB), + transferAmount + _discountedYield(), + 1e14, + 0 + ); + assertEq(vault.accumulatedFees(), YIELD / 10); + } + + function testWithdrawalInFlight() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + primaryToken.mintTo(CAROL, transferAmount); + vm.prank(CAROL); + primaryToken.approve(address(localToken), transferAmount); + vm.prank(CAROL); + localToken.transferRemote{value: 0}( + DESTINATION, + CAROL.addressToBytes32(), + transferAmount + ); + remoteMailbox.processNextInboundMessage(); + + _accrueYield(); + _accrueYield(); // earning 2x yield to be split + + localRebasingToken.rebase(DESTINATION); + vm.prank(CAROL); + + remoteToken.transferRemote( + ORIGIN, + CAROL.addressToBytes32(), + transferAmount + ); + localMailbox.processNextInboundMessage(); + + uint256 claimableFees = vault.getClaimableFees(); + assertApproxEqRelDecimal( + primaryToken.balanceOf(CAROL), + transferAmount + YIELD - (claimableFees / 2), + 1e14, + 0 + ); + + // until we process the rebase, the yield is not added on the remote + assertEq(remoteToken.balanceOf(BOB), transferAmount); + remoteMailbox.processNextInboundMessage(); + assertApproxEqRelDecimal( + remoteToken.balanceOf(BOB), + transferAmount + YIELD - (claimableFees / 2), + 1e14, + 0 + ); + assertEq(vault.accumulatedFees(), YIELD / 5); // 0.1 * 2 * yield + } + + function testWithdrawalAfterDrawdown() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + // decrease collateral in vault by 10% + uint256 drawdown = 5e18; + primaryToken.burnFrom(address(vault), drawdown); + localRebasingToken.rebase(DESTINATION); + remoteMailbox.processNextInboundMessage(); + + // Use balance here since it might be off by <1bp + uint256 bobsBalance = remoteToken.balanceOf(BOB); + vm.prank(BOB); + remoteToken.transferRemote{value: 0}( + ORIGIN, + BOB.addressToBytes32(), + bobsBalance + ); + localMailbox.processNextInboundMessage(); + assertApproxEqRelDecimal( + primaryToken.balanceOf(BOB), + transferAmount - drawdown, + 1e14, + 0 + ); + } + + function test_exchangeRate_setOnlyByCollateral() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + localRebasingToken.rebase(DESTINATION); + remoteMailbox.processNextInboundMessage(); + + vm.prank(BOB); + remoteToken.transferRemote{value: 0}( + PEER_DESTINATION, + BOB.addressToBytes32(), + transferAmount + ); + peerMailbox.processNextInboundMessage(); + + assertEq(remoteRebasingToken.exchangeRate(), 1045e7); // 5 * 0.9 = 4.5% yield + assertEq(peerRebasingToken.exchangeRate(), 1e10); // assertingthat transfers by the synthetic variant don't impact the exchang rate + + localRebasingToken.rebase(PEER_DESTINATION); + peerMailbox.processNextInboundMessage(); + + assertEq(peerRebasingToken.exchangeRate(), 1045e7); // asserting that the exchange rate is set finally by the collateral variant + } + + function test_cyclicTransfers() public { + // ALICE: local -> remote(BOB) + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + localRebasingToken.rebase(DESTINATION); // yield is added + remoteMailbox.processNextInboundMessage(); + + // BOB: remote -> peer(BOB) (yield is leftover) + vm.prank(BOB); + remoteToken.transferRemote{value: 0}( + PEER_DESTINATION, + BOB.addressToBytes32(), + transferAmount + ); + peerMailbox.processNextInboundMessage(); + + localRebasingToken.rebase(PEER_DESTINATION); + peerMailbox.processNextInboundMessage(); + + // BOB: peer -> local(CAROL) + vm.prank(BOB); + peerToken.transferRemote{value: 0}( + ORIGIN, + CAROL.addressToBytes32(), + transferAmount + ); + localMailbox.processNextInboundMessage(); + + assertApproxEqRelDecimal( + remoteToken.balanceOf(BOB), + _discountedYield(), + 1e14, + 0 + ); + assertEq(peerToken.balanceOf(BOB), 0); + assertApproxEqRelDecimal( + primaryToken.balanceOf(CAROL), + transferAmount, + 1e14, + 0 + ); + } + + function testTransfer_withHookSpecified( + uint256, + bytes calldata + ) public override { + // skip + } + + function testBenchmark_overheadGasUsage() public override { + _performRemoteTransferWithoutExpectation(0, transferAmount); + assertEq(remoteToken.balanceOf(BOB), transferAmount); + + _accrueYield(); + + localRebasingToken.rebase(DESTINATION); + remoteMailbox.processNextInboundMessage(); + assertEq( + remoteToken.balanceOf(BOB), + transferAmount + _discountedYield() + ); + + vm.prank(address(localMailbox)); + + uint256 gasBefore = gasleft(); + localToken.handle( + DESTINATION, + address(remoteToken).addressToBytes32(), + abi.encodePacked(BOB.addressToBytes32(), transferAmount) + ); + uint256 gasAfter = gasleft(); + console.log( + "Overhead gas usage for withdrawal: %d", + gasBefore - gasAfter + ); + } + + // ALICE: local -> remote(BOB) + function _performRemoteTransferWithoutExpectation( + uint256 _msgValue, + uint256 _amount + ) internal { + vm.startPrank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + localToken.transferRemote{value: _msgValue}( + DESTINATION, + BOB.addressToBytes32(), + _amount + ); + vm.stopPrank(); + + remoteMailbox.processNextInboundMessage(); + } + + function _accrueYield() public { + primaryToken.mintTo(address(vault), YIELD); + } + + function _discountedYield() internal view returns (uint256) { + return YIELD - vault.getClaimableFees(); + } +} diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts index d9f46b763..82a8e77a4 100644 --- a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts @@ -8,8 +8,8 @@ import { ERC20Test__factory, ERC4626Test__factory, GasRouter, - HypERC20CollateralVaultDeposit__factory, HypERC20__factory, + HypERC4626Collateral__factory, HypNative__factory, Mailbox, Mailbox__factory, @@ -160,11 +160,10 @@ describe('EvmERC20WarpHyperlaneModule', async () => { expect(tokenType).to.equal(TokenType.collateralVault); // Validate onchain token values - const collateralVaultContract = - HypERC20CollateralVaultDeposit__factory.connect( - deployedTokenRoute, - signer, - ); + const collateralVaultContract = HypERC4626Collateral__factory.connect( + deployedTokenRoute, + signer, + ); await validateCoreValues(collateralVaultContract); expect(await collateralVaultContract.vault()).to.equal(vault.address); expect(await collateralVaultContract.wrappedToken()).to.equal( diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts index f3d591773..55c8acece 100644 --- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts @@ -1,9 +1,9 @@ import { BigNumber, constants } from 'ethers'; import { - HypERC20CollateralVaultDeposit__factory, HypERC20Collateral__factory, HypERC20__factory, + HypERC4626Collateral__factory, TokenRouter__factory, } from '@hyperlane-xyz/core'; import { @@ -82,7 +82,7 @@ export class EvmERC20WarpRouteReader extends HyperlaneReader { Record > = { collateralVault: { - factory: HypERC20CollateralVaultDeposit__factory, + factory: HypERC4626Collateral__factory, method: 'vault', }, collateral: { diff --git a/typescript/sdk/src/token/contracts.ts b/typescript/sdk/src/token/contracts.ts index d9982522c..281c084ff 100644 --- a/typescript/sdk/src/token/contracts.ts +++ b/typescript/sdk/src/token/contracts.ts @@ -1,13 +1,13 @@ import { FastHypERC20Collateral__factory, FastHypERC20__factory, - HypERC20CollateralVaultDeposit__factory, HypERC20Collateral__factory, HypERC20__factory, HypERC721Collateral__factory, HypERC721URICollateral__factory, HypERC721URIStorage__factory, HypERC721__factory, + HypERC4626Collateral__factory, HypFiatToken__factory, HypNativeScaled__factory, HypNative__factory, @@ -36,7 +36,7 @@ export const hypERC20factories = { [TokenType.fastSynthetic]: new FastHypERC20__factory(), [TokenType.synthetic]: new HypERC20__factory(), [TokenType.collateral]: new HypERC20Collateral__factory(), - [TokenType.collateralVault]: new HypERC20CollateralVaultDeposit__factory(), + [TokenType.collateralVault]: new HypERC4626Collateral__factory(), [TokenType.collateralFiat]: new HypFiatToken__factory(), [TokenType.XERC20]: new HypXERC20__factory(), [TokenType.XERC20Lockbox]: new HypXERC20Lockbox__factory(),