Skip to main content
Version: 1.0.0

Sending Messages

Message Structure

TypeScript
import type { BytesLike } from 'ethers' // string | Uint8Array

// MessageInput type - only receiver is required, all other fields are optional
type MessageInput = {
receiver: BytesLike // Required: Destination address (hex string or bytes)
data?: BytesLike // Optional: Arbitrary data payload (hex string or bytes)
tokenAmounts?: { // Optional: Tokens to transfer
token: string // Source token address
amount: bigint // Amount in smallest unit
}[]
feeToken?: string // Optional: Fee payment token (address or zero for native)
extraArgs?: Partial<ExtraArgs> // Optional: Extra arguments (object, SDK encodes internally)
fee?: bigint // Optional: Fee amount (returned by getFee)
}

Arbitrary Messaging (Data Only)

Send arbitrary data to a contract on another chain:

TypeScript
import { EVMChain, networkInfo, CCIPError } from '@chainlink/ccip-sdk'
import { toHex } from 'viem'

const source = await EVMChain.fromUrl('https://rpc.sepolia.org')

const message = {
receiver: '0xReceiverContract...',
data: toHex('Hello from Sepolia!'),
tokenAmounts: [],
feeToken: '0x0000000000000000000000000000000000000000', // Native ETH
extraArgs: {
gasLimit: 200000n,
allowOutOfOrderExecution: false,
},
}

const router = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' // Sepolia Router
const destSelector = networkInfo('avalanche-testnet-fuji').chainSelector

try {
const fee = await source.getFee({
router,
destChainSelector: destSelector,
message,
})
console.log('Fee:', fee, 'wei')

const request = await source.sendMessage({
router,
destChainSelector: destSelector,
message: { ...message, fee },
wallet, // Required: ethers Signer or viemWallet(client)
})
console.log('Sent in tx:', request.tx.hash)
} catch (error) {
if (CCIPError.isCCIPError(error)) {
console.error('CCIP error:', error.code, error.message)
if (error.recovery) console.error('Recovery:', error.recovery)
} else {
throw error
}
}

Token Transfer

Transfer tokens cross-chain. For token-only transfers (no data), the SDK auto-populates all extraArgs with sensible defaults — you only need receiver and tokenAmounts:

TypeScript
import { EVMChain, networkInfo } from '@chainlink/ccip-sdk'

const source = await EVMChain.fromUrl('https://rpc.sepolia.org')

const LINK_TOKEN = '0x779877A7B0D9E8603169DdbD7836e478b4624789' // LINK on Sepolia

const message = {
receiver: '0xRecipientAddress...',
tokenAmounts: [
{
token: LINK_TOKEN,
amount: 1000000000000000000n, // 1 LINK (18 decimals)
},
],
feeToken: LINK_TOKEN, // Pay fee in LINK
}

const destSelector = networkInfo('avalanche-testnet-fuji').chainSelector
const router = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' // Sepolia Router

const fee = await source.getFee({
router,
destChainSelector: destSelector,
message,
})
console.log('Fee:', fee, 'LINK wei')

// Ensure LINK allowance is set for router before sending
const request = await source.sendMessage({
router,
destChainSelector: destSelector,
message: { ...message, fee },
wallet, // Required: ethers Signer or viemWallet(client)
})

Before sending tokens, approve the Router contract to spend your tokens. sendMessage fails if allowance is insufficient.

This works for any destination chain (EVM, Solana, Aptos, Sui, TON). The SDK handles chain-specific fields like computeUnits, tokenReceiver, and allowOutOfOrderExecution automatically for token-only transfers. See Multi-Chain for cross-chain examples.

Extra Arguments

Extra arguments control execution behavior on the destination chain. When using sendMessage or getFee, pass them as an object — the SDK encodes them internally:

TypeScript
const message = {
receiver: '0x...',
extraArgs: {
gasLimit: 200000n,
allowOutOfOrderExecution: true,
},
}
await source.sendMessage({ router, destChainSelector, message, wallet })

The encodeExtraArgs utility is available for low-level use (e.g., building raw on-chain transactions outside the SDK):

TypeScript
import { encodeExtraArgs } from '@chainlink/ccip-sdk'

// EVM V2 (recommended) - inferred from allowOutOfOrderExecution
const encoded = encodeExtraArgs({
gasLimit: 200000n, // Gas for receiver execution
allowOutOfOrderExecution: true, // Allow out-of-order execution
})

// EVM V1 (legacy) - inferred when only gasLimit is set
const encodedV1 = encodeExtraArgs({
gasLimit: 200000n,
})

Fee Estimation

Estimate fees before sending:

TypeScript
import { EVMChain, networkInfo } from '@chainlink/ccip-sdk'

const source = await EVMChain.fromUrl('https://rpc.sepolia.org')

