Page cover image

CCIP Architecture in Depth

How Chainlink CCIP works under the hood

CCIP concepts

Before you explore how Chainlink CCIP works in depth, it is best to understand the core concepts.

Interoperability

Interoperability is the ability to exchange information between different systems or networks, even if they are incompatible. Shared concepts on different networks ensure that each party understands and trusts the exchanged information. It also considers the concept of finality to establish trust in the exchanged information by validating its accuracy and integrity.

Finality

Finality refers to the state of irreversibility and permanent record of a transaction on the blockchain. If the parameters for finality are properly set, the likelihood of irreversibility is extremely low. For CCIP, source chain finality is the main factor that determines the end-to-end elapsed time for CCIP to send a message from one chain to another.

Finality varies across different networks. Some networks offer instant finality and others require multiple confirmations. These time differences are set to ensure the security of CCIP and its users. Finality is crucial for token transfers because funds are locked and not reorganized once they are released onto the destination chain. In this scenario, finality ensures that funds on the destination chain are available only after they have been successfully committed on the source chain.

Lane

To recap, a Chainlink CCIP lane is a distinct pathway between a source and a destination blockchain. Lanes are unidirectional. For instance, Ethereum Sepolia => Polygon Mumbai and Polygon Mumbai => Ethereum Sepolia are two different lanes.

Decentralized Oracle Network (DON)

Chainlink Decentralized Oracle Networks, or DONs, run Chainlink OCR2. The protocol runs in rounds during which an observed data value might be agreed upon. The output of this process results in a report which is attested to by a quorum of participants. The report is then transmitted on-chain by one of the participants. No single participant is responsible for transmitting on every round, and all of them will attempt to do so in a round-robin fashion until a transmission has taken place.

In the context of CCIP, a lane contains two OCR DON committees that monitor transactions between a source and destination blockchain: the Committing DON and Executing DON. More on that in the next subchapter.

High-Level Architecture

As mentioned in the Getting Started chapter with Chainlink CCIP, one can:

  • Transfer (supported) tokens

  • Send any kind of data

  • Send both tokens and data

CCIP receiver can be:

  • EOA

  • Any smart contract that implements CCIPReceiver.sol

Note: If you send a message and token(s) to EOA, only tokens will arrive.

Going down the rabbit hole

The figure below outlines the different components involved in a cross-chain transaction:

  • Cross-Chain dApps are user-specific. A smart contract or an EOA (Externally Owned Account) interacts with the CCIP Router to send arbitrary data and/or transfer tokens cross-chain.

  • The contracts in dark blue are the CCIP interface (Router.sol). As we already learned, to use CCIP, users need only to understand how to interact with the router; they don't need to understand the whole CCIP architecture. Note: The CCIP interface is static and remains consistent over time to provide reliability and stability to the users.

  • The contracts in light blue are internal to the CCIP protocol and subject to change.

Now let's explain each of the components from the above diagram.

On-chain components

1) Router

The Router is the primary contract CCIP users interface with. This contract is responsible for initiating cross-chain interactions. One router contract exists per chain. When transferring tokens, callers have to approve tokens for the router contract.

The router contract routes the instruction to the destination-specific OnRamp.

When a message is received on the destination chain, the router is the contract that “delivers” tokens to the user's account or the message to the receiver's smart contract.

Routing message code
 // ================================================================
 // |                      Message execution                       |
 // ================================================================

  /// @inheritdoc IRouter
  /// @dev Handles the edge case where we want to pass a specific amount of gas,
  /// @dev but EIP-150 sends all but 1/64 of the remaining gas instead so the user gets
  /// @dev less gas than they paid for. The other 2 parts of EIP-150 do not apply since
  /// @dev a) we hard code value=0 and b) we ensure code already exists.
  /// @dev If we revert instead, then that will never happen.
  /// @dev Separately we capture the return data up to a maximum size to avoid return bombs,
  /// @dev borrowed from https://github.com/nomad-xyz/ExcessivelySafeCall/blob/main/src/ExcessivelySafeCall.sol.
  function routeMessage(
    Client.Any2EVMMessage calldata message,
    uint16 gasForCallExactCheck,
    uint256 gasLimit,
    address receiver
  )
    external
    override
    onlyOffRamp(message.sourceChainSelector)
    whenHealthy
    returns (bool success, bytes memory retData)
  {
    // We encode here instead of the offRamps to constrain specifically what functions
    // can be called from the router.
    bytes memory data = abi.encodeWithSelector(IAny2EVMMessageReceiver.ccipReceive.selector, message);
    // allocate retData memory ahead of time
    retData = new bytes(MAX_RET_BYTES);

    // solhint-disable-next-line no-inline-assembly
    assembly {
      // solidity calls check that a contract actually exists at the destination, so we do the same
      // Note we do this check prior to measuring gas so gasForCallExactCheck (our "cushion")
      // doesn't need to account for it.
      if iszero(extcodesize(receiver)) {
        revert(0, 0)
      }

      let g := gas()
      // Compute g -= gasForCallExactCheck and check for underflow
      // The gas actually passed to the callee is _min(gasAmount, 63//64*gas available).
      // We want to ensure that we revert if gasAmount >  63//64*gas available
      // as we do not want to provide them with less, however that check itself costs
      // gas. gasForCallExactCheck ensures we have at least enough gas to be able
      // to revert if gasAmount >  63//64*gas available.
      if lt(g, gasForCallExactCheck) {
        revert(0, 0)
      }
      g := sub(g, gasForCallExactCheck)
      // if g - g//64 <= gasAmount, revert
      // (we subtract g//64 because of EIP-150)
      if iszero(gt(sub(g, div(g, 64)), gasLimit)) {
        revert(0, 0)
      }
      // call and return whether we succeeded. ignore return data
      // call(gas,addr,value,argsOffset,argsLength,retOffset,retLength)
      success := call(gasLimit, receiver, 0, add(data, 0x20), mload(data), 0, 0)

      // limit our copy to MAX_RET_BYTES bytes
      let toCopy := returndatasize()
      if gt(toCopy, MAX_RET_BYTES) {
        toCopy := MAX_RET_BYTES
      }
      // Store the length of the copied bytes
      mstore(retData, toCopy)
      // copy the bytes from retData[0:_toCopy]
      returndatacopy(add(retData, 0x20), 0, toCopy)
    }
    emit MessageExecuted(message.messageId, message.sourceChainSelector, msg.sender, keccak256(data));
    return (success, retData);
  }

