Arbitrary Cross-Chain Messaging with Chainlink CCIP
Chainlink CCIP is a generalized cross-chain messaging protocol — it can carry arbitrary bytes, token transfers, or both in a single message. It uses two independent committees (OCR DON and RMN) plus per-lane rate limits for defense-in-depth. This tutorial covers the pure-messaging path (different from the CCT tutorial which focused on TNZO token pools).
What You'll Build
- Discover supported chains and lanes
- Quote and send an arbitrary data message
- Send a combined data + token message in one atomic call
- Implement a Solidity
CCIPReceivercontract - Track messages and inspect rate-limit buckets
Security Model
Defense-in-depth on CCIP v1.5+
CCIP OCR DON RMN (Risk Management Network)
------------- -----------------------------
primary committee independent "second opinion"
commits merkle roots blesses/curses roots
executes transfers can halt cross-chain lanes
For a message to execute:
OCR -> commits root
RMN -> blesses root ◄── kill-switch on suspicious activity
exec -> runs on dst
Rate limits per lane+token (bucket):
capacity — max amount in-flight
rate — refill per second
tokens — current level
If a bridge is exploited, the RMN can curse the root before execution,
and the rate limiter bounds the worst-case loss even if both committees
are compromised.Step 1: Enumerate Chains and Lanes
ccip_get_supported_chains()
-> [
{ name: "ethereum", selector: "5009297550715157269", chain_id: 1 },
{ name: "base", selector: "15971525489660198786", chain_id: 8453 },
{ name: "arbitrum", selector: "4949039107694359620", chain_id: 42161 },
{ name: "optimism", selector: "3734403246176062136", chain_id: 10 },
{ name: "polygon", selector: "4051577828743386545", chain_id: 137 },
{ name: "solana", selector: "...", chain_id: 0 }
]ccip_get_lanes({ "source_chain": "ethereum" })
-> [
{ dest: "base", selector: "15971525489660198786", supports_messages: true, supports_tokens: true },
{ dest: "arbitrum", selector: "4949039107694359620", supports_messages: true, supports_tokens: true },
{ dest: "optimism", selector: "3734403246176062136", supports_messages: true, supports_tokens: true },
{ dest: "polygon", selector: "4051577828743386545", supports_messages: true, supports_tokens: true },
{ dest: "solana", selector: "...", supports_messages: true, supports_tokens: true }
]Step 2: Quote a Data-Only Message
Fees are denominated in LINK or native gas; the quote calls Router.getFee() on the source chain over eth_call:
// Arbitrary data (no token transfer) — build a CCIP message first.
ccip_get_fee({
"source_chain": "ethereum",
"dest_chain": "base",
"receiver": "0xReceiverOnBase...",
"data": "0xdeadbeef",
"token_transfers": [],
"fee_token": "LINK"
})
-> {
fee: "0.015",
fee_token: "LINK",
source_router: "0x80226fc0Ee2b096224EeAc085Bb9a8cba1Abf82D"
}Step 3: Send the Message
ccip_send_message({
"source_chain": "ethereum",
"dest_chain": "base",
"receiver": "0xReceiverOnBase...",
"data": "0xdeadbeef",
"token_transfers": [],
"fee_token": "LINK",
"gas_limit": 200000
})
-> {
tx_hash: "0x...",
message_id: "0x...",
source_router: "0x..."
}Step 4: Combine Data and Tokens
The power of CCIP over pure bridges — a single atomic message can carry both a payload and token transfers, unlocking programmable cross-chain flows (e.g., "deposit 25 USDC into the dst lending protocol on the receiver's behalf"):
// Data + token combined in one atomic CCIP message.
ccip_send_message({
"source_chain": "ethereum",
"dest_chain": "base",
"receiver": "0xReceiverOnBase...",
"data": "0x",
"token_transfers": [
{ "token": "USDC", "amount": "25.0" }
],
"fee_token": "LINK"
})
-> { tx_hash: "0x...", message_id: "0x..." }Step 5: Implement the Receiver
The destination contract extends CCIPReceiver. Token transfers are already settled into the contract before _ccipReceive runs:
// Solidity — receive a CCIP message on the destination chain.
import { CCIPReceiver } from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import { Client } from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
contract MyReceiver is CCIPReceiver {
event Received(bytes32 messageId, uint64 sourceSelector, bytes data);
constructor(address router) CCIPReceiver(router) {}
function _ccipReceive(Client.Any2EVMMessage memory msg_) internal override {
// msg_.messageId, msg_.sourceChainSelector, msg_.sender, msg_.data, msg_.destTokenAmounts
emit Received(msg_.messageId, msg_.sourceChainSelector, msg_.data);
// Handle token transfers:
for (uint i = 0; i < msg_.destTokenAmounts.length; i++) {
// destTokenAmounts[i].token and .amount are already minted/transferred to this contract
}
}
}Step 6: Track Delivery
ccip_track_message({ "message_id": "0x..." })
-> {
state: "Success", // Untouched | InProgress | Success | Failure
source_tx: "0x...",
dest_tx: "0x...",
dest_chain: "base",
committed_at: 1712998000
}Step 7: Inspect Rate Limits
Every token + lane pair has an independent token bucket. When tokens < amount, the transfer reverts on the source router — no silent queueing:
ccip_get_rate_limits({
"source_chain": "ethereum",
"dest_chain": "base",
"token": "USDC"
})
-> {
is_enabled: true,
capacity: "1000000.0", // tokens
rate: "100.0", // tokens per second refill
tokens: "987123.4" // current bucket level
}Step 8: CLI
# List CCIP-supported chains
tenzro chainlink ccip-get-supported-chains
# Inspect lanes from Ethereum
tenzro chainlink ccip-get-lanes --source ethereum
# Quote a pure data message
tenzro chainlink ccip-get-fee \
--source ethereum --dest base \
--receiver 0x... --data 0xdeadbeef \
--fee-token LINK
# Send the message
tenzro chainlink ccip-send \
--source ethereum --dest base \
--receiver 0x... --data 0xdeadbeef \
--fee-token LINK
# Track by messageId
tenzro chainlink ccip-track --message-id 0x...
# Inspect the rate limiter for a token lane
tenzro chainlink ccip-rate-limits \
--source ethereum --dest base --token USDCStep 9: TypeScript SDK
import { TenzroClient } from "@tenzro/sdk";
const client = new TenzroClient();
// 1. Quote the fee (real Router.getFee() eth_call)
const fee = await client.chainlink.ccipGetFee({
source_chain: "ethereum",
dest_chain: "base",
receiver: "0xReceiverOnBase...",
data: "0x" + Buffer.from("hello cross-chain").toString("hex"),
token_transfers: [],
fee_token: "LINK",
});
console.log("fee", fee.fee, fee.fee_token);
// 2. Send a combined data + token message
const send = await client.chainlink.ccipSend({
source_chain: "ethereum",
dest_chain: "base",
receiver: "0xReceiverOnBase...",
data: "0x",
token_transfers: [{ token: "USDC", amount: "25.0" }],
fee_token: "LINK",
});
// 3. Track delivery
let status = "InProgress";
while (status === "InProgress" || status === "Untouched") {
await new Promise(r => setTimeout(r, 30_000));
const t = await client.chainlink.ccipTrackMessage({ message_id: send.message_id });
status = t.state;
console.log("state:", status);
}CCIP messages vs CCT token transfers. CCT (see CCT Transfers) is a specialization of CCIP for same-token cross-chain balances using LockRelease/BurnMint pools. Pure CCIP messaging is the general mechanism that CCT is built on. Use CCT when you just need to move the same token; use raw CCIP when you need to deliver a payload plus optionally tokens.
What You Learned
- Lanes and selectors — how CCIP identifies chain pairs
- Router.getFee / Router.ccipSend — live fee quotes and message submission
- Data + token atomicity — program flows that combine both
- CCIPReceiver — Solidity pattern for handling inbound messages
- OCR + RMN + rate limits — three independent safety layers
Next Steps
- See the CCT Transfers tutorial for TNZO-specific cross-chain pools
- See the LayerZero V2 tutorial for an alternative trust domain
- Read the Chainlink integration docs