Skip to content

Portal Bridge

Introduction

This tutorial guides you through building a cross-chain token transfer application using the Wormhole TypeScript SDK and its Portal Bridge method. The Portal Bridge method enables secure and efficient cross-chain asset transfers across different blockchain networks, allowing users to move tokens seamlessly.

By leveraging Wormhole’s Portal Bridge, this guide shows you how to build an application that supports multiple transfer types:

  • EVM to EVM (e.g., Ethereum to Avalanche)
  • EVM to non-EVM chains (e.g., Ethereum to Solana)
  • Non-EVM to EVM chains (e.g., Sui to Avalanche)
  • Non-EVM to non-EVM chains (e.g., Solana to Sui)

Existing solutions for cross-chain transfers can be complex and inefficient, requiring multiple steps and transaction fees. However, the Portal Bridge method from Wormhole simplifies the process by handling the underlying attestation, transaction validation, and message passing across blockchains.

At the end of this guide, you’ll have a fully functional setup for transferring assets across chains using Wormhole’s Portal Bridge method.

Prerequisites

Before you begin, ensure you have the following:

  • Node.js and npm installed on your machine
  • TypeScript installed globally
  • Native tokens (testnet or mainnet) in Solana and Sui wallets
  • A wallet with a private key, funded with native tokens (testnet or mainnet) for gas fees

Supported Chains

The Wormhole SDK supports a wide range of EVM and non-EVM chains, allowing you to facilitate cross-chain transfers efficiently. You can find a complete list of supported chains in the Wormhole SDK GitHub repository, which covers both testnet and mainnet environments.

Project Setup

In this section, we’ll guide you through initializing the project, installing dependencies, and preparing your environment for cross-chain transfers.

  1. Initialize the project - start by creating a new directory for your project and initializing it with npm, which will create the package.json file for your project

    mkdir native-transfers
    cd native-transfers
    npm init -y
    
  2. Install dependencies - install the required dependencies, including the Wormhole SDK and helper libraries

    npm install @wormhole-foundation/sdk dotenv tsx
    
  3. Set up environment variables - to securely store your private key, create a .env file in the root of your project

    touch .env
    

    Inside the .env file, add your private keys.

    ETH_PRIVATE_KEY="INSERT_YOUR_PRIVATE_KEY"
    SOL_PRIVATE_KEY="INSERT_YOUR_PRIVATE_KEY"
    SUI_PRIVATE_KEY="INSERT_SUI_MNEMONIC"
    

    Note

    Ensure your private key contains native tokens for gas on both the source and destination chains. For Sui, you must provide a mnemonic instead of a private key.

  4. Create a helpers.ts file - to simplify the interaction between chains, create a file to store utility functions for fetching your private key, set up signers for different chains, and manage transaction relays

    1. Create the helpers file

      mkdir -p src/helpers
      touch src/helpers/helpers.ts
      
    2. Open the helpers.ts file and add the following code

      import {
        ChainAddress,
        ChainContext,
        Network,
        Signer,
        Wormhole,
        Chain,
        TokenId,
        isTokenId,
      } from '@wormhole-foundation/sdk';
      import evm from '@wormhole-foundation/sdk/evm';
      import solana from '@wormhole-foundation/sdk/solana';
      import sui from '@wormhole-foundation/sdk/sui';
      import { config } from 'dotenv';
      config();
      
      export interface SignerStuff<N extends Network, C extends Chain> {
        chain: ChainContext<N, C>;
        signer: Signer<N, C>;
        address: ChainAddress<C>;
      }
      
      // Function to fetch environment variables (like your private key)
      function getEnv(key: string): string {
        const val = process.env[key];
        if (!val) throw new Error(`Missing environment variable: ${key}`);
        return val;
      }
      
      // Signer setup function for different blockchain platforms
      export async function getSigner<N extends Network, C extends Chain>(
        chain: ChainContext<N, C>
      ): Promise<{
        chain: ChainContext<N, C>;
        signer: Signer<N, C>;
        address: ChainAddress<C>;
      }> {
        let signer: Signer;
        const platform = chain.platform.utils()._platform;
      
        switch (platform) {
          case 'Solana':
            signer = await (
              await solana()
            ).getSigner(await chain.getRpc(), getEnv('SOL_PRIVATE_KEY'));
            break;
          case 'Evm':
            signer = await (
              await evm()
            ).getSigner(await chain.getRpc(), getEnv('ETH_PRIVATE_KEY'));
            break;
          case 'Sui':
            signer = await (
              await sui()
            ).getSigner(await chain.getRpc(), getEnv('SUI_PRIVATE_KEY'));
            break;
          default:
            throw new Error('Unsupported platform: ' + platform);
        }
      
        return {
          chain,
          signer: signer as Signer<N, C>,
          address: Wormhole.chainAddress(chain.chain, signer.address()),
        };
      }
      
      export async function getTokenDecimals<
        N extends 'Mainnet' | 'Testnet' | 'Devnet'
      >(
        wh: Wormhole<N>,
        token: TokenId,
        sendChain: ChainContext<N, any>
      ): Promise<number> {
        return isTokenId(token)
          ? Number(await wh.getDecimals(token.chain, token.address))
          : sendChain.config.nativeTokenDecimals;
      }
      
      • getEnv - this function fetches environment variables like your private key from the .env file
      • getSigner - based on the chain you're working with (EVM, Solana, Sui, etc.), this function retrieves a signer for that specific platform. The signer is responsible for signing transactions and interacting with the blockchain. It securely uses the private key stored in your .env file
      • getTokenDecimals - this function fetches the number of decimals for a token on a specific chain. It helps handle token amounts accurately during transfers

