Skip to main content
This example demonstrates a complete cross-chain transfer from Base to Optimism using TypeScript and viem.

Prerequisites

  • Node.js 18+
  • An API key from LayerZero
  • A funded wallet on Base (source chain)

Installation


    pnpm add viem dotenv

Environment setup

Create a .env file in your project root:

    VT_API_KEY=your_api_key_here
    EVM_PRIVATE_KEY=0xyour_private_key_here


1

Validate transfer path

Before requesting a quote, verify that the destination token is reachable from your source token.Query the tokens endpoint with filters to check if your destination exists in the list of transferrable tokens:
const response = await fetch(
  'https://transfer.layerzero-api.com/v1/tokens?' +
    new URLSearchParams({
      transferrableFromChainKey: 'base',
      transferrableFromTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
    }),
);

const {tokens} = await response.json();

// Check if destination token exists in reachable tokens
const isSupported = tokens.some(
  (t) => t.chainKey === 'optimism' && t.address === '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
);

if (!isSupported) {
  throw new Error('Transfer path not supported');
}

console.log(`Found ${tokens.length} reachable destinations`);
Why validate first? Validating the transfer path before requesting quotes prevents unnecessary API calls and provides immediate feedback if a route doesn’t exist.
2

Get quotes

Request a quote for your cross-chain transfer. The API returns available routes with fees, estimated duration, and the steps needed to execute.
const quoteResponse = await fetch('https://transfer.layerzero-api.com/v1/quotes', {
  method: 'POST',
  headers: {
    'x-api-key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    srcChainKey: 'base',
    dstChainKey: 'optimism',
    srcTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
    dstTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
    srcWalletAddress: '0xYourWallet',
    dstWalletAddress: '0xYourWallet',
    amount: '100000000000000', // 0.0001 ETH in wei
    options: {
      amountType: 'EXACT_SRC_AMOUNT',
      feeTolerance: {type: 'PERCENT', amount: 2},
    },
  }),
});

const {quotes} = await quoteResponse.json();
const quote = quotes[0];

console.log('Quote ID:', quote.id);
console.log('Route:', quote.routeSteps[0].type);
console.log('Fee:', quote.feeUsd, 'USD');
Quote response structure:For native token transfers (like ETH), the response contains a single bridge step. For ERC20 transfers (like USDC), the response contains two steps — an approve followed by a bridge. See Executing userSteps safely for details on the two-step flow.

    {
      id: '0x0000...7a', // hex quote ID
      routeSteps: [
        {
          type: 'STARGATE_V2_TAXI',
          srcChainKey: 'base',
          description: 'Stargate',
        },
      ],
      feeUsd: '0.42',
      feePercent: '0.42',
      srcAmount: '100000000000000',
      dstAmount: '99580000000000',
      duration: {estimated: '60000'},
      userSteps: [
        // Native transfers: single bridge step
        // ERC20 transfers: approve step + bridge step (execute both in order)
        {
          type: 'TRANSACTION',
          description: 'bridge',
          chainKey: 'base',
          chainType: 'EVM',
          signerAddress: '0xYourWallet',
          transaction: {
            encoded: {
              chainId: 8453,
              data: '0xc7c7f5b3...',
              from: '0xYourWallet',
              to: '0x27a16dc786820B16E5c9028b75B99F6f604b5d26',
              value: '100024887844265667',
            },
          },
        },
      ],
    }

3

Execute user steps

Process each user step in the quote. The quote contains an array of steps—execute them in order.
Never approve the LZMulticall (Wrapper) as a token spenderLZMulticall executes bridge transactions. It is not the right spender, and approving it will lose you tokens. The correct spender is the TransferDelegate, and the API’s approve step already has this set in the calldata. Execute every userStep as returned.See Contracts Overview for details on the contract architecture.
import {createWalletClient, createPublicClient, http} from 'viem';
import {privateKeyToAccount} from 'viem/accounts';
import {base} from 'viem/chains';

const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
const wallet = createWalletClient({account, chain: base, transport: http()});
const client = createPublicClient({chain: base, transport: http()});

let txHash;

for (const step of quote.userSteps) {
  if (step.type === 'TRANSACTION') {
    const tx = step.transaction.encoded;
    txHash = await wallet.sendTransaction({
      account,
      to: tx.to,
      data: tx.data,
      value: BigInt(tx.value ?? '0'),
    });
    await client.waitForTransactionReceipt({hash: txHash});
    console.log('Transaction sent:', txHash);
  } else if (step.type === 'SIGNATURE') {
    const typed = step.signature.typedData;
    const signature = await wallet.signTypedData({
      account,
      domain: typed.domain,
      types: typed.types,
      primaryType: typed.primaryType,
      message: typed.message,
    });

    await fetch('https://transfer.layerzero-api.com/v1/submit-signature', {
      method: 'POST',
      headers: {
        'x-api-key': 'YOUR_API_KEY',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        quoteId: quote.id,
        signatures: [signature],
      }),
    });
    console.log('Signature submitted');
  }
}
Step types: Most EVM routes use TRANSACTION steps. Intent-based routes (like Aori) may include SIGNATURE steps for EIP-712 signed messages.
4

