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 programtoken_mint: the token's mintdestination_token_authority: a PDA of your program, with seeds[b"axelar-its-token-authority"]destination_program_ata: the associated token account ofdestination_token_authority, which holds the tokens you receivedinterchain_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.Ethereumsource_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 Solanaamount: 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:
| Sender | source_address |
|---|---|
| An EVM account or contract | The 20-byte address |
| A Solana wallet | The 32-byte public key |
| A Solana program, through CPI | The 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:
| Bytes | Content |
|---|---|
| 1 | Encoding scheme: 0x00 for Borsh (0x01 is ABI encoding) |
| 4 | Length of your data, u32 little-endian |
| Length | Your data |
| 4 | Number of accounts, u32 little-endian |
| 33 for each account | The 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:
| Argument | Meaning |
|---|---|
token_id | The token ID of the token you send |
destination_chain | The destination chain's Axelar name, e.g. Ethereum, matched exactly, including case (see Chain names). ITS on Solana must trust the chain |
destination_address | The recipient as raw bytes, not a string: the 20-byte address on EVM chains |
amount | The amount in the token's smallest unit on Solana. Must be more than 0 |
gas_value | The 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_id | Your program's ID, when the authority is a PDA of your program. None when the authority is a wallet |
caller_pda_seeds | The seeds of the authority PDA, without the bump. None when the authority is a wallet |
data | Data 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 type | On sending |
|---|---|
NativeInterchainToken, MintBurn, MintBurnFrom | Burns the tokens. The authority signs the burn, so no approval is needed |
LockUnlock | Moves the tokens into the token manager's token account |
LockUnlockFee | Moves 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.