Building a Cross-Chain Token Bridge with LayerZero V2
We built a cross-chain ERC20 token bridge using LayerZero V2. It moves BRG tokens across Ethereum, Arbitrum, Base, and Optimism — 12 directional pathways, all trustless, with multi-DVN verification. This post walks through the actual code. The full source is at github.com/gnuser/brg-bridge.
Architecture
Lock-and-Mint Model
The bridge uses LayerZero’s OFT (Omnichain Fungible Token) standard:
- Ethereum (home chain):
BridgeOFTAdapterlocks ERC20 tokens when bridging out, unlocks when bridging back. - L2 chains:
BridgeOFTmints synthetic tokens on receive, burns on send. No pre-minted supply.
Total supply is always conserved: locked on Ethereum = minted across L2s.
The Contracts
The entire bridge is three Solidity files. Here they are.
BridgeToken.sol — The ERC20 (Ethereum only)
contract BridgeToken is ERC20, Ownable {
uint256 public constant FAUCET_AMOUNT = 1_000 * 10 ** 18;
constructor(address _initialOwner) ERC20("BridgeToken", "BRG") Ownable(_initialOwner) {
_mint(_initialOwner, 1_000_000 * 10 ** decimals());
}
/// @notice Mint test tokens to the caller. Testnet only.
function faucet() external {
_mint(msg.sender, FAUCET_AMOUNT);
}
}Standard ERC20 with 1M initial supply. The faucet() is for testnet — no access control, anyone can mint 1,000 BRG. Remove before mainnet.
BridgeOFTAdapter.sol — Lock/Unlock (Ethereum only)
contract BridgeOFTAdapter is OFTAdapter {
constructor(address _token, address _lzEndpoint, address _delegate)
OFTAdapter(_token, _lzEndpoint, _delegate)
Ownable(_delegate)
{ }
}That’s the entire contract. OFTAdapter from LayerZero handles everything — it wraps the existing ERC20, locks tokens on send(), unlocks on receive. You pass it the BridgeToken address, the LayerZero endpoint, and an owner address. The heavy lifting is in the inherited contract.
BridgeOFT.sol — Burn/Mint (Each L2)
contract BridgeOFT is OFT {
constructor(string memory _name, string memory _symbol, address _lzEndpoint, address _delegate)
OFT(_name, _symbol, _lzEndpoint, _delegate)
Ownable(_delegate)
{ }
}Same pattern. OFT is a full ERC20 that can also receive LayerZero messages. When a message arrives from the adapter, it mints tokens. When a user calls send(), it burns them and sends a message to the destination.
Both contracts deploy to the same address on all three L2 testnets: 0x4dBBdC8CE1267c170E5aB37831cdC9870f386Dc9.
Wiring Up 12 Pathways
The LayerZero config defines all peer connections. Every chain talks to every other chain directly — not just hub-and-spoke through Ethereum:
// contracts/layerzero.config.ts
const LZ_DVN = '0x589dEDbD617F7E266a090e916B2a37dDc4e3b0C4';
const GOOGLE_DVN = '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc';
const REQUIRED_DVNS = [LZ_DVN, GOOGLE_DVN]; // sorted ascending
function makeUlnConfig(confirmations: number) {
return {
confirmations: BigInt(confirmations),
requiredDVNCount: 2,
optionalDVNCount: 0,
optionalDVNThreshold: 0,
requiredDVNs: REQUIRED_DVNS,
optionalDVNs: [],
};
}
function makeConnection(fromEid, fromName, toEid, toName, sendConf, recvConf) {
return {
from: { eid: fromEid, contractName: fromName },
to: { eid: toEid, contractName: toName },
config: {
sendConfig: { ulnConfig: makeUlnConfig(sendConf) },
receiveConfig: { ulnConfig: makeUlnConfig(recvConf) },
},
};
}
// All 12 directional pathways
connections: [
// Ethereum ↔ Arbitrum
makeConnection(ETH, ADAPTER, ARB, OFT, 15, 5),
makeConnection(ARB, OFT, ETH, ADAPTER, 5, 15),
// Ethereum ↔ Base
makeConnection(ETH, ADAPTER, BASE, OFT, 15, 5),
makeConnection(BASE, OFT, ETH, ADAPTER, 5, 15),
// Ethereum ↔ Optimism
makeConnection(ETH, ADAPTER, OPT, OFT, 15, 5),
makeConnection(OPT, OFT, ETH, ADAPTER, 5, 15),
// Arbitrum ↔ Base
makeConnection(ARB, OFT, BASE, OFT, 5, 5),
makeConnection(BASE, OFT, ARB, OFT, 5, 5),
// Arbitrum ↔ Optimism
makeConnection(ARB, OFT, OPT, OFT, 5, 5),
makeConnection(OPT, OFT, ARB, OFT, 5, 5),
// Base ↔ Optimism
makeConnection(BASE, OFT, OPT, OFT, 5, 5),
makeConnection(OPT, OFT, BASE, OFT, 5, 5),
]Dual-DVN verification: both LayerZero Labs and Google Cloud must independently verify every message. Block confirmations are 15 for Ethereum (slower, more secure), 5 for L2s.
Frontend: The Bridge Hook
The core of the frontend is useBridge — a React hook that constructs the LayerZero SendParam and calls send() on the contract:
// apps/web/src/hooks/useBridge.ts
async function bridge(params: BridgeParams): Promise<`0x${string}`> {
const contractAddress = BRIDGE_CONTRACTS[params.srcChainId];
const sendParam = {
dstEid: getLzEid(params.dstChainId), // LayerZero endpoint ID
to: addressToBytes32(params.recipientAddress), // pad address to bytes32
amountLD: params.amount, // amount in local decimals
minAmountLD: params.amount, // slippage = 0
extraOptions: buildLzReceiveOptions(), // Type 1 gas options
composeMsg: '0x' as `0x${string}`,
oftCmd: '0x' as `0x${string}`,
};
const messagingFee = {
nativeFee: params.fee, // from quoteSend()
lzTokenFee: 0n,
};
const hash = await writeContractAsync({
address: contractAddress,
abi: OFT_ABI,
functionName: 'send',
args: [sendParam, messagingFee, params.recipientAddress],
value: params.fee, // pay native fee as msg.value
});
addTxToHistory({
txHash: hash,
srcChainId: params.srcChainId,
dstChainId: params.dstChainId,
amount: params.amount.toString(),
timestamp: Date.now(),
status: 'pending',
});
return hash;
}Key detail: params.fee comes from quoteSend() and must be passed as both messagingFee.nativeFee and value. If the value is too low, the transaction reverts.
Fee Quoting
Before every bridge, we call quoteSend() on-chain to get the exact native fee:
// apps/web/src/hooks/useQuote.ts
const sendParam = useMemo(() => {
if (!srcChainId || !dstChainId || !userAddress || amount === 0n) return undefined;
return {
dstEid: getLzEid(dstChainId),
to: addressToBytes32(userAddress),
amountLD: amount,
minAmountLD: amount,
extraOptions: buildLzReceiveOptions(),
composeMsg: '0x' as `0x${string}`,
oftCmd: '0x' as `0x${string}`,
};
}, [srcChainId, dstChainId, amount, userAddress]);
const { data } = useReadContract({
address: contractAddress,
abi: OFT_ABI,
functionName: 'quoteSend',
args: sendParam ? [sendParam, false] : undefined,
chainId: srcChainId,
query: { enabled: !!sendParam && !!contractAddress },
});
const fee = data ? (data as { nativeFee: bigint }).nativeFee : undefined;The sendParam is memoized and recomputed on every amount/chain change. useReadContract from wagmi handles the RPC call. The false arg means we pay in native token, not LZ token.
Multi-Chain Balances
One hook queries BRG balance across all 4 chains simultaneously:
// apps/web/src/hooks/useMultiChainBalances.ts
const contracts = SUPPORTED_CHAINS.map((chain) => ({
address: chain.contractType === 'adapter'
? TOKEN_ADDRESS // ERC20 balanceOf on Ethereum
: BRIDGE_CONTRACTS[chain.chainId], // OFT balanceOf on L2s
abi: chain.contractType === 'adapter' ? ERC20_ABI : OFT_ABI,
functionName: 'balanceOf' as const,
args: userAddress ? [userAddress] : undefined,
chainId: chain.chainId,
}));
const { data } = useReadContracts({
contracts: userAddress ? contracts : [],
query: { enabled: !!userAddress, refetchInterval: 30_000 },
});On Ethereum, balance comes from the ERC20 contract. On L2s, it comes from the OFT contract (which is also an ERC20). useReadContracts from wagmi batches all 4 calls and polls every 30 seconds.
Chain Configuration
The chain config handles testnet/mainnet switching via a single env var:
// apps/web/src/config/chains.ts
const isTestnet = import.meta.env.VITE_NETWORK_MODE === 'testnet';
export const SUPPORTED_CHAINS: ChainConfig[] = [
{
chainId: isTestnet ? sepolia.id : mainnet.id,
name: 'Ethereum',
lzEid: 30101,
lzTestnetEid: 40161,
contractType: 'adapter', // OFTAdapter on Ethereum
},
{
chainId: isTestnet ? arbitrumSepolia.id : arbitrum.id,
name: 'Arbitrum',
lzEid: 30110,
lzTestnetEid: 40231,
contractType: 'oft', // OFT on L2s
},
// ... Base and Optimism follow the same pattern
];The contractType field drives behavior everywhere: which ABI to use, whether to check ERC20 allowance, which address to query for balance.
Contract Addresses
// apps/web/src/config/contracts.ts
export const BRIDGE_CONTRACTS: Record<number, `0x${string}`> = isTestnet
? {
11155111: '0xECC80fc532b80F0Fa9D160F90921EE7b94374e16', // Sepolia: OFTAdapter
421614: '0x4dBBdC8CE1267c170E5aB37831cdC9870f386Dc9', // Arb Sepolia: OFT
84532: '0x4dBBdC8CE1267c170E5aB37831cdC9870f386Dc9', // Base Sepolia: OFT
11155420: '0x4dBBdC8CE1267c170E5aB37831cdC9870f386Dc9', // Opt Sepolia: OFT
}
: { /* mainnet addresses — deploy when ready */ };
export const TOKEN_ADDRESS: `0x${string}` = isTestnet
? '0x2F774239ca92404C3Cf9D2363a2e2624Af19dA60' // BridgeToken ERC20
: '0x0000000000000000000000000000000000000000';LayerZero V2 Gotchas
Things that cost us time:
Use legacy Type 1 options for extraOptions. The format is 0x0001 + uint256 gas as a 32-byte hex value. Type 3 options require ULN config that isn’t set on most testnets.
LZ Scan API doesn’t index small testnet projects. We initially relied on it for transaction tracking. The workaround: verify delivery on-chain by extracting the guid from the OFTSent event on source and checking for OFTReceived on destination.
Alchemy free tier rate-limits hard. Use it for Ethereum Sepolia only, public RPCs for L2 testnets. Keep polling intervals at 30s minimum. VITE_ env vars bake into JS at build time — RPC URLs are client-visible.
Always quoteSend() before send(). The native fee must match. Too low reverts, too high refunds (but bad UX).
Deployment
Contracts deploy via Hardhat + Foundry. Frontend deploys to Cloudflare Pages:
cd apps/web && npm run build
npx wrangler pages deploy dist/ --project-name=bridge-app --branch=mainLive at bridge-app-erc.pages.dev. Source at github.com/gnuser/brg-bridge.
Stack
| Layer | Technology |
|---|---|
| Contracts | Solidity + Foundry + Hardhat |
| Cross-chain | LayerZero V2 (OFT + OFTAdapter) |
| Frontend | React 18 + Vite 5 + TypeScript |
| Web3 | wagmi 2 + viem 2 + Web3Modal |
| Styling | Tailwind CSS 3 |
| Hosting | Cloudflare Pages |
| Security | Dual-DVN (LZ Labs + Google Cloud) |
Three contracts, one frontend, 12 cross-chain pathways. The OFT standard does the heavy lifting — the contracts are almost entirely inherited. The real work is the LayerZero config (getting DVNs and confirmations right) and the frontend hooks (fee quoting, multi-chain balance, approval flow).