Fusionist
Behind the Code

Bridging USDC from Ethereum to a New EVM Chain with Hyperlane v4.x

This article is from my colleague @lyfsn. He just finished deploying Hyperlane on our Endurance chain with the Hyperlane team. Considering that Hyperlane CLI v4 is a significantly updated version and there aren’t many new tutorials available right now, I believe it offers valuable insights.


Hyperlane is a permissionless cross-chain project that enables bridging any ERC-20, ERC-721, or similar assets to other chains. It also supports bridging native tokens and even non-EVM chains like Cosmos.

Endurance is an EVM chain, and in the first phase, we aim to bridge USDC from Ethereum’s L2, including Base and Arbitrum, to Endurance. Here are some insights on how to achieve that.

1. Add New Chain Metadata

First, you need to install the latest hyperlane CLI tool on your machine:

npm install -g @hyperlane-xyz/cli

Then follow this documentation by Hyperlane to create new chain registry information. Afterward, submit a pull request with your chain’s metadata to hyperlane-registry. This step does not require address information such as mailbox or proxyAdmin, just your chain’s metadata.

For your PR to pass the hyperlane-registry’s CI verification, remember to run yarn lint and yarn prettier on your local repo to verify the format of metadata.yaml is correct. This will save time for both you and the Hyperlane reviewer.

This is an example PR #94 when adding Endurance to the Hyperlane-registr. You can notice in this PR, the reviewer asked Endurance to remove whitespace in the logo SVG file, so you also need to take care of your logo file before you commit it.

Once your PR is merged by Hyperlane-registr, your chain name will appear in this command output:

hyperlane registry list

2. Deploy Core Contract

Every chain needs a suite contract called the core contract. All contracts revolve around the mailbox contract, and other contracts like the factory contract will use the mailbox address as a constructor.

If you have already followed this documentation step by step, you might have already executed the command hyperlane core deploy and deployed the contract on your chain. But before deploying it, you need to clarify those concepts.

ISM

ISM is Hyperlane’s security module. If you use the command with --advanced, you can see all the types of ISM that the CLI currently provides:

hyperlane core init --advanced

You will get output such as:

❯ staticAggregationIsm
  defaultFallbackRoutingIsm
  merkleRootMultisigIsm
  messageIdMultisigIsm
  domainRoutingIsm
  testIsm
  trustedRelayerIsm

If you initialize the core contract configuration without the --advanced parameter, the CLI defaults to using the trustedRelayerIsm ISM in your configuration. It requires you to enter a trusted relayer address in the CLI init process.

In Endurance, we select the 2/2 threshold staticAggregationIsm and aggregate merkleRootMultisigIsm and messageIdMultisigIsm on it because we believe the relayer should be permissionless, allowing anyone to run a relayer and relay the message from mailbox. Therefore, we don’t use the default trustedRelayerIsm. If you use trustedRelayerIsm in mailbox as the defaultIsm, it means you are selecting a centralized mode. Only the relayer’s private key owner can run this relayer instance.

Relayer

The relayer is responsible for relaying messages from chain A to chain B. If you have two chains, A and B, and run a relayer to relay messages between them, the relayer will listen to every message log in A’s mailbox and deliver it to chain B, and do the same from B to A.

Validator

The Validator is the signer for the ISM contract. If you deploy a 1/1 threshold messageIdMultisigIsm on Arbitrum and Endurance, and 0x111 and 0x222 are the validator’s addresses, you deploy both side ISMs using this configuration:

arbitrum:
  interchainSecurityModule:
    threshold: 0
    type: messageIdMultisigIsm
    validators:
      - "0x111"
endurance:
  interchainSecurityModule:
    threshold: 1
    type: messageIdMultisigIsm
    validators:
      - "0x222"

When your message goes from Arbitrum to Endurance, you need to start your validator using the private key of address 0x222 as a startup parameter, and use the origin chain name Arbitrum as another startup parameter, like this:

docker run \
  [...]
  gcr.io/abacus-labs-dev/hyperlane-agent:main \
  [...]
  --originChainName arbitrum \
  --validator.id [0x222's private key]

This will make your validator listen to the mailbox logs on Arbitrum. When a message from Arbitrum is detected, the validator will sign it to prove that the message is actually from chain Arbitrum. At this point, the relayer delivers the message to chain Endurance.

When the relayer delivers the cross-chain message, it will also fetch the validator’s signature data from 0x2222’s local database or S3, and use this signature data as metadata, sending it to Endurance’s mailbox along with the message itself.

Core Contract Addresses

After the CLI finishes deploying the core contract, it will generate a [chainName]-addresses.yaml file in the ~/.hyperlane/chains/ folder. The CLI’s output log will also print the contract addresses information in your terminal.

This is an example endurance-addresses.yaml file (just for test) after deployment:

staticMerkleRootMultisigIsmFactory: "0x516195dF89ADaAf3088338a8dc56316166ABd903"
staticMessageIdMultisigIsmFactory: "0xb363780814b5b34fC8D1CDE37C206060898F3B52"
staticAggregationIsmFactory: "0x2D55f6D280D442d9Ec2E18f18ca421615787374D"
staticAggregationHookFactory: "0x47a59613AF0128688aB1C2D5Ce3Bd7B74076Ca22"
domainRoutingIsmFactory: "0x6FF4f158c9cc15A5150eD86E1Dd05E2dDCc9918b"
proxyAdmin: "0xF6fB32e86acB47Ea69De0BdcEce30C4c83bF2cA0"
mailbox: "0x3e5EC2C305C9C7B98dA695E2C424a33B250A4149"
interchainAccountRouter: "0x99F63cEA7C57a446C6a66d2B7dFdc18967644def"
interchainAccountIsm: "0x8F8B0Fad946c48252d7DD660Be8CF516D0c8abd3"
validatorAnnounce: "0x62926506C0529804DB22D446C9011D1D9D9EDd31"
testRecipient: "0xa4Ab0C0ed7342dab5090B28D579C01358998f3f0"

But there is a lack of the current least 4.1.0 CLI tool, that both [chainName-addresses.yaml] and output logs in the terminal do not contain implementation contract info, such as the below contract address. The contract should be created by the Factory contract, and the CLI actually does it in the deploy core contract process, but the CLI does not record those contract addresses in the result file:

interchainSecurityModule
messageIdMultisigIsm
merkleRootMultisigIsm
staticAggregationIsm
merkleTreeHook
protocolFee

Why is this contract address info useful? Because when you want to run your own relayer and validator, you need these contract addresses and put them into your config file for the relayer and validator. If you don’t have this address info, your relayer and validator can’t start.

So in this condition, you need to retain your terminal output logs when the core contract’s deployment process prints and use these logs to find the implementation contract address info. For example, if you want to find your merkleTreeHook contract address, you can check your log for this part:

Deploy merkleTreeHook on endurance with constructor args (0x3e5EC2C305C9C7B98dA695E2C424a33B250A4149)
Pending 0x5987ec507cede66111c08d388c42045d0574804ba678687a3b174132164ce1b8 (waiting 1 blocks for confirmation)

This log part contains a transaction hash 0x5987ec507cede66111c08d388c42045d0574804ba678687a3b174132164ce1b8 and you can check this transaction in explorer. The transaction details show which contract was deployed by this transaction, and you can get your merkleTreeHook contract address now.

For another contract such as the protocolFee contract, you may get 3 transactions in your logs. Just focus on the first transaction; the first transaction deployed the contract, while the other 2 interacted with your ISM factory and mailbox, not creating new contracts.

Deploy protocolFee on endurance with constructor args (2000000000000000000, 1000000000000000000, 0x96E82161fbC0a755704d04Fd9EbF3ee9099488e4, 0x96E82161fbC0a755704d04Fd9EbF3ee9099488e4)
Pending 0xc3a3e4ed7ce97e4e450eab339799a87dd3ebd6edb5ecd9bb1d30a1bca04e17e2 (waiting 1 blocks for confirmation)
Pending 0x84295bce47b87d5d73eb1ecfb54133e7b5293ecfcb1d9efe9b2e553601776d3d (waiting 1 blocks for confirmation)
Pending 0x36e729653f6ab0fd26c4ad8ec07567115952719434f81a82d82356c25c6e9db6 (waiting 1 blocks for confirmation)

This is a whole config for the agent. This originally would be generated after deploying the core contract in CLI version 3.x, but the latest 4.x version has dropped this function. So you should follow the above steps to collate and complete this config yourself:

{
  "chains": {
    "endurance": {
      "name": "endurance",
      "chainId": 648,
      "domainId": 648,
      "protocol": "ethereum",
      "rpcUrls": [{"http": "https://rpc-endurance.fusionist.io/"}],
      "staticMerkleRootMultisigIsmFactory": "0x516195dF89ADaAf3088338a8dc56316166ABd903",
      "staticMessageIdMultisigIsmFactory": "0xb363780814b5b34fC8D1CDE37C206060898F3B52",
      "staticAggregationIsmFactory": "0x2D55f6D280D442d9Ec2E18f18ca421615787374D",
      "staticAggregationHookFactory": "0x47a59613AF0128688aB1C2D5Ce3Bd7B74076Ca22",
      "domainRoutingIsmFactory": "0x6FF4f158c9cc15A5150eD86E1Dd05E2dDCc9918b",
      "interchainSecurityModule": "0x4b7869F2371F45797761cA247248Ea34717A172B",
      "messageIdMultisigIsm": "0x09dd2f8c3dc24d7B92925f4a942f6ca2d8F59706",
      "merkleRootMultisigIsm": "0x9b5AdFE3787D62d65bD876D2c6d5E522f9a37274",
      "staticAggregationIsm": "0x4b7869F2371F45797761cA247248Ea34717A172B",
      "merkleTreeHook": "0x92C85ad03A4a9092882A88D993322c19908849CB",
      "protocolFee": "0x44AC6029537E850BBfA4F83acBf09aff1A0ba75e",
      "testRecipient": "0xa4Ab0C0ed7342dab5090B28D579C01358998f3f0",
      "mailbox": "0x3e5EC2C305C9C7B98dA695E2C424a33B250A4149",
      "proxyAdmin": "0xF6fB32e86acB47Ea69De0BdcEce30C4c83bF2cA0",
      "validatorAnnounce": "0x62926506C0529804DB22D446C9011D1D9D9EDd31",
      "interchainGasPaymaster": "0x0000000000000000000000000000000000000000",
      "index": {"from": 866153}
    },
    "arbitrum": {
      "rpcUrls": [{"http": "https://<your infra>"}]
    }
  },
  "defaultRpcConsensusType": "fallback"
}

Deploy Core Contract by Hyperlane Team

Your team could also try to collaborate with the Hyperlane Team. They will deploy the core contract more professionally due to their extensive experience with many chains. They could even add your chain to their relayer, allowing you to save resources for infra resources, deploy IGP and oracle to your chain for relayer’s economics, and other collaborative items, etc.

3. Run Agent

After you deploy your core contract on your chain, your cross-chain message will be processed by this command:

hyperlane send message --relay

The parameter --relay means you don’t need to run a relayer node, as the CLI tool will deliver your message. However, if you use a custom ISM and configure validator info when deploying it, you will need to run a validator.

So now, we should run a relayer and validator instance.

Relayer

These docs provide full steps on how to run a relayer. There are two key points you should focus on.

Sync Speed

If a relayer starts from an empty database folder, it may need some time to sync the data. The duration depends on how many messages are in the mailbox history, as different ISM require different sync times.

For messageIdMultisigIsm in the Arbitrum mainnet, the message delivery takes about 30 minutes the first time because the relayer needs to sync messages forward at least. You can check your relayer logs and get something like this:

  2024-07-13T05:19:13.845083Z  INFO hyperlane_base::contract_sync::cursors::sequence_aware::forward: return: Ok(None)
    at hyperlane-base/src/contract_sync/cursors/sequence_aware/forward.rs:95
    in hyperlane_base::contract_sync::cursors::sequence_aware::forward::get_next_range with self: ForwardSequenceAwareSyncCursor { chunk_size: 1999, last_indexed_snapshot: LastIndexedSnapshot { sequence: Some(866553), at_block: 231668300 }, current_indexing_snapshot: TargetSnapshot { sequence: 866554, at_block: 231668447 }, target_snapshot: Some(TargetSnapshot { sequence: 866553, at_block: 231668301 }), index_mode: Block }
    in hyperlane_base::contract_sync::fetch_logs_with_cursor with cursor: ForwardBackwardSequenceAwareSyncCursor { forward: ForwardSequenceAwareSyncCursor { chunk_size: 1999, last_indexed_snapshot: LastIndexedSnapshot { sequence: Some(866553), at_block: 231668300 }, current_indexing_snapshot: TargetSnapshot { sequence: 866554, at_block: 231668447 }, target_snapshot: Some(TargetSnapshot { sequence: 866553, at_block: 231668301 }), index_mode: Block }, backward: BackwardSequenceAwareSyncCursor { chunk_size: 1999, last_indexed_snapshot: LastIndexedSnapshot { sequence: Some(0), at_block: 143972855 }, current_indexing_snapshot: None, index_mode: Block }, last_direction: Forward }, domain: "arbitrum"
    in hyperlane_base::contract_sync::ContractSync with label: "dispatched_messages", domain: "arbitrum"

There are two kinds of cursors::sequence_aware: forward and backward. In forward sync, you can see the value of LastIndexedSnapshot is what the current relayer’s database has. Once it reaches the whole network’s latest status, the relayer will deliver your message normally.

For merkleRootMultisigIsm, something is different. You should check the backward process:

  2024-07-13T05:19:13.845122Z  INFO hyperlane_base::contract_sync::cursors::sequence_aware::backward: return: Ok(None)
    at hyperlane-base/src/contract_sync/cursors/sequence_aware/backward.rs:82
    in hyperlane_base::contract_sync::cursors::sequence_aware::backward::get_next_range with self: BackwardSequenceAwareSyncCursor { chunk_size: 1999, last_indexed_snapshot: LastIndexedSnapshot { sequence: Some(0), at_block: 143972855 }, current_indexing_snapshot: None, index_mode: Block }
    in hyperlane_base::contract_sync::fetch_logs_with_cursor with cursor: ForwardBackwardSequenceAwareSyncCursor { forward: ForwardSequenceAwareSyncCursor { chunk_size: 1999, last_indexed_snapshot: LastIndexedSnapshot { sequence: Some(866553), at_block: 231668300 }, current_indexing_snapshot: TargetSnapshot { sequence: 866554, at_block: 231668447 }, target_snapshot: Some(TargetSnapshot { sequence: 866553, at_block: 231668301 }), index_mode: Block }, backward: BackwardSequenceAwareSyncCursor { chunk_size: 1999, last_indexed_snapshot: LastIndexedSnapshot { sequence: Some(0), at_block: 143972855 }, current_indexing_snapshot: None, index_mode: Block }, last_direction: Forward }, domain: "arbitrum"
    in hyperlane_base::contract_sync::ContractSync with label: "dispatched_messages", domain: "arbitrum"

Until the last_indexed_snapshot goes from the least to 0, it shows that all the message data is in the database. Due to merkleRootMultisigIsm using a Merkle Tree data structure, this structure needs all leaf node data to calculate the root node’s value. So before the backward process finishes, the merkleRootMultisigIsm will not pass.

In the current Arbitrum mainnet, syncing all backward takes about 4 hours or more. So you should be patient during this time, and rest assured that your message is not lost or dropped; it’s just your relayer syncing history data.

Whitelist Parameter

The relayer’s whitelist parameter is not working in version cd08d61b. All whitelist configurations are handled as blacklist due to a typo in the code in PR #4000.

For example, if you use the command line like this to start Realyer:

docker run \
  [...]
  gcr.io/abacus-labs-dev/hyperlane-agent:main \
  ./relayer \
  --whitelist='[{"senderAddress":"*","destinationDomain":[648,42161,8453],"recipientAddress":"*"}}]'
  --log.level debug

You will get some logs in your relayer when it should process a message but it does not:

2024-07-05T03:27:52.899855Z DEBUG relayer::msg::processor: Message blacklisted, skipping, msg: HyperlaneMessage { id: 0xeaf3296ca99c45808d920727c22bf6b33a553072eae89b4c1d6429590fd30427, version: 3, nonce: 820647, origin: arbitrum, sender: 0x96e82161fbc0a755704d04fd9ebf3ee9099488e4, destination: 648, recipient: 0x000000000000000000000000a4ab0c0ed7342dab5090b28d579c01358998f3f0, body: 0x32 }, blacklist: MatchingList(Some([ListElement { origin_domain: Wildcard, sender_address: Wildcard, destination_domain: Enumerated([648, 42161, 8453]), recipient_address: Wildcard }]))

We created a PR #4099 to fix this problem, but it has not been merged yet. So if you are confused about why your relayer is not working when using the --whitelist parameter, this is the reason.

Validator

Hyperlane also provides docs on how to run a validator. However, there is a small problem I found but haven’t resolved yet. When you use --validator.type=aws with an environment variable to start the validator:

export VALIDATOR_KEY_ROOT="alias/my-key-1
docker run \
  [...]
  -e VALIDATOR_KEY=$VALIDATOR_KEY_ROOT \
  gcr.io/abacus-labs-dev/hyperlane-agent:main \
  ./validator \
  [...]
  --validator.type aws \
  --validator.id $VALIDATOR_KEY

The validator will be broken due to some reason. However, it will work fine if you set the --validator.id value as a string directly:

docker run \
  [...]
  -e VALIDATOR_KEY=$VALIDATOR_KEY_ROOT \
  gcr.io/abacus-labs-dev/hyperlane-agent:main \
  ./validator \
  [...]
  --validator.type aws \
  --validator.id "alias/my-key-1"

So this is just a reminder, if you run your validator and encounter some errors without knowing the reason, this might be the cause.

4. Deploy Warp Route

At this time, you have already deployed the core contract and are running a relayer and validator. You can deploy a warp route as the next step. There are two points the documents do not emphasize for you.

ISM of warp route

Every warp route should deploy an ISM in the current CLI process when you initialize your warp route config. If you use the basic mode to initialize:

hyperlane warp init

The CLI will use trustedRelayerIsm for your config and require you to provide a relayer address. Similar to initializing the core contract config, you can also use the --advanced parameter to customize your ISM config. With this command, you can select which ISM type your warp route will use.

hyperlane warp init  --advanced 

If you want to use the default ISM in your mailbox, you should select the defaultFallbackRoutingIsm, which will make your warp route use the mailbox’s default ISM.

Otherwise, if you choose another ISM type, the CLI will deploy the corresponding ISM contract for your route. This new ISM contract will have no relationship with your default ISM in the mailbox.

Choose token type

The CLI provides several token types for you to use. In Endurance scenarios, for testing we use collateral on the Arbitrum side and synthetic on the Endurance side.

The collateral type is used for an existing ERC-20 contract, and we use USDC for it. The synthetic type will create a new warped ERC-20 contract in Endurance, as there is no existing USDC contract in Endurance.

If your chain already has some tokens and you just need to bridge them cross-chain, you can use collateral on both sides. The type you use depends on your chain’s conditions. It is important to note that the docs do not emphasize that synthetic is crucial if your chain does not have existing contracts.

More deeply, Endurance production environment uses collateralFiat to bridge USDC, which is more complex than synthetic. There is no documentation by the Hyperlane Team yet; you can check the How to use the collateralFiat token type section to know how Endurance does that.

5. Deploy a UI

After deploying your warp route, you will get a token config file in the ~/.hyperlane/deployments/warp_routes folder. Next, just follow the docs to run your frontend UI, similar to Hyperlane Nexus.

There is still a small problem in the UI. When you want to bridge USDC from Arbitrum (or Base) to Endurance, the UI will show that the needed Local Gas (est.) is 0.0014 ETH:

ui1

But currently, Ethereum’s L2 network’s gas fee is very low, and you do not need so much ETH. Actually, your wallet will not consume the amount of ETH shown on the page. When you send this transaction after approving your USDC, you will see:

ui2

So this is just an estimated gas fee problem showing the wrong value in the frontend. It will not make you spend more than the network fee’s amount. You should be aware of this.

6. Dive Deeper

These are a few problems you may be concerned about.

How ISM verifies validator’s address

The synthetic chain’s warped USDC will be minted by the relayer, and ISM verifies the relayer’s message and metadata if it is illegal. You may notice that the ISM contract verifies the message by this function:

function verify(
    bytes calldata _metadata,
    bytes calldata _message
) public view returns (bool) {
    bytes32 _digest = digest(_metadata, _message);
    (
        address[] memory _validators,
        uint8 _threshold
    ) = validatorsAndThreshold(_message);   // <-- Get validator from _message

But wait, do the two parameters _metadata and _message all pass by the relayer? And the validator’s address is parsed by _message? Does it mean the relayer could make a fake message and metadata, then let any validator’s address info in metadata, and use the validator key to sign the message and put it in metadata?

But the Hyperlane docs say the relayer is a permissionless role, and anyone can run a relayer in another network. If the relayer could make fake metadata, how does ISM ensure the message is secure by the validator?

This problem is resolved in this code.

function validatorsAndThreshold(
    bytes calldata
) public pure override returns (address[] memory, uint8) {
    return abi.decode(MetaProxy.metadata(), (address[], uint8));   // <-- Not use calldata
}

Actually, the validator’s address is not parsed by _message; it’s decoded by the contract’s metadata. This validator’s address is written in the ISM contract when the CLI deploys it. So the relayer has no ability to make fake metadata, only the validator addresses initialized in the CLI config can pass verification.

So if you have the same confusion about this problem, you can know how to understand it.

How to use the collateralFiat token type

There is a token type named collateralFiat, and you can also see the HypFiatToken contract in the repo. So, how do you use the collateralFiat token type? What does this token type do?

The collateralFiat token type is used for two chains that already have a fiat contract in place. If Endurance already has a USDC token deployed by the Circle Team, we can use collateral in Arbitrum and collateralFiat in Endurance. This way, USDC can be bridged directly from Arbitrum to Endurance. Using collateralFiat ensures that the bridged USDC will be the original USDC, not wrapped USDC.

The USDC contract can also be deployed yourself, so before you use collateralFiat to bridge USDC from another chain, you should deploy it first on your chain. Although it is not controlled by the Circle Team, it is okay because your USDC contract uses the USDC fiat token standard. If someday the Circle Team decides to control USDC on your chain, you can transfer the contract ownership to the Circle Team.

So Endurance deployed a USDC contract independently and uses it with Hyperlane’s collateralFiat bridge function. Hyperlane’s CLI tool currently cannot deploy a new fiat token on a synthetic chain, but the Hyperlane Team is working on this, and it may become available in the next few weeks.

How to Deploy IGP

The CLI does not provide a function to deploy the IGP contract suite, so you need to do it yourself. You need to deploy two contracts to use IGP. One is InterchainGasPaymaster.sol. Use this command to deploy it:

forge create ~/hyperlane-monorepo/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol:InterchainGasPaymaster

You should initialize the contract with the _owner and _beneficiary addresses after deployment:

cast send <InterchainGasPaymaster.Address> \
  "initialize(address,address)" <Address> <Address>

And the other is StorageGasOracle.sol. Use this command to deploy it:

forge create ~/hyperlane-monorepo/solidity/contracts/hooks/igp/StorageGasOracle.sol:StorageGasOracle 

Then, set up the target domain and gas price rate by calling the setRemoteGasDataConfigs function of StorageGasOracle:

cast send <StorageGasOracle.Address> \
   "setRemoteGasDataConfigs((uint32,uint128,uint128)[])" \
  '[(648,10000000000000,10000000000)]'

The last step is to let your InterchainGasPaymaster contract know which StorageGasOracle contract you are using:

cast send <InterchainGasPaymaster.Address> \
  "setDestinationGasConfigs((uint32,(address,uint96))[])" \
  '[(648,(<StorageGasOracle.Address>,10000))]'

Now you have an IGP contract, you can use it in the warp route configuration file or set it directly in your mailbox contract.

How to change ISM address

As a developer, you might want to debug different types of ISM or temporarily use a different ISM. So, how do you change the ISM address in the mailbox or warp route?

To set up which ISM your mailbox uses, call this function:

cast send <Mailbox.Address> \
  "setDefaultIsm(address)" <ISM.address>

For warp route, every HyperERC20 token is a MailboxClient. Call this function to set up which ISM your warp route uses.

cast send <HyperERC20.Address> \
  "setHook(address)" <ISM.address>
Update: 2024-07-18
Tags: