CCIP End-to-End: Tracing One Cross-Chain Message From the First Function Call to the Final Receipt

Why this article exists

Days 12 through 20 of this series covered CCIP in pieces: the onchain components, the offchain Role DON and OCR plugins, the token transfer mechanisms, the CCT standard, and rate limiting with institutional deployments. Each article explained one layer. None of them traced a single message all the way through from start to finish.

This is the article that does that.

One concrete example: a sender on Ethereum wants to send 1,000 USDC and a message payload to a receiver contract on Arbitrum. We'll trace every step from the sender calling Router.ccipSend() to the receiver's ccipReceive() being called on Arbitrum. Every contract that touches the message is named. Every event emitted is named. Every offchain component is identified at the moment it acts.

This is day 21 of the 28-day Chainlink architecture series. If you've read days 12 through 20, you already know each component. Today you see how they fit together.

chainlink on-chain architechture

The setup

Source chain: Ethereum mainnet
Destination chain: Arbitrum One
Payload: 1,000 USDC + arbitrary bytes message data
Sender: a smart contract that has already computed the message and approved USDC spending
Receiver: a contract on Arbitrum that implements ccipReceive()

Before the sender calls ccipSend(), one prerequisite: the fee. The sender calls Router.getFee(destinationChainSelector, message). Internally, the Router delegates this to the OnRamp, which calls the FeeQuoter. The FeeQuoter prices the fee based on destination gas limit, message size, token count, current gas prices on the destination chain, and the current LINK/ETH exchange rate. It returns a fee in the sender's chosen fee token (either LINK or ETH). The sender must have approved the Router to spend this fee amount before calling ccipSend().

Phase 1: Source chain — from sender to CCIPMessageSent

Step 1: Sender calls Router.ccipSend(destinationChainSelector, message)

The Router is the only stable address in the system. It's the single immutable user-facing entry point on Ethereum. The Router:

Step 2: Router forwards to OnRamp

The Router passes the message to the OnRamp for the Ethereum → Arbitrum lane. The OnRamp:

Step 3: OnRamp emits CCIPMessageSent

The OnRamp emits CCIPMessageSent containing:

This event is now permanently on-chain on Ethereum. The transaction is complete from the sender's perspective. The sender's USDC is locked. The fee is paid. The message is recorded. The sender cannot cancel or modify what happens next.

Phase 2: Offchain — Commit DON builds and submits the Merkle root

Step 4: Commit OCR plugin detects the event

The Role DON's nodes running the Commit OCR plugin are continuously monitoring Ethereum's OnRamp for CCIPMessageSent events. Each node independently observes the event and records:

The Commit plugin uses OCR3's three-round structure:

Round 1 — Observation: Each node calls LatestMsgSeqNum() on the source OnRamp and GetExpectedNextSequenceNumber() on the destination OffRamp to determine the range of new messages to include in the next commit report. Our message's sequence number falls in this range.

Round 2 — Merkle construction: The merkleroot.Processor reads the full message data for all messages in the batch (our message may be batched with other messages on the same lane). It computes a Merkle tree over the batch and produces a Merkle root. Because this is an EVM-to-EVM lane that requires RMN signatures (the blessed chain configuration), the rmn.Controller requests RMN blessing signatures from the Risk Management Network nodes for this Merkle root. The RMN nodes independently verify the Merkle root matches the source chain events, then sign.

Round 3 — OCR consensus and transmission: The Commit DON reaches OCR3 consensus on the Commit Report, which contains:

One node, selected by the randomized OCR3 transmission schedule, submits this Commit Report to the destination chain.

How long does this take? The Commit DON waits for the configured number of source-chain block confirmations before committing. For Ethereum mainnet as the source chain, this is typically 64 block confirmations, roughly 13 minutes, to protect against chain reorganizations. The message has been on Ethereum for those 13 minutes before the Commit DON acts.

Phase 3: Destination chain — Commit Report accepted

Step 5: OffRamp processes the Commit Report

