LayerZero V2 Part 4: OFT Token Bridging
This is Part 4 of a series on LayerZero V2. Part 1 covers the basics.
OFT (Omnichain Fungible Token) is the most common application built on LayerZero. It moves ERC20 tokens between chains.
The Lock-and-Mint Model
Ethereum (Home Chain) L2 Chains (Arb, Base, Opt)
┌──────────────────┐ ┌──────────────────┐
│ Your ERC20 Token │ │ OFT Contract │
│ (real token) │ │ (synthetic token) │
└────────┬─────────┘ └────────┬─────────┘
│ │
┌────────┴─────────┐ │
│ OFTAdapter │ ◄── LayerZero ──────► │
│ LOCKS tokens │ message │ MINTS tokens
│ when bridging out│ │ when receiving
│ UNLOCKS tokens │ │ BURNS tokens
│ when bridging in │ │ when sending
└──────────────────┘ │Bridge OUT (Ethereum → Arbitrum):
- OFTAdapter locks 100 BRG on Ethereum
- LayerZero sends a message to Arbitrum
- OFT on Arbitrum mints 100 BRG to the recipient
Bridge BACK (Arbitrum → Ethereum):
- OFT on Arbitrum burns 100 BRG
- LayerZero sends a message to Ethereum
- OFTAdapter unlocks 100 BRG to the recipient
Supply is always conserved: locked on Ethereum = minted across all L2s.
Two Contract Types
| Contract | Where | What It Does | Token Operation |
|---|---|---|---|
| OFTAdapter | Home chain only | Wraps existing ERC20 | Lock on send, unlock on receive |
| OFT | Every other chain | Is the token (ERC20 + LayerZero) | Burn on send, mint on receive |
Critical rule: There can only be ONE OFTAdapter in the entire deployment. Multiple adapters on different chains break the supply accounting and can cause permanent token loss.
The Three Contracts
The entire bridge is three Solidity files:
// 1. Your ERC20 (home chain only)
contract BridgeToken is ERC20, Ownable {
constructor(address _owner) ERC20("BridgeToken", "BRG") Ownable(_owner) {
_mint(_owner, 1_000_000 * 10 ** decimals());
}
}
// 2. OFTAdapter (home chain only — wraps the ERC20)
contract BridgeOFTAdapter is OFTAdapter {
constructor(address _token, address _endpoint, address _delegate)
OFTAdapter(_token, _endpoint, _delegate)
Ownable(_delegate)
{}
}
// 3. OFT (every other chain — mint/burn)
contract BridgeOFT is OFT {
constructor(string memory _name, string memory _symbol,
address _endpoint, address _delegate)
OFT(_name, _symbol, _endpoint, _delegate)
Ownable(_delegate)
{}
}That’s it. The OFT standard from LayerZero handles all the cross-chain logic. Your contracts are just wrappers.
The send() Flow
When a user bridges tokens, four things happen:
function send(SendParam calldata _sendParam, MessagingFee calldata _fee,
address _refundAddress) external payable {
// Step 1: Debit tokens (burn or lock)
(uint256 amountSentLD, uint256 amountReceivedLD) = _debit(
msg.sender, _sendParam.amountLD, _sendParam.minAmountLD, _sendParam.dstEid
);
// Step 2: Build the cross-chain message
(bytes memory message, bytes memory options) = _buildMsgAndOptions(
_sendParam, amountReceivedLD
);
// Step 3: Send via LayerZero
msgReceipt = _lzSend(_sendParam.dstEid, message, options, _fee, _refundAddress);
// Step 4: Emit event
emit OFTSent(msgReceipt.guid, _sendParam.dstEid, msg.sender,
amountSentLD, amountReceivedLD);
}L2-to-L2 Transfers
Tokens can move directly between L2s without going through Ethereum:
Arbitrum → Base:
OFT on Arbitrum burns 100 BRG
→ LayerZero message →
OFT on Base mints 100 BRGNo lock/unlock needed — both sides are mint/burn. This is why you can have 12 directional pathways (4 chains × 3 destinations), not just hub-and-spoke through Ethereum.
Shared Decimals: Why Amounts Lose Precision
Different chains use different number sizes. EVM uses uint256 (huge). Solana uses uint64 (smaller). OFT normalizes all amounts to 6 shared decimals to work everywhere.
How the Conversion Works
Your token: 18 decimals (standard EVM)
Shared decimals: 6
Conversion rate: 10^12 (= 10^(18-6))When you send 1.234567890123456789 tokens:
Input: 1,234,567,890,123,456,789 (18 decimals)
÷ 10^12: 1,234,567 (6 shared decimals, sent over wire)
× 10^12: 1,234,567,000,000,000,000 (18 decimals, received on destination)The last 12 digits are called “dust.” The sender keeps them — they’re not burned, just not included in the transfer.
Sent: 1.234567890123456789 tokens
Received: 1.234567000000000000 tokens
Dust: 0.000000890123456789 tokens (stays in sender's wallet)Rules
- Shared decimals must be the same on all chains. Inconsistent values enable double spending.
- Never set sharedDecimals == localDecimals (both 18). The uint64 cast silently truncates amounts above ~18.44 tokens.
- Minimum transferable amount is 0.000001 tokens (1 unit in 6 decimals). Below that rounds to zero.
Fee Quoting
Every cross-chain message costs gas on the destination chain. The user pays upfront.
Step 1: Quote
const sendParam = {
dstEid: 40231, // Arbitrum Sepolia endpoint ID
to: padAddress(recipientAddress), // bytes32
amountLD: parseEther("100"), // 100 tokens
minAmountLD: parseEther("100"), // slippage = 0
extraOptions: buildGasOptions(200000), // gas for lzReceive
composeMsg: "0x", // empty = simple transfer
oftCmd: "0x", // reserved
};
const fee = await contract.quoteSend(sendParam, false);
// fee = { nativeFee: 0.001 ETH, lzTokenFee: 0 }
Step 2: Send
await contract.send(sendParam, { nativeFee: fee, lzTokenFee: 0n }, sender, {
value: fee, // pay as msg.value
});What’s in the Fee
Total Fee = DVN Fees + Executor Fee + Treasury Fee
DVN Fees: Each DVN charges for verification
Executor Fee: Gas cost to call lzReceive() on destination
Treasury Fee: Small protocol fee to LayerZeroImportant: quoteSend() must be called with the EXACT same SendParam you’ll use in send(). Different options = different fees. If msg.value < nativeFee, the transaction reverts. If msg.value > nativeFee, excess is refunded.
Gas Options
You tell the Executor how much gas to use on the destination chain.
Type 1 (Simple — Use on Testnets)
Just a gas limit:
0x0001 + uint256(gasLimit)Type 3 (Full — Use in Production)
Supports gas, msg.value, compose gas, and native airdrops:
bytes memory options = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(200000, 0); // 200k gas for lzReceiveenforceOptions (Safety Net)
Contract owners set minimum gas requirements per chain and message type:
setEnforcedOptions([{
eid: 40231, // Arbitrum
msgType: 1, // SEND
options: OptionsBuilder.newOptions().addExecutorLzReceiveOption(65000, 0)
}]);Prevents users from setting gas too low and causing failed deliveries.
Next
- Part 5: Composed Messages — Bridge + swap/stake/deposit in one operation