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:
-
Authority Validation
- Verify the calling authority
- is a legitimate Offramp PDA
- This prevents unauthorized programs from sending fake messages
-
Router Validation
- Verify the Offramp is authorized by the Router
- This ensures only trusted Offramps approved by the Router can deliver messages
-
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
The CCIP security architecture consists of:
-
Receiver Program (your SVM program)
- Implements the
ccip_receive
instruction - Validates that messages come from an authorized OffRamp program
- Implements the
-
CCIP Router
- Central coordinator of cross-chain communication
- Manages allowed OffRamps
- Creates PDAs that verify OffRamp authorization
-
CCIP Offramp
- Delivers messages to the receiver program
- Uses a specific PDA to sign transactions calling your program
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 messagesource_chain_selector
: The chain ID of the source chainsender
: The address of the sender on the source chaindata
: The arbitrary data payloadtoken_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:
- The
allowed_offramp
PDA exists and is owned by the router program - The PDA is derived using the correct seeds that include the source chain and offramp program ID
- 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:
- The tokens are initially delivered to a token account specified as the
tokenReceiver
in the CCIP message - For programmatic token transfers, this
tokenReceiver
must be a PDA that your program has authority over - 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:
- Be initialized during program setup
- Be used as the authority for token accounts that will receive CCIP tokens
- 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:
token_mint
: The mint account of the tokensource_token_account
: The account that received tokens from CCIPtoken_admin
: Your program's PDA with authority over the source accountrecipient_token_account
: The final destination for the tokenstoken_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:
-
Follow the Security Pattern: Always use the exact account validation pattern shown in the
CcipReceive
context -
Store the Router Address: Store and validate the router address to ensure only allowed offramps can call your program
-
Handle Token Security: Use PDAs with proper token authority for receiving and managing tokens
-
Consider Message Deduplication: Track message IDs to prevent replaying the same message
-
Implement Proper Error Handling: Use specific error codes and messages for better debugging and security
-
Use Events for Tracking: Emit events when processing messages to facilitate off-chain tracking and indexing
-
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.