2) OnRamp

As mentioned in the previous paragraph, the router contract routes the instruction to the destination-specific OnRamp. One OnRamp contract per lane exists.

This contract performs the following tasks:

  • Checks destination-blockchain-specific validity, such as validating account address syntax

  • Verifies the message size limit and gas limits

  • Keeps track of sequence numbers to preserve the sequence of messages for the receiver

  • Manages billing

  • Interacts with the TokenPool if the message includes a token transfer.

  • Emits an event monitored by the committing DON

Forwarding from Router code
  function forwardFromRouter(
    Client.EVM2AnyMessage calldata message,
    uint256 feeTokenAmount,
    address originalSender
  ) external whenHealthy returns (bytes32) {
    // EVM destination addresses should be abi encoded and therefore always 32 bytes long
    if (message.receiver.length != 32) revert InvalidAddress(message.receiver);
    uint256 decodedReceiver = abi.decode(message.receiver, (uint256));
    // We want to disallow sending to address(0) and to precompiles, which exist on address(1) through address(9).
    if (decodedReceiver > type(uint160).max || decodedReceiver < 10) revert InvalidAddress(message.receiver);

    Client.EVMExtraArgsV1 memory extraArgs = _fromBytes(message.extraArgs);
    // Validate the message with various checks
    _validateMessage(message.data.length, extraArgs.gasLimit, message.tokenAmounts.length, originalSender);
    // Rate limit on aggregated token value
    _rateLimitValue(message.tokenAmounts, IPriceRegistry(s_dynamicConfig.priceRegistry));

    // Convert feeToken to link if not already in link
    if (message.feeToken == i_linkToken) {
      // Since there is only 1b link this is safe
      s_nopFeesJuels += uint96(feeTokenAmount);
    } else {
      // the cast from uint256 to uint96 is considered safe, uint96 can store more than max supply of link token
      s_nopFeesJuels += uint96(
        IPriceRegistry(s_dynamicConfig.priceRegistry).convertTokenAmount(message.feeToken, feeTokenAmount, i_linkToken)
      );
    }
    if (s_nopFeesJuels > i_maxNopFeesJuels) revert MaxFeeBalanceReached();

    if (s_senderNonce[originalSender] == 0 && i_prevOnRamp != address(0)) {
      // If this is first time send for a sender in new OnRamp, check if they have a nonce
      // from the previous OnRamp and start from there instead of zero.
      s_senderNonce[originalSender] = IEVM2AnyOnRamp(i_prevOnRamp).getSenderNonce(originalSender);
    }

    // We need the next available sequence number so we increment before we use the value
    Internal.EVM2EVMMessage memory newMessage = Internal.EVM2EVMMessage({
      sourceChainSelector: i_chainSelector,
      sequenceNumber: ++s_sequenceNumber,
      feeTokenAmount: feeTokenAmount,
      sender: originalSender,
      nonce: ++s_senderNonce[originalSender],
      gasLimit: extraArgs.gasLimit,
      strict: extraArgs.strict,
      receiver: address(uint160(decodedReceiver)),
      data: message.data,
      tokenAmounts: message.tokenAmounts,
      feeToken: message.feeToken,
      messageId: ""
    });
    newMessage.messageId = Internal._hash(newMessage, i_metadataHash);

    // Lock the tokens as last step. TokenPools may not always be trusted.
    // There should be no state changes after external call to TokenPools.
    for (uint256 i = 0; i < message.tokenAmounts.length; ++i) {
      Client.EVMTokenAmount memory tokenAndAmount = message.tokenAmounts[i];
      getPoolBySourceToken(IERC20(tokenAndAmount.token)).lockOrBurn(
        originalSender,
        message.receiver,
        tokenAndAmount.amount,
        i_destChainSelector,
        bytes("") // any future extraArgs component would be added here
      );
    }

    // Emit message request
    emit CCIPSendRequested(newMessage);
    return newMessage.messageId;
  }

3) Token Pool

Each token has its own token pool, an abstraction layer over ERC-20 tokens that facilitates OnRamp and OffRamp token-related operations.

Token pools are configurable to lock or burn at the source blockchain and unlock or mint at the destination blockchain. The mechanism for handling tokens depends on the characteristics of the token in question. Token handling mechanisms are described in the Token handling mechanisms paragraph.

Token pools provide rate limiting, which is a security feature enabling token issuers to set a maximum rate at which their token can be transferred. Rate limits are described in the Rate Limits paragraph.

Lock or Burn code (called by the OnRamp)
  function lockOrBurn(
    address originalSender,
    bytes calldata,
    uint256 amount,
    uint64,
    bytes calldata
  ) external override onlyOnRamp checkAllowList(originalSender) whenHealthy returns (bytes memory) {
    _consumeOnRampRateLimit(amount);
    IBurnMintERC20(address(i_token)).burn(amount);
    emit Burned(msg.sender, amount);
    return "";
  }
