Sending Messages
Send Workflow
Every CCIP send follows these steps:
- Check lane capabilities — determine which extra arguments the lane supports
- Build the message — set receiver, data, tokens, and extra arguments
- Estimate the fee — call
getFeeto get the cost - Send — call
sendMessagewith the fee and a wallet
import { EVMChain, networkInfo } from '@chainlink/ccip-sdk'
const source = await EVMChain.fromUrl('https://rpc.sepolia.org')
const router = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59'
const destSelector = networkInfo('avalanche-testnet-fuji').chainSelector
// 1. Check lane capabilities
const features = await source.getLaneFeatures({ router, destChainSelector: destSelector })
// 2. Build message with version-appropriate extra arguments
const message = {
receiver: '0xReceiverAddress...',
data: '0x',
extraArgs: features.MIN_BLOCK_CONFIRMATIONS != null
? { gasLimit: 200_000n } // v2.0+ lane — V3 encoding (V2-style fields also accepted)
: { gasLimit: 200_000n, allowOutOfOrderExecution: true }, // pre-v2.0 lane — V2 encoding
}
// 3. Estimate fee
const fee = await source.getFee({ router, destChainSelector: destSelector, message })
// 4. Send
const request = await source.sendMessage({
router,
destChainSelector: destSelector,
message: { ...message, fee },
wallet, // ethers Signer or viemWallet(client)
})
console.log('Message ID:', request.message.messageId)
console.log('Tx hash:', request.tx.hash)
The SDK auto-encodes extraArgs and auto-populates defaults for omitted fields. You never need to call encodeExtraArgs when using sendMessage or getFee.
Message Types
CCIP supports three message types. The only required field for all is receiver.
Data Only
Send ABI-encoded data to a contract on the destination chain. The receiver contract's ccipReceive function is called with the data payload — use abi.decode on the receiver side to unpack it.
import { AbiCoder } from 'ethers'
const abiCoder = AbiCoder.defaultAbiCoder()
const message = {
receiver: '0xReceiverContract...',
data: abiCoder.encode(
['address', 'uint256'],
['0xUserAddress...', 1000n]
),
extraArgs: { gasLimit: 200_000n, allowOutOfOrderExecution: true },
}
Requires extraArgs.gasLimit — the receiver contract needs gas to process the data.
Token Transfer
Transfer tokens cross-chain. For token-only transfers (no data), the SDK auto-populates extraArgs with gasLimit: 0n and allowOutOfOrderExecution: true — you only need receiver and tokenAmounts:
import { parseUnits } from 'viem'
const LINK_TOKEN = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
// Use getTokenInfo to get decimals — don't hardcode
const tokenInfo = await source.getTokenInfo(LINK_TOKEN)
const message = {
receiver: '0xRecipientAddress...',
tokenAmounts: [
{ token: LINK_TOKEN, amount: parseUnits('1', tokenInfo.decimals) }, // 1 LINK
],
feeToken: LINK_TOKEN, // pay fee in LINK
}
Before sending tokens, approve the Router to spend your tokens. sendMessage handles approvals automatically if allowance is insufficient (generates approval transactions internally).
Data + Tokens (Programmable Transfer)
Send both data and tokens. The receiver contract executes custom logic with the tokens (deposit, swap, stake). ABI-encode the data so the receiver can decode it with abi.decode:
import { AbiCoder } from 'ethers'
import { parseUnits } from 'viem'
const abiCoder = AbiCoder.defaultAbiCoder()
const LINK_TOKEN = '0x779877A7B0D9E8603169DdbD7836e478b4624789'
const tokenInfo = await source.getTokenInfo(LINK_TOKEN)
const message = {
receiver: '0xReceiverContract...',
data: abiCoder.encode(
['uint8', 'address'],
[1, '0xUserAddress...'] // e.g., action=1 (deposit), user address
),
tokenAmounts: [
{ token: LINK_TOKEN, amount: parseUnits('0.1', tokenInfo.decimals) },
],
extraArgs: { gasLimit: 300_000n, allowOutOfOrderExecution: false },
}
Unlike token-only transfers, programmable transfers require extraArgs.gasLimit because the receiver contract runs custom logic.
Native ETH Transfers
Use ZeroAddress as the token address to transfer native ETH. The SDK wraps it automatically:
const message = {
receiver: '0xRecipientAddress...',
tokenAmounts: [
{ token: '0x0000000000000000000000000000000000000000', amount: parseEther('0.1') },
],
feeToken: '0x0000000000000000000000000000000000000000', // pay fee in native ETH
}
The token amount and fee are sent as transaction value (native ETH), not via ERC-20 approval.
Checking Lane Capabilities
Why Check?
The SDK does not validate your extraArgs against the lane's on-chain version. If you pass V3 fields (e.g. blockConfirmations) to a pre-v2.0 lane, the transaction will revert on-chain. Always call getLaneFeatures first.
MIN_BLOCK_CONFIRMATIONS
| Value | Lane version | What it means | Extra arguments to use |
|---|---|---|---|
| undefined | Pre-v2.0 | V3 is not supported | V2: gasLimit + allowOutOfOrderExecution |
| 0 | v2.0+ | V3 supported, FTF not enabled for this token | V2 or V3 (no speed-up from blockConfirmations) |
| > 0 | v2.0+ with FTF | Minimum blockConfirmations for faster relaying | V3 with blockConfirmations >= minValue |
const features = await source.getLaneFeatures({
router,
destChainSelector: destSelector,
token, // optional — pass to get token-specific FTF and rate limit info
})
const minConfirmations = features.MIN_BLOCK_CONFIRMATIONS
Without a token, the result reflects lane version only: undefined for pre-v2.0, or a hardcoded 1 for v2.0+ (confirming V3 support without pool-specific FTF data). Pass a token to get the actual pool-specific value.
Choosing Extra Arguments
let extraArgs
if (minConfirmations == null) {
// Pre-v2.0 lane — V2 encoding only
extraArgs = {
gasLimit: 200_000n,
allowOutOfOrderExecution: true,
}
} else if (minConfirmations > 0) {
// v2.0+ lane with FTF enabled — use V3 with blockConfirmations
extraArgs = {
gasLimit: 200_000n,
blockConfirmations: minConfirmations, // minimum allowed value
}
} else {
// v2.0+ lane, FTF not enabled (minConfirmations === 0) —
// V3 is supported but blockConfirmations has no effect.
// V2-style args or V3 without blockConfirmations both work.
extraArgs = {
gasLimit: 200_000n,
allowOutOfOrderExecution: true,
}
}
Checking Rate Limits
When a token is provided, getLaneFeatures also returns outbound rate limiter state:
const rateLimits = features.RATE_LIMITS
if (rateLimits) {
console.log(`Available: ${rateLimits.tokens}, Capacity: ${rateLimits.capacity}`)
console.log(`Refill rate: ${rateLimits.rate} tokens/second`)
}
// FTF-specific rate limits (only present when FTF is enabled)
const ftfRateLimits = features.CUSTOM_BLOCK_CONFIRMATIONS_RATE_LIMITS
if (ftfRateLimits) {
console.log(`FTF available: ${ftfRateLimits.tokens}`)
}
Rate limiter values are null when rate limiting is disabled (unlimited throughput).
Fee Estimation
getFee returns the fee in the feeToken's smallest unit. It auto-encodes extraArgs and auto-populates missing fields, just like sendMessage.
// Fee in native ETH
const fee = await source.getFee({
router,
destChainSelector: destSelector,
message: { ...message, feeToken: '0x' + '0'.repeat(40) },
})
// Fee in LINK
const linkFee = await source.getFee({
router,
destChainSelector: destSelector,
message: { ...message, feeToken: LINK_TOKEN },
})
If you omit fee in sendMessage, the SDK calls getFee internally. Calling it explicitly lets you display the fee to users or add a buffer:
const feeWithBuffer = (fee * 110n) / 100n // 10% buffer for gas price fluctuations
Extra Arguments Reference
The SDK auto-detects the encoding version based on which fields you provide. Pass extraArgs as an object — the SDK encodes internally.
Version Auto-Detection
| Fields present | Detected version | Encoding tag |
|---|---|---|
gasLimit only | EVMExtraArgsV1 | 0x97a657c9 |
gasLimit + allowOutOfOrderExecution | EVMExtraArgsV2 | 0x181dcf10 |
Any of: blockConfirmations, ccvs, ccvArgs, executor, executorArgs, tokenReceiver, tokenArgs | GenericExtraArgsV3 | 0xa69dd4aa |
computeUnits | SVMExtraArgsV1 (Solana) | 0x1f3b3aba |
receiverObjectIds | SuiExtraArgsV1 | 0x21ea4ca9 |
Unknown fields throw CCIPArgumentInvalidError, catching typos before the transaction is submitted.
V3 (GenericExtraArgsV3) Fields
V3 does not include allowOutOfOrderExecution. If migrating from V2, remove that field.
| Field | Type | Default | Description |
|---|---|---|---|
gasLimit | bigint | 0n (no data) / 200_000n (with data) | Gas for receiver execution |
blockConfirmations | number | 0 | Source-chain confirmations before relaying (FTF) |
ccvs | string[] | [] | Cross-chain verifier addresses |
ccvArgs | BytesLike[] | [] | Per-CCV arguments |
executor | string | '' | Custom executor address |
executorArgs | BytesLike | '0x' | Executor-specific arguments |
tokenReceiver | string | '' | Per-token receiver address |
tokenArgs | BytesLike | '0x' | Token pool-specific arguments |
encodeExtraArgs (Low-Level)
encodeExtraArgs is only needed when building raw on-chain transactions outside the SDK (e.g., direct contract calls, transaction data for external systems). sendMessage and getFee auto-encode internally.
- EVM (V1/V2)
- EVM (V3)
- Solana
import { encodeExtraArgs } from '@chainlink/ccip-sdk'
// V2 — inferred from allowOutOfOrderExecution
const encoded = encodeExtraArgs({
gasLimit: 200_000n,
allowOutOfOrderExecution: true,
})
// V1 (legacy) — inferred when only gasLimit is set
const encodedV1 = encodeExtraArgs({ gasLimit: 200_000n })
import { encodeExtraArgs } from '@chainlink/ccip-sdk'
// V3 — auto-detected from blockConfirmations (or any V3-only field)
const encoded = encodeExtraArgs({
gasLimit: 200_000n,
blockConfirmations: 5,
ccvs: [],
ccvArgs: [],
executor: '',
executorArgs: '0x',
tokenReceiver: '',
tokenArgs: '0x',
})
import { encodeExtraArgs } from '@chainlink/ccip-sdk'
// SVM — inferred from computeUnits
const encoded = encodeExtraArgs({
computeUnits: 200_000n,
accountIsWritableBitmap: 0n,
allowOutOfOrderExecution: true,
tokenReceiver: '', // Solana token account
accounts: [], // Additional accounts for CPI
})
Unsigned Transactions
Use generateUnsignedSendMessage to separate transaction generation from signing. This lets you generate transaction data on a backend with reliable RPCs and return it to clients for signing — or feed it into multi-sig wallets, offline signing workflows, or any system where signing happens separately from transaction construction.
const unsignedTx = await source.generateUnsignedSendMessage({
router,
destChainSelector: destSelector,
message: { ...message, fee },
sender: walletAddress, // address of the wallet that will sign
})
// transactions[] contains approval TXs (if needed) followed by the ccipSend TX
for (const tx of unsignedTx.transactions) {
const hash = await walletClient.sendTransaction(tx)
await publicClient.waitForTransactionReceipt({ hash })
}
// Extract message ID from the final (ccipSend) transaction
const sendTxHash = /* hash of last transaction */
const messages = await source.getMessagesInTx(sendTxHash)
console.log('Message ID:', messages[0].message.messageId)
For token transfers, transactions[] includes ERC-20 approval transactions before the ccipSend transaction. Process them in order.
Complete Example
Send a token transfer that works on both pre-v2.0 and v2.0+ lanes:
import { EVMChain, networkInfo, CCIPError } from '@chainlink/ccip-sdk'
async function sendTokens(
sourceRpc: string,
destName: string,
routerAddress: string,
tokenAddress: string,
amount: bigint,
receiver: string,
wallet: import('ethers').Signer
) {
const source = await EVMChain.fromUrl(sourceRpc)
const destSelector = networkInfo(destName).chainSelector
// 1. Check lane capabilities and rate limits
const features = await source.getLaneFeatures({
router: routerAddress,
destChainSelector: destSelector,
token: tokenAddress,
})
// 2. Verify rate limits
const rateLimits = features.RATE_LIMITS
if (rateLimits && amount > rateLimits.tokens) {
throw new Error(
`Amount ${amount} exceeds available rate limit capacity ${rateLimits.tokens}`
)
}
// 3. Choose extra arguments based on lane version
let extraArgs
const minConfirmations = features.MIN_BLOCK_CONFIRMATIONS
if (minConfirmations == null) {
// Pre-v2.0 lane
extraArgs = { gasLimit: 0n, allowOutOfOrderExecution: true }
} else if (minConfirmations > 0) {
// v2.0+ with FTF — use minimum block confirmations for faster delivery
extraArgs = { gasLimit: 0n, blockConfirmations: minConfirmations }
} else {
// v2.0+, FTF not enabled — V2-style args work fine
extraArgs = { gasLimit: 0n, allowOutOfOrderExecution: true }
}
// 4. Build message
const message = {
receiver,
tokenAmounts: [{ token: tokenAddress, amount }],
extraArgs,
}
// 5. Estimate fee (native ETH)
const fee = await source.getFee({
router: routerAddress,
destChainSelector: destSelector,
message,
})
console.log('Fee:', fee, 'wei')
// 6. Send
try {
const request = await source.sendMessage({
router: routerAddress,
destChainSelector: destSelector,
message: { ...message, fee },
wallet,
})
console.log('Message ID:', request.message.messageId)
console.log('Tx hash:', request.tx.hash)
return request
} catch (error) {
if (CCIPError.isCCIPError(error)) {
console.error(`${error.code}: ${error.message}`)
if (error.recovery) console.error('Recovery:', error.recovery)
}
throw error
}
}
MessageInput Reference
| Field | Type | Required | Default |
|---|---|---|---|
receiver | BytesLike | Yes | — |
data | BytesLike | No | '0x' |
tokenAmounts | { token: string, amount: bigint }[] | No | [] |
feeToken | string | No | ZeroAddress (native token) |
extraArgs | Partial<ExtraArgs> | No | Auto-populated by SDK (see below) |
fee | bigint | No | Auto-calculated via getFee if omitted |
Auto-Populated Defaults
When extraArgs is omitted or partial, the SDK fills in defaults:
| Condition | Default extraArgs |
|---|---|
Message has data | { gasLimit: 200_000n, allowOutOfOrderExecution: true } |
Token-only (no data) | { gasLimit: 0n, allowOutOfOrderExecution: true } |
| Any V3-only field present | V3 defaults: blockConfirmations: 0, empty arrays/strings for other fields |
Non-EVM Destinations
For Solana and Sui destinations, extraArgs requires chain-specific fields. The SDK auto-populates defaults for token-only transfers, but programmable transfers need explicit configuration.
| Destination | Required extraArgs fields | Reference |
|---|---|---|
| Solana | computeUnits, accountIsWritableBitmap, tokenReceiver, accounts | Build messages to Solana |
| Aptos | gasLimit, allowOutOfOrderExecution | Build messages to Aptos |
| Sui | gasLimit, allowOutOfOrderExecution, tokenReceiver, receiverObjectIds | — |
See Multi-Chain for cross-chain send examples.
Related
- Tracking Messages — Monitor sent messages
- Manual Execution — Execute stuck messages
- Token Pools — Query pool configuration and rate limits
- Error Handling — Handle failures and retry