Implementing CCIP Receivers

Implementing CCIP Receivers for Solana

This reference guide explains the key components and security patterns required for building Solana programs that can receive cross-chain messages via Chainlink's Cross-Chain Interoperability Protocol (CCIP).

Introduction

A CCIP Receiver is a Solana program that implements the ccip_receive instruction, allowing it to process incoming cross-chain messages. It can handle both arbitrary data payloads and/or token transfers, serving as the destination endpoint for CCIP messages.

Security Architecture

To build a secure CCIP receiver program, you need to understand how it interacts with the core CCIP programs.

Your receiver program interacts with two external CCIP components:

The security model relies on three critical validations:

  1. Authority Validation

    • Verify the calling authority
    • is a legitimate Offramp PDA
    • This prevents unauthorized programs from sending fake messages
  2. Router Validation

    • Verify the Offramp is authorized by the Router
    • This ensures only trusted Offramps approved by the Router can deliver messages
  3. Optional Sender Validation

    • Optionally validate the source chain and sender address
    • This provides an additional layer of security by restricting which sources can send messages

Core Components of a CCIP Receiver

A complete CCIP Receiver implementation contains several key components, each serving a specific purpose in the cross-chain messaging system.

Message Structure

CCIP messages follow a standardized structure that your program must be prepared to receive:

#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)]
pub struct Any2SVMMessage {
    pub message_id: [u8; 32],
    pub source_chain_selector: u64,
    pub sender: Vec<u8>,
    pub data: Vec<u8>,
    pub token_amounts: Vec<SVMTokenAmount>,
}

#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, Default)]
pub struct SVMTokenAmount {
    pub token: Pubkey,
    pub amount: u64, // solana local token amount
}

These structures contain:

  • message_id: A unique identifier for the message
  • source_chain_selector: The chain ID of the source chain
  • sender: The address of the sender on the source chain
  • data: The arbitrary data payload
  • token_amounts: An array of tokens and amounts being transferred

The CcipReceive Context

The account context for the ccip_receive instruction is the most critical security component of your program. It must follow this exact pattern:

#[derive(Accounts, Debug)]
#[instruction(message: Any2SVMMessage)]
pub struct CcipReceive<'info> {
    // Offramp CPI signer PDA must be first.
    #[account(
        seeds = [EXTERNAL_EXECUTION_CONFIG_SEED, crate::ID.as_ref()],
        bump,
        seeds::program = offramp_program.key(),
    )]
    pub authority: Signer<'info>,

    /// CHECK offramp program: exists only to derive the allowed offramp PDA
    pub offramp_program: UncheckedAccount<'info>,

    /// CHECK PDA of the router program verifying the signer is an allowed offramp.
    #[account(
        owner = state.router @ CcipReceiverError::InvalidCaller,
        seeds = [
            ALLOWED_OFFRAMP,
            message.source_chain_selector.to_le_bytes().as_ref(),
            offramp_program.key().as_ref()
        ],
        bump,
        seeds::program = state.router,
    )]
    pub allowed_offramp: UncheckedAccount<'info>,

    // Program-specific accounts follow...
    #[account(
        seeds = [STATE],
        bump,
    )]
    pub state: Account<'info, BaseState>,

    // Additional program accounts as needed...
}

Program State

Your program needs state accounts to store configuration information, most importantly the router address:

#[account]
#[derive(InitSpace, Default, Debug)]
pub struct BaseState {
    pub owner: Pubkey,
    pub proposed_owner: Pubkey,
    pub router: Pubkey,
}

This is a basic example of state storage, but your program can have more complex state structures with additional data. The critical requirement is that the state must store the router address for verification of the allowed_offramp PDA.

Extending the CcipReceive Context

While the first three accounts in the CcipReceive context are mandatory and must follow the exact security pattern shown above, you must extend this structure with additional program-specific accounts based on your specific use case.

These additional accounts will be provided when your program is called through the CCIP Offramp. In your ccip_receive instruction, you should validate these accounts according to your application's security requirements before using them.

The ccip_receive Instruction

The core instruction that implements the CCIP receiver interface:

/// This instruction is called by the CCIP Offramp to execute the CCIP message.
/// The method name needs to be ccip_receive with Anchor encoding.
/// If not using Anchor, the discriminator needs to be [0x0b, 0xf4, 0x09, 0xf9, 0x2c, 0x53, 0x2f, 0xf5]
pub fn ccip_receive(ctx: Context<CcipReceive>, message: Any2SVMMessage) -> Result<()> {
    // Process message data
    if !message.data.is_empty() {
        // Custom data processing logic here
    }

    // Process token transfers
    if !message.token_amounts.is_empty() {
        // Custom token handling logic here
    }

    // Emit event for tracking
    emit!(MessageReceived {
        message_id: message.message_id,
        source_chain_selector: message.source_chain_selector,
        sender: message.sender.clone(),
    });

    Ok(())
}