Native Token Transfers

In this section, you'll create a script to transfer native tokens across chains using Wormhole's Portal Bridge method. The script will handle the transfer of Sui native tokens to Solana, demonstrating the seamless cross-chain transfer capabilities of the Wormhole SDK. Since both chains are non-EVM compatible, you'll need to manually handle the attestation and finalization steps.

Configure Transfer Details

Before initiating a cross-chain transfer, you must set up the chain context and signers for both the source and destination chains.

  1. Create the native-transfer.ts file in the src directory to hold your script for transferring native tokens across chains

    mkdir -p src/scripts
    touch src/scripts/native-transfer.ts
    
  2. Open the native-transfer.ts file and begin by importing the necessary modules from the SDK and helper files

    import {
      Chain,
      Network,
      Wormhole,
      amount,
      wormhole,
      TokenId,
      TokenTransfer,
    } from '@wormhole-foundation/sdk';
    import evm from '@wormhole-foundation/sdk/evm';
    import solana from '@wormhole-foundation/sdk/solana';
    import sui from '@wormhole-foundation/sdk/sui';
    import { SignerStuff, getSigner, getTokenDecimals } from '../helpers/helpers';
    
    • evm - this import is for working with EVM-compatible chains, like Avalanche, Ethereum, Base Sepolia, and more
    • solana - this adds support for Solana, a non-EVM chain
    • sui - support for Sui chain, another non-EVM chain
    • helpers.ts methods defined in the previous section
  3. Initialize the Wormhole SDK - initialize the wormhole function for the Testnet environment and specify the platforms (EVM, Solana, and Sui) to support. This allows us to interact with both EVM-compatible chains like Ethereum and non-EVM chains like Solana and Sui

    (async function () {
      const wh = await wormhole('Testnet', [evm, solana, sui]);
    

    Note

    You can replace 'Testnet' with 'Mainnet' if you want to perform transfers on Mainnet.

  4. Set up source and destination chains - specify the source chain (Sui) and the destination chain (Solana) using the getChain method. This allows us to define where to send the native tokens and where to receive them

      const sendChain = wh.getChain('Sui');
      const rcvChain = wh.getChain('Solana');
    
  5. Configure the signers - use the getSigner function to retrieve the signers responsible for signing transactions on the respective chains. This ensures that transactions are correctly authorized on both the source and destination chains

      const source = await getSigner(sendChain);
      const destination = await getSigner(rcvChain);
    
  6. Define the token to transfer - specify the native token on the source chain (Sui in this example) by creating a TokenId object

      const token = Wormhole.tokenId(sendChain.chain, 'native');
    
  7. Define the transfer amount - the amount of native tokens to transfer is specified. In this case, we're transferring 1 unit

      const amt = '1';
    
  8. Set transfer mode - specify that the transfer should be manual by setting automatic = false. This means you will need to handle the attestation and finalization steps yourself

      const automatic = false;
    

    Note

    Automatic transfers are only supported for EVM chains. For non-EVM chains like Solana and Sui, you must manually handle the attestation and finalization steps.

  9. Define decimals - fetch the number of decimals for the token on the source chain (Sui) using the getTokenDecimals function

      const decimals = await getTokenDecimals(wh, token, sendChain);
    
  10. Perform the token transfer and exit the process - initiate the transfer by calling the tokenTransfer function, which we’ll define in the next step. This function takes an object containing all required details for executing the transfer, including the source and destination chains, token, mode, and transfer amount

      const xfer = await tokenTransfer(wh, {
        token,
        amount: amount.units(amount.parse(amt, decimals)),
        source,
        destination,
        automatic,
      });
    

    Finally, we use process.exit(0); to close the script once the transfer completes

      process.exit(0);
    })();
    