Unlock or Mint code (called by the OffRamp)
  function releaseOrMint(
    bytes memory,
    address receiver,
    uint256 amount,
    uint64,
    bytes memory
  ) external virtual override whenHealthy onlyOffRamp {
    _consumeOffRampRateLimit(amount);
    IBurnMintERC20(address(i_token)).mint(receiver, amount);
    emit Minted(msg.sender, receiver, amount);
  }

4) Risk Management Network contract

The Risk Management Network contract maintains the list of Risk Management Network nodes' addresses allowed to bless/curse and holds the quorum logic for blessing a committed Merkle Root and cursing CCIP on a destination blockchain.

Risk Management Network is described in the Risk Management Network chapter.

5) Commit Store

The Committing DON interacts with the CommitStore contract on the destination blockchain to store the Merkle root of the finalized messages on the source blockchain. This Merkle root must be blessed by the Risk Management Network before Executing DON can execute them on the destination blockchain. The CommitStore ensures the message is blessed by the Risk Management Network and only one CommitStore exists per lane.

Commit reporting code
function _report(bytes calldata encodedReport, uint40 epochAndRound) internal override whenNotPaused whenHealthy {
    CommitReport memory report = abi.decode(encodedReport, (CommitReport));

    // Check if the report contains price updates
    if (report.priceUpdates.tokenPriceUpdates.length > 0 || report.priceUpdates.destChainSelector != 0) {
      // Check for price staleness based on the epoch and round
      if (s_latestPriceEpochAndRound < epochAndRound) {
        // If prices are not stale, update the latest epoch and round
        s_latestPriceEpochAndRound = epochAndRound;
        // And update the prices in the price registry
        IPriceRegistry(s_dynamicConfig.priceRegistry).updatePrices(report.priceUpdates);

        // If there is no root, the report only contained fee updated and
        // we return to not revert on the empty root check below.
        if (report.merkleRoot == bytes32(0)) return;
      } else {
        // If prices are stale and the report doesn't contain a root, this report
        // does not have any valid information and we revert.
        // If it does contain a merkle root, continue to the root checking section.
        if (report.merkleRoot == bytes32(0)) revert StaleReport();
      }
    }

    // If we reached this section, the report should contain a valid root
    if (s_minSeqNr != report.interval.min || report.interval.min > report.interval.max)
      revert InvalidInterval(report.interval);

    if (report.merkleRoot == bytes32(0)) revert InvalidRoot();
    // Disallow duplicate roots as that would reset the timestamp and
    // delay potential manual execution.
    if (s_roots[report.merkleRoot] != 0) revert RootAlreadyCommitted();

    s_minSeqNr = report.interval.max + 1;
    s_roots[report.merkleRoot] = block.timestamp;
    emit ReportAccepted(report);
}

6) OffRamp

One OffRamp contract per lane exists.

This contract performs the following tasks:

  • Ensures the message is authentic by verifying the proof provided by the Executing DON against a committed and blessed Merkle root

  • Makes sure transactions are executed only once

  • After validation, the OffRamp contract transmits any received message to the Router contract. If the CCIP transaction includes token transfers, the OffRamp contract calls the TokenPool to transfer the correct assets to the receiver.

