Skip to main content
Version: 1.1.1

Sending Messages

Send Workflow

Every CCIP send follows these steps:

  1. Check lane capabilities — determine which extra arguments the lane supports
  2. Build the message — set receiver, data, tokens, and extra arguments
  3. Estimate the fee — call getFee to get the cost
  4. Send — call sendMessage with the fee and a wallet
TypeScript
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.

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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

ValueLane versionWhat it meansExtra arguments to use
undefinedPre-v2.0V3 is not supportedV2: gasLimit + allowOutOfOrderExecution
0v2.0+V3 supported, FTF not enabled for this tokenV2 or V3 (no speed-up from blockConfirmations)
> 0v2.0+ with FTFMinimum blockConfirmations for faster relayingV3 with blockConfirmations >= minValue
TypeScript
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
note

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

TypeScript
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:

TypeScript
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.

TypeScript
// 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:

TypeScript
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 presentDetected versionEncoding tag
gasLimit onlyEVMExtraArgsV10x97a657c9
gasLimit + allowOutOfOrderExecutionEVMExtraArgsV20x181dcf10
Any of: blockConfirmations, ccvs, ccvArgs, executor, executorArgs, tokenReceiver, tokenArgsGenericExtraArgsV30xa69dd4aa
computeUnitsSVMExtraArgsV1 (Solana)0x1f3b3aba
receiverObjectIdsSuiExtraArgsV10x21ea4ca9

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.

FieldTypeDefaultDescription
gasLimitbigint0n (no data) / 200_000n (with data)Gas for receiver execution
blockConfirmationsnumber0Source-chain confirmations before relaying (FTF)
ccvsstring[][]Cross-chain verifier addresses
ccvArgsBytesLike[][]Per-CCV arguments
executorstring''Custom executor address
executorArgsBytesLike'0x'Executor-specific arguments
tokenReceiverstring''Per-token receiver address
tokenArgsBytesLike'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.

TypeScript
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 })

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.

TypeScript
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:

TypeScript
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

FieldTypeRequiredDefault
receiverBytesLikeYes
dataBytesLikeNo'0x'
tokenAmounts{ token: string, amount: bigint }[]No[]
feeTokenstringNoZeroAddress (native token)
extraArgsPartial<ExtraArgs>NoAuto-populated by SDK (see below)
feebigintNoAuto-calculated via getFee if omitted

Auto-Populated Defaults

When extraArgs is omitted or partial, the SDK fills in defaults:

ConditionDefault extraArgs
Message has data{ gasLimit: 200_000n, allowOutOfOrderExecution: true }
Token-only (no data){ gasLimit: 0n, allowOutOfOrderExecution: true }
Any V3-only field presentV3 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.

DestinationRequired extraArgs fieldsReference
SolanacomputeUnits, accountIsWritableBitmap, tokenReceiver, accountsBuild messages to Solana
AptosgasLimit, allowOutOfOrderExecutionBuild messages to Aptos
SuigasLimit, allowOutOfOrderExecution, tokenReceiver, receiverObjectIds

See Multi-Chain for cross-chain send examples.