// Fee in native token
const nativeMessage = { ...message, feeToken: '0x' + '0'.repeat(40) }
const nativeFee = await source.getFee({
router,
destChainSelector: destSelector,
message: nativeMessage,
})

// Fee in LINK
const linkMessage = { ...message, feeToken: LINK_TOKEN }
const linkFee = await source.getFee({
router,
destChainSelector: destSelector,
message: linkMessage,
})

console.log('Native fee:', nativeFee, 'wei')
console.log('LINK fee:', linkFee, 'wei')

Fees depend on destination chain gas costs, token transfer complexity, message data size, and current gas prices.

Unsigned Transactions

Generate unsigned transactions for browser wallets, offline signing, or multi-sig wallets.

Why Use Unsigned Transactions?

Browser wallets (MetaMask, Phantom) don't support signTransaction() - they only support sendTransaction(). The SDK's sendMessage() method uses signTransaction() internally, which won't work in browsers.

Solution: Use generateUnsignedSendMessage() to get unsigned transactions, then sign them with your wallet provider.

Basic Usage

TypeScript
import { EVMChain, networkInfo } from '@chainlink/ccip-sdk'

const source = await EVMChain.fromUrl('https://rpc.sepolia.org')

const unsignedTx = await source.generateUnsignedSendMessage({
router,
destChainSelector: destSelector,
message,
sender: walletAddress, // Required: address of wallet that will send
})

console.log('Unsigned tx:', unsignedTx)

EVM Multi-Transaction Flow

For token transfers on EVM, you typically need two transactions:

  1. Approve - Allow the CCIP Router to spend your tokens
  2. ccipSend - Execute the cross-chain transfer

The SDK returns both in unsignedTx.transactions[]:

TypeScript
import { EVMChain, networkInfo } from '@chainlink/ccip-sdk'

const source = await EVMChain.fromUrl('https://rpc.sepolia.org')

const unsignedTx = await source.generateUnsignedSendMessage({
router,
destChainSelector: destSelector,
message: {
receiver: '0xReceiver...',
tokenAmounts: [{ token: tokenAddress, amount }],
fee,
},
sender: walletAddress,
})

// Process all transactions in order (approvals first, then send)
for (const tx of unsignedTx.transactions) {
const hash = await walletClient.sendTransaction(tx)
await publicClient.waitForTransactionReceipt({ hash })
}

Get Message After Sending

After the final transaction confirms, extract the message ID:

TypeScript
// Last transaction is the ccipSend
const sendTx = unsignedTx.transactions[unsignedTx.transactions.length - 1]
const hash = await walletClient.sendTransaction(sendTx)
const receipt = await publicClient.waitForTransactionReceipt({ hash })

// Get message details
const messages = await source.getMessagesInTx(hash)
const messageId = messages[0].message.messageId

console.log('Message ID:', messageId)

Programmable Token Transfer

Send both data and tokens in a single message. The receiver contract executes custom logic with the tokens (e.g., deposit, swap, stake):

TypeScript
import {
EVMChain,
networkInfo,
CCIPError
} from '@chainlink/ccip-sdk'
import { viemWallet } from '@chainlink/ccip-sdk/viem'
import { toHex, parseEther, type WalletClient } from 'viem'

async function sendCrossChainMessage(walletClient: WalletClient) {
const source = await EVMChain.fromUrl('https://rpc.sepolia.org')

const router = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' // Sepolia Router
const destSelector = networkInfo('avalanche-testnet-fuji').chainSelector

const message = {
receiver: '0xReceiverContract...',
data: toHex(JSON.stringify({ action: 'deposit', user: '0x...' })),
tokenAmounts: [
{
token: '0x779877A7B0D9E8603169DdbD7836e478b4624789', // LINK
amount: parseEther('0.1'),
},
],
feeToken: '0x0000000000000000000000000000000000000000', // Native ETH
extraArgs: {
gasLimit: 300000n,
allowOutOfOrderExecution: false,
},
}

try {
const fee = await source.getFee({
router,
destChainSelector: destSelector,
message,
})
console.log('Estimated fee:', fee, 'wei')

// Add 10% buffer for gas price fluctuations
const feeWithBuffer = (fee * 110n) / 100n

const request = await source.sendMessage({
router,
destChainSelector: destSelector,
message: { ...message, fee: feeWithBuffer },
wallet: viemWallet(walletClient), // Wrap viem WalletClient
})

console.log('Transaction hash:', request.tx.hash)
console.log('Message ID:', request.message.messageId)

return request
} catch (error) {
if (CCIPError.isCCIPError(error)) {
console.error('CCIP error:', error.code, error.message)
if (error.recovery) console.error('Recovery:', error.recovery)
}
throw error
}
}

Unlike token-only transfers, programmable token transfers require extraArgs.gasLimit (for the receiver contract execution) and a data payload.

Non-EVM destinations have additional requirements for programmable token transfers. For the full message structure details, see: