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.
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:
- Looks up the correct OnRamp for the Ethereum → Arbitrum lane
- Calls
isCursed()on the RMNRemote contract. If Ethereum or Arbitrum is currently cursed, this reverts immediately before anything else happens - Validates that the destination chain selector exists in its routing table
- Collects the fee from the sender
Step 2: Router forwards to OnRamp
The Router passes the message to the OnRamp for the Ethereum → Arbitrum lane. The OnRamp:
- Validates message parameters: token count (max 10), gas limit, data payload length
- Calls
isCursed()on RMNRemote again (separate check at the OnRamp level) - Checks the sender allowlist if one is configured for this lane
- Calls the FeeQuoter to finalize the fee calculation
- For the 1,000 USDC: looks up the USDC Token Pool address from the Token Admin Registry and calls
TokenPool.lockOrBurn(). Since USDC on Ethereum uses Lock-and-Mint for the Arbitrum lane, the USDC is locked in the Token Pool vault. The Token Pool checks the outbound rate limit bucket before allowing the lock. - Assigns a sequence number to this message (the last used sequence number incremented by 1)
- Generates a unique
messageId(a bytes32 hash of the message content and metadata)
Step 3: OnRamp emits CCIPMessageSent
The OnRamp emits CCIPMessageSent containing:
-
messageId: the unique identifier for this specific message -
sequenceNumber: this message's position in the lane's ordered sequence -
sourceChainSelector: Ethereum -
destChainSelector: Arbitrum -
sender: the address that calledccipSend() -
receiver: the Arbitrum contract address - The full encoded message (data + token amounts + gas limit)
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 message's sequence number
- The full message contents (to compute the Merkle tree)
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:
- The Merkle root of the batch of messages
- The sequence number range covered
- RMN blessing signatures (where required)
- Price updates for fee tokens (LINK/ETH/USDC price data for the destination FeeQuoter)
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:
- Validates the quorum of OCR signatures on the report (this is the on-chain trust verification: the OffRamp doesn't trust whoever submitted the transaction, it trusts that enough independent signers agreed on this exact payload)
- Calls
isCursed()on Arbitrum's RMNRemote contract - Stores the Merkle root in contract storage, keyed by the source chain selector and sequence number range
- Processes any price updates included in the report (updates the FeeQuoter on Arbitrum with current token prices)
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:
- Validates the Merkle proof for our specific message against the stored Merkle root. If the proof doesn't match, execution reverts
- Calls
isCursed()on Arbitrum's RMNRemote - Checks that this exact message ID hasn't been executed before (replay protection)
For the 1,000 USDC:
- Looks up the USDC Token Pool address on Arbitrum from the Token Admin Registry
- Calls
TokenPool.releaseOrMint()on the Arbitrum USDC pool - The Token Pool checks the inbound rate limit bucket before allowing the mint
- Since this is Lock-and-Mint for this lane: the Arbitrum USDC pool mints 1,000 USDC to the receiver address
For the arbitrary message data:
- The OffRamp calls the Router to deliver the CCIP message to the receiver
- The Router calls
receiver.ccipReceive(message)with the fullClient.Any2EVMMessagestruct containing the message ID, source chain, sender address, data payload, and token amounts
Step 9: OffRamp emits ExecutionStateChanged
The final event. Status is either Success or Failure.
-
Success:ccipReceive()executed without reverting, 1,000 USDC arrived, message data was delivered -
Failure:ccipReceive()reverted (insufficient gas limit, logic error in receiver, receiver not implementing the interface correctly)
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:
- Did
CCIPMessageSentemit on the source chain? If not, the transaction reverted before the OnRamp. Check the sender's fee approval and token allowance. - Did
CommitReportAcceptedemit 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. - Did
ExecutionStateChangedemit? 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 whetherpermissionLessExecutionThresholdSecondshas passed and consider manual execution. - Did
ExecutionStateChangedemit withFailure? Check the receiver contract'sccipReceiveimplementation 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.




Top comments (0)