On this page

Transfer

A client-side transfer flow has five actions: identify the source token, check the destination, approve an original ERC-20, quote the message, and submit the matching send call.

The primary examples use React, wagmi, and viem. An ethers v6 version is included at the end.

Install the client stack

npm install wagmi @wagmi/core viem @tanstack/react-query

Configure wagmi with every chain your app supports. You need:

  • a wallet client on the source chain;
  • a public client on the source chain;
  • a public client on the destination chain.

The public Omnisea address is the same on every supported EVM chain:

import type { Address } from "viem";

export const omnisea = "0x7AEEBd64211eB405C081a2085BF712fb5ad2e133" as Address;

Use the verified full ABI in production. This minimal viem ABI covers the transfer flow and its common errors:

import { parseAbi } from "viem";

export const omniseaAbi = parseAbi([
  "function oftToOriginal(address token) view returns (uint32 originalEid, bytes originalToken, bool exists)",
  "function representationFor(uint32 originalEid, bytes originalToken) view returns (address)",
  "function minTransferGas() view returns (uint128)",
  "function minCreationGas() view returns (uint128)",
  "function quoteSendOriginal(uint32 dstEid, address token, uint256 amount, address recipient, bool isFirstTransfer, (address composer, uint128 gasLimit, bytes message) compose, bytes options) view returns (uint256)",
  "function quoteSendOFT(uint32 dstEid, address token, uint256 amount, address recipient, bool isFirstTransfer, (address composer, uint128 gasLimit, bytes message) compose, bytes options) view returns (uint256)",
  "function sendOriginal(uint32 dstEid, address token, uint256 amount, address recipient, bool isFirstTransfer, (address composer, uint128 gasLimit, bytes message) compose, bytes options) payable",
  "function sendOFT(uint32 dstEid, address token, uint256 amount, address recipient, bool isFirstTransfer, (address composer, uint128 gasLimit, bytes message) compose, bytes options) payable",
  "error InvalidFee()",
  "error InvalidDestination()",
  "error NotRepresentation()",
  "error RepresentationMustUseSendOFT()",
  "error InsufficientLayerZeroGas(uint128 providedGas, uint128 requiredGas)",
]);

Prepare the route

Read oftToOriginal on the source to choose the original or Omniasset function. Then read representationFor on the destination to choose the LayerZero gas profile.

import { zeroAddress, type Address, type Hex, type PublicClient } from "viem";

type PrepareTransferInput = {
  sourceClient: PublicClient;
  destinationClient: PublicClient;
  sourceEid: number;
  dstEid: number;
  token: Address;
};

export async function prepareTransfer(input: PrepareTransferInput) {
  const [storedEid, storedToken, exists] = await input.sourceClient.readContract({
    address: omnisea,
    abi: omniseaAbi,
    functionName: "oftToOriginal",
    args: [input.token],
  });

  const isRepresentation = exists;
  const originalEid = isRepresentation ? storedEid : input.sourceEid;
  const originalToken = isRepresentation ? storedToken : input.token;

  const destinationRepresentation =
    input.dstEid === originalEid
      ? zeroAddress
      : await input.destinationClient.readContract({
          address: omnisea,
          abi: omniseaAbi,
          functionName: "representationFor",
          args: [originalEid, originalToken],
        });

  const isFirstTransfer =
    input.dstEid !== originalEid && destinationRepresentation === zeroAddress;

  const minimumGas = await input.sourceClient.readContract({
    address: omnisea,
    abi: omniseaAbi,
    functionName: isFirstTransfer ? "minCreationGas" : "minTransferGas",
  });
  const receiveGas = minimumGas + minimumGas / 5n;
  const u128 = (value: bigint) => value.toString(16).padStart(32, "0");
  const options = `0x000301002101${u128(receiveGas)}${u128(0n)}` as Hex;

  return { isRepresentation, isFirstTransfer, options };
}

isFirstTransfer does not control deployment. It only selects the source Router's LayerZero gas floor. The destination checks its Registry and deploys when required.

Approve and send

Original tokens require approval. Omniassets burn through sendOFT and do not use an allowance.

import { erc20Abi, zeroAddress, type Account, type Address, type WalletClient } from "viem";

type SendTransferInput = PrepareTransferInput & {
  walletClient: WalletClient;
  account: Account;
  amount: bigint;
  recipient: Address;
};

