Send & Receive GMP Messages

This guide walks through building a Solana program that can send and receive cross-chain messages via Axelar’s General Message Passing (GMP) protocol.

For prerequisites and setup, see the Introduction.

When a cross-chain message arrives on Solana, the Axelar relayer calls your program’s execute instruction. Your program must validate the message via CPI to the Gateway and then process the payload.

Use the executable_accounts! macro to generate the AxelarExecuteAccounts 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_gateway::{executable::*, executable_accounts};
// Generate AxelarExecuteAccounts and wire it to your struct
executable_accounts!(MyGmpExecute);
#[derive(Accounts)]
pub struct MyGmpExecute<'info> {
// Gateway accounts for message validation (generated by the macro)
pub executable: AxelarExecuteAccounts<'info>,
// Your program's accounts
#[account(mut)]
pub my_state: Account<'info, MyState>,
}

The macro generates AxelarExecuteAccounts with:

  • incoming_message_pda — The approved message PDA (derived from the command ID)
  • signing_pda — Validates the message signer
  • gateway_root_pda — Gateway configuration
  • event_authority — For Gateway event emission
  • axelar_gateway_program — Reference to the Gateway program

💡

Do not add #[instruction(message: Message, payload: Vec<u8>)] to this struct. The macro’s inner struct already declares the message instruction parameter. Adding it again causes an Anchor bug.

Your handler must call validate_message() before processing any payload data. This performs a CPI to the Gateway to verify the message was approved and marks it as executed.

pub fn execute_handler(
ctx: Context<MyGmpExecute>,
message: Message,
payload: Vec<u8>,
encoding_scheme: ExecutablePayloadEncodingScheme,
) -> Result<()> {
// Validate the message via CPI to the Gateway.
// This verifies the message was approved and marks it as executed.
validate_message(ctx.accounts, message, &payload, encoding_scheme)?;
// The payload contains your application-specific data.
// Decode it according to your protocol — for example, as a UTF-8 string:
let data = std::str::from_utf8(&payload)
.map_err(|_| ProgramError::InvalidInstructionData)?;
// Or as a custom Borsh-encoded struct:
// let data = MyPayload::try_from_slice(&payload)?;
// Your business logic here...
Ok(())
}

The validate_message function:

  1. Reconstructs the full payload (data + account metadata) from the instruction accounts.
  2. Verifies the payload hash matches the approved message.
  3. Calls the Gateway’s validate_message instruction via CPI, which marks the message as executed.

The Axelar relayer looks for a specific instruction discriminator when calling your program. You must either name the instruction exactly execute (so Anchor generates the matching discriminator), or use the EXECUTE_IX_DISC constant to override the discriminator on a differently-named instruction.

Option A: Name it execute:

#[program]
pub mod my_program {
use super::*;
pub fn execute(
ctx: Context<MyGmpExecute>,
message: Message,
payload: Vec<u8>,
encoding_scheme: ExecutablePayloadEncodingScheme,
) -> Result<()> {
instructions::execute_handler(ctx, message, payload, encoding_scheme)
}
}

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

use solana_axelar_gateway::executable::EXECUTE_IX_DISC;
#[instruction(discriminator = EXECUTE_IX_DISC)]
pub fn process_gmp_message(
ctx: Context<MyGmpExecute>,
message: Message,
payload: Vec<u8>,
encoding_scheme: ExecutablePayloadEncodingScheme,
) -> Result<()> {
instructions::execute_handler(ctx, message, payload, encoding_scheme)
}

💡

The executable_accounts! macro is a convenience helper that generates the required account struct and trait implementations. If the macro does not fit your setup, you can copy the generated code from solana-axelar-gateway/src/executable.rs and use it directly.

Sending a cross-chain message from your program involves two CPI calls within a single instruction: first pay for gas, then call the Gateway. Both happen in the same instruction handler.

Your send instruction needs accounts for the Gas Service, the Gateway, and a signing PDA:

use solana_axelar_gateway::{
cpi::accounts::CallContract,
program::SolanaAxelarGateway,
CallContractSigner,
};
use solana_axelar_gas_service::program::SolanaAxelarGasService;
#[derive(Accounts)]
pub struct SendMessage<'info> {
#[account(mut)]
pub payer: Signer<'info>,
/// Reference to this program
pub my_program: Program<'info, crate::program::MyProgram>,
/// Signing PDA derived from this program's ID
/// CHECK: validated by the Gateway
#[account(
seeds = [CallContractSigner::SEED_PREFIX],
bump,
)]
pub signing_pda: AccountInfo<'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>,
pub gateway_program: Program<'info, SolanaAxelarGateway>,
// Gas Service
/// CHECK: checked by the gas service program
#[account(mut)]
pub gas_treasury: UncheckedAccount<'info>,
pub gas_service: Program<'info, SolanaAxelarGasService>,
/// CHECK: checked by the gas service program
pub gas_event_authority: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}

The handler pays gas first, then sends the message — both via CPI in a single instruction:

pub fn send_message_handler(
ctx: Context<SendMessage>,
destination_chain: String,
destination_address: String,
payload: Vec<u8>,
gas_amount: u64,
refund_address: Pubkey,
) -> Result<()> {
// 1. Pay gas
let payload_hash = solana_keccak_hasher::hash(&payload).to_bytes();
let gas_cpi_accounts = solana_axelar_gas_service::cpi::accounts::PayGas {
sender: ctx.accounts.payer.to_account_info(),
treasury: ctx.accounts.gas_treasury.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
event_authority: ctx.accounts.gas_event_authority.to_account_info(),
program: ctx.accounts.gas_service.to_account_info(),
};
solana_axelar_gas_service::cpi::pay_gas(
CpiContext::new(ctx.accounts.gas_service.to_account_info(), gas_cpi_accounts),
destination_chain.clone(),
destination_address.clone(),
payload_hash,
gas_amount,
refund_address,
)?;
// 2. Send the message
let bump = ctx.bumps.signing_pda;
let signer_seeds = &[CallContractSigner::SEED_PREFIX, &[bump]];
let signer_seeds = &[&signer_seeds[..]];
let call_cpi_accounts = CallContract {
caller: ctx.accounts.my_program.to_account_info(),
signing_pda: Some(ctx.accounts.signing_pda.to_account_info()),
gateway_root_pda: ctx.accounts.gateway_root_pda.to_account_info(),
event_authority: ctx.accounts.gateway_event_authority.to_account_info(),
program: ctx.accounts.gateway_program.to_account_info(),
};
solana_axelar_gateway::cpi::call_contract(
CpiContext::new_with_signer(
ctx.accounts.gateway_program.key(),
call_cpi_accounts,
signer_seeds,
),
destination_chain,
destination_address,
payload,
bump,
)?;
Ok(())
}

💡

The signing_pda is derived from the seed b"gtw-call-contract" (CallContractSigner::SEED_PREFIX) and your program’s ID. The Gateway uses this to verify that your program authorized the call. Programs on Solana don’t possess private keys, so they sign via PDAs.

💡

The Gateway also supports calling call_contract directly from a wallet (without a signing PDA). In that case, signing_pda is set to None and the wallet signs the transaction directly. See the Contract Reference for details.

Cross-chain payloads sent to Solana can include both data and account metadata. The account metadata tells the relayer which Solana accounts to pass to the destination program.

use solana_axelar_gateway::executable::{ExecutablePayload, ExecutablePayloadEncodingScheme};
use anchor_lang::solana_program::instruction::AccountMeta;
// Encode your data with account metadata
let payload = ExecutablePayload::new(
&your_data_bytes,
&vec![
AccountMeta::new(some_writable_account, false),
AccountMeta::new_readonly(some_readonly_account, false),
],
ExecutablePayloadEncodingScheme::Borsh,
);
let encoded = payload.encode()?;

The accounts specified in the payload become remaining_accounts in the destination program’s execute handler. Two encoding schemes are supported:

  • ExecutablePayloadEncodingScheme::Borsh — Standard Borsh serialization.
  • ExecutablePayloadEncodingScheme::AbiEncoding — ABI-compatible encoding for interoperability with EVM chains.

💡

Coming soon: Documentation for constructing Solana-compatible payloads from EVM source chains (ABI encoding format for account metadata).

For a working end-to-end implementation of both sending and receiving GMP messages, see the Memo Program:

Edit on GitHub