Skip to main content
This example demonstrates a complete cross-chain transfer from Solana to Arbitrum using TypeScript and the Solana Web3.js library.

Prerequisites

  • Node.js 18+
  • An API key from LayerZero
  • A funded Solana wallet with the token you want to transfer

Installation


    pnpm add @solana/web3.js bs58 dotenv

Environment setup

Create a .env file in your project root:

    VT_API_KEY=your_api_key_here
    SOLANA_PRIVATE_KEY=your_base58_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: 'solana',
      transferrableFromTokenAddress: 'CAW777xcHVTQZ4CRwVQGB8CV1BVKPm5bNVxFJHWFKiH8',
    }),
);

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

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

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 quote ID.
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: 'solana',
    dstChainKey: 'arbitrum',
    srcTokenAddress: 'CAW777xcHVTQZ4CRwVQGB8CV1BVKPm5bNVxFJHWFKiH8',
    dstTokenAddress: '0x16f1967565aaD72DD77588a332CE445e7cEF752b',
    srcWalletAddress: 'YourSolanaPublicKey',
    dstWalletAddress: '0xYourEVMWallet',
    amount: '1000000000000',
    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:

    {
      id: '0x0000...7a', // hex quote ID
      routeSteps: [
        {
          type: 'OFT_V2',
          srcChainKey: 'solana',
          description: 'Transfer OFT from Solana to Arbitrum',
        },
      ],
      feeUsd: '0.15',
      feePercent: '0.015',
      srcAmount: '1000000000000',
      dstAmount: '999850000000',
      duration: {estimated: '120000'},
    }

3

Build and execute user steps

For Solana transfers, call /build-user-steps to get fresh transaction data, then sign and submit the transaction.

Build user steps