Token Transfer Logic

This section defines the tokenTransfer function, which manages the core steps for cross-chain transfer execution. This function will handle initiating the transfer on the source chain, retrieving the attestation, and completing the transfer on the destination chain.

Defining the Token Transfer Function

The tokenTransfer function initiates and manages the transfer process, handling all necessary steps to move tokens across chains with the Wormhole SDK. This function uses types from the SDK and our helpers.ts file to ensure chain compatibility.

async function tokenTransfer<N extends Network>(
  wh: Wormhole<N>,
  route: {
    token: TokenId;
    amount: bigint;
    source: SignerStuff<N, Chain>;
    destination: SignerStuff<N, Chain>;
    automatic: boolean;
    payload?: Uint8Array;
  }
) {
  // Token Transfer Logic
}

Steps to Transfer Tokens

The tokenTransfer function consists of several key steps to facilitate the cross-chain transfer. Let’s break down each step:

  1. Initialize the transfer object - the tokenTransfer function begins by creating a TokenTransfer object, xfer, which tracks the state of the transfer process and provides access to relevant methods for each transfer step

      const xfer = await wh.tokenTransfer(
        route.token,
        route.amount,
        route.source.address,
        route.destination.address,
        route.automatic ?? false,
        route.payload
      );
    
  2. Estimate transfer fees and validate amount - we obtain a fee quote for the transfer before proceeding. This step is significant in automatic mode (automatic = true), where the quote will include additional fees for relaying

      const quote = await TokenTransfer.quoteTransfer(
        wh,
        route.source.chain,
        route.destination.chain,
        xfer.transfer
      );
    
      if (xfer.transfer.automatic && quote.destinationToken.amount < 0)
        throw 'The amount requested is too low to cover the fee and any native gas requested.';
    
  3. Submit the transaction to the source chain - initiate the transfer on the source chain by submitting the transaction using route.source.signer, starting the token transfer process

      const srcTxids = await xfer.initiateTransfer(route.source.signer);
      console.log(`Source Trasaction ID: ${srcTxids[0]}`);
    
    • srcTxids - the resulting transaction IDs are printed to the console. These IDs can be used to track the transfer’s progress on the source chain and Wormhole network
    How Cross-Chain Transfers Work in the Background

    When xfer.initiateTransfer(route.source.signer) is called, it initiates the transfer on the source chain. Here’s what happens in the background:

    • Token lock or burn - tokens are either locked in a smart contract or burned on the source chain, representing the transfer amount
    • VAA creation - Wormhole’s network of Guardians generates a Verifiable Action Approval (VAA)—a signed proof of the transaction, which ensures it’s recognized across chains
    • Tracking the transfer - the returned transaction IDs allow you to track the transfer's progress both on the source chain and within Wormhole’s network
    • Redemption on destination - once detected, the VAA is used to release or mint the corresponding token amount on the destination chain, completing the transfer

    This process ensures a secure and verifiable transfer across chains, from locking tokens on the source chain to redeeming them on the destination chain.

  4. Wait for the attestation - retrieve the Wormhole attestation (VAA), which serves as cryptographic proof of the transfer. In manual mode, you must wait for the VAA before redeeming the transfer on the destination chain

      await xfer.fetchAttestation(60_000);
    
  5. Complete the transfer on the destination chain - redeem the VAA on the destination chain to finalize the transfer

      const destTxids = await xfer.completeTransfer(route.destination.signer);
      console.log(`Completed Transfer: `, destTxids);
    

