Skip to main content
Version: 1.14.0

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.FINALITY_FAST != 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​

The SDK does not validate your extraArgs against the lane's on-chain version. If you pass V3 fields (e.g., finality) to a pre-v2.0 lane, the transaction will revert on-chain. Call getLaneFeatures first to check what the lane supports:

TypeScript
const features = await source.getLaneFeatures({
router,
destChainSelector: destSelector,
token: '0xTokenAddress...', // pass to get token-specific FTF and rate limit info
})

const minConfirmations = features.FINALITY_FAST
// undefined = pre-v2.0 (V2 only), 0 = v2.0+ (V3 supported, no FTF), >0 = FTF enabled

For the full FTF workflow — checking availability, choosing extraArgs, estimating FTF latency and fees, and querying FTF rate limits — see Faster-Than-Finality.

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: finality, 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
finalitynumber | 'safe' | 'finalized'0Source-chain confirmations before relaying (FTF), or 'safe'/'finalized' for mode-based finality
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
// Each tx may include `value` (native token amount for fees or native token transfers)
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.FINALITY_FAST

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, finality: 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: finality: 0, empty arrays/strings for other fields

Non-EVM Destinations​

For Solana, Sui, and Canton 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—
Cantonfinality (destination); feeTokenHoldingCids, ccvRawAddresses (source)Canton getting started

See Multi-Chain for cross-chain send examples.