Development of Omnichain Token (OFT)
Standard approach to multi-chain token: wrap/bridge model. Original token on Ethereum, on each other chain — wrapped version via bridge. Problem: liquidity fragmented, canonical supply unclear, user holds "USDC-Polygon" separate from "USDC-Arbitrum", bridge risks multiply with chain count.
OFT (Omnichain Fungible Token) from LayerZero solves this differently: one contract on each chain, unified supply, cross-chain transfer works via burn-and-mint without wrapping. Token on Arbitrum is the same token, not wrapped copy.
How LayerZero OFT Works
Burn-and-mint Mechanism
Transfer: Arbitrum → Optimism
1. User calls oft.send() on Arbitrum
2. OFT contract burns X tokens on Arbitrum (decreases supply)
3. LayerZero relayer sends message to Optimism
4. OFT contract on Optimism mints X tokens (increases supply)
5. Total supply across chains unchanged
vs Bridge (wrap) model:
1. Lock X tokens on source in bridge contract
2. Mint X wrapped tokens on destination
Problem: bridge contract — single point of failure for all locked supply
In OFT there's no central bridge contract with locked assets. No "TVL in bridge" to exploit. Only LayerZero messaging layer can be hacked — separate risk, not "everything at once".
OFT v2 Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { OFT } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oft/OFT.sol";
contract MyOFT is OFT {
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint, // LayerZero endpoint for this chain
address _delegate // owner/admin
) OFT(_name, _symbol, _lzEndpoint, _delegate) {}
// Mint only on home chain (usually)
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
Deploy same contract on each target chain. Difference only in _lzEndpoint — address specific to each chain's LayerZero endpoint.
// Deployment script (Hardhat/Foundry)
const LZ_ENDPOINTS = {
ethereum: "0x1a44076050125825900e736c501f859c50fE728c",
arbitrum: "0x1a44076050125825900e736c501f859c50fE728c", // V2 same
optimism: "0x1a44076050125825900e736c501f859c50fE728c",
polygon: "0x1a44076050125825900e736c501f859c50fE728c",
base: "0x1a44076050125825900e736c501f859c50fE728c",
}
// LayerZero V2: endpoint same on all EVM networks
// But EID (Endpoint ID) unique for each chain
const LZ_EIDS = {
ethereum: 30101,
arbitrum: 30110,
optimism: 30111,
polygon: 30109,
base: 30184,
}
Peers Configuration (Wire-up)
After deploying to all chains — connect contracts to each other. Each OFT should know its peers' addresses.
import { ethers } from "ethers"
import { Options } from "@layerzerolabs/lz-v2-utilities"
// On Arbitrum: set peer for Optimism
const oftArbitrum = new ethers.Contract(OFT_ARBITRUM, OFT_ABI, signerArbitrum)
// Peer address must be bytes32-encoded (left-padded with zeros)
const optimismPeerBytes32 = ethers.zeroPadValue(OFT_OPTIMISM, 32)
await oftArbitrum.setPeer(
LZ_EIDS.optimism, // destination EID
optimismPeerBytes32 // peer address on destination
)
// Similarly on Optimism: set peer for Arbitrum
const oftOptimism = new ethers.Contract(OFT_OPTIMISM, OFT_ABI, signerOptimism)
await oftOptimism.setPeer(LZ_EIDS.arbitrum, ethers.zeroPadValue(OFT_ARBITRUM, 32))
Not automatic — need to call setPeer for each pair of chains. With N chains: N*(N-1) calls (each chain knows all others).
Sending Tokens Between Chains
// User: send 100 MTK from Arbitrum to Optimism
const oft = new ethers.Contract(OFT_ARBITRUM, OFT_ABI, signer)
// 1. Get quote (how much native gas to pay)
const sendParam = {
dstEid: LZ_EIDS.optimism,
to: ethers.zeroPadValue(recipientAddress, 32),
amountLD: ethers.parseEther("100"), // amount in local decimals
minAmountLD: ethers.parseEther("99"), // 1% slippage tolerance
extraOptions: "0x",
composeMsg: "0x",
oftCmd: "0x",
}
const [nativeFee, lzTokenFee] = await oft.quoteSend(sendParam, false)
console.log(`Fee: ${ethers.formatEther(nativeFee)} ETH`)
// 2. Send (msg.value = nativeFee to pay LayerZero)
const tx = await oft.send(
sendParam,
{ nativeFee, lzTokenFee: 0n },
signer.address, // refund address (if overpay)
{ value: nativeFee }
)
const receipt = await tx.wait()
// Tokens on Arbitrum burned
// ~15-60 seconds later appear on Optimism
Decimals: Potential Pitfall
Different chains may have different native decimals. Solana uses 6 decimals (lamports), EVM — 18. OFT v2 solves this via shared decimals: all chains work with smaller number (usually 6), converting on send.
// OFT v2: sharedDecimals defaults to 6
// On send from EVM (18 decimals) → dust removed
// Example: sending 1.000000000000000001 MTK (18 decimals)
// Real amount arriving: 1.000000 MTK (6 shared decimals)
// 0.000000000000000001 MTK stays with sender as "dust"
// Always check: quoteSend → amountReceivedLD
// amountReceivedLD = actual amount received including conversion
Important for UI: show user amountReceivedLD, not original amount.
OFTAdapter: For Existing Tokens
If token already exists on Ethereum and contract can't be modified (no mint/burn functions in needed context) — use OFTAdapter. On Ethereum: Lock&Release (tokens locked in adapter). On other chains: OFT with burn&mint.
import { OFTAdapter } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oft/OFTAdapter.sol";
contract MyTokenAdapter is OFTAdapter {
constructor(
address _token, // existing ERC-20 token
address _lzEndpoint,
address _delegate
) OFTAdapter(_token, _lzEndpoint, _delegate) {}
}
On other chains deploy regular OFT (with mint/burn). Only Ethereum uses lock model — other chains use burn/mint.
OFTAdapter tradeoff: Ethereum-locked tokens are again bridge risk. If adapter hacked — supply on other chains unsecured. For critical projects: multisig or timelock on adapter, cap on locked amount.
DVN: Security Configuration
LayerZero V2 lets configure DVN (Decentralized Verifier Network) — who verifies cross-chain messages.
// Custom DVN configuration
// Default: LayerZero DVN (single verifier)
// Hardened: require 2-of-3 verification
const enforcedOptions = [
{
eid: LZ_EIDS.optimism,
msgType: 1,
options: Options.newOptions()
.addExecutorLzReceiveOption(200_000, 0) // 200k gas on destination
.toHex()
}
]
// Set via OApp config
await oft.setEnforcedOptions(enforcedOptions)
For production OFT with large TVL: configure minimum two independent DVNs (LayerZero + Google Cloud DVN or Polyhedra). Tradeoff: more DVNs = higher fee, but better security.
Monitoring and Failed Message Handling
Message can hang if destination chain momentarily unavailable or gas insufficient. LayerZero V2 stores failed messages, can retry.
// LayerZero Scan API for monitoring
const response = await fetch(
`https://scan.layerzero-api.com/v1/messages/tx/${txHash}`
)
const { data } = await response.json()
if (data.status === 'FAILED') {
console.log(`Message failed: ${data.failReason}`)
// Call lzEndpoint.retryMessage() or
// lzEndpoint.forceResumeReceive() if need to skip
}
For UX: show user cross-chain transaction status via LayerZero Scan embeddable widget or custom polling.
Process Workflow
Prep (3-5 days). List target chains, tokenomics (where to mint initial supply, adapter needed for existing token), security requirements (DVN config, multisig).
Development (1-2 weeks). OFT contracts → Foundry tests for each scenario (send, receive, failed message retry) → deployment scripts for all chains → wire-up (setPeer on each).
Testing (1 week). Testnet deploy to all target chains → functional testing cross-chain transfers → verify decimals conversion → load test with concurrent messages.
Mainnet deploy and verify. Contract verification on Etherscan/explorer of each chain. Test small-amount transaction before announcement.
OFT for 3-5 chains with basic functionality — 3-4 weeks. With OFTAdapter for existing token, custom DVNs, and monitoring dashboard — 5-7 weeks.







