Interchain Token Transfers

This guide walks through building a Solana program that can send and receive cross-chain token transfers via Axelar’s Interchain Token Service (ITS).

ITS transfers can include optional execution data, allowing you to combine token transfers with arbitrary logic — for example, transferring tokens and triggering a swap in a single cross-chain transaction.

For prerequisites and setup, see the Introduction.

When a cross-chain token transfer arrives on Solana, the ITS program handles the Gateway validation, mints/unlocks the tokens, and then calls your program’s execute_with_interchain_token instruction via CPI.

Use the executable_with_interchain_token_accounts! macro to generate the AxelarExecuteWithInterchainTokenAccounts struct, then embed it in your own accounts struct. The argument you pass to the macro is the name of your outer struct — it can be any name you choose:

use anchor_lang::prelude::*;
use solana_axelar_its::{executable::*, executable_with_interchain_token_accounts};
// Generate AxelarExecuteWithInterchainTokenAccounts and wire it to your struct
executable_with_interchain_token_accounts!(MyItsExecute);
#[derive(Accounts)]
pub struct MyItsExecute<'info> {
// ITS accounts for token reception (generated by the macro)
pub its_executable: AxelarExecuteWithInterchainTokenAccounts<'info>,
// Your program's accounts
#[account(mut)]
pub my_state: Account<'info, MyState>,
}

The macro generates AxelarExecuteWithInterchainTokenAccounts with:

  • token_program — SPL Token or Token-2022 program
  • token_mint — The token being transferred
  • destination_token_authority — A PDA your program can sign for (seeds: [b"axelar-its-token-authority"])
  • destination_program_ata — The Associated Token Account holding received tokens (authority = destination_token_authority)
  • interchain_transfer_execute — A signer PDA proving ITS called your program

Your handler receives an AxelarExecuteWithInterchainTokenPayload containing transfer details and optional execution data. Unlike GMP messages, you do not need to call validate_message() — the ITS program handles Gateway validation before calling your program.

pub fn execute_with_interchain_token_handler<'info>(
ctx: Context<'info, MyItsExecute<'info>>,
execute_payload: AxelarExecuteWithInterchainTokenPayload,
) -> Result<()> {
let amount = execute_payload.amount;
let token_id = execute_payload.token_id;
let token_mint = execute_payload.token_mint;
let source_chain = &execute_payload.source_chain;
let data = &execute_payload.data;
msg!(
"Received {} tokens (id: {:?}) from {}",
amount,
token_id,
source_chain
);
// Process your custom data
if !data.is_empty() {
// Decode and handle the execution data...
}
// Tokens are now in destination_program_ata.
// See "Spending Received Tokens" below to transfer them.
Ok(())
}

The AxelarExecuteWithInterchainTokenPayload contains:

  • command_id — Unique message identifier ([u8; 32])
  • source_chain — Origin chain name
  • source_address — Sender address on the source chain
  • token_id — Interchain token identifier ([u8; 32])
  • token_mint — SPL token mint address on Solana
  • amount — Number of tokens transferred
  • data — Optional execution data (Vec<u8>)

Tokens arrive in destination_program_ata, whose authority is destination_token_authority — a PDA derived from your program with seeds [b"axelar-its-token-authority"]. Your program can sign for this PDA to transfer the tokens:

use anchor_lang::solana_program::program::invoke_signed;
use anchor_spl::token_2022::spl_token_2022;
// Get the bump for the token authority PDA
let bump = ctx.bumps.its_executable.destination_token_authority;
// Build a transfer instruction
let transfer_ix = spl_token_2022::instruction::transfer_checked(
&ctx.accounts.its_executable.token_program.key(),
&ctx.accounts.its_executable.destination_program_ata.key(), // source
&ctx.accounts.its_executable.token_mint.key(),
&destination_ata.key(), // target
&ctx.accounts.its_executable.destination_token_authority.key(),
&[],
amount,
ctx.accounts.its_executable.token_mint.decimals,
)?;
// Sign with the token authority PDA
let signer_seeds: &[&[&[u8]]] = &[&[
solana_axelar_its::seed_prefixes::ITS_TOKEN_AUTHORITY_SEED,
&[bump],
]];
invoke_signed(
&transfer_ix,
&[
ctx.accounts.its_executable.destination_program_ata.to_account_info(),
ctx.accounts.its_executable.token_mint.to_account_info(),
destination_ata.to_account_info(),
ctx.accounts.its_executable.destination_token_authority.to_account_info(),
],
signer_seeds,
)?;

The Axelar relayer looks for a specific instruction discriminator when calling your program. You must either name the instruction exactly execute_with_interchain_token (so Anchor generates the matching discriminator), or use the ITS_EXECUTE_IX_DISC constant to override it.

Option A: Name it execute_with_interchain_token:

#[program]
pub mod my_program {
use super::*;
pub fn execute_with_interchain_token<'info>(
ctx: Context<'info, MyItsExecute<'info>>,
execute_payload: AxelarExecuteWithInterchainTokenPayload,
) -> Result<()> {
instructions::execute_with_interchain_token_handler(ctx, execute_payload)
}
}

Option B: Use a custom name with the discriminator override:

use solana_axelar_its::executable::ITS_EXECUTE_IX_DISC;
#[instruction(discriminator = ITS_EXECUTE_IX_DISC)]
pub fn receive_tokens<'info>(
ctx: Context<'info, MyItsExecute<'info>>,
execute_payload: AxelarExecuteWithInterchainTokenPayload,
) -> Result<()> {
instructions::execute_with_interchain_token_handler(ctx, execute_payload)
}

💡

The executable_with_interchain_token_accounts! macro is a convenience helper. If the macro does not fit your setup, you can copy the generated code from solana-axelar-its/src/executable.rs and use it directly.

To send tokens cross-chain, your program calls the ITS program’s interchain_transfer instruction via CPI. This handles token locking/burning, gas payment, and sending the message through the Gateway.

The ITS interchain_transfer instruction requires accounts for the Gateway, Gas Service, ITS, and token information:

use solana_axelar_its::program::SolanaAxelarIts;
#[derive(Accounts)]
#[instruction(token_id: [u8; 32])]
pub struct SendInterchainTransfer<'info> {
#[account(mut)]
pub payer: Signer<'info>,
/// The authority that owns the tokens being sent.
/// Must be a signer (either a wallet or a PDA your program signs for).
pub authority: Signer<'info>,
// Gateway
/// CHECK: checked by the gateway program
pub gateway_root_pda: UncheckedAccount<'info>,
/// CHECK: checked by the gateway program
pub gateway_event_authority: UncheckedAccount<'info>,
/// CHECK: checked by the gateway program
pub gateway_program: UncheckedAccount<'info>,
/// CHECK: checked by the gateway program
pub call_contract_signing_pda: UncheckedAccount<'info>,
// Gas Service
/// CHECK: checked by the gas service program
#[account(mut)]
pub gas_treasury: UncheckedAccount<'info>,
/// CHECK: checked by the gas service program
pub gas_service: UncheckedAccount<'info>,
/// CHECK: checked by the gas service program
pub gas_event_authority: UncheckedAccount<'info>,
// ITS
/// CHECK: checked by the ITS program
pub its_root_pda: UncheckedAccount<'info>,
pub its_program: Program<'info, SolanaAxelarIts>,
/// CHECK: checked by the ITS program
pub its_event_authority: UncheckedAccount<'info>,
/// CHECK: checked by the ITS program
#[account(mut)]
pub token_manager_pda: UncheckedAccount<'info>,
// Token
/// CHECK: checked by the ITS program
pub token_program: UncheckedAccount<'info>,
/// CHECK: checked by the ITS program
#[account(mut)]
pub token_mint: UncheckedAccount<'info>,
/// The sender's token account
/// CHECK: checked by the ITS program
#[account(mut)]
pub authority_token_account: UncheckedAccount<'info>,
/// CHECK: checked by the ITS program
pub token_manager_ata: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn send_interchain_transfer_handler(
ctx: Context<SendInterchainTransfer>,
token_id: [u8; 32],
destination_chain: String,
destination_address: Vec<u8>,
amount: u64,
gas_value: u64,
) -> Result<()> {
let cpi_accounts = solana_axelar_its::cpi::accounts::InterchainTransfer {
payer: ctx.accounts.payer.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
gateway_root_pda: ctx.accounts.gateway_root_pda.to_account_info(),
gateway_event_authority: ctx.accounts.gateway_event_authority.to_account_info(),
gateway_program: ctx.accounts.gateway_program.to_account_info(),
call_contract_signing_pda: ctx.accounts.call_contract_signing_pda.to_account_info(),
gas_treasury: ctx.accounts.gas_treasury.to_account_info(),
gas_service: ctx.accounts.gas_service.to_account_info(),
gas_event_authority: ctx.accounts.gas_event_authority.to_account_info(),
its_root_pda: ctx.accounts.its_root_pda.to_account_info(),
program: ctx.accounts.its_program.to_account_info(),
event_authority: ctx.accounts.its_event_authority.to_account_info(),
token_manager_pda: ctx.accounts.token_manager_pda.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
token_mint: ctx.accounts.token_mint.to_account_info(),
authority_token_account: ctx.accounts.authority_token_account.to_account_info(),
token_manager_ata: ctx.accounts.token_manager_ata.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
};
// If the authority is a PDA your program signs for, pass the seeds:
let signer_seeds = &[MyState::SEED_PREFIX, &[ctx.accounts.my_state.bump]];
let signer_seeds_arg: Vec<Vec<u8>> = signer_seeds
.iter()
.map(|seed| seed.to_vec())
.collect();
let signer_seeds = &[&signer_seeds[..]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.its_program.key(),
cpi_accounts,
signer_seeds,
);
solana_axelar_its::cpi::interchain_transfer(
cpi_ctx,
token_id,
destination_chain,
destination_address,
amount,
gas_value,
Some(crate::ID), // caller_program_id
Some(signer_seeds_arg), // caller PDA seeds for ITS to verify
None, // optional execution data
)?;
Ok(())
}

💡

The caller_program_id and caller_pda_seeds parameters allow ITS to verify that the CPI came from your program. The data parameter (last argument) lets you attach execution data that will be delivered to the destination program alongside the tokens.

SeedDerived FromPurpose
b"axelar-its-token-authority"Your program IDAuthority for the ATA holding received tokens. Sign with this to spend.
b"interchain-transfer-execute" + your_program_idITS program IDSigner proving ITS invoked your program. Verified automatically by the macro.

For a working end-to-end implementation, see the Memo Program:

Edit on GitHub