Solana transactions have short blockhash validity, so you must generate transaction data immediately before signing:
const stepsResponse = await fetch('https://transfer.layerzero-api.com/v1/build-user-steps', {
  method: 'POST',
  headers: {
    'x-api-key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({quoteId: quote.id}),
});

const {userSteps} = await stepsResponse.json();
User steps response:
[
  {
    type: 'TRANSACTION',
    chainKey: 'solana',
    chainType: 'SOLANA',
    description: 'Send OFT tokens via LayerZero',
    signerAddress: 'YourSolanaPublicKey',
    transaction: {
      encoded: {
        encoding: 'base64',
        data: 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDAf...',
      },
    },
  },
];

Execute the transaction

Deserialize, sign, and submit the transaction using Solana Web3.js:
import * as web3 from '@solana/web3.js';
import bs58 from 'bs58';

const connection = new web3.Connection(web3.clusterApiUrl('mainnet-beta'), 'confirmed');

// Parse private key (supports hex or base58)
function parseSolanaSecretKey(raw: string): Uint8Array {
  const isHex = /^0x[0-9a-fA-F]+$/.test(raw) || /^[0-9a-fA-F]+$/.test(raw);
  if (isHex) {
    const hex = raw.replace(/^0x/, '');
    return new Uint8Array(Buffer.from(hex, 'hex'));
  }
  return bs58.decode(raw);
}

const keypair = web3.Keypair.fromSecretKey(parseSolanaSecretKey(process.env.SOLANA_PRIVATE_KEY!));

let lastSignature;

for (const step of userSteps) {
  if (step.type !== 'TRANSACTION') continue;

  const tx = step.transaction.encoded;
  if (tx.encoding !== 'base64') continue;

  // Deserialize the transaction
  const raw = Buffer.from(tx.data, 'base64');
  const msg = web3.VersionedMessage.deserialize(raw);
  const vtx = new web3.VersionedTransaction(msg);

  // Sign the transaction
  vtx.sign([keypair]);

  // Send the transaction
  lastSignature = await connection.sendTransaction(vtx);
  console.log('Transaction sent:', lastSignature);

  // Wait for confirmation
  const latest = await connection.getLatestBlockhash();
  await connection.confirmTransaction({
    signature: lastSignature,
    blockhash: latest.blockhash,
    lastValidBlockHeight: latest.lastValidBlockHeight,
  });
  console.log('Transaction confirmed');
}
Solana requires build-user-steps: Unlike EVM chains where userSteps are included in the quote response, Solana requires calling /build-user-steps to generate fresh transaction data with a valid blockhash.
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, txSignature?: string) {
  const deadline = Date.now() + 5 * 60_000; // 5 minute timeout

  while (Date.now() < deadline) {
    const query = txSignature ? `?txHash=${encodeURIComponent(txSignature)}` : '';
    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, lastSignature);
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 * as web3 from '@solana/web3.js';
import bs58 from 'bs58';
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.SOLANA_PRIVATE_KEY!;

const connection = new web3.Connection(web3.clusterApiUrl('mainnet-beta'), 'confirmed');

function parseSolanaSecretKey(raw: string): Uint8Array {
  const isHex = /^0x[0-9a-fA-F]+$/.test(raw) || /^[0-9a-fA-F]+$/.test(raw);
  if (isHex) {
    const hex = raw.replace(/^0x/, '');
    return new Uint8Array(Buffer.from(hex, 'hex'));
  }
  return bs58.decode(raw);
}

const keypair = web3.Keypair.fromSecretKey(parseSolanaSecretKey(PRIVATE_KEY));

async function main() {
  // Step 1: Validate transfer path
  const tokensRes = await fetch(
    `${API}/tokens?` +
      new URLSearchParams({
        transferrableFromChainKey: 'solana',
        transferrableFromTokenAddress: 'CAW777xcHVTQZ4CRwVQGB8CV1BVKPm5bNVxFJHWFKiH8',
      }),
  );
  const {tokens} = await tokensRes.json();
  const isSupported = tokens.some(
    (t) => t.chainKey === 'arbitrum' && t.address === '0x16f1967565aaD72DD77588a332CE445e7cEF752b',
  );
  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: 'solana',
      dstChainKey: 'arbitrum',
      srcTokenAddress: 'CAW777xcHVTQZ4CRwVQGB8CV1BVKPm5bNVxFJHWFKiH8',
      dstTokenAddress: '0x16f1967565aaD72DD77588a332CE445e7cEF752b',
      srcWalletAddress: keypair.publicKey.toBase58(),
      dstWalletAddress: '0x6d9798053f498451bec79c0397f7f95b079bdcd6',
      amount: '1000000000000',
      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);

  // Step 3: Build user steps (required for Solana)
  const stepsRes = await fetch(`${API}/build-user-steps`, {
    method: 'POST',
    headers: {'x-api-key': API_KEY, 'Content-Type': 'application/json'},
    body: JSON.stringify({quoteId: quote.id}),
  });

  const {userSteps} = await stepsRes.json();

  // Execute transactions
  let lastSignature: string | undefined;
  for (const step of userSteps) {
    if (step.type !== 'TRANSACTION') continue;

    const tx = step.transaction.encoded;
    if (tx.encoding !== 'base64') continue;

    const raw = Buffer.from(tx.data, 'base64');
    const msg = web3.VersionedMessage.deserialize(raw);
    const vtx = new web3.VersionedTransaction(msg);
    vtx.sign([keypair]);

    lastSignature = await connection.sendTransaction(vtx);
    console.log('Transaction sent:', lastSignature);

    const latest = await connection.getLatestBlockhash();
    await connection.confirmTransaction({
      signature: lastSignature,
      blockhash: latest.blockhash,
      lastValidBlockHeight: latest.lastValidBlockHeight,
    });
  }

  // Step 4: Poll status
  const deadline = Date.now() + 5 * 60_000;
  while (Date.now() < deadline) {
    const query = lastSignature ? `?txHash=${encodeURIComponent(lastSignature)}` : '';
    const statusRes = await fetch(`${API}/status/${encodeURIComponent(quote.id)}${query}`, {
      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);

Key differences from EVM

AspectEVMSolana
Transaction formatJSON with to, data, valueBase64-encoded versioned transaction
User stepsIncluded in quote responseRequires /build-user-steps call
SigningEIP-191 or EIP-712Ed25519
ConfirmationwaitForTransactionReceiptconfirmTransaction with blockhash

Next steps