On Arbitrum, the OffRamp receives the Commit Report from the Commit DON transmitter. The OffRamp:

Step 6: OffRamp emits CommitReportAccepted

This event confirms that a valid commit has been accepted. At this point, the message's existence on Ethereum has been attested to by a quorum of the Role DON and recorded on Arbitrum. The tokens are still locked on Ethereum. The receiver hasn't been called yet.

Phase 4: Offchain — Execute DON prepares execution

Step 7: Execute OCR plugin detects CommitReportAccepted

The Role DON's nodes running the Execute OCR plugin monitor the Arbitrum OffRamp for CommitReportAccepted events. When they detect our commit report, they identify pending messages in that commit:

Round 1: execute.Plugin calls CommitReportsGTETimestamp() on the OffRamp to find unexecuted commit reports.

Round 2: The Execute DON reads the full message data from the source Ethereum OnRamp via MsgsBetweenSeqNums(), independently verifying the message contents. It computes a Merkle proof for our specific message against the committed Merkle root. It verifies this proof matches. It checks that our message hasn't already been executed (preventing double execution). It calculates whether the gas limit we specified is sufficient for execution.

Round 3: The Execute DON reaches OCR3 consensus on an Execute Report containing the message and its Merkle proof, then one node transmits it to the Arbitrum OffRamp.

Note: the Execute OCR plugin runs without signature verification (unlike the Commit plugin). The security comes from the Merkle proof verification on-chain, not from OCR signature verification at the execution stage.

Phase 5: Destination chain — execution

Step 8: OffRamp executes the message

The Arbitrum OffRamp receives the Execute Report. It:

For the 1,000 USDC:

For the arbitrary message data:

Step 9: OffRamp emits ExecutionStateChanged

The final event. Status is either Success or Failure.

If Failure: the message is not lost. The permissionLessExecutionThresholdSeconds is a configured waiting period after which anyone can re-execute the failed message without DON involvement. Manual execution provides the Merkle proof directly to the OffRamp's manuallyExecute() function with a higher gas limit.

The complete timeline

Step Chain What happens Time from Step 1
1-3 Ethereum Sender calls ccipSend, OnRamp locks USDC, CCIPMessageSent emitted ~30 seconds (1-2 blocks)
4 Offchain Commit DON waits for 64 block confirmations ~13 minutes
5 Offchain Commit DON builds Merkle root, reaches OCR consensus ~1-2 minutes
6 Arbitrum OffRamp records Commit Report, CommitReportAccepted emitted ~30 seconds
7 Offchain Execute DON reads message, computes proof, reaches consensus ~1-2 minutes
8-9 Arbitrum OffRamp executes, USDC minted, ccipReceive called, ExecutionStateChanged emitted ~30 seconds
Total ~17-20 minutes for Ethereum → Arbitrum

This timeline is dominated by the 64-block finality requirement on Ethereum. L2-to-L2 messages (Arbitrum to Base, for example) are significantly faster because L2 finality is measured in seconds rather than minutes.

What to trace when something goes wrong

If you're debugging a CCIP message that didn't arrive, the trace gives you the exact places to check in order:

  1. Did CCIPMessageSent emit on the source chain? If not, the transaction reverted before the OnRamp. Check the sender's fee approval and token allowance.
  2. Did CommitReportAccepted emit on the destination chain? If not, the Commit DON hasn't processed the message yet. Check the source chain block confirmations — it may still be within the finality window.
  3. Did ExecutionStateChanged emit? If not, the Execute DON hasn't transmitted yet. Check the time elapsed since commit — if it has been more than a few minutes, check whether permissionLessExecutionThresholdSeconds has passed and consider manual execution.
  4. Did ExecutionStateChanged emit with Failure? Check the receiver contract's ccipReceive implementation and verify the gas limit set at send time is sufficient.

I'm a smart contract security researcher writing through Chainlink's full architecture for 28 days. Follow along at ramprasadgoud.dev or on X @0xramprasad.