Track transfer status

Poll the status endpoint until the transfer completes. The API returns the current status and explorer link.
async function pollStatus(quoteId: string, txHash?: string) {
  const deadline = Date.now() + 5 * 60_000; // 5 minute timeout

  while (Date.now() < deadline) {
    const query = txHash ? `?txHash=${txHash}` : '';
    const response = await fetch(
      `https://transfer.layerzero-api.com/v1/status/${encodeURIComponent(quoteId)}${query}`,
      {headers: {'x-api-key': 'YOUR_API_KEY'}},
    );

    const {status, explorerUrl} = await response.json();
    console.log('Status:', status);

    if (status === 'SUCCEEDED') {
      console.log('Transfer complete!');
      console.log('Explorer:', explorerUrl);
      return status;
    }

    if (status === 'FAILED' || status === 'UNKNOWN') {
      throw new Error(`Transfer ${status.toLowerCase()}`);
    }

    await new Promise((r) => setTimeout(r, 4000));
  }

  throw new Error('Transfer timed out');
}

await pollStatus(quote.id, txHash);
Status values:
StatusDescription
PENDINGTransfer initiated, waiting for confirmation
PROCESSINGTransfer in progress across chains
SUCCEEDEDTransfer completed successfully
FAILEDTransfer failed
UNKNOWNStatus cannot be determined

Complete example

import {createWalletClient, createPublicClient, http, type Hex} from 'viem';
import {privateKeyToAccount} from 'viem/accounts';
import {base} from 'viem/chains';
import * as dotenv from 'dotenv';
dotenv.config();

const API = 'https://transfer.layerzero-api.com/v1';
const API_KEY = process.env.VT_API_KEY!;
const PRIVATE_KEY = process.env.EVM_PRIVATE_KEY as Hex;

const account = privateKeyToAccount(PRIVATE_KEY);
const wallet = createWalletClient({account, chain: base, transport: http()});
const client = createPublicClient({chain: base, transport: http()});

async function main() {
  // Step 1: Validate transfer path
  const tokensRes = await fetch(
    `${API}/tokens?` +
      new URLSearchParams({
        transferrableFromChainKey: 'base',
        transferrableFromTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
      }),
  );
  const {tokens} = await tokensRes.json();
  const isSupported = tokens.some(
    (t) => t.chainKey === 'optimism' && t.address === '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
  );
  if (!isSupported) throw new Error('Transfer path not supported');
  console.log(`Found ${tokens.length} reachable destinations`);

  // Step 2: Get quote
  const quoteRes = await fetch(`${API}/quotes`, {
    method: 'POST',
    headers: {'x-api-key': API_KEY, 'Content-Type': 'application/json'},
    body: JSON.stringify({
      srcChainKey: 'base',
      dstChainKey: 'optimism',
      srcTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
      dstTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
      srcWalletAddress: account.address,
      dstWalletAddress: account.address,
      amount: '100000000000000',
      options: {amountType: 'EXACT_SRC_AMOUNT', feeTolerance: {type: 'PERCENT', amount: 2}},
    }),
  });

  const {quotes} = await quoteRes.json();
  const quote = quotes[0];
  if (!quote) throw new Error('No quote available');

  console.log('Quote received:', quote.id);
  console.log('Fee:', quote.feeUsd, 'USD');

  // Step 3: Execute user steps
  let txHash: Hex | undefined;
  for (const step of quote.userSteps) {
    if (step.type === 'SIGNATURE') {
      const typed = step.signature.typedData;
      const signature = await wallet.signTypedData({
        account,
        domain: typed.domain,
        types: typed.types,
        primaryType: typed.primaryType,
        message: typed.message,
      });
      await fetch(`${API}/submit-signature`, {
        method: 'POST',
        headers: {'x-api-key': API_KEY, 'Content-Type': 'application/json'},
        body: JSON.stringify({quoteId: quote.id, signatures: [signature]}),
      });
      console.log('Signature submitted');
    } else if (step.type === 'TRANSACTION') {
      const tx = step.transaction.encoded;
      txHash = await wallet.sendTransaction({
        account,
        to: tx.to,
        data: tx.data,
        value: BigInt(tx.value ?? '0'),
      });
      await client.waitForTransactionReceipt({hash: txHash});
      console.log('Transaction sent:', txHash);
    }
  }

  // Step 4: Poll status
  const deadline = Date.now() + 5 * 60_000;
  while (Date.now() < deadline) {
    const statusRes = await fetch(
      `${API}/status/${encodeURIComponent(quote.id)}${txHash ? `?txHash=${txHash}` : ''}`,
      {headers: {'x-api-key': API_KEY}},
    );
    const {status, explorerUrl} = await statusRes.json();
    console.log('Status:', status);
    if (status === 'SUCCEEDED') {
      console.log('Explorer:', explorerUrl);
      break;
    }
    if (status === 'FAILED' || status === 'UNKNOWN') throw new Error(`Transfer ${status.toLowerCase()}`);
    await new Promise((r) => setTimeout(r, 4000));
  }
}

main().catch(console.error);

Next steps