Bridge an ERC-20
This guide transfers an ERC-20 from Base to Optimism through OmniseaOFTs. The same code works for any ERC-20 and any supported route - only the endpoint IDs change.
1. Pick the right send function
Check whether your token is an original or an Omnisea representation on the source chain:
import { createPublicClient, http } from "viem";
import { base } from "viem/chains";
const OFT_BRIDGE = "0xA98dF8b908F5a70b8036FD00239E66D9B73c64ad";
const client = createPublicClient({ chain: base, transport: http() });
const [originalChainId, originalToken, isRepresentation] = await client.readContract({
address: OFT_BRIDGE,
abi: bridgeAbi,
functionName: "oftToOriginal",
args: [token],
});isRepresentation === false-> the token is an original here: usequoteSendOriginal/sendOriginal.isRepresentation === true-> the token is a representation: usequoteSendOFT/sendOFT.
Using the wrong pair reverts (RepresentationMustUseSendOFT / NotRepresentation), so a mistake costs gas, not funds.
2. Determine isFirstTransfer
The first time an asset arrives on a chain, its representation must be deployed, which needs more destination gas. Check the destination chain:
const representation = await dstClient.readContract({
address: OFT_BRIDGE,
abi: bridgeAbi,
functionName: "representationFor",
args: [originalChainId, originalToken],
});
const isFirstTransfer = representation === "0x0000000000000000000000000000000000000000";(Exception: if the destination is the asset's home chain, the original unlocks and nothing is deployed - isFirstTransfer can be false.)
3. Build executor options
Buy destination gas via LayerZero type-3 options. Use at least 400000 for a plain transfer, 500000 when isFirstTransfer is true, and 1500000 for anything going to Tempo:
import { concatHex, numberToHex, type Hex } from "viem";
function lzReceiveOptions(gas: bigint, value = 0n): Hex {
return concatHex([
"0x000301002101", // type-3 options, executor lzReceive
numberToHex(gas, { size: 16 }), // uint128 gas
numberToHex(value, { size: 16 }), // uint128 value
]);
}
const options = lzReceiveOptions(isFirstTransfer ? 500_000n : 400_000n);See Executor options for the encoding details.
4. Approve the bridge
Originals are pulled with transferFrom, so the bridge needs an allowance (representations are burned directly - no approval needed for sendOFT):
await walletClient.writeContract({
address: token,
abi: erc20Abi,
functionName: "approve",
args: [OFT_BRIDGE, amount],
});5. Quote, then send with the exact fee
const DST_EID = 30111; // Optimism
const totalFee = await client.readContract({
address: OFT_BRIDGE,
abi: bridgeAbi,
functionName: "quoteSendOriginal",
args: [DST_EID, token, amount, recipient, isFirstTransfer, options],
});
const hash = await walletClient.writeContract({
address: OFT_BRIDGE,
abi: bridgeAbi,
functionName: "sendOriginal",
args: [DST_EID, token, amount, recipient, isFirstTransfer, options],
value: totalFee, // must match the bridge's quote EXACTLY
});For representations, the call is symmetric:
await walletClient.writeContract({
address: OFT_BRIDGE,
abi: bridgeAbi,
functionName: "sendOFT",
args: [DST_EID, representation, amount, recipient, isFirstTransfer, options],
value: totalFee,
});6. Track delivery
sendOriginal / sendOFT return a MessagingReceipt whose guid identifies the transfer end to end:
- Watch
BridgeMessageSent(guid, ...)on the source andBridgeMessageReceived(guid, ...)on the destination. - Paste the source transaction into LayerZero Scan to see verification and execution status.
- Or use the Omnisea Explorer to follow indexed transfer status once the events are indexed.
If the destination execution fails, the transfer is recoverable by anyone - see Failed messages.