You can find the complete native token transfer script below:

native-transfer.ts
import {
  Chain,
  Network,
  Wormhole,
  amount,
  wormhole,
  TokenId,
  TokenTransfer,
} from '@wormhole-foundation/sdk';
import evm from '@wormhole-foundation/sdk/evm';
import solana from '@wormhole-foundation/sdk/solana';
import sui from '@wormhole-foundation/sdk/sui';
import { SignerStuff, getSigner, getTokenDecimals } from '../helpers/helpers';

(async function () {
  const wh = await wormhole('Testnet', [evm, solana, sui]);

  // Set up source and destination chains
  const sendChain = wh.getChain('Sui');
  const rcvChain = wh.getChain('Solana');

  // Get signer from local key but anything that implements
  const source = await getSigner(sendChain);
  const destination = await getSigner(rcvChain);

  // Shortcut to allow transferring native gas token
  const token = Wormhole.tokenId(sendChain.chain, 'native');

  // Define the amount of tokens to transfer
  const amt = '1';

  // Set automatic transfer to false for manual transfer
  const automatic = false;

  // Used to normalize the amount to account for the tokens decimals
  const decimals = await getTokenDecimals(wh, token, sendChain);

  // Perform the token transfer if no recovery transaction ID is provided
  const xfer = await tokenTransfer(wh, {
    token,
    amount: amount.units(amount.parse(amt, decimals)),
    source,
    destination,
    automatic,
  });

  process.exit(0);
})();

async function tokenTransfer<N extends Network>(
  wh: Wormhole<N>,
  route: {
    token: TokenId;
    amount: bigint;
    source: SignerStuff<N, Chain>;
    destination: SignerStuff<N, Chain>;
    automatic: boolean;
    payload?: Uint8Array;
  }
) {
  // Token Transfer Logic
  // Create a TokenTransfer object to track the state of the transfer over time
  const xfer = await wh.tokenTransfer(
    route.token,
    route.amount,
    route.source.address,
    route.destination.address,
    route.automatic ?? false,
    route.payload
  );

  const quote = await TokenTransfer.quoteTransfer(
    wh,
    route.source.chain,
    route.destination.chain,
    xfer.transfer
  );

  if (xfer.transfer.automatic && quote.destinationToken.amount < 0)
    throw 'The amount requested is too low to cover the fee and any native gas requested.';

  // Submit the transactions to the source chain, passing a signer to sign any txns
  console.log('Starting transfer');
  const srcTxids = await xfer.initiateTransfer(route.source.signer);
  console.log(`Source Trasaction ID: ${srcTxids[0]}`);
  console.log(`Wormhole Trasaction ID: ${srcTxids[1] ?? srcTxids[0]}`);

  // Wait for the VAA to be signed and ready (not required for auto transfer)
  console.log('Getting Attestation');
  await xfer.fetchAttestation(60_000);

  // Redeem the VAA on the dest chain
  console.log('Completing Transfer');
  const destTxids = await xfer.completeTransfer(route.destination.signer);
  console.log(`Completed Transfer: `, destTxids);
}

Run the Native Token Transfer

Now that you’ve set up the project and defined the transfer logic, you can execute the script to transfer native tokens from the Sui chain to Solana. You can use tsx to run the TypeScript file directly:

npx tsx src/scripts/native-transfer.ts

This initiates the native token transfer from the source chain (Sui) and completes it on the destination chain (Solana).

You can monitor the status of the transaction on the Wormhole explorer.

Resources

If you'd like to explore the complete project or need a reference while following this tutorial, you can find the complete codebase in Wormhole's demo GitHub repository. The repository includes all the example scripts and configurations needed to perform native token cross-chain transfers, including manual, automatic, and partial transfers using the Wormhole SDK.

Conclusion

You've successfully built a cross-chain token transfer application using Wormhole's TypeScript SDK and the Portal Bridge method. This guide took you through the setup, configuration, and transfer logic required to move native tokens across non-EVM chains like Sui and Solana.

The same transfer logic will apply if you’d like to extend this application to different chain combinations, including EVM-compatible chains.

Got any questions?

Find out more