diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 95095810f8..2bb0aaa208 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -50,6 +50,9 @@ jobs: with: node-version: 18 + - name: Install Foundry + uses: onbjerg/foundry-toolchain@v1 + - name: yarn-cache uses: actions/cache@v3 with: @@ -99,6 +102,9 @@ jobs: with: submodules: recursive + - name: Install Foundry + uses: onbjerg/foundry-toolchain@v1 + - uses: actions/cache@v3 with: path: ./* @@ -176,7 +182,7 @@ jobs: run: yarn workspace @hyperlane-xyz/core run test - name: Run Slither - uses: crytic/slither-action@main + uses: crytic/slither-action@v0.3.0 id: slither with: target: 'solidity/' diff --git a/.gitmodules b/.gitmodules index 3077b9e20f..a8fce55006 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "solidity/lib/forge-std"] path = solidity/lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "typescript/token/lib/forge-std"] + path = typescript/token/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/typescript/token/contracts/HypNative.sol b/typescript/token/contracts/HypNative.sol index e4291fef72..30aae9e123 100644 --- a/typescript/token/contracts/HypNative.sol +++ b/typescript/token/contracts/HypNative.sol @@ -11,6 +11,13 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; * @dev Supply on each chain is not constant but the aggregate supply across all chains is. */ contract HypNative is TokenRouter { + /** + * @dev Emitted when native tokens are donated to the contract. + * @param sender The address of the sender. + * @param amount The amount of native tokens donated. + */ + event Donation(address indexed sender, uint256 amount); + /** * @notice Initializes the Hyperlane router, ERC20 metadata, and mints initial supply to deployer. * @param _mailbox The address of the mailbox contract. @@ -35,16 +42,10 @@ contract HypNative is TokenRouter { uint32 _destination, bytes32 _recipient, uint256 _amount - ) public payable override returns (bytes32 messageId) { + ) public payable virtual override returns (bytes32 messageId) { require(msg.value >= _amount, "Native: amount exceeds msg.value"); uint256 gasPayment = msg.value - _amount; - messageId = _dispatchWithGas( - _destination, - Message.format(_recipient, _amount, ""), - gasPayment, - msg.sender - ); - emit SentTransferRemote(_destination, _recipient, _amount); + return _transferRemote(_destination, _recipient, _amount, gasPayment); } function balanceOf(address _account) external view returns (uint256) { @@ -52,9 +53,9 @@ contract HypNative is TokenRouter { } /** + * @inheritdoc TokenRouter * @dev No-op because native amount is transferred in `msg.value` * @dev Compiler will not include this in the bytecode. - * @inheritdoc TokenRouter */ function _transferFromSender(uint256) internal @@ -73,7 +74,11 @@ contract HypNative is TokenRouter { address _recipient, uint256 _amount, bytes calldata // no metadata - ) internal override { + ) internal virtual override { Address.sendValue(payable(_recipient), _amount); } + + receive() external payable { + emit Donation(msg.sender, msg.value); + } } diff --git a/typescript/token/contracts/extensions/HypNativeScaled.sol b/typescript/token/contracts/extensions/HypNativeScaled.sol new file mode 100644 index 0000000000..d187be4168 --- /dev/null +++ b/typescript/token/contracts/extensions/HypNativeScaled.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; + +import {HypNative} from "../HypNative.sol"; +import {TokenRouter} from "../libs/TokenRouter.sol"; + +/** + * @title Hyperlane Native Token that scales native value by a fixed factor for consistency with other tokens. + * @dev The scale factor multiplies the `message.amount` to the local native token amount. + * Conversely, it divides the local native `msg.value` amount by `scale` to encode the `message.amount`. + * @author Abacus Works + */ +contract HypNativeScaled is HypNative { + uint256 public immutable scale; + + constructor(uint256 _scale) { + scale = _scale; + } + + /** + * @inheritdoc HypNative + * @dev Sends scaled `msg.value` (divided by `scale`) to `_recipient`. + */ + function transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amount + ) public payable override returns (bytes32 messageId) { + require(msg.value >= _amount, "Native: amount exceeds msg.value"); + uint256 gasPayment = msg.value - _amount; + uint256 scaledAmount = _amount / scale; + return + _transferRemote(_destination, _recipient, scaledAmount, gasPayment); + } + + /** + * @dev Sends scaled `_amount` (multipled by `scale`) to `_recipient`. + * @inheritdoc TokenRouter + */ + function _transferTo( + address _recipient, + uint256 _amount, + bytes calldata metadata // no metadata + ) internal override { + uint256 scaledAmount = _amount * scale; + HypNative._transferTo(_recipient, scaledAmount, metadata); + } +} diff --git a/typescript/token/contracts/libs/TokenRouter.sol b/typescript/token/contracts/libs/TokenRouter.sol index c072d88b71..ff95e9539c 100644 --- a/typescript/token/contracts/libs/TokenRouter.sol +++ b/typescript/token/contracts/libs/TokenRouter.sol @@ -51,12 +51,32 @@ abstract contract TokenRouter is GasRouter { uint32 _destination, bytes32 _recipient, uint256 _amountOrId - ) public payable virtual returns (bytes32 messageId) { + ) external payable virtual returns (bytes32 messageId) { + return + _transferRemote(_destination, _recipient, _amountOrId, msg.value); + } + + /** + * @notice Transfers `_amountOrId` token to `_recipient` on `_destination` domain. + * @dev Delegates transfer logic to `_transferFromSender` implementation. + * @dev Emits `SentTransferRemote` event on the origin chain. + * @param _destination The identifier of the destination chain. + * @param _recipient The address of the recipient on the destination chain. + * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient. + * @param _gasPayment The amount of native token to pay for interchain gas. + * @return messageId The identifier of the dispatched message. + */ + function _transferRemote( + uint32 _destination, + bytes32 _recipient, + uint256 _amountOrId, + uint256 _gasPayment + ) internal returns (bytes32 messageId) { bytes memory metadata = _transferFromSender(_amountOrId); messageId = _dispatchWithGas( _destination, Message.format(_recipient, _amountOrId, metadata), - msg.value, // interchain gas payment + _gasPayment, msg.sender // refund address ); emit SentTransferRemote(_destination, _recipient, _amountOrId); diff --git a/typescript/token/contracts/test/ERC20Test.sol b/typescript/token/contracts/test/ERC20Test.sol index 4a4e66d8eb..33f6766800 100644 --- a/typescript/token/contracts/test/ERC20Test.sol +++ b/typescript/token/contracts/test/ERC20Test.sol @@ -4,11 +4,19 @@ pragma solidity >=0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract ERC20Test is ERC20 { + uint8 public immutable _decimals; + constructor( string memory name, string memory symbol, - uint256 totalSupply + uint256 totalSupply, + uint8 __decimals ) ERC20(name, symbol) { + _decimals = __decimals; _mint(msg.sender, totalSupply); } + + function decimals() public view override returns (uint8) { + return _decimals; + } } diff --git a/typescript/token/foundry.toml b/typescript/token/foundry.toml index 6bc742ac75..9c895773e9 100644 --- a/typescript/token/foundry.toml +++ b/typescript/token/foundry.toml @@ -3,5 +3,8 @@ src = "contracts" out = "out" libs = ["lib"] allow_paths = ["../../node_modules", "../../solidity"] +solc = '0.8.15' +optimizer = true +optimizer_runs = 999_999 # See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/typescript/token/lib/forge-std b/typescript/token/lib/forge-std new file mode 160000 index 0000000000..74cfb77e30 --- /dev/null +++ b/typescript/token/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 74cfb77e308dd188d2f58864aaf44963ae6b88b1 diff --git a/typescript/token/src/config.ts b/typescript/token/src/config.ts index 1876f0ef73..cd17e00356 100644 --- a/typescript/token/src/config.ts +++ b/typescript/token/src/config.ts @@ -16,26 +16,29 @@ export type TokenMetadata = { totalSupply: ethers.BigNumberish; }; -export type ERC20Metadata = TokenMetadata & { +export type TokenDecimals = { decimals: number; + scale?: number; }; +export type ERC20Metadata = TokenMetadata & TokenDecimals; + export const isTokenMetadata = (metadata: any): metadata is TokenMetadata => metadata.name && metadata.symbol && metadata.totalSupply !== undefined; // totalSupply can be 0 export const isErc20Metadata = (metadata: any): metadata is ERC20Metadata => metadata.decimals && isTokenMetadata(metadata); -export type SyntheticConfig = TokenMetadata & { +export type SyntheticConfig = { type: TokenType.synthetic | TokenType.syntheticUri; -}; +} & TokenMetadata; export type CollateralConfig = { type: TokenType.collateral | TokenType.collateralUri; token: string; } & Partial; export type NativeConfig = { type: TokenType.native; -}; +} & Partial; export type TokenConfig = SyntheticConfig | CollateralConfig | NativeConfig; @@ -58,7 +61,9 @@ export const isUriConfig = (config: TokenConfig) => config.type === TokenType.collateralUri; export type HypERC20Config = GasRouterConfig & SyntheticConfig & ERC20Metadata; -export type HypERC20CollateralConfig = GasRouterConfig & CollateralConfig; +export type HypERC20CollateralConfig = GasRouterConfig & + CollateralConfig & + Partial; export type HypNativeConfig = GasRouterConfig & NativeConfig; export type ERC20RouterConfig = | HypERC20Config diff --git a/typescript/token/src/deploy.ts b/typescript/token/src/deploy.ts index c8f9db6381..683d3833c4 100644 --- a/typescript/token/src/deploy.ts +++ b/typescript/token/src/deploy.ts @@ -45,6 +45,7 @@ import { HypERC721URIStorage__factory, HypERC721__factory, HypNative, + HypNativeScaled__factory, HypNative__factory, } from './types'; @@ -140,12 +141,22 @@ export class HypERC20Deployer extends GasRouterDeployer< chain: ChainName, config: HypNativeConfig, ): Promise { - const router = await this.deployContractFromFactory( - chain, - new HypNative__factory(), - 'HypNative', - [], - ); + let router: HypNative; + if (config.scale) { + router = await this.deployContractFromFactory( + chain, + new HypNativeScaled__factory(), + 'HypNativeScaled', + [config.scale], + ); + } else { + router = await this.deployContractFromFactory( + chain, + new HypNative__factory(), + 'HypNative', + [], + ); + } await this.multiProvider.handleTx( chain, router.initialize(config.mailbox, config.interchainGasPaymaster), diff --git a/typescript/token/test/HypNativeScaled.t.sol b/typescript/token/test/HypNativeScaled.t.sol new file mode 100644 index 0000000000..d26d138c08 --- /dev/null +++ b/typescript/token/test/HypNativeScaled.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; + +import {HypNativeScaled} from "../contracts/extensions/HypNativeScaled.sol"; +import {HypERC20} from "../contracts/HypERC20.sol"; +import {TypeCasts} from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol"; +import {MockHyperlaneEnvironment} from "@hyperlane-xyz/core/contracts/mock/MockHyperlaneEnvironment.sol"; + +contract HypNativeScaledTest is Test { + uint32 nativeDomain = 1; + uint32 synthDomain = 2; + + uint8 decimals = 9; + uint256 mintAmount = 123456789; + uint256 nativeDecimals = 18; + uint256 scale = 10**(nativeDecimals - decimals); + + event Donation(address indexed sender, uint256 amount); + event SentTransferRemote( + uint32 indexed destination, + bytes32 indexed recipient, + uint256 amount + ); + event ReceivedTransferRemote( + uint32 indexed origin, + bytes32 indexed recipient, + uint256 amount + ); + + HypNativeScaled native; + HypERC20 synth; + + MockHyperlaneEnvironment environment; + + function setUp() public { + environment = new MockHyperlaneEnvironment(synthDomain, nativeDomain); + + synth = new HypERC20(decimals); + synth.initialize( + address(environment.mailboxes(synthDomain)), + address(environment.igps(synthDomain)), + mintAmount * (10**decimals), + "Zebec BSC Token", + "ZBC" + ); + + native = new HypNativeScaled(scale); + native.initialize( + address(environment.mailboxes(nativeDomain)), + address(environment.igps(nativeDomain)) + ); + + native.enrollRemoteRouter( + synthDomain, + TypeCasts.addressToBytes32(address(synth)) + ); + synth.enrollRemoteRouter( + nativeDomain, + TypeCasts.addressToBytes32(address(native)) + ); + } + + function test_constructor() public { + assertEq(native.scale(), scale); + } + + uint256 receivedValue; + + receive() external payable { + receivedValue = msg.value; + } + + function test_receive(uint256 amount) public { + vm.assume(amount < address(this).balance); + vm.expectEmit(true, true, true, true); + emit Donation(address(this), amount); + (bool success, bytes memory returnData) = address(native).call{ + value: amount + }(""); + assert(success); + assertEq(returnData.length, 0); + } + + function test_handle(uint256 amount) public { + vm.assume(amount <= mintAmount); + + uint256 synthAmount = amount * (10**decimals); + uint256 nativeAmount = amount * (10**nativeDecimals); + + vm.deal(address(native), nativeAmount); + + bytes32 recipient = TypeCasts.addressToBytes32(address(this)); + synth.transferRemote(nativeDomain, recipient, synthAmount); + + vm.expectEmit(true, true, true, true); + emit ReceivedTransferRemote(synthDomain, recipient, synthAmount); + environment.processNextPendingMessage(); + + assertEq(receivedValue, nativeAmount); + } + + function test_handle_reverts_whenAmountExceedsSupply(uint256 amount) + public + { + vm.assume(amount <= mintAmount); + + bytes32 recipient = TypeCasts.addressToBytes32(address(this)); + synth.transferRemote(nativeDomain, recipient, amount); + + uint256 nativeValue = amount * scale; + vm.deal(address(native), nativeValue / 2); + + if (amount > 0) { + vm.expectRevert(bytes("Address: insufficient balance")); + } + environment.processNextPendingMessage(); + } + + function test_tranferRemote(uint256 amount) public { + vm.assume(amount <= mintAmount); + + uint256 nativeValue = amount * (10**nativeDecimals); + uint256 synthAmount = amount * (10**decimals); + address recipient = address(0xdeadbeef); + bytes32 bRecipient = TypeCasts.addressToBytes32(recipient); + + vm.assume(nativeValue < address(this).balance); + vm.expectEmit(true, true, true, true); + emit SentTransferRemote(synthDomain, bRecipient, synthAmount); + native.transferRemote{value: nativeValue}( + synthDomain, + bRecipient, + nativeValue + ); + environment.processNextPendingMessageFromDestination(); + assertEq(synth.balanceOf(recipient), synthAmount); + } + + function test_transferRemote_reverts_whenAmountExceedsValue( + uint256 nativeValue + ) public { + vm.assume(nativeValue < address(this).balance); + + address recipient = address(0xdeadbeef); + bytes32 bRecipient = TypeCasts.addressToBytes32(recipient); + vm.expectRevert("Native: amount exceeds msg.value"); + native.transferRemote{value: nativeValue}( + synthDomain, + bRecipient, + nativeValue + 1 + ); + } +} diff --git a/typescript/token/test/erc20.test.ts b/typescript/token/test/erc20.test.ts index fbbccc80f2..eb346d5b07 100644 --- a/typescript/token/test/erc20.test.ts +++ b/typescript/token/test/erc20.test.ts @@ -82,6 +82,7 @@ for (const variant of [ tokenMetadata.name, tokenMetadata.symbol, tokenMetadata.totalSupply, + tokenMetadata.decimals, ); localTokenConfig = { type: variant,