Transfer Tokens via the Token Bridge#
Introduction#
This tutorial guides you through building a cross-chain token transfer application using the Wormhole TypeScript SDK and its Token Bridge method. The Token Bridge method enables secure and efficient cross-chain asset transfers across different blockchain networks, allowing users to move tokens seamlessly.
By leveraging Wormhole’s Token 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 Token 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 Token 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.
-
Initialize the project - start by creating a new directory for your project and initializing it with
npm
, which will create thepackage.json
file for your project -
Install dependencies - install the required dependencies, including the Wormhole SDK and helper libraries
-
Set up environment variables - to securely store your private key, create a
.env
file in the root of your projectInside 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.
-
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-
Create the helpers file
-
Open the
helpers.ts
file and add the following codeimport { 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 aptos from '@wormhole-foundation/sdk/aptos'; 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>, gasLimit?: bigint ): 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': const evmSignerOptions = gasLimit ? { gasLimit } : {}; signer = await ( await evm() ).getSigner( await chain.getRpc(), getEnv('ETH_PRIVATE_KEY'), evmSignerOptions ); break; case 'Sui': signer = await ( await sui() ).getSigner(await chain.getRpc(), getEnv('SUI_MNEMONIC')); break; case 'Aptos': signer = await ( await aptos() ).getSigner(await chain.getRpc(), getEnv('APTOS_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
filegetSigner
- 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
filegetTokenDecimals
- this function fetches the number of decimals for a token on a specific chain. It helps handle token amounts accurately during transfers
-
Check and Create Wrapped Tokens#
Before tokens are transferred across chains, it should be checked whether a wrapped version exists on the destination chain. If not, an attestation must be generated to wrap it so it can be sent and received on that chain.
In this section, you'll create a script that automates this process by checking whether Arbitrum Sepolia has a wrapped version on Base Sepolia and registering it if needed.
Configure the Wrapped Token Script#
-
Create the
create-wrapped.ts
file - set up the script file that will handle checking and wrapping tokens in thesrc
directory -
Open
create-wrapped.ts
and import the required modules - import the necessary SDK modules to interact with Wormhole, EVM, Solana, and Sui chains, as well as helper functions for signing and sending transactionsimport { Wormhole, signSendWait, wormhole } 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 { inspect } from 'util'; import { getSigner } from '../helpers/helpers';
-
Initialize the Wormhole SDK - initialize the
wormhole
function for theTestnet
environment and specify the platforms (EVM, Solana, and Sui) to supportNote
You can replace
'Testnet'
with'Mainnet'
if you want to perform transfers on Mainnet. -
Configure transfer parameters - specify Arbitrum Sepolia as the source chain and Base Sepolia as the destination, retrieve the token ID from the source chain for transfer, and set the gas limit (optional)
-
Set up the destination chain signer - the signer authorizes transactions, such as submitting the attestation
-
Check if the token is wrapped on the destination chain - verify if the token already exists as a wrapped asset before creating an attestation
const tbDest = await destChain.getTokenBridge(); try { const wrapped = await tbDest.getWrappedAsset(token); console.log( `Token already wrapped on ${destChain.chain}. Skipping attestation.` ); return { chain: destChain.chain, address: wrapped }; } catch (e) { console.log( `No wrapped token found on ${destChain.chain}. Proceeding with attestation.` ); }
If the token is already wrapped, the script exits, and you may proceed to the next section. Otherwise, an attestation must be generated.
-
Set up the source chain signer - the signer creates and submits the attestation transaction
-
Create an attestation transaction - generate and send an attestation for the token on the source chain to register it on the destination chain, then save the transaction ID to verify the attestation in the next step
const tbOrig = await srcChain.getTokenBridge(); const attestTxns = tbOrig.createAttestation( token.address, Wormhole.parseAddress(origSigner.chain(), origSigner.address()) ); const txids = await signSendWait(srcChain, attestTxns, origSigner); console.log('txids: ', inspect(txids, { depth: null })); const txid = txids[0]!.txid; console.log('Created attestation (save this): ', txid);
-
Retrieve the signed VAA - once the attestation transaction is confirmed, use
parseTransaction(txid)
to extract Wormhole messages, then retrieve the signed VAA from the messages. The timeout defines how long to wait for the VAA before failure -
Submit the attestation on the destination chain - submit the signed VAA using
submitAttestation(vaa, recipient)
to create the wrapped token on the destination chain, then send the transaction and await confirmation -
Wait for the wrapped asset to be available - poll until the wrapped token is available on the destination chain
async function waitForIt() { do { try { const wrapped = await tbDest.getWrappedAsset(token); return { chain: destChain.chain, address: wrapped }; } catch (e) { console.error('Wrapped asset not found yet. Retrying...'); } console.log('Waiting before checking again...'); await new Promise((r) => setTimeout(r, 2000)); } while (true); } console.log('Wrapped Asset: ', await waitForIt()); })().catch((e) => console.error(e));
If the token is not found, it logs a message and retries after a short delay. Once the wrapped asset is detected, its address is returned.
Complete script
import { Wormhole, signSendWait, wormhole } 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 { inspect } from 'util';
import { getSigner } from '../helpers/helpers';
(async function () {
const wh = await wormhole('Testnet', [evm, solana, sui]);
// Define the source and destination chains
const srcChain = wh.getChain('ArbitrumSepolia');
const destChain = wh.getChain('BaseSepolia');
const token = await srcChain.getNativeWrappedTokenId();
const gasLimit = BigInt(2_500_000);
// Destination chain signer setup
const { signer: destSigner } = await getSigner(destChain, gasLimit);
const tbDest = await destChain.getTokenBridge();
try {
const wrapped = await tbDest.getWrappedAsset(token);
console.log(
`Token already wrapped on ${destChain.chain}. Skipping attestation.`
);
return { chain: destChain.chain, address: wrapped };
} catch (e) {
console.log(
`No wrapped token found on ${destChain.chain}. Proceeding with attestation.`
);
}
// Source chain signer setup
const { signer: origSigner } = await getSigner(srcChain);
// Create an attestation transaction on the source chain
const tbOrig = await srcChain.getTokenBridge();
const attestTxns = tbOrig.createAttestation(
token.address,
Wormhole.parseAddress(origSigner.chain(), origSigner.address())
);
const txids = await signSendWait(srcChain, attestTxns, origSigner);
console.log('txids: ', inspect(txids, { depth: null }));
const txid = txids[0]!.txid;
console.log('Created attestation (save this): ', txid);
// Retrieve the Wormhole message ID from the attestation transaction
const msgs = await srcChain.parseTransaction(txid);
console.log('Parsed Messages:', msgs);
const timeout = 25 * 60 * 1000;
const vaa = await wh.getVaa(msgs[0]!, 'TokenBridge:AttestMeta', timeout);
if (!vaa) {
throw new Error(
'VAA not found after retries exhausted. Try extending the timeout.'
);
}
console.log('Token Address: ', vaa.payload.token.address);
// Submit the attestation on the destination chain
console.log('Attesting asset on destination chain...');
const subAttestation = tbDest.submitAttestation(
vaa,
Wormhole.parseAddress(destSigner.chain(), destSigner.address())
);
const tsx = await signSendWait(destChain, subAttestation, destSigner);
console.log('Transaction hash: ', tsx);
// Poll for the wrapped asset until it's available
async function waitForIt() {
do {
try {
const wrapped = await tbDest.getWrappedAsset(token);
return { chain: destChain.chain, address: wrapped };
} catch (e) {
console.error('Wrapped asset not found yet. Retrying...');
}
console.log('Waiting before checking again...');
await new Promise((r) => setTimeout(r, 2000));
} while (true);
}
console.log('Wrapped Asset: ', await waitForIt());
})().catch((e) => console.error(e));
Run the Wrapped Token Creation#
Once the script is ready, execute it with:
If the token is already wrapped, the script exits. Otherwise, it generates an attestation and submits it. Once complete, you’re ready to transfer tokens across chains.
Token Transfers#
In this section, you'll create a script to transfer native tokens across chains using Wormhole's Token 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.
-
Create the
native-transfer.ts
file in thesrc
directory to hold your script for transferring native tokens across chains -
Open the
native-transfer.ts
file and begin by importing the necessary modules from the SDK and helper filesimport { 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';
-
Initialize the Wormhole SDK - initialize the
wormhole
function for theTestnet
environment and specify the platforms (EVM, Solana, and Sui) to support -
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 -
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 -
Define the token to transfer - specify the native token on the source chain (Sui in this example) by creating a
TokenId
object -
Define the transfer amount - the amount of native tokens to transfer is specified. In this case, we're transferring 1 unit
-
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 yourselfNote
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.
-
Define decimals - fetch the number of decimals for the token on the source chain (Sui) using the
getTokenDecimals
function -
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 thesource
anddestination
chains,token
,mode
, and transferamount
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
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:
-
Initialize the transfer object - the
tokenTransfer
function begins by creating aTokenTransfer
object,xfer
, which tracks the state of the transfer process and provides access to relevant methods for each transfer step -
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 -
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 processconst 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.
-
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
-
Complete the transfer on the destination chain - redeem the VAA on the destination chain to finalize the transfer
Complete script
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:
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 Token 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.