export async function sendTransfer(input: SendTransferInput) {
  const route = await prepareTransfer(input);
  const compose = { composer: zeroAddress, gasLimit: 0n, message: "0x" } as const;

  if (!route.isRepresentation) {
    const allowance = await input.sourceClient.readContract({
      address: input.token,
      abi: erc20Abi,
      functionName: "allowance",
      args: [input.account.address, omnisea],
    });

    if (allowance < input.amount) {
      const approvalHash = await input.walletClient.writeContract({
        account: input.account,
        address: input.token,
        abi: erc20Abi,
        functionName: "approve",
        args: [omnisea, input.amount],
      });
      await input.sourceClient.waitForTransactionReceipt({ hash: approvalHash });
    }
  }

  const args = [
    input.dstEid,
    input.token,
    input.amount,
    input.recipient,
    route.isFirstTransfer,
    compose,
    route.options,
  ] as const;
  const quoteFunction = route.isRepresentation ? "quoteSendOFT" : "quoteSendOriginal";
  const sendFunction = route.isRepresentation ? "sendOFT" : "sendOriginal";

  const fee = await input.sourceClient.readContract({
    address: omnisea,
    abi: omniseaAbi,
    functionName: quoteFunction,
    args,
  });

  const { request } = await input.sourceClient.simulateContract({
    account: input.account,
    address: omnisea,
    abi: omniseaAbi,
    functionName: sendFunction,
    args,
    value: fee,
  });

  const hash = await input.walletClient.writeContract(request);
  return input.sourceClient.waitForTransactionReceipt({ hash });
}

The quote occurs after approval and immediately before simulation. Quote and send use the same argument tuple.

Connect it to React and wagmi

Keep the contract workflow in the plain TypeScript function above. The hook only supplies current wallet and chain clients.

"use client";

import { getWalletClient } from "@wagmi/core";
import { useAccount, useConfig, usePublicClient, useSwitchChain } from "wagmi";

export function useOmniassetTransfer(sourceChainId: number, destinationChainId: number) {
  const { address, chainId } = useAccount();
  const config = useConfig();
  const sourceClient = usePublicClient({ chainId: sourceChainId });
  const destinationClient = usePublicClient({ chainId: destinationChainId });
  const { switchChainAsync } = useSwitchChain();

  async function send(input: Omit<SendTransferInput, "account" | "sourceClient" | "destinationClient" | "walletClient">) {
    if (!address || !sourceClient || !destinationClient) {
      throw new Error("Connect a wallet and configure both chains.");
    }
    if (chainId !== sourceChainId) {
      await switchChainAsync({ chainId: sourceChainId });
    }
    const walletClient = await getWalletClient(config, { chainId: sourceChainId });

    return sendTransfer({
      ...input,
      account: walletClient.account,
      sourceClient,
      destinationClient,
      walletClient,
    });
  }

  return { send };
}

Disable the transfer button while approval or send is pending. After the source receipt, switch the UI from transaction state to delivery state. See Track a transfer.

ethers v6

With ethers, the route decisions stay the same. Use a signer on the source and a read-only provider on the destination:

import { BrowserProvider, Contract, JsonRpcProvider, ZeroAddress } from "ethers";

const browserProvider = new BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
const source = new Contract(omnisea, omniseaAbi, signer);
const destination = new Contract(
  omnisea,
  omniseaAbi,
  new JsonRpcProvider(destinationRpcUrl),
);

const [storedEid, storedToken, exists] = await source.oftToOriginal(token);
const originalEid = exists ? Number(storedEid) : sourceEid;
const originalToken = exists ? storedToken : token;
const representation =
  dstEid === originalEid
    ? ZeroAddress
    : await destination.representationFor(originalEid, originalToken);
const isFirstTransfer = dstEid !== originalEid && representation === ZeroAddress;

const minimumGas = isFirstTransfer
  ? await source.minCreationGas()
  : await source.minTransferGas();
const receiveGas = minimumGas + minimumGas / 5n;
const u128 = (value: bigint) => value.toString(16).padStart(32, "0");
const options = `0x000301002101${u128(receiveGas)}${u128(0n)}`;
const compose = { composer: ZeroAddress, gasLimit: 0n, message: "0x" };

if (!exists) {
  const original = new Contract(
    token,
    ["function allowance(address,address) view returns (uint256)", "function approve(address,uint256) returns (bool)"],
    signer,
  );
  const owner = await signer.getAddress();
  if (await original.allowance(owner, omnisea) < amount) {
    await (await original.approve(omnisea, amount)).wait();
  }
}

const args = [dstEid, token, amount, recipient, isFirstTransfer, compose, options];
const fee = exists
  ? await source.quoteSendOFT(...args)
  : await source.quoteSendOriginal(...args);
const transaction = exists
  ? await source.sendOFT(...args, { value: fee })
  : await source.sendOriginal(...args, { value: fee });

await transaction.wait();

Use Quote for fee-preview UX and Handle errors before exposing the flow to users.

Introducing Omnipad-Launch tokens between chains