Tenzro Testnet is live —request testnet TNZO
← Back to Tutorials

Arbitrary Cross-Chain Messaging with Chainlink CCIP

BridgeIntermediate30 min

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 CCIPReceiver contract
  • 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 USDC

Step 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

Next Steps