Documentation menu

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: use quoteSendOriginal / sendOriginal.
  • isRepresentation === true -> the token is a representation: use quoteSendOFT / 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 and BridgeMessageReceived(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.

Feedback