Attest Tokens#
This guide demonstrates token attestation for registering a token for transfer using the Token Bridge protocol. An attestation of the token's metadata (e.g., symbol, name, decimals) ensures consistent handling by the destination chain for ease of multichain interoperability. These steps are only required the first time a token is sent to a particular destination chain.
Completing this guide will help you to accomplish the following:
- Verify if a wrapped version of a token exists on a destination chain.
- Create and submit token attestation to register a wrapped version of a token on a destination chain.
- Check for the wrapped version to become available on the destination chain and return the wrapped token address.
The example will register an arbitrary ERC-20 token deployed to Moonbase Alpha for transfer to Solana but can be adapted for any supported chains.
Prerequisites#
Before you begin, ensure you have the following:
- Node.js and npm installed on your machine.
- TypeScript installed globally.
- The contract address for the token you wish to register.
- A wallet setup with the following:
- Private keys for your source and destination chains.
- A small amount of gas tokens on your source and destination chains.
Set Up Your Development Environment#
Follow these steps to initialize your project, install dependencies, and prepare your developer environment for token attestation.
-
Create a new directory and initialize a Node.js project using the following commands:
-
Install dependencies, including the Wormhole TypeScript SDK:
-
Set up secure access to your wallets. This guide assumes you are loading your private key values from a secure keystore of your choice, such as a secrets manager or a CLI-based tool like
cast wallet
.Warning
If you use a
.env
file during development, add it to your.gitignore
to exclude it from version control. Never commit private keys or mnemonics to your repository. -
Create a new file named
helper.ts
to hold signer functions: -
Open
helper.ts
and add the following code:helper.tsimport { Chain, ChainAddress, ChainContext, Wormhole, Network, Signer, } from '@wormhole-foundation/sdk'; import type { SignAndSendSigner } 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'; /** * Returns a signer for the given chain using locally scoped credentials. * The required values (EVM_PRIVATE_KEY, SOL_PRIVATE_KEY, SUI_MNEMONIC) must * be loaded securely beforehand, for example via a keystore, secrets * manager, or environment variables (not recommended). */ export async function getSigner<N extends Network, C extends Chain>( chain: ChainContext<N, C> ): Promise<{ chain: ChainContext<N, C>; signer: SignAndSendSigner<N, C>; address: ChainAddress<C>; }> { let signer: Signer<any, any>; const platform = chain.platform.utils()._platform; // Customize the signer by adding or removing platforms as needed. Be sure // to import the necessary packages for the platforms you want to support switch (platform) { case 'Evm': signer = await ( await evm() ).getSigner(await chain.getRpc(), EVM_PRIVATE_KEY!); break; case 'Solana': signer = await ( await solana() ).getSigner(await chain.getRpc(), SOL_PRIVATE_KEY!); break; case 'Sui': signer = await ( await sui() ).getSigner(await chain.getRpc(), SUI_MNEMONIC!); break; default: throw new Error(`Unsupported platform: ${platform}`); } const typedSigner = signer as SignAndSendSigner<N, C>; return { chain, signer: typedSigner, address: Wormhole.chainAddress(chain.chain, signer.address()), }; }
You can view the list of supported platform constants in the Wormhole SDK GitHub repo.
Check for a Wrapped Version of a Token#
If you are working with a newly created token that you know has never been transferred to the destination chain, you can continue to the Create Attestation on the Source Chain section.
Since attestation is a one-time process, it is good practice when working with existing tokens to incorporate a check for wrapped versions into your Token Bridge transfer flow. Follow these steps to check for a wrapped version of a token:
-
Create a new file called
attest.ts
to hold the wrapped version check and attestation logic: -
Open
attest.ts
and add the following code:attest.tsimport { wormhole, Wormhole, TokenId, TokenAddress, } from '@wormhole-foundation/sdk'; import { signSendWait, toNative } from '@wormhole-foundation/sdk-connect'; import evm from '@wormhole-foundation/sdk/evm'; import solana from '@wormhole-foundation/sdk/solana'; import { getSigner } from './helper'; async function attestToken() { // Initialize wormhole instance, define the network, platforms, and chains const wh = await wormhole('Testnet', [evm, solana]); const sourceChain = wh.getChain('Moonbeam'); const destinationChain = wh.getChain('Solana'); // Define the token to check for a wrapped version const tokenId: TokenId = Wormhole.tokenId( sourceChain.chain, 'INSERT_TOKEN_CONTRACT_ADDRESS' ); // Check if the token is registered with the destination chain Token Bridge contract // Registered = returns the wrapped token ID // Not registered = runs the attestation flow to register the token let wrappedToken: TokenId; try { wrappedToken = await wh.getWrappedAsset(destinationChain.chain, tokenId); console.log( '✅ Token already registered on destination:', wrappedToken.address ); } catch (e) { // Attestation on the source chain flow code console.log( '⚠️ Token is NOT registered on destination. Running attestation flow...' ); } } attestToken().catch((e) => { console.error('❌ Error in attestToken', e); process.exit(1); });
After initializing a Wormhole instance and defining the source and destination chains, this code does the following:
- Defines the token to check: Use the contract address on the source chain for this value.
- Calls
getWrappedAsset
: Part of theWormhole
class, the method does the following:- Accepts a
TokenId
representing a token on the source chain. - Checks for a corresponding wrapped version of the destination chain's Token Bridge contract.
- Returns the
TokenId
for the wrapped token on the destination chain if a wrapped version exists.
- Accepts a
-
Run the script using the following command:
-
If the token has a wrapped version registered with the destination chain Token Bridge contract, you will see terminal output similar to the following:
npx tsx attest.ts ✅ Token already registered on destination: SolanaAddress { type: 'Native', address: PublicKey [PublicKey(2qjSAGrpT2eTb673KuGAR5s6AJfQ1X5Sg177Qzuqt7yB)] { _bn: BN: 1b578bb9b7a04a1aab3b5b64b550d8fc4f73ab343c9cf8532d2976b77ec4a8ca } }You can safely use Token Bridge to transfer this token to the destination chain.
If a wrapped version isn't found on the destination chain, your terminal output will be similar to the following and you must attest the token before transfer:
npx tsx attest.ts ⚠️ Token is NOT registered on destination. Running attestation flow...
Create Attestation on the Source Chain#
To create the attestation transaction on the source chain, open attest.ts
and replace the // Attestation flow code
comment with the following code:
// Retrieve the Token Bridge context for the source chain
const tb = await sourceChain.getTokenBridge();
// Get the signer for the source chain
const sourceSigner = await getSigner(sourceChain);
// Define the token to attest and a payer address
const token: TokenAddress<typeof sourceChain.chain> = toNative(
sourceChain.chain,
tokenId.address.toString()
);
const payer = toNative(sourceChain.chain, sourceSigner.signer.address());
// Create a new attestation and sign and send the transaction
for await (const tx of tb.createAttestation(token, payer)) {
const txids = await signSendWait(
sourceChain,
tb.createAttestation(token),
sourceSigner.signer
);
// Attestation on the destination chain flow code
console.log('✅ Attestation transaction sent:', txids);
This code does the following:
- Gets the source chain Token Bridge context: This is where the transaction is sent to create the attestation.
- Defines the token to attest and the payer.
- Calls
createAttestation
: Defined in theTokenBridge
interface, thecreateAttestation
method does the following:- Accepts a
TokenAddress
representing the token on its native chain. - Accepts an optional
payer
address to cover the transaction fees for the attestation transaction. - Prepares an attestation for the token including metadata such as address, symbol, and decimals.
- Returns an
AsyncGenerator
that yields unsigned transactions, which are then signed and sent to initiate the attestation process on the source chain.
- Accepts a
Submit Attestation on Destination Chain#
The attestation flow finishes with the following:
- Using the transaction ID returned from the
createAttestation
transaction on the source chain to retrieve the associated signedTokenBridge:AttestMeta
VAA. - Submitting the signed VAA to the destination chain to provide Guardian-backed verification of the attestation transaction on the source chain.
- The destination chain uses the attested metadata to create the wrapped version of the token and register it with its Token Bridge contract.
Follow these steps to complete your attestation flow logic:
-
Add the following code to
attest.ts
:attest.ts// Parse the transaction to get Wormhole message ID const messages = await sourceChain.parseTransaction(txids[0].txid); console.log('✅ Attestation messages:', messages); // Set a timeout for fetching the VAA, this can take several minutes // depending on the source chain network and finality const timeout = 25 * 60 * 1000; // Fetch the VAA for the attestation message const vaa = await wh.getVaa( messages[0]!, 'TokenBridge:AttestMeta', timeout ); if (!vaa) throw new Error('❌ VAA not found before timeout.'); // Get the Token Bridge context for the destination chain // and submit the attestation VAA const destTb = await destinationChain.getTokenBridge(); // Get the signer for the destination chain const destinationSigner = await getSigner(destinationChain); const payer = toNative( destinationChain.chain, destinationSigner.signer.address() ); const destTxids = await signSendWait( destinationChain, destTb.submitAttestation(vaa, payer), destinationSigner.signer ); console.log('✅ Attestation submitted on destination:', destTxids); } // Poll for the wrapped token to appear on the destination chain const maxAttempts = 50; // ~5 minutes with 6s interval const interval = 6000; let attempt = 0; let registered = false; while (attempt < maxAttempts && !registered) { attempt++; try { const wrapped = await wh.getWrappedAsset( destinationChain.chain, tokenId ); console.log( `✅ Wrapped token is now available on ${destinationChain.chain}:`, wrapped.address ); registered = true; } catch { console.log( `⏳ Waiting for wrapped token to register on ${destinationChain.chain}...` ); await new Promise((res) => setTimeout(res, interval)); } } if (!registered) { throw new Error( `❌ Token attestation did not complete in time on ${destinationChain.chain}` ); } console.log( `🚀 Token attestation complete! Token registered with ${destinationChain.chain}.` );
-
Run the script using the following command:
-
You will see terminal output similar to the following:
npx tsx attest.ts ⚠️ Token is NOT registered on destination. Running attestation flow... ✅ Attestation transaction sent: [ { chain: 'Moonbeam', txid: '0xbaf7429e1099cac6f39ef7e3c30e38776cfb5b6be837dcd8793374c8ee491799' } ] ✅ Attestation messages: [ { chain: 'Moonbeam', emitter: UniversalAddress { address: [Uint8Array] }, sequence: 1507n } ] Retrying Wormholescan:GetVaaBytes, attempt 0/750 Retrying Wormholescan:GetVaaBytes, attempt 1/750 ..... Retrying Wormholescan:GetVaaBytes, attempt 10/750 📨 Submitting attestation VAA to Solana... ✅ Attestation submitted on destination: [ { chain: 'Solana', txid: '3R4oF5P85jK3wKgkRs5jmE8BBLoM4wo2hWSgXXL6kA8efbj2Vj9vfuFSb53xALqYZuv3FnXDwJNuJfiKKDwpDH1r' } ] ✅ Wrapped token is now available on Solana: SolanaAddress { type: 'Native', address: PublicKey [PublicKey(2qjSAGrpT2eTb673KuGAR5s6AJfQ1X5Sg177Qzuqt7yB)] { _bn: BN: 1b578bb9b7a04a1aab3b5b64b550d8fc4f73ab343c9cf8532d2976b77ec4a8ca } } 🚀 Token attestation complete!View complete script
attest.tsimport { wormhole, Wormhole, TokenId, TokenAddress, } from '@wormhole-foundation/sdk'; import { signSendWait, toNative } from '@wormhole-foundation/sdk-connect'; import evm from '@wormhole-foundation/sdk/evm'; import solana from '@wormhole-foundation/sdk/solana'; import { getSigner } from './helper'; async function attestToken() { // Initialize wormhole instance, define the network, platforms, and chains const wh = await wormhole('Testnet', [evm, solana]); const sourceChain = wh.getChain('Moonbeam'); const destinationChain = wh.getChain('Solana'); // Define the token to check for a wrapped version const tokenId: TokenId = Wormhole.tokenId( sourceChain.chain, 'INSERT_TOKEN_CONTRACT_ADDRESS' ); // Check if the token is registered with the destination chain Token Bridge contract // Registered = returns the wrapped token ID // Not registered = runs the attestation flow to register the token let wrappedToken: TokenId; try { wrappedToken = await wh.getWrappedAsset(destinationChain.chain, tokenId); console.log( '✅ Token already registered on destination:', wrappedToken.address ); } catch (e) { // Attestation on the source chain flow code console.log( '⚠️ Token is NOT registered on destination. Running attestation flow...' ); // Retrieve the Token Bridge context for the source chain const tb = await sourceChain.getTokenBridge(); // Get the signer for the source chain const sourceSigner = await getSigner(sourceChain); // Define the token to attest and a payer address const token: TokenAddress<typeof sourceChain.chain> = toNative( sourceChain.chain, tokenId.address.toString() ); const payer = toNative(sourceChain.chain, sourceSigner.signer.address()); // Create a new attestation and sign and send the transaction for await (const tx of tb.createAttestation(token, payer)) { const txids = await signSendWait( sourceChain, tb.createAttestation(token), sourceSigner.signer ); // Attestation on the destination chain flow code console.log('✅ Attestation transaction sent:', txids); // Parse the transaction to get Wormhole message ID const messages = await sourceChain.parseTransaction(txids[0].txid); console.log('✅ Attestation messages:', messages); // Set a timeout for fetching the VAA, this can take several minutes // depending on the source chain network and finality const timeout = 25 * 60 * 1000; // Fetch the VAA for the attestation message const vaa = await wh.getVaa( messages[0]!, 'TokenBridge:AttestMeta', timeout ); if (!vaa) throw new Error('❌ VAA not found before timeout.'); // Get the Token Bridge context for the destination chain // and submit the attestation VAA const destTb = await destinationChain.getTokenBridge(); // Get the signer for the destination chain const destinationSigner = await getSigner(destinationChain); const payer = toNative( destinationChain.chain, destinationSigner.signer.address() ); const destTxids = await signSendWait( destinationChain, destTb.submitAttestation(vaa, payer), destinationSigner.signer ); console.log('✅ Attestation submitted on destination:', destTxids); } // Poll for the wrapped token to appear on the destination chain const maxAttempts = 50; // ~5 minutes with 6s interval const interval = 6000; let attempt = 0; let registered = false; while (attempt < maxAttempts && !registered) { attempt++; try { const wrapped = await wh.getWrappedAsset( destinationChain.chain, tokenId ); console.log( `✅ Wrapped token is now available on ${destinationChain.chain}:`, wrapped.address ); registered = true; } catch { console.log( `⏳ Waiting for wrapped token to register on ${destinationChain.chain}...` ); await new Promise((res) => setTimeout(res, interval)); } } if (!registered) { throw new Error( `❌ Token attestation did not complete in time on ${destinationChain.chain}` ); } console.log( `🚀 Token attestation complete! Token registered with ${destinationChain.chain}.` ); } } attestToken().catch((e) => { console.error('❌ Error in attestToken', e); process.exit(1); });
Congratulations! You've successfully created and submitted an attestation to register a token for transfer via Token Bridge.
Next Steps#
- Transfer Wrapped Assets: Follow this guide to incorporate token attestation and registration into an end-to-end Token Bridge transfer flow.