Message execution code
function _execute(Internal.ExecutionReport memory report, uint256[] memory manualExecGasLimits) internal whenHealthy {
    uint256 numMsgs = report.messages.length;
    if (numMsgs == 0) revert EmptyReport();
    if (numMsgs != report.offchainTokenData.length) revert UnexpectedTokenData();

    bytes32[] memory hashedLeaves = new bytes32[](numMsgs);

    for (uint256 i = 0; i < numMsgs; ++i) {
      Internal.EVM2EVMMessage memory message = report.messages[i];
      // We do this hash here instead of in _verifyMessages to avoid two separate loops
      // over the same data, which increases gas cost
      hashedLeaves[i] = Internal._hash(message, i_metadataHash);
      // For EVM2EVM offramps, the messageID is the leaf hash.
      // Asserting that this is true ensures we don't accidentally commit and then execute
      // a message with an unexpected hash.
      if (hashedLeaves[i] != message.messageId) revert InvalidMessageId();
    }

    // SECURITY CRITICAL CHECK
    uint256 timestampCommitted = ICommitStore(i_commitStore).verify(hashedLeaves, report.proofs, report.proofFlagBits);
    if (timestampCommitted == 0) revert RootNotCommitted();

    // Execute messages
    bool manualExecution = manualExecGasLimits.length != 0;
    for (uint256 i = 0; i < numMsgs; ++i) {
      Internal.EVM2EVMMessage memory message = report.messages[i];
      Internal.MessageExecutionState originalState = getExecutionState(message.sequenceNumber);
      // Two valid cases here, we either have never touched this message before, or we tried to execute
      // and failed. This check protects against reentry and re-execution because the other states are
      // IN_PROGRESS and SUCCESS, both should not be allowed to execute.
      if (
        !(originalState == Internal.MessageExecutionState.UNTOUCHED ||
          originalState == Internal.MessageExecutionState.FAILURE)
      ) revert AlreadyExecuted(message.sequenceNumber);

      if (manualExecution) {
        bool isOldCommitReport = (block.timestamp - timestampCommitted) >
          s_dynamicConfig.permissionLessExecutionThresholdSeconds;
        // Manually execution is fine if we previously failed or if the commit report is just too old
        // Acceptable state transitions: FAILURE->SUCCESS, UNTOUCHED->SUCCESS, FAILURE->FAILURE
        if (!(isOldCommitReport || originalState == Internal.MessageExecutionState.FAILURE))
          revert ManualExecutionNotYetEnabled();

        // Manual execution gas limit can override gas limit specified in the message. Value of 0 indicates no override.
        if (manualExecGasLimits[i] != 0) {
          message.gasLimit = manualExecGasLimits[i];
        }
      } else {
        // DON can only execute a message once
        // Acceptable state transitions: UNTOUCHED->SUCCESS, UNTOUCHED->FAILURE
        if (originalState != Internal.MessageExecutionState.UNTOUCHED) revert AlreadyAttempted(message.sequenceNumber);
      }

      // In the scenario where we upgrade offRamps, we still want to have sequential nonces.
      // Referencing the old offRamp to check the expected nonce if none is set for a
      // given sender allows us to skip the current message if it would not be the next according
      // to the old offRamp. This preserves sequencing between updates.
      uint64 prevNonce = s_senderNonce[message.sender];
      if (prevNonce == 0 && i_prevOffRamp != address(0)) {
        prevNonce = IAny2EVMOffRamp(i_prevOffRamp).getSenderNonce(message.sender);
        if (prevNonce + 1 != message.nonce) {
          // the starting v2 onramp nonce, i.e. the 1st message nonce v2 offramp is expected to receive,
          // is guaranteed to equal (largest v1 onramp nonce + 1).
          // if this message's nonce isn't (v1 offramp nonce + 1), then v1 offramp nonce != largest v1 onramp nonce,
          // it tells us there are still messages inflight for v1 offramp
          emit SkippedSenderWithPreviousRampMessageInflight(message.nonce, message.sender);
          continue;
        }
        // Otherwise this nonce is indeed the "transitional nonce", that is
        // all messages sent to v1 ramp have been executed by the DON and the sequence can resume in V2.
        // Note if first time user in V2, then prevNonce will be 0,
        // and message.nonce = 1, so this will be a no-op. If in strict mode and nonce isn't bumped due to failure,
        // then we'll call the old offramp again until it succeeds.
        s_senderNonce[message.sender] = prevNonce;
      }

      // UNTOUCHED messages MUST be executed in order always
      if (originalState == Internal.MessageExecutionState.UNTOUCHED) {
        if (prevNonce + 1 != message.nonce) {
          // We skip the message if the nonce is incorrect
          emit SkippedIncorrectNonce(message.nonce, message.sender);
          continue;
        }
      }

      bytes[] memory offchainTokenData = report.offchainTokenData[i];
      _isWellFormed(message, offchainTokenData.length);

      _setExecutionState(message.sequenceNumber, Internal.MessageExecutionState.IN_PROGRESS);
      (Internal.MessageExecutionState newState, bytes memory returnData) = _trialExecute(message, offchainTokenData);
      _setExecutionState(message.sequenceNumber, newState);

      // The only valid prior states are UNTOUCHED and FAILURE (checked above)
      // The only valid post states are FAILURE and SUCCESS (checked below)
      if (newState != Internal.MessageExecutionState.FAILURE && newState != Internal.MessageExecutionState.SUCCESS)
        revert InvalidNewState(message.sequenceNumber, newState);

      // Nonce changes per state transition strict
      // UNTOUCHED -> FAILURE  no nonce bump
      // UNTOUCHED -> SUCCESS: nonce bump
      // FAILURE   -> FAILURE: no nonce bump
      // FAILURE   -> SUCCESS: nonce bump
      if (message.strict) {
        if (newState == Internal.MessageExecutionState.SUCCESS) {
          s_senderNonce[message.sender]++;
        }
        // Nonce changes per state transition non-strict
        // UNTOUCHED -> FAILURE  nonce bump
        // UNTOUCHED -> SUCCESS  nonce bump
        // FAILURE   -> FAILURE  no nonce bump
        // FAILURE   -> SUCCESS  no nonce bump
      } else if (originalState == Internal.MessageExecutionState.UNTOUCHED) {
        s_senderNonce[message.sender]++;
      }

      emit ExecutionStateChanged(message.sequenceNumber, message.messageId, newState, returnData);
    }
}
  
  
function _trialExecute(
    Internal.EVM2EVMMessage memory message,
    bytes[] memory offchainTokenData
  ) internal returns (Internal.MessageExecutionState, bytes memory) {
    try this.executeSingleMessage(message, offchainTokenData) {} catch (bytes memory err) {
      if (ReceiverError.selector == bytes4(err) || TokenHandlingError.selector == bytes4(err)) {
        // If CCIP receiver execution is not successful, bubble up receiver revert data,
        // prepended by the 4 bytes of ReceiverError.selector
        // Max length of revert data is Router.MAX_RET_BYTES, max length of err is 4 + Router.MAX_RET_BYTES
        return (Internal.MessageExecutionState.FAILURE, err);
      } else {
        // If revert is not caused by CCIP receiver, it is unexpected, bubble up the revert.
        revert ExecutionError(err);
      }
    }
    // If message execution succeeded, no CCIP receiver return data is expected, return with empty bytes.
    return (Internal.MessageExecutionState.SUCCESS, "");
}
  
  
function executeSingleMessage(Internal.EVM2EVMMessage memory message, bytes[] memory offchainTokenData) external {
    if (msg.sender != address(this)) revert CanOnlySelfCall();
    Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](0);
    if (message.tokenAmounts.length > 0) {
      destTokenAmounts = _releaseOrMintTokens(
        message.tokenAmounts,
        abi.encode(message.sender),
        message.receiver,
        offchainTokenData
      );
    }
    if (
      !message.receiver.isContract() || !message.receiver.supportsInterface(type(IAny2EVMMessageReceiver).interfaceId)
    ) return;

    (bool success, bytes memory returnData) = IRouter(s_dynamicConfig.router).routeMessage(
      Internal._toAny2EVMMessage(message, destTokenAmounts),
      GAS_FOR_CALL_EXACT_CHECK,
      message.gasLimit,
      message.receiver
    );
    // If CCIP receiver execution is not successful, revert the call including token transfers
    if (!success) revert ReceiverError(returnData);
}

Off-chain components

1) Committing DON

The Committing DON has several jobs where each job monitors cross-chain transactions between a given source blockchain and destination blockchain:

  • Each job monitors events from a given OnRamp contract on the source blockchain.

  • The job waits for finality, which depends on the source blockchain.

  • The job bundles transactions and creates a Merkle root. This Merkle root is signed by a quorum of oracles nodes part of the Committing DON.

  • Finally, the job writes the Merkle root to the CommitStore contract on the given destination blockchain.

2) Executing DON

Like the Committing DON, the Executing DON has several jobs where each executes cross-chain transactions between a source blockchain and a destination blockchain:

  • Each job monitors events from a given OnRamp contract on the source blockchain.

  • The job checks whether the transaction is part of the relayed Merkle root in the CommitStore contract.

  • The job waits for the Risk Management Network to bless the message.

  • Finally, the job creates a valid Merkle proof, which is verified by the OffRamp contract against the Merkle root in the CommitStore contract. After these check pass, the job calls the OffRamp contract to complete the CCIP transactions on the destination blockchain.

Separating commitment and execution permits the Risk Management Network to have enough time to check the commitment of messages before executing them. The delay between commitment and execution also permits additional checks such as abnormal reorg depth, potential simulation, and slashing.

Saving a commitment is compact and has a fixed gas cost, whereas executing user callbacks can be highly gas intensive. Separating commitment and execution permits execution by end users in various cases, such as retrying failed executions.

3) Risk Management Network

The Risk Management Network is a set of independent nodes that monitor the Merkle roots committed by the Committing DON into the Commit Store.

Each node compares the committed Merkle roots with the transactions received by the OnRamp contract. After the verification succeeds, it calls the Risk Management Network contract to "bless" the committed Merkle root. When there are enough blessing votes, the root becomes available for execution. In case of anomalies, each Risk Management Network node calls the Risk Management Network contract to "curse" the system. If the cursed quorum is reached, the Risk Management Network contract is paused to prevent any CCIP transaction from being executed.

Risk Management Network is described in the Risk Management Network chapter.

Processing the CCIP Message

We will now see what the workflow of a CCIP Message looks like in a couple of steps.

Step 1: Prepare

  1. The Sender prepares a CCIP Message (EVM2AnyMessage) for their cross-chain transaction to a destination blockchain (chainSelector) of choice. A CCIP message includes the following information:

    • Receiver

    • Data payload

    • Tokens and amounts

    • Fee token

    • Additional parameters (gasLimit, strict)

  2. The Sender calls Router.getFee() to receive the total fees (gas + premium) to pay CCIP and approves the requested fee amount.

  3. The Sender calls Router.ccipSend(), providing the CCIP Message they want to send along with their desired destination chainSelector. In the case of token transfers, the amount to be transferred must be approved to the Router.

Step 2: Send

  1. The Router validates the received Message (e.g., valid and supported destination chainId and supported tokens at the destination chain).

  2. The Router receives and transfers fees to the OnRamp.

  3. The Router receives and transfers tokens to its corresponding Token Pool. If the sender has not approved the tokens to the Router, this will fail.

  4. The Router forwards the Message to the correct OnRamp (based on destination chainSelector) for processing:

    • Validate the message (number of tokens, gasLimit, data length …)

    • [For token transfers] Ensure that the transferred value does not hit the aggregate rate limit of the lane.

    • Sequence the message with a sequence number.

    • For each Token included in the Message: instruct the token pool to lock/burn the tokens. This will also validate the token pool rate limit for this lane.

  5. The OnRamp emits an event containing the sequenced message. This triggers the DONs to process the message.

  6. A messageId is generated and returned to the Sender.

Step 3: Committing DON

  1. Nodes in the Committing DON listen for events of Messages that are ready to be sent

  2. Messages must be finalized to be considered secure against reorg attacks.

  3. Triggered by time or number of messages queued in the OnRamp, the Committing DON creates a Report with a commitment of all messages ready to be sent, in a batch. This commitment takes the form of a Merkle Root

  4. Upon consensus in the Committing DON, the Report containing the Merkle Root is transmitted to the CommitStore contract on the destination chain

  5. The Risk Management Network “blesses” the Merkle Root in the CommitStore, to make sure it is a correct representation of the queued messages at the OnRamp.

Merkle Root & Merkle Proof

  • Merkle Root: the root hash of the Merkle Tree. It is a commitment to all the leaves (Messages M1-M4) in the tree. Each node in the tree is the hash of the nodes below it.

  • Merkle Proof: to prove that Message M1 is included in the Merkle Root (commitment), a Prover provides the following elements as proof to a Verifier (who only has the root hash):

    • M1

    • H(M2)

    • H(H(M3),H(M4))

    Using this Merkle Proof, a Verifier can easily verify that, indeed, M1 is included in the commitment (root hash) that it possesses.

