Skip to content

Commit

Permalink
fix(contracts): add rebasing compatibility for HypERC4626 (hyperlan…
Browse files Browse the repository at this point in the history
…e-xyz#4524)

### Description

- Added overrides for transferFrom, totalSupply to reflect the internal
share based accounting for the 4626 mirror asset

### Drive-by changes

- Overridden `_transfer` to update the Transfer event to display the
asset being transfers as amount not the internal shares.

### Related issues

- fixes https://github.com/chainlight-io/2024-08-hyperlane/issues/6

### Backward compatibility

Yes

### Testing

Fuzz testing
  • Loading branch information
aroralanuk authored and tiendn committed Oct 25, 2024
1 parent 27e9086 commit 14e0c2e
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/real-starfishes-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/core': patch
---

Added overrides for transferFrom, totalSupply to reflect the internal share based accounting for the 4626 mirror asset
95 changes: 66 additions & 29 deletions solidity/contracts/token/extensions/HypERC4626.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import {TokenRouter} from "../libs/TokenRouter.sol";

// ============ External Imports ============
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";

/**
* @title Hyperlane ERC20 Rebasing Token
* @author Abacus Works
* @notice This contract implements a rebasing token that reflects yields from the origin chain
*/
contract HypERC4626 is HypERC20 {
using Math for uint256;
Expand All @@ -49,6 +52,67 @@ contract HypERC4626 is HypERC20 {
_disableInitializers();
}

// ============ Public Functions ============

/// Override transfer to handle underlying amounts while using shares internally
/// @inheritdoc ERC20Upgradeable
/// @dev the Transfer event emitted from ERC20Upgradeable will be in terms of shares not assets, so it may be misleading
function transfer(
address to,
uint256 amount
) public virtual override returns (bool) {
_transfer(_msgSender(), to, assetsToShares(amount));
return true;
}

/// Override transferFrom to handle underlying amounts while using shares internally
/// @inheritdoc ERC20Upgradeable
function transferFrom(
address sender,
address recipient,
uint256 amount
) public virtual override returns (bool) {
address spender = _msgSender();
uint256 shares = assetsToShares(amount);
_spendAllowance(sender, spender, amount);
_transfer(sender, recipient, shares);
return true;
}

/// Override totalSupply to return the total assets instead of shares. This reflects the actual circulating supply in terms of assets, accounting for rebasing
/// @inheritdoc ERC20Upgradeable
function totalSupply() public view virtual override returns (uint256) {
return sharesToAssets(totalShares());
}

/// This returns the balance of the account in terms of assets, accounting for rebasing
/// @inheritdoc ERC20Upgradeable
function balanceOf(
address account
) public view virtual override returns (uint256) {
return sharesToAssets(shareBalanceOf(account));
}

/// This function provides the total supply in terms of shares
function totalShares() public view returns (uint256) {
return super.totalSupply();
}

/// This returns the balance of the account in terms of shares
function shareBalanceOf(address account) public view returns (uint256) {
return super.balanceOf(account);
}

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

// ============ Internal Functions ============

/// Override to send shares instead of assets from synthetic
/// @inheritdoc TokenRouter
function _transferRemote(
Expand Down Expand Up @@ -78,6 +142,8 @@ contract HypERC4626 is HypERC20 {
emit SentTransferRemote(_destination, _recipient, _amountOrId);
}

/// override _handle to update exchange rate
/// @inheritdoc TokenRouter
function _handle(
uint32 _origin,
bytes32 _sender,
Expand All @@ -97,33 +163,4 @@ contract HypERC4626 is HypERC20 {
}
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);
}
}
133 changes: 133 additions & 0 deletions solidity/test/token/HypERC4626Test.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ contract HypERC4626CollateralTest is HypTokenTest {
_connectRouters(domains, addresses);
}

function testDisableInitializers() public {
vm.expectRevert("Initializable: contract is already initialized");
remoteToken.initialize(0, "", "", address(0), address(0), address(0));
}

function test_collateralDomain() public view {
assertEq(
remoteRebasingToken.collateralDomain(),
Expand Down Expand Up @@ -242,6 +247,108 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
}

function testTransferFrom() public {
_performRemoteTransferWithoutExpectation(0, transferAmount);
assertEq(remoteToken.balanceOf(BOB), transferAmount);

uint256 transferAmount2 = 50e18;
vm.prank(BOB);
remoteToken.approve(CAROL, transferAmount2);

vm.prank(CAROL);
bool success = remoteToken.transferFrom(BOB, DANIEL, transferAmount2);
assertTrue(success, "TransferFrom should succeed");

assertEq(
remoteToken.balanceOf(BOB),
transferAmount - transferAmount2,
"BOB's balance should decrease"
);
assertEq(
remoteToken.balanceOf(DANIEL),
transferAmount2,
"DANIEL's balance should increase"
);
assertEq(
remoteToken.allowance(BOB, CAROL),
0,
"Allowance should be zero after transfer"
);
}

event Transfer(address indexed from, address indexed to, uint256 value);

function testTransferEvent() public {
_performRemoteTransferWithoutExpectation(0, transferAmount);
assertEq(remoteToken.balanceOf(BOB), transferAmount);

uint256 transferAmount2 = 50e18;
vm.expectEmit(true, true, false, true);
emit Transfer(BOB, CAROL, transferAmount2);

vm.prank(BOB);
remoteToken.transfer(CAROL, transferAmount2);

assertEq(
remoteToken.balanceOf(BOB),
transferAmount - transferAmount2,
"BOB's balance should decrease"
);
assertEq(
remoteToken.balanceOf(CAROL),
transferAmount2,
"CAROL's balance should increase"
);
}

function testTotalShares() public {
uint256 initialShares = remoteRebasingToken.totalShares();
assertEq(initialShares, 0, "Initial shares should be zero");

_performRemoteTransferWithoutExpectation(0, transferAmount);
uint256 sharesAfterTransfer = remoteRebasingToken.totalShares();
assertEq(
sharesAfterTransfer,
remoteRebasingToken.assetsToShares(transferAmount),
"Shares should match transferred amount converted to shares"
);

_accrueYield();
localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
remoteMailbox.processNextInboundMessage();

uint256 sharesAfterYield = remoteRebasingToken.totalShares();
assertEq(
sharesAfterYield,
sharesAfterTransfer,
"Total shares should remain constant after yield accrual"
);
}

function testShareBalanceOf() public {
_performRemoteTransferWithoutExpectation(0, transferAmount);

uint256 bobShareBalance = remoteRebasingToken.shareBalanceOf(BOB);
assertEq(
bobShareBalance,
remoteRebasingToken.assetsToShares(transferAmount),
"Bob's share balance should match transferred amount converted to shares"
);

_accrueYield();
localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
remoteMailbox.processNextInboundMessage();

uint256 bobShareBalanceAfterYield = remoteRebasingToken.shareBalanceOf(
BOB
);
assertEq(
bobShareBalanceAfterYield,
bobShareBalance,
"Bob's share balance should remain constant after yield accrual"
);
}

function testWithdrawalWithoutYield() public {
uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB);
_performRemoteTransferWithoutExpectation(0, transferAmount);
Expand Down Expand Up @@ -480,6 +587,32 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
}

function testTotalSupply() public {
uint256 initialSupply = remoteToken.totalSupply();
assertEq(initialSupply, 0, "Initial supply should be zero");

_performRemoteTransferWithoutExpectation(0, transferAmount);
uint256 supplyAfterTransfer = remoteToken.totalSupply();
assertEq(
supplyAfterTransfer,
transferAmount,
"Supply should match transferred amount"
);

_accrueYield();
localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
remoteMailbox.processNextInboundMessage();

uint256 supplyAfterYield = remoteToken.totalSupply();
assertApproxEqRelDecimal(
supplyAfterYield,
transferAmount + _discountedYield(),
1e14,
0,
"Supply should include yield"
);
}

function testTransfer_withHookSpecified(
uint256,
bytes calldata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ describe('ERC20WarpRouterReader', async () => {
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: TOKEN_DECIMALS,
totalSupply: TOKEN_SUPPLY,
...baseConfig,
},
};
Expand Down
4 changes: 3 additions & 1 deletion typescript/sdk/src/token/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ abstract class TokenDeployer<
];
if (isCollateralConfig(config) || isNativeConfig(config)) {
return defaultArgs;
} else if (isSyntheticConfig(config) || isSyntheticRebaseConfig(config)) {
} else if (isSyntheticConfig(config)) {
return [config.totalSupply, config.name, config.symbol, ...defaultArgs];
} else if (isSyntheticRebaseConfig(config)) {
return [0, config.name, config.symbol, ...defaultArgs];
} else {
throw new Error('Unknown collateral type when initializing arguments');
}
Expand Down
7 changes: 5 additions & 2 deletions typescript/sdk/src/token/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ export const NativeConfigSchema = TokenMetadataSchema.partial().extend({
type: z.enum([TokenType.native, TokenType.nativeScaled]),
});

export const CollateralRebaseConfigSchema =
TokenMetadataSchema.partial().extend({
export const CollateralRebaseConfigSchema = TokenMetadataSchema.omit({
totalSupply: true,
})
.partial()
.extend({
type: z.literal(TokenType.collateralVaultRebase),
});

Expand Down

0 comments on commit 14e0c2e

Please sign in to comment.