Why cross-chain bridges keep getting drained
Between 2021 and 2023, cross-chain bridges lost over $2.5 billion to exploits. Not because the blockchain technology was wrong. Because the trust models were. Most bridge architectures concentrate trust in one or two contracts that, if compromised, hand an attacker complete control over both the source and destination sides of every transfer in flight.
The Ronin Bridge ($625M, March 2022) was drained when an attacker compromised 5 of 9 validator private keys, signed fraudulent withdrawal transactions, and extracted funds before anyone noticed. The Wormhole exploit ($320M, February 2022) exploited a signature verification bug that let an attacker fake guardian approvals. In both cases, the single layer of trust wasn't enough.
This is day 12 of the 28-day Chainlink architecture series and the start of Week 3, the CCIP deep dive. Today covers the onchain architecture specifically: the Router, OnRamp, OffRamp, Token Pools, Fee Quoter, Token Admin Registry, and RMN Contract. The offchain components (the Role DON, Committing and Executing OCR plugins, and the Risk Management Network) come tomorrow. Understanding the onchain layer first is the right sequence because the contracts define the security surface, and every audit starts with the contracts.
The Router: one per chain, immutable, the only stable address
The Router is the single user-facing entry point for CCIP on each blockchain. One Router contract per chain. Users and dApps call Router.getFee() to estimate costs and Router.ccipSend() to dispatch a cross-chain message or token transfer. The Router is the contract you can hardcode. Everything else in the CCIP contract set is internal, can upgrade, and should be derived from the Router rather than assumed to be at a fixed address.
This design is a deliberate architectural security decision. The Router is immutable. If Chainlink needs to upgrade the OnRamp or OffRamp to add features, fix bugs, or respond to a security finding, it can do so without changing the address that every integration, every UI, and every contract in the ecosystem has bookmarked. From the user's perspective, nothing changes. From the internal architecture's perspective, the underlying implementation can evolve.
The Router validates that the destination chain exists in its routing table before passing anything to an OnRamp. It also checks whether the destination chain is cursed (more on that below) before processing the message. These checks happen before any fees are taken or any state is modified.
The OnRamp: source-chain processing, per lane
Each lane (unidirectional path between two chains) has its own OnRamp contract on the source chain. When the Router forwards a ccipSend call, the OnRamp takes over:
Fee collection: the OnRamp calls the Fee Quoter to get the precise fee for this specific message (size, token count, destination gas limit, current gas prices, and current LINK/ETH price all factor in). The calculated fee is collected from the sender in the specified fee token.
Message validation: the OnRamp checks parameters including the number of tokens in the message (currently capped at 10 tokens per message), the gas limit for the callback on the destination chain, and the data payload length.
Curse check: the OnRamp calls isCursed() on the RMN Contract to verify the destination chain is not currently flagged as compromised or under active monitoring. If the destination is cursed, the transfer is rejected at this point.
Token handling: if the message includes tokens, the OnRamp interacts with the Token Pool for each included token, calling lockOrBurn. The specific mechanism (lock vs burn) depends on how the token pool is configured for that token.
Message dispatch: after all validations pass, the OnRamp assigns a sequence number to the message, generates a unique message ID, and emits a CCIPMessageSent event. This event is what the offchain Committing DON monitors.
One important implementation detail: the OnRamp address can change when CCIP ships product updates. Integrating contracts should never hardcode the OnRamp address. They should derive it from the Router. The Router always knows the current OnRamp for each lane.
The OffRamp: destination-chain processing, per lane
The OffRamp is the destination-chain counterpart. It's an internal contract that only the CCIP DONs can call to process incoming messages. Two distinct phases happen at the OffRamp.
Commit phase: the Committing DON calls commit() on the OffRamp with a Commit Report containing a Merkle root of a batch of messages from the source chain, along with price update data. The OffRamp stores this Merkle root. It emits a CommitReportAccepted event. The OffRamp does not execute anything at this stage. It just records that a DON has attested to this Merkle root being valid.
Execution phase: the Executing DON (or a manual executor in fallback scenarios) provides a Merkle proof for a specific message against a stored root. The OffRamp validates the proof, checks the source chain is not cursed, checks message-level rate limits, and if everything passes, executes:
- For token transfers: the OffRamp retrieves the relevant Token Pool from the Token Admin Registry and calls
releaseOrMint. Tokens are released (if lock-and-release) or minted (if burn-and-mint) to the specified receiver. - For messages with data: the OffRamp calls the Router to deliver the arbitrary bytes payload to the receiver contract's
ccipReceivefunction.
The OffRamp emits ExecutionStateChanged with a final status of either Success or Failure. If execution fails (due to insufficient gas limit in the receiver's callback or a logic error in the receiver contract), the message doesn't disappear. It remains available for permissionless manual execution after a configured time delay. This fallback path ensures message delivery is guaranteed as long as someone eventually triggers execution with sufficient gas.
Like the OnRamp, the OffRamp address can change. Always derive it from the Router.
The Lane: the unit of configuration
A Lane is the conceptual unidirectional path between two chains. Ethereum to Arbitrum is one lane. Arbitrum to Ethereum is a different lane. They're configured independently. This matters more than it sounds.
Each lane has its own settings for:
- How many source-chain block confirmations to wait before the Committing DON posts a Merkle root (Ethereum mainnet sources typically use 64 confirmations, roughly 13 minutes, for deep finality guarantees)
- Which tokens are supported
- Rate limits per token per direction
- Whether an allowlist restricts which senders can use the lane
The independence of lane configuration is what lets institutional deployments tune their risk parameters specifically. A lane connecting a bank's private chain to Ethereum for high-value settlements can be configured with deeper finality requirements, more conservative rate limits, and a sender allowlist. A lane connecting two DeFi protocols for retail-scale token flows can be configured with lighter finality and higher throughput. Neither configuration affects the other.
Token Pools and rate limits: the capacity bucket model
Each token has its own Token Pool on each chain. The OnRamp and OffRamp use the Token Admin Registry to look up which Token Pool handles a given token before calling lock/burn or release/mint. Token Pools are deployed by token developers and exist independently of the core CCIP contracts.
Rate limits in Token Pools use a capacity bucket model: the pool has a maximum capacity and a refill rate. Transfers draw down capacity from the bucket. If insufficient capacity is available, the transfer is rejected until enough has refilled. Crucially, each token pool maintains two independent limits: an outbound rate limit (from this chain to a remote chain) and an inbound rate limit (from a remote chain into this chain). Inbound and outbound limits can differ in capacity and refill rate, allowing asymmetric risk tuning depending on the direction of value flow.
Rate limits are configured per token per lane. Changing a rate limit affects only that specific token on that specific lane, not all CCIP traffic. Disabling rate limits entirely removes an important safety mechanism and should only be done deliberately.
The RMN Contract and the curse mechanism
The RMN (Risk Management Network) Contract is deployed on every CCIP-enabled chain. It serves two functions that are both security-critical.
Curse detection: the Router, OnRamp, OffRamp, and Token Pool contracts all call isCursed() on the RMN Contract before processing transactions. If the chain is marked as cursed, all CCIP operations involving that chain halt. A curse can be initiated manually by the CCIP Owner if an active threat is detected, or propagated by the Risk Management Network's offchain nodes if they detect anomalies in the Merkle roots being committed. The curse mechanism is the emergency brake, and it operates across the entire stack: the source chain's OnRamp won't dispatch, and the destination chain's OffRamp won't execute, while a curse is active.
Upgrade control: all security-critical configuration changes and infrastructure upgrades for CCIP pass through a Role-based Access Control Timelock contract. This provides a review period during which CCIP node operators can veto an upgrade, or in time-sensitive situations, explicitly approve it before the timelock expires. This prevents a compromised governance key from instantly pushing a malicious upgrade through.
Audit checklist for any CCIP integration
1. Is ccipReceive access-controlled?
The most common CCIP integration mistake: a receiver contract that doesn't verify the caller is the legitimate Router. Any address can call ccipReceive on an unprotected contract and inject arbitrary data.
function ccipReceive(Client.Any2EVMMessage memory message)
external override onlyRouter {
// onlyRouter modifier checks msg.sender == i_router
}
2. Are OnRamp and OffRamp addresses derived from the Router, not hardcoded?
Both can change with product updates. A contract that hardcodes an OnRamp or OffRamp address will silently break after an upgrade.
3. Is the gas limit set high enough for ccipReceive?
The gas limit for the destination callback is set at the source chain when ccipSend is called. If ccipReceive runs out of gas, execution fails and the message sits in a failed state until manually re-executed. Profile the receiver's gas consumption before setting this value.
4. Does ccipReceive handle failed execution gracefully?
If the receiver logic reverts, the message can be manually re-executed permissionlessly. Make ccipReceive idempotent: check whether the message has already been processed before acting on it, so re-execution doesn't double-apply effects.
5. Is the message ID stored and checked?
Track which message IDs have been processed. Even with permissionless manual execution, a receiver that processes the same message twice because it doesn't track IDs can produce unintended outcomes.
6. Are Token Pool rate limits appropriate for the volume and risk profile?
Rate limits that are too high provide no protection during an exploit. Rate limits that are too low throttle legitimate volume. For each token on each lane your protocol uses, verify the outbound and inbound limits are explicitly configured and match the expected flow, not defaulted to off.
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)