LayerZero V2 on SEI
Overview
This guide provides a comprehensive, step-by-step tutorial for integrating LayerZero V2 with the SEI blockchain. By the end of this guide, you will understand how to create, deploy, and manage omnichain tokens that can seamlessly move between SEI and any other LayerZero-supported blockchain.
What This Guide Covers
- Understanding LayerZero: Core concepts and architecture
- Project Setup: Creating a LayerZero-enabled project from scratch
- Smart Contracts: Building an Omnichain Fungible Token (OFT) with detailed explanations
- Deployment & Configuration: Step-by-step deployment to SEI and other chains
- Cross-Chain Transfers: Creating tasks and scripts for token transfers
Prerequisites
- Basic knowledge of Solidity and smart contracts
- Node.js and npm installed
- A wallet with SEI and other chain tokens for gas fees
- Familiarity with Hardhat or Foundry
What is LayerZero?
LayerZero is an omnichain interoperability protocol that enables secure, permissionless communication between different blockchains. Think of it as a universal translator that allows blockchains to talk to each other.
Core Concepts
- Endpoints: Immutable smart contracts deployed on each blockchain that serve as entry/exit points for messages
- Messages: Data packets sent between blockchains containing instructions or information
- Security Stack: Customizable verification system ensuring message authenticity
- Omnichain Applications: Smart contracts that can operate across multiple blockchains
Key Applications
- Omnichain Fungible Tokens (OFT): Tokens that exist across multiple chains with unified supply
- Omnichain NFTs (ONFT): NFTs that can move between blockchains
- Cross-chain DeFi: Protocols that operate across multiple networks
- Unified Governance: DAOs that function across chains
- Message Passing: Send arbitrary data between blockchains
SEI Network Information
SEI Mainnet Configuration
- Network Name: SEI EVM
- Chain ID: 1329
- RPC URL: https://evm-rpc.sei-apis.com
- Explorer: https://seitrace.com/
- Currency: SEI
- LayerZero Endpoint ID: 30280
Step 1: Project Setup
Project scaffold
LayerZero’s CLI lets you spin up an OFT workspace in seconds:
npx create-lz-oapp@latest # choose → "OFT example"
The wizard creates a repo with Hardhat + Foundry, sample contracts, tests and LayerZero helper scripts.
Add private keys
Rename .env.example
file to .env
and update it with needed configurations:
PRIVATE_KEY=your_private_key
At a minimum, you need to have the PRIVATE_KEY
. RPC URLs are optional, but strongly recommended. If you don’t provide them, public RPCs will be used, but public RPCs can be unreliable or slow, leading to long waiting times for transactions to be confirmed or, at worst, cause your transactions to fail.
Step 2: Configure Networks
Hardhat network config
Update your hardhat.config.ts
file to include the networks you want to deploy your contracts to:
networks: {
// the network you are deploying to or are already on
// Sei Mainnet (EID=30280)
'sei-mainnet': {
eid: EndpointId.SEI_V2_MAINNET,
url: process.env.RPC_URL_SEI || 'https://evm-rpc.sei-apis.com',
accounts,
},
// another network you want to connect to
'optimism-mainnet': {
eid: EndpointId.OPTIMISM_V2_MAINNET,
url: process.env.RPC_URL_OPTIMISM || 'https://mainnet.optimism.io',
accounts,
},
}
LayerZero wiring config
Modify your layerzero.config.ts
file to include the chains and channel security settings you want for each connection:
import { EndpointId } from '@layerzerolabs/lz-definitions';
import type { OmniPointHardhat } from '@layerzerolabs/toolbox-hardhat';
import { OAppEnforcedOption } from '@layerzerolabs/toolbox-hardhat';
import { ExecutorOptionType } from '@layerzerolabs/lz-v2-utilities';
import { TwoWayConfig, generateConnectionsConfig } from '@layerzerolabs/metadata-tools';
const seiContract: OmniPointHardhat = {
eid: EndpointId.SEI_V2_MAINNET,
contractName: 'MyOFT'
};
const optimismContract: OmniPointHardhat = {
eid: EndpointId.OPTIMISM_V2_MAINNET,
contractName: 'MyOFT'
};
// To connect all the above chains to each other, we need the following pathways:
// Optimism <-> sei
// sei <-> Optimism
// For this example's simplicity, we will use the same enforced options values for sending to all chains
// To learn more, read https://docs.layerzero.network/v2/concepts/applications/oapp-standard#execution-options-and-enforced-settings
const EVM_ENFORCED_OPTIONS: OAppEnforcedOption[] = [
{
msgType: 1,
optionType: ExecutorOptionType.LZ_RECEIVE,
gas: 80000,
value: 0
}
];
const pathways: TwoWayConfig[] = [
[
// 1) Chain B's contract (e.g. Optimism)
optimismContract,
// 2) Chain A's contract (e.g. sei)
seiContract,
// 3) Channel security settings:
// • first array = "required" DVN names
// • second array = "optional" DVN names array + threshold
// • third value = threshold (i.e., number of optionalDVNs that must sign)
// [ requiredDVN[], [ optionalDVN[], threshold ] ]
[['LayerZero Labs' /* ← add more DVN names here */], []],
// 4) Block confirmations:
// [confirmations for Optimism → sei, confirmations for sei → Optimism]
[1, 1],
// 5) Enforced execution options:
// [options for Optimism → sei, options for sei → Optimism]
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS]
]
];
export default async function () {
// Generate the connections config based on the pathways
const connections = await generateConnectionsConfig(pathways);
return {
contracts: [{ contract: optimismContract }, { contract: seiContract }],
connections
};
}
It is strongly recommended to review LayerZero’s Channel Security Model and understand the impact of each of these configuration settings.
See Next Steps to review the available providers and security settings.
Step 3: Create Utility Tasks
Before deployment, let’s create a utility task for minting tokens that we’ll need later.
Create Minting Task
Create tasks/mint.ts
:
import { task } from 'hardhat/config';
import { types } from '@layerzerolabs/devtools-evm-hardhat';
task('mint', 'Mint tokens to an address')
.addParam('to', 'Address to mint tokens to', undefined, types.string)
.addParam('amount', 'Amount to mint (without decimals)', undefined, types.string)
.setAction(async ({ to, amount }, { ethers, deployments }) => {
// Get the deployed contract
const deployment = await deployments.get('MyOFT');
const [signer] = await ethers.getSigners();
// Create contract instance
const oft = new ethers.Contract(deployment.address, deployment.abi, signer);
// Get decimals
const decimals = await oft.decimals();
// Convert amount to proper decimals
const amountWithDecimals = ethers.utils.parseUnits(amount, decimals);
// Mint tokens
console.log(`Minting ${amount} tokens to ${to}...`);
const tx = await oft.mint(to, amountWithDecimals);
await tx.wait();
console.log(`✅ Minted ${amount} tokens to ${to}`);
console.log(`Transaction hash: ${tx.hash}`);
});
Update Hardhat Configuration
Now update your hardhat.config.ts
to import the mint task:
// Get the environment configuration from .env file
//
// To make use of automatic environment setup:
// - Duplicate .env.example file and name it .env
// - Fill in the environment variables
import 'dotenv/config';
import 'hardhat-deploy';
import 'hardhat-contract-sizer';
import '@nomiclabs/hardhat-ethers';
import '@layerzerolabs/toolbox-hardhat';
import { HardhatUserConfig, HttpNetworkAccountsUserConfig } from 'hardhat/types';
import { EndpointId } from '@layerzerolabs/lz-definitions';
import './tasks/sendOFT';
import './tasks/mint';
// Set your preferred authentication method
//
// If you prefer using a mnemonic, set a MNEMONIC environment variable
// to a valid mnemonic
const MNEMONIC = process.env.MNEMONIC;
// If you prefer to be authenticated using a private key, set a PRIVATE_KEY environment variable
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const accounts: HttpNetworkAccountsUserConfig | undefined = MNEMONIC ? { mnemonic: MNEMONIC } : PRIVATE_KEY ? [PRIVATE_KEY] : undefined;
if (accounts == null) {
console.warn('Could not find MNEMONIC or PRIVATE_KEY environment variables. It will not be possible to execute transactions in your example.');
}
const config: HardhatUserConfig = {
paths: {
cache: 'cache/hardhat'
},
solidity: {
compilers: [
{
version: '0.8.22',
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
}
]
},
networks: {
// the network you are deploying to or are already on
// Sei Mainnet (EID=30280)
'sei-mainnet': {
eid: EndpointId.SEI_V2_MAINNET,
url: process.env.RPC_URL_SEI || 'https://evm-rpc.sei-apis.com',
accounts
},
// another network you want to connect to
'optimism-mainnet': {
eid: EndpointId.OPTIMISM_V2_MAINNET,
url: process.env.RPC_URL_OPTIMISM || 'https://mainnet.optimism.io',
accounts
}
},
namedAccounts: {
deployer: {
default: 0 // wallet address of index[0], of the mnemonic in .env
}
}
};
export default config;
Step 4: Smart Contract Development
The token contract
The OFT contract handles all cross-chain logic automatically. When tokens are sent:
- Source Chain: Burns tokens from sender’s balance
- LayerZero Protocol: Verifies and relays the message
- Destination Chain: Mints equivalent tokens to recipient
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
contract MyOFT is OFT, Ownable {
constructor(string memory name, string memory symbol, address endpoint, address owner)
OFT(name, symbol, endpoint, owner) Ownable(owner) {}
/**
* @notice Mint new tokens (only owner)
* @param to Address to receive the minted tokens
* @param amount Amount of tokens to mint (with decimals)
*/
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
The OFT contract uses the ERC20 token standard. You may want to add a mint(...)
function in the constructor(...)
or contract body if this is your first time deploying an OFT. If you have an existing ERC20 token, you will want to use an OFT Adapter contract.
You can read the general OFT Quickstart for a better understanding of how OFTs work and what contracts to use.
Understanding OFT Functions
Key inherited functions from the OFT contract:
Function | Description |
---|---|
send() | Initiates cross-chain token transfer |
quoteSend() | Estimates fees for transfer |
_lzSend() | Internal function that sends the message |
_lzReceive() | Internal function that receives messages |
Step 5: Deployment Process
Deploy
npx hardhat lz:deploy # choose sei
You will be presented with a list of networks to deploy to.
Step 6: Wire the Contracts
After deployment, contracts need to be connected:
Connect the chains
npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
Verify Configuration
Verify peers:
npx hardhat lz:oapp:peers:get --oapp-config layerzero.config.ts
This command will show you the current LayerZero wiring configuration, including the connected chains and their peer addresses.
Step 7: Mint Initial Tokens
Before transferring, you need tokens to send. Let’s mint some tokens on SEI:
# Mint 10,000 tokens to your address on SEI
npx hardhat mint --to 0xYourAddress --amount 10000 --network sei-mainnet
Step 8: Transfer Tokens Cross-Chain
Calling send
Since the send
logic has already been defined, we’ll instead view how the function should be called.
Option 1: Hardhat Task
Create tasks/sendOFT.ts
:
import { task } from 'hardhat/config';
import { getNetworkNameForEid, types } from '@layerzerolabs/devtools-evm-hardhat';
import { EndpointId } from '@layerzerolabs/lz-definitions';
import { addressToBytes32 } from '@layerzerolabs/lz-v2-utilities';
import { Options } from '@layerzerolabs/lz-v2-utilities';
import { BigNumberish, BytesLike } from 'ethers';
interface Args {
amount: string;
to: string;
toEid: EndpointId;
}
interface SendParam {
dstEid: EndpointId; // Destination endpoint ID, represented as a number.
to: BytesLike; // Recipient address, represented as bytes.
amountLD: BigNumberish; // Amount to send in local decimals.
minAmountLD: BigNumberish; // Minimum amount to send in local decimals.
extraOptions: BytesLike; // Additional options supplied by the caller to be used in the LayerZero message.
composeMsg: BytesLike; // The composed message for the send() operation.
oftCmd: BytesLike; // The OFT command to be executed, unused in default OFT implementations.
}
// send tokens from a contract on one network to another
task('lz:oft:send', 'Sends tokens from either OFT or OFTAdapter')
.addParam('to', 'contract address on network B', undefined, types.string)
.addParam('toEid', 'destination endpoint ID', undefined, types.eid)
.addParam('amount', 'amount to transfer in token decimals', undefined, types.string)
.setAction(async (taskArgs: Args, { ethers, deployments }) => {
const toAddress = taskArgs.to;
const eidB = taskArgs.toEid;
// Get the contract factories
const oftDeployment = await deployments.get('MyOFT');
const [signer] = await ethers.getSigners();
// Create contract instances
const oftContract = new ethers.Contract(oftDeployment.address, oftDeployment.abi, signer);
const decimals = await oftContract.decimals();
const amount = ethers.utils.parseUnits(taskArgs.amount, decimals);
let options = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toBytes();
// Now you can interact with the correct contract
const oft = oftContract;
const sendParam: SendParam = {
dstEid: eidB,
to: addressToBytes32(toAddress),
amountLD: amount,
minAmountLD: amount,
extraOptions: options,
composeMsg: ethers.utils.arrayify('0x'), // Assuming no composed message
oftCmd: ethers.utils.arrayify('0x') // Assuming no OFT command is needed
};
// Get the quote for the send operation
const feeQuote = await oft.quoteSend(sendParam, false);
const nativeFee = feeQuote.nativeFee;
console.log(`sending ${taskArgs.amount} token(s) to network ${getNetworkNameForEid(eidB)} (${eidB})`);
const ERC20Factory = await ethers.getContractFactory('ERC20');
const innerTokenAddress = await oft.token();
// // If the token address !== address(this), then this is an OFT Adapter
// if (innerTokenAddress !== oft.address) {
// // If the contract is OFT Adapter, get decimals from the inner token
// const innerToken = ERC20Factory.attach(innerTokenAddress);
// // Approve the amount to be spent by the oft contract
// await innerToken.approve(oftDeployment.address, amount);
// }
const r = await oft.send(sendParam, { nativeFee: nativeFee, lzTokenFee: 0 }, signer.address, {
value: nativeFee
});
console.log(`Send tx initiated. See: https://layerzeroscan.com/tx/${r.hash}`);
});
Don’t forget to import the task in your hardhat.config.ts
(if not already added):
// Add this import to hardhat.config.ts (should already be there from Step 3)
import './tasks/sendOFT';
Execute the transfer:
# Send 100 tokens from SEI to Optimism (EID: 30111)
npx hardhat lz:oft:send \
--to 0xRecipientAddress \
--toEid 30111 \
--amount 100 \
--network sei-mainnet
Option 2: Foundry Script
Create script/SendOFT.s.sol
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol";
import { SendParam, OFTReceipt } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol";
import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol";
import { MessagingFee } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { MyOFT } from "../contracts/MyOFT.sol";
contract SendOFT is Script {
using OptionsBuilder for bytes;
/**
* @dev Converts an address to bytes32.
* @param _addr The address to convert.
* @return The bytes32 representation of the address.
*/
function addressToBytes32(address _addr) internal pure returns (bytes32) {
return bytes32(uint256(uint160(_addr)));
}
function run() public {
// Fetching environment variables
address oftAddress = vm.envAddress("OFT_ADDRESS");
address toAddress = vm.envAddress("TO_ADDRESS");
uint256 _tokensToSend = vm.envUint("TOKENS_TO_SEND");
// Fetch the private key from environment variable
uint256 privateKey = vm.envUint("PRIVATE_KEY");
// Start broadcasting with the private key
vm.startBroadcast(privateKey);
MyOFT sourceOFT = MyOFT(oftAddress);
bytes memory _extraOptions = OptionsBuilder.newOptions().addExecutorLzReceiveOption(65000, 0);
SendParam memory sendParam = SendParam(
30111, // You can also make this dynamic if needed
addressToBytes32(toAddress),
_tokensToSend,
_tokensToSend * 9 / 10,
_extraOptions,
"",
""
);
MessagingFee memory fee = sourceOFT.quoteSend(sendParam, false);
console.log("Fee amount: ", fee.nativeFee);
sourceOFT.send{value: fee.nativeFee}(sendParam, fee, msg.sender);
// Stop broadcasting
vm.stopBroadcast();
}
}
Environment Setup:
# Set environment variables
export OFT_ADDRESS=0xYourOFTAddress
export TO_ADDRESS=0xRecipientAddress
export TOKENS_TO_SEND=100000000000000000000 # 100 tokens with 18 decimals
# Run the script
forge script script/SendOFT.s.sol:SendOFT --rpc-url $RPC_URL_SEI --broadcast
Step 9: Done!
You’ve issued an omnichain token and bridged it from Sei Mainnet to Optimism. Customize supply logic, fees, or add more chains by applying changes to the core contract, redeploying, and repeating the wiring step.
Track Your Transaction
- Visit https://layerzeroscan.com/tx/YOUR_TX_HASH
- You’ll see:
- Source transaction
- Message status
- Destination transaction
- Time elapsed
- Check balances on both chains to confirm the transfer
Troubleshooting
Common Issues and Solutions
Quote Send Reverts
If your quoteSend
call reverts, it usually means that your LayerZero wiring hasn’t been fully configured or there’s no default pathway for the chains you’re trying to bridge. Here’s how to diagnose and fix it:
1. Wiring Didn’t Succeed:
Run the following to inspect your on‑chain wiring configuration:
npx hardhat lz:oapp:config:get --oapp-config layerzero.config.ts
See that your source configuration has a valid send library, DVN address, and target eid.
2. No Default Pathway:
LayerZero default settings should be considered placeholders. Sometimes the LayerZero defaults will contain a LzDeadDVN
. Those entries indicate that a default pathway setting does not exist.
-
Check: You can see if your configuration contains a LzDeadDVN by viewing the Default Config Checker on LayerZero Scan.
-
Fix: Open your
layerzero.config.ts
and under the relevantpathways
entry, add working DVN providers (in the[ requiredDVN[], [ optionalDVN[], threshold ] ]
section). -
Re-run your wiring command for the connections so that the wiring on both chains is live.
[
optimismContract,
currentContract,
[['LayerZero Labs'], []], // required & optional DVNs
[1, 1], // required block confirmations
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS],
],
Once you’ve updated your config, retry your quoteSend
flow. It should now return a fee estimate instead of reverting.
Insufficient Gas
If the transaction fails on the destination chain, try to increase the gas like this:
// Increase gas in options
let options = Options.newOptions().addExecutorLzReceiveOption(150000, 0).toBytes(); // Increase from 65000 to 150000
Next Steps
- Add More Chains: Expand to Arbitrum, Base, Polygon, etc.
- Advanced Features: Implement rate limiting, pausable transfers, fee collection
- Composed Messages: Execute actions after token arrival
- Build a UI: Create a frontend for easy transfers
Learn More
- See the Configuring Pathways section to learn more about managing your OFTs across the entire network mesh.
- See the Available DVNs and Executor to configure between.
- Learn how the protocol works by reading the Core Concepts section.
- Learn more about how the OFT contract works on the EVM by reading the OFT Quickstart in the EVM section.
Resources
- LayerZero Documentation: https://docs.layerzero.network/v2
- LayerZero Scan: https://layerzeroscan.com/
- SEI Explorer: https://seitrace.com/
Summary
You’ve successfully:
- ✅ Created an Omnichain Fungible Token project
- ✅ Configured networks and LayerZero pathways
- ✅ Deployed contracts to multiple chains
- ✅ Connected the deployments via LayerZero wiring
- ✅ Created tasks for cross-chain token transfers
- ✅ Learned how LayerZero enables omnichain applications
Your tokens can now move freely between SEI and any connected chain, maintaining a unified supply and enabling true cross-chain functionality!