feat: implementation of yield rebasing warp route contracts (#4203)

### Description

- To reflect the rebased underlying asset, `_transferRemote` is
overridden because we need to pass in the ERC4626 vault shares which on
the synthetic `HypERC4626VaultYield` which change in value as the
underlying vault shares accrue yield or receive a drawdown.
- The exchangeRate (i.e., assets/shares) is encoded as metadata and sent
to the synthetic token to calculate the value of the synthetic token.
- On synthetic transfers, the overridden `ERC20.transfer()` function
calculates the needed shares for the amount and transfers them instead.

Design decisions

- Do we need to rebase frequently? Yes, otherwise the exchangeRate on
the synthetic token will be stale compared to the the actual vault
claimable yield, which means a user who deposits later and right before
the infrequent rebase will get disproportionate share of the yield. You
can rebase by calling `rebase()` on the collateral token which makes a
transferRemote call with 0 amount.
- Why exchangeRate and not underlyingDepositedAmount on the synthetic?
If a user withdraws (ie calls transferRemote on the synthetic) while a
rebase is inflight, the underlyingDepositedAmount will get an inaccurate
update (will be overreported and hence will derive inaccurate balance
amounts on the synthetic. The exchangeRate doesn't change if the user is
depositing or withdrawing.
- Why override the `transfer` on the synthetic? Otherwise if a user A
sends 100 USDC worth of shares to user B which has a exchange rate of
1.05, user A would have unwittingly sent 105 #tokens (5 excess).
- What happens in a drawdown of the value? Once the user calls withdraws
the the withdrawal they get out is accordingly discounted. If a user
transfers on the synthetic, it will still gets the stale exchangeRate
until rebase.
- What if the owner of the vault charges a fee on the yield? In that
case, you need to override the totalAssets to reflect the assets -
feesByOwner and the warp route implementation will stay the same. One
example implementation is `MockERC4626YieldSharing` and we have adequate
tests to cover the functionality.
- Rounding error? We're rounding down the exchangeRate as per the
general recommendation for ERC4626 vault math.
- Why not make the synthetic token an ERC4626 itself, given that there's
some overlapping functionality? Even though, the assetsToShares and
assetsToShares functions are the same, the synthetic token itself
doesn't have an underlying token so it doesn't confer to the 4626
interface.
- What if there are multiple synthetic tokens for a single rebasing
collateral contract and what happens is the exchange rate is not in sync
between the synthetics?

Things to keep in mind for 

- How do I keep the exchangeRate in sync with the underlying vault? Keep
calling rebase as a cron job frequently, or make a hook call in deposit,
redeem, and withdraw function to automatically resync the exchangeRate.
- 4626 math pitfalls: https://www.zellic.io/blog/exploring-erc-4626/

### Drive-by changes

- Renaming `HypERC20CollateralVaultDeposit` ->
`HypERC4626OwnerYieldCollateral`

### Related issues

- fixes https://github.com/hyperlane-xyz/issues/issues/1334

### Backward compatibility

Yes

### Testing

Unit tests
pull/4302/head
Kunal Arora 3 months ago committed by GitHub
parent cb404cb85c
commit 63abe564d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/rich-donkeys-visit.md
  2. 80
      solidity/contracts/mock/MockERC4626YieldSharing.sol
  3. 4
      solidity/contracts/test/ERC20Test.sol
  4. 1
      solidity/contracts/test/ERC4626/ERC4626Test.sol
  5. 102
      solidity/contracts/token/extensions/HypERC4626.sol
  6. 111
      solidity/contracts/token/extensions/HypERC4626Collateral.sol
  7. 5
      solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol
  8. 65
      solidity/test/token/HypERC20.t.sol
  9. 15
      solidity/test/token/HypERC20CollateralVaultDeposit.t.sol
  10. 447
      solidity/test/token/HypERC4626Test.t.sol
  11. 11
      typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts
  12. 4
      typescript/sdk/src/token/EvmERC20WarpRouteReader.ts
  13. 4
      typescript/sdk/src/token/contracts.ts

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/sdk': minor
'@hyperlane-xyz/core': minor
---
Added yield route with yield going to message recipient.

@ -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();
}
}

@ -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 {

@ -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";

@ -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);
}
}

@ -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)
);
}
}

@ -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;

@ -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 {

@ -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)
);

@ -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();
}
}

@ -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(

@ -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<TokenType, { factory: any; method: string }>
> = {
collateralVault: {
factory: HypERC20CollateralVaultDeposit__factory,
factory: HypERC4626Collateral__factory,
method: 'vault',
},
collateral: {

@ -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(),

Loading…
Cancel
Save