Step 4: Executing DON

  1. Nodes in the Executing DON listen for events of Messages that are ready to be sent, similar to the Committing DON

  2. Messages must be finalized to be considered secure against reorg attacks.

  3. In addition to the time or number of messages queued in the OnRamp, the Executing DON also monitors the CommitStore to make sure the messages are ready to be executed at the destination chain, i.e., are the messages included in a blessed on-chain commitment?

  4. If conditions are met, the Executing DON creates a Report with all messages ready to be sent, in a batch. It accounts for the gasLimit of each message in its batching logic. It also calculates a relevant Merkle Proof for each message to prove that the message is included in the Merkle Root submitted by the Committing DON in the CommitStore. Note that Executing DON batches can be any subset of a Committing DON batch.

  5. Upon consensus, the Report is transmitted to the OffRamp contract on the destination chain

Step 5: Execute

  1. For each message in the batch received, the OffRamp verifies using the provided Merkle Proof whether the transaction is included in the blessed commitment in the CommitStore.

  2. If tokens are included in the transaction, the OffRamp validates the aggregate rate limit of the lane and identifies the matching Token Pool(s).

  3. OffRamp calls the TokenPool’s unlock/mint function. This will validate the token pool rate limit, unlock or mint the token and transfer them to the specified receiver.

  4. If the receiver is not an EOA and has the correct interface implemented, the OffRamp uses the Router to call the receiver’s ccipReceive() function

  5. The receiver processes the message and is informed where the message comes from (blockchain + sender), the tokens transferred, and the data payload (with relevant instructions).

  6. Based on the data payload, the receiver might transfer the tokens to the final recipient (end-user)

Token handling mechanisms

To transfer tokens using CCIP, token pools on both blockchains must exist. That's why, if you remember the first chapter of this Masterclass, we said that you can transfer only supported tokens, not all of them.

Technically, tokens are not transferred. Instead, they are locked or burned on the source chain and then unlocked or minted on the destination chain

Token handling mechanisms are a key aspect of how token transfers work. They each have different characteristics with trade-offs for issuers, holders, and DeFi applications.

Burn & Mint

Tokens are burned on the source chain and minted natively on the destination chain

  • Use cases:

    • Tokens that are natively minted on multiple blockchains and have a variable total supply.

    • Examples: stablecoins, synthetic/derivative tokens, wrapped tokens (from Lock & Mint)

Lock & Mint (Reverse: Burn & Unlock)

Tokens are locked on the source chain (in Token Pools), and wrapped/synthetic/derivative tokens that represent the locked tokens are minted on the destination chain.

  • Use cases:

    • Tokens minted on a single chain (e.g., LINK)

    • Tokens with encoded constraints (supply/burn/mint)

    • Secure minting function with Proof-of-Reserve

Lock & Unlock [ON THE ROADMAP]

Transferred tokens are locked on the source chain (in Token Pools) and unlocked from Token Pools on the destination chain. This feature is not live yet.

  • Use cases:

    • Canonical/dominant wrapped tokens (eg: WETH, LINK …)

Rate Limits

The main objective of Rate Limits is to manage risk by limiting the value flowing through CCIP. A rate limit always applies to a lane.

Each available lane has two rate limits:

  • Network rate limit: Each network has a limit on the total USD value that can be transferred from one network across all available lanes. CCIP uses Chainlink Data Feeds to calculate the total USD value of tokens transferred on one network.

  • Token rate limits: Each individual lane on a network might have a limit on the total number of tokens that it can transfer. This limit is independent of the USD value of the tokens.

Both limits have the following concepts:

  • Bucket: holds the value that can be transferred at any given moment.

  • Capacity: the capacity of the bucket represents the maximum value that can be transferred at once via a single transaction.

  • Refill Rate: the rate at which the bucket is refilled, denominated per second.

  • Availability: the current amount of value in the bucket to be transferred.

Token Bucket Rate Limiting code
function _consume(TokenBucket storage s_bucket, uint256 requestTokens, address tokenAddress) internal {
    // If there is no value to remove or rate limiting is turned off, skip this step to reduce gas usage
    if (!s_bucket.isEnabled || requestTokens == 0) {
      return;
    }

    uint256 tokens = s_bucket.tokens;
    uint256 capacity = s_bucket.capacity;
    uint256 timeDiff = block.timestamp - s_bucket.lastUpdated;

    if (timeDiff != 0) {
      if (tokens > capacity) revert BucketOverfilled();

      // Refill tokens when arriving at a new block time
      tokens = _calculateRefill(capacity, tokens, timeDiff, s_bucket.rate);

      s_bucket.lastUpdated = uint32(block.timestamp);
    }

    if (capacity < requestTokens) {
      // Token address 0 indicates consuming aggregate value rate limit capacity.
      if (tokenAddress == address(0)) revert AggregateValueMaxCapacityExceeded(capacity, requestTokens);
      revert TokenMaxCapacityExceeded(capacity, requestTokens, tokenAddress);
    }
    if (tokens < requestTokens) {
      uint256 rate = s_bucket.rate;
      // Wait required until the bucket is refilled enough to accept this value, round up to next higher second
      // Consume is not guaranteed to succeed after wait time passes if there is competing traffic.
      // This acts as a lower bound of wait time.
      uint256 minWaitInSeconds = ((requestTokens - tokens) + (rate - 1)) / rate;

      if (tokenAddress == address(0)) revert AggregateValueRateLimitReached(minWaitInSeconds, tokens);
      revert TokenRateLimitReached(minWaitInSeconds, tokens, tokenAddress);
    }
    tokens -= requestTokens;

    // Downcast is safe here, as tokens is not larger than capacity
    s_bucket.tokens = uint128(tokens);
    emit TokensConsumed(requestTokens);
}


function _currentTokenBucketState(TokenBucket memory bucket) internal view returns (TokenBucket memory) {
    // We update the bucket to reflect the status at the exact time of the
    // call. This means we might need to refill a part of the bucket based
    // on the time that has passed since the last update.
    bucket.tokens = uint128(
      _calculateRefill(bucket.capacity, bucket.tokens, block.timestamp - bucket.lastUpdated, bucket.rate)
    );
    bucket.lastUpdated = uint32(block.timestamp);
    return bucket;
}


function _calculateRefill(
    uint256 capacity,
    uint256 tokens,
    uint256 timeDiff,
    uint256 rate
  ) private pure returns (uint256) {
    return _min(capacity, tokens + timeDiff * rate);
  }

Let's see how these Rate Limits work in an example:

  • Capacity = $100,000

  • Refill rate = $100 per second

  • Simulation

    • t=0s: the bucket is empty

    • t=300s: availability of $30,000

    • t=300s: user tries to send $40,000 → has to wait until t=400s when bucket has $40,000 available

    • t=400s: user submits $40,000 transfer again → transfer is executed, availability is $0

    • t=500s: availability of $10,000

    • t=1400s: bucket has maxed at $100,000 capacity

You can check these limits for each lane on the Official Chainlink Documentation. Here are the parameters for the real-world example, Ethereum Sepolia => Optimism Goerli lane:

If a rate limit is hit, the following errors can be returned. The user can handle these gracefully in their front end so end-users are optimally informed to decide their next step. In the case of multiple tokens in a single token transfer, the error is returned for the first hit. That is why tokenAddress is included in the error as a parameter.

  • TokenMaxCapacityExceeded(uint256 capacity, uint256 requested, address tokenAddress); - User requests to transfer more of a token than the capacity of the bucket.

  • TokenRateLimitReached(uint256 minWaitInSeconds, uint256 available, address tokenAddress); - User requests to transfer more of a token than is currently available in the bucket. The User might have to wait at least minWaitInSeconds for enough availability or transfer the currently available amount.

  • AggregateValueMaxCapacityExceeded(uint256 capacity, uint256 requested); - User requests to transfer more value than the capacity of the aggregate rate limit bucket.

  • AggregateValueRateLimitReached(uint256 minWaitInSeconds, uint256 available); - User requests to transfer more value than currently available in the bucket. The User might have to wait at least minWaitInSeconds for enough availability or transfer the currently available amount.

Risk Management Network

The Risk Management Network is built using off-chain and on-chain components:

  • Off-chain: Several Risk Management Network nodes continually monitor all supported chains against abnormal activities

  • On-chain: One Risk Management Network contract per supported CCIP chain

Off-chain (Risk Management Network nodes)

The Risk Management Network is a secondary validation service parallel to the primary CCIP system. It doesn't run the same codebase as the DON to mitigate against security vulnerabilities that might affect the DON's codebase. The Risk Management Network has two main modes of operation:

  • Blessing: Each Risk Management Network node monitors all Merkle roots of messages committed on each destination chain. The Committing DON commits these Merkle roots. The Risk Management Network node independently reconstructs the Merkle tree by fetching all messages on the source chain. Then, it checks for a match between the Merkle root committed by the Committing DON and the root of the reconstructed Merkle tree. If both Merkle roots match, the Risk Management Network node blesses the root to the Risk Management Network contract on the destination chain. The Risk Management Network contract tracks the votes. When a quorum is met, the Risk Management Network contract dubs the Merkle root blessed.

  • Cursing: If a Risk Management Network node detects an anomaly, the Risk Management Network node will curse the CCIP system. After a quorum of votes has been met, the Risk Management Network contract dubs the CCIP system cursed. CCIP will automatically pause on that chain and wait until the contract owner assesses the situation before potentially lifting the curse. There are two cases where Risk Management Network nodes pause CCIP:

    • Finality violation: A deep reorganization that violates the safety parameters set by the Risk Management Network configuration occurs on a CCIP chain.

    • Execution safety violation: A message is executed on the destination chain without any matching transaction being on the source chain. Double executions fall into this category since the executing DON can only execute a message once.

On-chain (Risk Management Network contract)

There is one Risk Management Network contract for each supported destination chain. The Risk Management Network contract maintains a group of nodes authorized to participate in the Risk Management Network blessing/cursing. Each Risk Management Network node has five components:

  • an address for voting to curse

  • an address for voting to bless

  • an address for withdrawing a vote to curse

  • a curse weight

  • a blessing weight

Risk Management Network Node representation
struct Voter {
    // This is the address the voter should use to call voteToBless.
    address blessVoteAddr;
    // This is the address the voter should use to call voteToCurse.
    address curseVoteAddr;
    // This is the address the voter should use to call unvoteToCurse.
    address curseUnvoteAddr;
    // The weight of this voter's vote for blessing.
    uint8 blessWeight;
    // The weight of this voter's vote for cursing.
    uint8 curseWeight;
 }

The contract also maintains two thresholds to determine the quorum for blessing and cursing. There are two different voting logics depending on the mode:

  • Blessing voting procedure: every time a Risk Management Network node blesses a Merkle root, the Risk Management Networkcontract adds the blessing weight for that node. If the sum exceeds the blessing threshold, the Risk Management Network contract considers the contract blessed.

  • Cursing voting procedure: a Risk Management Network node that sends a vote to curse assigns the vote a random 32-byte ID. The node may have multiple active votes to curse at any time. However, if there is at least one active cursing vote, the Risk Management Network contract considers the node to have voted to curse. The Risk Management Network contract adds the cursing weight for that node. If the sum of the weights of votes to curse exceeds the curse threshold, the Risk Management Network contract considers the contract cursed.

Blessing code
struct Config {
    Voter[] voters;
    uint16 blessWeightThreshold;
    uint16 curseWeightThreshold;
}

struct BlesserRecord {
    uint32 configVersion;
    uint8 weight;
    uint8 index;
}

