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
:
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:
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>