Security Considerations

Building secure CCIP Receivers requires attention to several key areas:

Caller Validation

The most critical security aspect is validating that the caller is a legitimate CCIP Offramp. This is handled by the account constraints in the CcipReceive context:

// Offramp CPI signer PDA must be first.
#[account(
    seeds = [EXTERNAL_EXECUTION_CONFIG_SEED, crate::ID.as_ref()],
    bump,
    seeds::program = offramp_program.key(),
)]
pub authority: Signer<'info>,

This constraint ensures that the transaction is signed by a PDA derived from the offramp program using a specific seed. Only the legitimate CCIP Offramp can produce this signature.

Router Authorization

The second critical validation is ensuring that the offramp is authorized by the CCIP Router:

#[account(
    owner = state.router @ CcipReceiverError::InvalidCaller,
    seeds = [
        ALLOWED_OFFRAMP,
        message.source_chain_selector.to_le_bytes().as_ref(),
        offramp_program.key().as_ref()
    ],
    bump,
    seeds::program = state.router,
)]
pub allowed_offramp: UncheckedAccount<'info>,

This validates that:

  1. The allowed_offramp PDA exists and is owned by the router program
  2. The PDA is derived using the correct seeds that include the source chain and offramp program ID
  3. This proves the router has authorized this specific offramp for this specific source chain

Optional Sender Validation

For additional security, you can implement sender validation:

// Optional additional validation in ccip_receive
pub fn ccip_receive(ctx: Context<CcipReceive>, message: Any2SVMMessage) -> Result<()> {
    // Verify sender is approved (if implementing allowlist)
    let is_approved = is_sender_approved(
        ctx.accounts.state,
        message.source_chain_selector,
        &message.sender
    );

    require!(is_approved, CcipReceiverError::InvalidChainAndSender);

    // Continue with message processing...
    Ok(())
}

Message Deduplication

To prevent replay attacks, consider tracking processed message IDs:

// In your ccip_receive instruction:

// Check if message has already been processed
let is_duplicate = ctx.accounts.processed_messages
    .messages
    .contains(&message.message_id);

require!(!is_duplicate, CcipReceiverError::DuplicateMessage);

// Record the message ID to prevent reprocessing
ctx.accounts.processed_messages.messages.push(message.message_id);
if ctx.accounts.processed_messages.messages.len() > MAX_STORED_MESSAGES {
    ctx.accounts.processed_messages.messages.remove(0);
}

Token Handling for Receivers

CCIP receivers that handle tokens need to understand how tokens are delivered and how to properly manage them.

Token Delivery Process

When tokens are sent via CCIP to a Solana program:

  1. The tokens are initially delivered to a token account specified as the tokenReceiver in the CCIP message
  2. For programmatic token transfers, this tokenReceiver must be a PDA that your program has authority over
  3. Your program must implement the logic to handle the received tokens

Token Admin PDA

Create a dedicated PDA to serve as your program's token administrator:

#[account(
    init,
    seeds = [TOKEN_ADMIN_SEED],
    bump,
    payer = authority,
    space = ANCHOR_DISCRIMINATOR,
)]
/// CHECK: CPI signer for tokens
pub token_admin: UncheckedAccount<'info>,

This token_admin PDA should:

  1. Be initialized during program setup
  2. Be used as the authority for token accounts that will receive CCIP tokens
  3. Sign token transfer instructions (e.g., for forwarding tokens to their final destination)

remaining_accounts

For each token being transferred, your remaining_accounts typically needs:

  1. token_mint: The mint account of the token
  2. source_token_account: The account that received tokens from CCIP
  3. token_admin: Your program's PDA with authority over the source account
  4. recipient_token_account: The final destination for the tokens
  5. token_program: The SPL Token program (Token or Token-2022)

Note: The pattern may vary depending on your specific implementation needs.

Best Practices

When implementing CCIP Receivers, follow these best practices:

  1. Follow the Security Pattern: Always use the exact account validation pattern shown in the CcipReceive context

  2. Store the Router Address: Store and validate the router address to ensure only allowed offramps can call your program

  3. Handle Token Security: Use PDAs with proper token authority for receiving and managing tokens

  4. Consider Message Deduplication: Track message IDs to prevent replaying the same message

  5. Implement Proper Error Handling: Use specific error codes and messages for better debugging and security

  6. Use Events for Tracking: Emit events when processing messages to facilitate off-chain tracking and indexing

  7. Test Thoroughly: Test your receiver with various message types, token amounts, and edge cases

Example Implementation

For a complete, audited reference implementation of a CCIP Receiver, you can examine the example-ccip-receiver in the Chainlink CCIP repository. This example demonstrates all the security patterns and best practices covered in this guide and can serve as a starting point for your own implementation.

Get the latest Chainlink content straight to your inbox.