struct BlessVoteProgress {
    uint32 configVersion;
    uint16 accumulatedWeight;
    uint128 voterBitmap;
    bool weightThresholdMet;
}

function voteToBless(IARM.TaggedRoot[] calldata taggedRoots) external {
    // If we have an active curse, something is really wrong. Let's err on the
    // side of caution and not accept further blessings during this time of
    // uncertainty.
    if (isCursed()) revert MustRecoverFromCurse();

    uint32 configVersion = s_versionedConfig.configVersion;
    BlesserRecord memory blesserRecord = s_blesserRecords[msg.sender];
    if (blesserRecord.configVersion != configVersion) revert InvalidVoter(msg.sender);

    for (uint256 i = 0; i < taggedRoots.length; ++i) {
      IARM.TaggedRoot memory taggedRoot = taggedRoots[i];
      bytes32 taggedRootHash = _taggedRootHash(taggedRoot);
      BlessVoteProgress memory voteProgress = s_blessVoteProgressByTaggedRootHash[taggedRootHash];
      if (voteProgress.weightThresholdMet) {
        // We don't revert here because it's unreasonable to expect from the
        // voter to know exactly when to stop voting. Most likely when they
        // voted they didn't realize the threshold would be reached by the time
        // their vote was counted.
        // Additionally, there might be other tagged roots for which votes might
        // count, and we want to allow that to happen.
        emit AlreadyBlessed(configVersion, msg.sender, taggedRoot);
        continue;
      }
      if (voteProgress.configVersion != configVersion) {
        // Note that voteProgress.weightThresholdMet must be false at this point

        // If votes were received while an older config was in effect,
        // invalidate them and start from scratch.
        // If votes were never received, set the current config version.
        voteProgress = BlessVoteProgress({
          configVersion: configVersion,
          voterBitmap: 0,
          accumulatedWeight: 0,
          weightThresholdMet: false
        });
      }
      if (_bitmapGet(voteProgress.voterBitmap, blesserRecord.index)) {
        // We don't revert here because there might be other tagged roots for
        // which votes might count, and we want to allow that to happen.
        emit AlreadyVotedToBless(configVersion, msg.sender, taggedRoot);
        continue;
      }
      voteProgress.voterBitmap = _bitmapSet(voteProgress.voterBitmap, blesserRecord.index);
      voteProgress.accumulatedWeight += blesserRecord.weight;
      emit VotedToBless(configVersion, msg.sender, taggedRoot, blesserRecord.weight);
      if (voteProgress.accumulatedWeight >= s_versionedConfig.config.blessWeightThreshold) {
        voteProgress.weightThresholdMet = true;
        emit TaggedRootBlessed(configVersion, taggedRoot, voteProgress.accumulatedWeight);
      }
      s_blessVoteProgressByTaggedRootHash[taggedRootHash] = voteProgress;
    }
}

If the Risk Management Network contract is cursed, then the owner of the original contract must resolve any underlying issues the original contract might have. If the owner is satisfied that these issues have been resolved, they can revoke the cursing on behalf of Risk Management Network nodes.

Cursing code
struct Config {
    Voter[] voters;
    uint16 blessWeightThreshold;
    uint16 curseWeightThreshold;
}

struct CurserRecord {
    bool active;
    uint8 weight;
    uint32 voteCount;
    address curseUnvoteAddr;
    bytes32 cursesHash;
}

struct CurseVoteProgress {
    uint16 curseWeightThreshold;
    uint16 accumulatedWeight;
    bool curseActive;
}


function voteToCurse(bytes32 curseId) external {
    CurserRecord memory curserRecord = s_curserRecords[msg.sender];
    if (!curserRecord.active) revert InvalidVoter(msg.sender);
    if (s_curseVotes[msg.sender][curseId]) revert AlreadyVotedToCurse(msg.sender, curseId);
    s_curseVotes[msg.sender][curseId] = true;
    ++curserRecord.voteCount;
    curserRecord.cursesHash = keccak256(abi.encode(curserRecord.cursesHash, curseId));
    s_curserRecords[msg.sender] = curserRecord;

    CurseVoteProgress memory curseVoteProgress = s_curseVoteProgress;

    if (curserRecord.voteCount == 1) {
      curseVoteProgress.accumulatedWeight += curserRecord.weight;
    }

    // NOTE: We could pack configVersion into CurserRecord that we already load in the beginning of this function to
    // avoid the following extra storage read for it, but since voteToCurse is not on the hot path we'd rather keep
    // things simple.
    uint32 configVersion = s_versionedConfig.configVersion;
    emit VotedToCurse(
      configVersion,
      msg.sender,
      curserRecord.weight,
      curserRecord.voteCount,
      curseId,
      curserRecord.cursesHash,
      curseVoteProgress.accumulatedWeight
    );
    if (
      !curseVoteProgress.curseActive && curseVoteProgress.accumulatedWeight >= curseVoteProgress.curseWeightThreshold
    ) {
      curseVoteProgress.curseActive = true;
      emit Cursed(configVersion, block.timestamp);
    }
    s_curseVoteProgress = curseVoteProgress;
  }

  /// @notice Enables the owner to immediately have the system enter the cursed state.
  function ownerCurse() external onlyOwner {
    emit OwnerCursed(block.timestamp);
    if (!s_curseVoteProgress.curseActive) {
      s_curseVoteProgress.curseActive = true;
      emit Cursed(s_versionedConfig.configVersion, block.timestamp);
    }
}

If you made it this far, congratulations! You were amazing!

It was an intense ride, but this is the end of our CCIP Masterclass. If you are still curious about what you can build with CCIP, there is a Going Beyond Masterclass section.

Last updated