Template project for a cross-chain token (OFT) powered by the LayerZero protocol. This example's config involves connecting EVM chains, like Hedera, to other EVM chains.
- Prerequisite Knowledge
- Requirements
- Scaffold this example
- Setup
- Build
- Deploy
- Simple Workers (For Testnets Without Default Workers)
- Next Steps
- Production Deployment Checklist
Node.js->=18.16.0pnpm(recommended) - or another package manager of your choice (npm, yarn)forge(optional) ->=0.2.0for testing, and if not using Hardhat for compilation
Create your local copy of this example:
git clone https://github.com/hedera-dev/template-hedera-lz-app
cd template-hedera-lz-app
pnpm install- Copy
.env.exampleinto a new.env - Add your Hedera Portal or existing deployer address/account to the
.env - If using an existing account, fund it with the native tokens of the chains you want to deploy to e.g. the Hedera Faucet. This example by default will deploy to the following chains' testnets: Hedera and Base.
This project supports both hardhat and forge compilation. By default, the compile command will execute both:
pnpm compileIf you prefer one over the other, you can use the tooling-specific commands:
pnpm compile:forge
pnpm compile:hardhatTo deploy the OFT contracts to your desired blockchains, run the following command:
pnpm hardhat lz:deploy --tags MyOFTSelect all the chains you want to deploy the OFT to.
⚠️ Development Only: Simple Workers are mock implementations for testing on testnets. They should NEVER be used in production as they provide no security or service guarantees. For mainnet use the LayerZero worker addresses.
Simple Workers consist of:
- SimpleDVNMock: A minimal DVN that allows manual message verification
- SimpleExecutorMock: A mock executor that charges zero fees and enables manual message execution
Deploy the Simple Workers:
# Deploy SimpleDVNMock
pnpm hardhat lz:deploy --tags SimpleDVNMock
# Deploy SimpleExecutorMock
pnpm hardhat lz:deploy --tags SimpleExecutorMockYou can now use custom DVNs and Executors with the standard lz:oapp:wire command by adding them to your metadata configuration.
-
Get your deployed addresses from the deployment files:
- SimpleDVNMock:
./deployments/<network-name>/SimpleDVNMock.json - SimpleExecutorMock:
./deployments/<network-name>/SimpleExecutorMock.json
- SimpleDVNMock:
-
Update your
layerzero.simple-worker.config.tsto include your deployed Simple Workers:- SECTION 4: Add your Simple Worker addresses:
// In layerzero.simple-worker.config.ts, SECTION 4: CUSTOM EXECUTOR AND DVN ADDRESSES
const customExecutorsByEid: Record<number, { address: string }> = {
[EndpointId.BASESEP_V2_TESTNET]: { address: "0x..." }, // From deployments/base-sepolia/SimpleExecutorMock.json
[EndpointId.HEDERA_V2_TESTNET]: { address: "0x..." }, // From deployments/hedera-testnet/SimpleExecutorMock.json
};
const customDVNsByEid: Record<number, { address: string }> = {
[EndpointId.BASESEP_V2_TESTNET]: { address: "0x..." }, // From deployments/base-sepolia/SimpleDVNMock.json
[EndpointId.HEDERA_V2_TESTNET]: { address: "0x..." }, // From deployments/hedera-testnet/SimpleDVNMock.json
};- Wire normally using the custom configuration:
pnpm hardhat lz:oapp:wire --oapp-config layerzero.simple-worker.config.tsThis command will automatically:
- Detect pathways without DVN configurations in your LayerZero config
- Configure SimpleDVNMock and SimpleExecutorMock for those pathways
- Set both send and receive configurations on the appropriate chains
- Skip pathways that already have DVN configurations
ℹ️ The command only configures pathways with empty DVN arrays, preserving any existing configurations.
When sending OFTs with Simple Workers, add the --simple-workers flag to enable the manual verification and execution flow:
# Hedera Testnet -> Base Sepolia
pnpm hardhat lz:oft:send --src-eid 40285 --dst-eid 40245 --amount 1 --to <RECIPIENT> --simple-workers
# Base Sepolia -> Hedera Testnet
pnpm hardhat lz:oft:send --src-eid 40245 --dst-eid 40285 --amount 1 --to <RECIPIENT> --simple-workersWith the --simple-workers flag, the task will:
- Send the OFT transaction as normal
- Automatically trigger the manual verification process on the destination chain
- Execute the message delivery through the Simple Workers
The manual verification flow involves three steps on the destination chain:
- Verify: SimpleDVNMock verifies the message payload
- Commit: SimpleDVNMock commits the verification to the ULN
- Execute: SimpleExecutorMock executes the message delivery
Without the --simple-workers flag, you would need to manually call these steps using the provided tasks:
lz:oapp:wire:simple-workers- Configure Simple Workers for all pathways without DVN configurationslz:simple-dvn:verify- Verify the message with SimpleDVNMocklz:simple-dvn:commit- Commit the verification to ULNlz:simple-workers:commit-and-execute- Execute the message deliverylz:simple-workers:skip- Skip a stuck message (permanent action!)
- Zero Fees: Simple Workers charge no fees, breaking the economic security model
- No Real Verification: Messages are manually verified without actual validation
- Testnet Only: These mocks provide no security and must never be used on mainnet
- Manual Process: Requires manual intervention or the
--simple-workersflag for automation
LayerZero enforces ordered message delivery per channel (source → destination). Messages must be processed in the exact order they were sent. If a message fails or is skipped, all subsequent messages on that channel will be blocked.
Common Error: "InvalidNonce"
warn: Lazy inbound nonce is not equal to inboundNonce + 1. You will run into an InvalidNonce error.
This means there are pending messages that must be processed first.
When a message is stuck, you have two options:
Option 1: Process the Pending Message
# Find the pending nonce from the error message, then:
npx hardhat lz:simple-dvn:verify --src-eid <SRC_EID> --dst-eid <DST_EID> --nonce <PENDING_NONCE> --src-oapp <SRC_OAPP> --to-address <RECIPIENT> --amount <AMOUNT>
npx hardhat lz:simple-workers:commit-and-execute --src-eid <SRC_EID> --dst-eid <DST_EID> --nonce <PENDING_NONCE> ...Option 2: Skip the Message (Cannot be undone!)
# Skip a stuck message on the destination chain
npx hardhat lz:simple-workers:skip --src-eid <SRC_EID> --src-oapp <SRC_OAPP> --nonce <NONCE_TO_SKIP> --receiver <RECEIVER_OAPP>
⚠️ Skipping is permanent! Once skipped, the message cannot be recovered. The tokens/value in that message will be permanently lost.
If your RPC connection fails during --simple-workers processing:
- The outbound message may already be sent but not verified/executed
- You'll see detailed recovery information in the error output
- You must handle this nonce before sending new messages
- Either wait for RPC limits to reset and complete processing, or skip the message
If nonce 6 fails because nonce 4 is pending:
- First process or skip nonce 4
- Then process or skip nonce 5
- Finally, you can process nonce 6
Remember: All messages must be handled in order!
Now that you've gone through a simplified walkthrough, here are what you can do next.
- If you are planning to deploy to production, go through the Production Deployment Checklist.
- Read on DVNs / Security Stack
- Read on Message Execution Options
Before deploying, ensure the following:
- (required) if you previously uncommented the testnet mint line in
contracts/MyOFT.sol, ensure it is commented out for production - (recommended) you have profiled the gas usage of
lzReceiveon your destination chains
The optimal values you should specify for the gas parameter in the LZ Config depends on the destination chain, and requires profiling. This section walks through how to estimate the optimal gas value.
This guide explains how to use the pnpm commands to estimate gas usage for LayerZero's lzReceive and lzCompose functions. These commands wrap Foundry scripts for easier invocation and allow you to pass the required arguments dynamically.
-
gas:lzReceiveThis command profiles the
lzReceivefunction for estimating gas usage across multiple runs."gas:lzReceive": "forge script scripts/GasProfiler.s.sol:GasProfilerScript --via-ir --sig 'run_lzReceive(string,address,uint32,address,uint32,address,bytes,uint256,uint256)'"
-
gas:lzComposeThis command profiles the
lzComposefunction for estimating gas usage across multiple runs."gas:lzCompose": "forge script scripts/GasProfiler.s.sol:GasProfilerScript --via-ir --sig 'run_lzCompose(string,address,uint32,address,uint32,address,address,bytes,uint256,uint256)'"
To estimate the gas for the lzReceive function:
pnpm gas:lzReceive
<rpcUrl> \
<endpointAddress> \
<srcEid> \
<sender> \
<dstEid> \
<receiver> \
<message> \
<msg.value> \
<numOfRuns>Where:
rpcUrl: The RPC URL for the target blockchain (e.g., Hedera, Base etc.).endpointAddress: The deployed LayerZero EndpointV2 contract address.srcEid: The source endpoint ID (uint32).sender: The sender's address (OApp).dstEid: The destination endpoint ID (uint32).receiver: The address intended to receive the message (OApp).message: The message payload as abytesarray.msg.value: The amount of Ether sent with the message (in wei).numOfRuns: The number of test runs to execute.
To estimate the gas for the lzCompose function:
pnpm gas:lzCompose
<rpcUrl> \
<endpointAddress> \
<srcEid> \
<sender> \
<dstEid> \
<receiver> \
<composer> \
<composeMsg> \
<msg.value> \
<numOfRuns>Where:
rpcUrl: The RPC URL for the target blockchain (e.g. Hedera, Base, etc.).endpointAddress: The deployed LayerZero EndpointV2 contract address.srcEid: The source endpoint ID (uint32).sender: The originating OApp address.dstEid: The destination endpoint ID (uint32).receiver: The address intended to receive the message (OApp).composer: The LayerZero Composer contract address.composeMsg: The compose message payload as abytesarray.msgValue: The amount of Ether sent with the message (in wei).numOfRuns: The number of test runs to execute.
- Modify
numOfRunsbased on the level of accuracy or performance you require for gas profiling. - Log outputs will provide metrics such as the average, median, minimum, and maximum gas usage across all successful runs.
This approach simplifies repetitive tasks and ensures consistent testing across various configurations.