Docs
IntegrationsSolana

Send & Receive Interchain Transfers

Build a Solana program that sends interchain tokens, and receives them with data.

This guide walks through a Solana program that sends tokens to other chains through ITS, and receives tokens with data from them. For setup, see the Introduction.

Receive tokens with data

A transfer to Solana can carry data. ITS then mints or unlocks the tokens into an account your program controls, and calls your program's execute_with_interchain_token instruction with the data, in the same transaction. A transfer without data only delivers the tokens: ITS doesn't call your program.

Step 1: Define your accounts struct

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:

use anchor_lang::prelude::*;
use anchor_spl::token_interface::TokenAccount;
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 (generated by the macro)
    pub its_executable: AxelarExecuteWithInterchainTokenAccounts<'info>,

    // Your program's accounts, listed by the sender in `data`
    #[account(seeds = [MyState::SEED_PREFIX], bump = my_state.bump)]
    pub my_state: Account<'info, MyState>,

    #[account(mut)]
    pub recipient_token_account: InterfaceAccount<'info, TokenAccount>,
}

The macro generates AxelarExecuteWithInterchainTokenAccounts with:

  • token_program: the SPL Token or Token-2022 program
  • token_mint: the token's mint
  • destination_token_authority: a PDA of your program, with seeds [b"axelar-its-token-authority"]
  • destination_program_ata: the associated token account of destination_token_authority, which holds the tokens you received
  • interchain_transfer_execute: a signer PDA of ITS, which proves that ITS called your program

ITS passes your own accounts after these five, in the order the sender lists them in the transfer's data. They fill the rest of your struct; any extra ones are in ctx.remaining_accounts.

MyState is your program's account. In this guide, it holds the sender and token your program accepts, and owns the tokens your program sends:

#[account]
#[derive(InitSpace)]
pub struct MyState {
    #[max_len(32)]
    pub trusted_chain: String,
    #[max_len(64)]
    pub trusted_address: Vec<u8>,
    pub token_id: [u8; 32],
    pub bump: u8,
}

impl MyState {
    pub const SEED_PREFIX: &'static [u8] = b"my-state";
}

Step 2: Implement the handler

ITS has already validated the message with the Gateway and delivered the tokens when it calls your program, so you don't call validate_message(). Check the sender first:

pub fn execute_with_interchain_token_handler<'info>(
    ctx: Context<'info, MyItsExecute<'info>>,
    execute_payload: AxelarExecuteWithInterchainTokenPayload,
) -> Result<()> {
    // Accept only your contract on the source chain, and only your token
    let state = &ctx.accounts.my_state;
    require!(
        execute_payload.source_chain == state.trusted_chain
            && execute_payload.source_address == state.trusted_address
            && execute_payload.token_id == state.token_id,
        MyError::UntrustedSender
    );

    msg!(
        "Received {} of token {} from {}",
        execute_payload.amount,
        execute_payload.token_mint,
        execute_payload.source_chain
    );

    // Decode `execute_payload.data` according to your protocol...

    // The tokens are now in `destination_program_ata`
    forward_tokens(&ctx, execute_payload.amount)
}

AxelarExecuteWithInterchainTokenPayload contains:

  • command_id: the message's unique ID ([u8; 32])
  • source_chain: the Axelar name of the chain the transfer was sent from, e.g. Ethereum
  • source_address: the sender on the source chain, as raw bytes (Vec<u8>)
  • token_id: the token ID ([u8; 32])
  • token_mint: the token's mint on Solana
  • amount: the amount you received, in the token's smallest unit on Solana. For a token with a transfer fee, it's after the fee.
  • data: the data the sender attached, without the account list

If your handler fails, the whole transaction reverts, including the delivery of the tokens: the message stays approved, and can be executed again.

Check the sender

⚠️

ITS doesn't check who sent the tokens

The interchain_transfer_execute signer proves that ITS called your program. It doesn't prove that the transfer came from your contract on the other chain, or that it's your token: anyone can send any interchain token, with data, to your program. Unless your program accepts transfers from anyone, check the source chain, the source address and the token ID.

source_address is the sender's address as raw bytes, not a string as in GMP:

Sendersource_address
An EVM account or contractThe 20-byte address
A Solana walletThe 32-byte public key
A Solana program, through CPIThe 32-byte program ID (not its PDA)

Compare them with values your program stores, as in the handler above.

Step 3: Spend the tokens

The tokens arrive in destination_program_ata, whose authority is destination_token_authority. Your program signs for that PDA to move them:

use anchor_spl::token_interface::{self, TransferChecked};

fn forward_tokens<'info>(ctx: &Context<'info, MyItsExecute<'info>>, amount: u64) -> Result<()> {
    let its = &ctx.accounts.its_executable;
    let bump = ctx.bumps.its_executable.destination_token_authority;
    let signer_seeds: &[&[&[u8]]] = &[&[
        solana_axelar_its::seed_prefixes::ITS_TOKEN_AUTHORITY_SEED,
        &[bump],
    ]];

    token_interface::transfer_checked(
        CpiContext::new_with_signer(
            its.token_program.key(),
            TransferChecked {
                from: its.destination_program_ata.to_account_info(),
                mint: its.token_mint.to_account_info(),
                to: ctx.accounts.recipient_token_account.to_account_info(),
                authority: its.destination_token_authority.to_account_info(),
            },
            signer_seeds,
        ),
        amount,
        its.token_mint.decimals,
    )
}

Transfers to your program without data land in the same account, so your program can spend them the same way.

Step 4: Register the instruction

ITS calls your program with a fixed instruction discriminator. Either name the instruction exactly execute_with_interchain_token, so Anchor generates the matching discriminator, or set the discriminator to ITS_EXECUTE_IX_DISC. In both cases, import solana_axelar_its::executable::* in your lib.rs, where #[program] is.

Option A: name it execute_with_interchain_token:

use solana_axelar_its::executable::*;

#[program]
pub mod my_program {
    use super::*;

    pub fn execute_with_interchain_token<'info>(
        ctx: Context<'info, MyItsExecute<'info>>,
        execute_payload: AxelarExecuteWithInterchainTokenPayload,
    ) -> Result<()> {
        execute_with_interchain_token_handler(ctx, execute_payload)
    }
}

Option B: use another name, with the discriminator override:

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

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

Data for a Solana program

The data of a transfer to a Solana program is an encoded payload: your program's data, followed by the accounts your program needs, so the relayer can pass them. ITS decodes it, checks that the transaction passes exactly these accounts, and gives your program the data without the account list. Data in any other format fails on Solana.

Build it with ExecutablePayload, listing the accounts in the order of your accounts struct after its_executable:

use anchor_lang::solana_program::instruction::AccountMeta;
use solana_axelar_its::executable::{ExecutablePayload, ExecutablePayloadEncodingScheme};

pub fn encode_data(app_data: &[u8], my_state: Pubkey, recipient_token_account: Pubkey) -> Result<Vec<u8>> {
    let data = ExecutablePayload::new(
        app_data,
        &[
            AccountMeta::new_readonly(my_state, false),       // my_state
            AccountMeta::new(recipient_token_account, false), // recipient_token_account
        ],
        ExecutablePayloadEncodingScheme::Borsh,
    )
    .encode()?;
    Ok(data)
}

With the Borsh scheme, the encoded data is:

BytesContent
1Encoding scheme: 0x00 for Borsh (0x01 is ABI encoding)
4Length of your data, u32 little-endian
LengthYour data
4Number of accounts, u32 little-endian
33 for each accountThe account's 32-byte public key, then a flags byte: 0x01 signer, 0x02 writable
💡

Coming soon: Documentation for building this data on EVM source chains.

Solana caps a transaction at 1232 bytes, and your data and the accounts it lists travel in the transaction that delivers the transfer, with the accounts ITS needs. The relayer compresses the accounts with Address Lookup Tables, and creates a temporary one when the transaction doesn't fit otherwise, which costs more gas. Keep the data and the account list short.

Send tokens

To send tokens to another chain, your program calls the ITS program's interchain_transfer instruction through CPI. ITS burns or locks the tokens, pays the gas and sends the transfer through the Gateway.

Accounts

The tokens must be in a token account owned by a PDA of your program, here my_state:

use solana_axelar_its::program::SolanaAxelarIts;

#[derive(Accounts)]
pub struct SendInterchainTransfer<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,

    /// The PDA that owns the tokens. Your program signs for it.
    #[account(seeds = [MyState::SEED_PREFIX], bump = my_state.bump)]
    pub my_state: Account<'info, MyState>,

    // Gateway
    /// CHECK: checked by the ITS and gateway programs
    pub gateway_root_pda: UncheckedAccount<'info>,
    /// CHECK: checked by the ITS and gateway programs
    pub gateway_event_authority: UncheckedAccount<'info>,
    /// CHECK: checked by the ITS program
    pub gateway_program: UncheckedAccount<'info>,
    /// CHECK: checked by the ITS 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 ITS 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 token account the tokens are sent from, owned by `my_state`
    /// CHECK: checked by the ITS program
    #[account(mut)]
    pub my_state_token_account: UncheckedAccount<'info>,
    /// CHECK: checked by the ITS program
    #[account(mut)]
    pub token_manager_ata: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
}

To derive these accounts, see Deriving the accounts.

Handler

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,
    data: Option<Vec<u8>>,
) -> Result<()> {
    let cpi_accounts = solana_axelar_its::cpi::accounts::InterchainTransfer {
        payer: ctx.accounts.payer.to_account_info(),
        authority: ctx.accounts.my_state.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.my_state_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(),
    };

    // Seeds of the authority PDA, with the bump: to sign the CPI
    let bump = ctx.accounts.my_state.bump;
    let signer_seeds: &[&[&[u8]]] = &[&[MyState::SEED_PREFIX, &[bump]]];

    // The same seeds without the bump: for ITS to check the PDA
    let caller_pda_seeds = vec![MyState::SEED_PREFIX.to_vec()];

    solana_axelar_its::cpi::interchain_transfer(
        CpiContext::new_with_signer(ctx.accounts.its_program.key(), cpi_accounts, signer_seeds),
        token_id,
        destination_chain,
        destination_address,
        amount,
        gas_value,
        Some(crate::ID),        // caller_program_id
        Some(caller_pda_seeds), // caller_pda_seeds
        data,                   // optional data for the destination contract
    )
}

The arguments of interchain_transfer:

ArgumentMeaning
token_idThe token ID of the token you send
destination_chainThe destination chain's Axelar name, e.g. Ethereum, matched exactly, including case (see Chain names). ITS on Solana must trust the chain
destination_addressThe recipient as raw bytes, not a string: the 20-byte address on EVM chains
amountThe amount in the token's smallest unit on Solana. Must be more than 0
gas_valueThe fee for both hops, in lamports, paid from payer, which also gets any refund. Estimate it as in Estimate ITS fees. With 0, ITS pays no gas: add gas later, or the transfer isn't executed
caller_program_idYour program's ID, when the authority is a PDA of your program. None when the authority is a wallet
caller_pda_seedsThe seeds of the authority PDA, without the bump. None when the authority is a wallet
dataData for the destination contract, or None for a plain transfer. On EVM chains, the destination contract receives it as an interchain token executable

ITS derives the PDA from caller_pda_seeds and caller_program_id, and checks that it's the authority. The bump must not be in caller_pda_seeds: ITS finds it itself, and with the bump included, it derives a different address and the transfer fails. The recipient sees caller_program_id as the sender.

To send from a wallet instead, the wallet signs as the authority and owns the token account, and you pass None for both caller_program_id and caller_pda_seeds. ITS then rejects an authority that isn't a wallet, and passing only one of the two fails.

How ITS takes the tokens depends on the token manager:

Token manager typeOn sending
NativeInterchainToken, MintBurn, MintBurnFromBurns the tokens. The authority signs the burn, so no approval is needed
LockUnlockMoves the tokens into the token manager's token account
LockUnlockFeeMoves the tokens into the token manager's token account, and sends the amount after the fee

The token's flow limit applies to both directions.

Complete example

The Memo program receives tokens with data in execute_with_interchain_token.rs. It doesn't check the sender.

On this page