A global standard for developers to easily build secure cross-chain services and applications. CCIP is secured by Chainlink decentralized oracle networks and a newly-invented risk management system called the Active Risk Management (ARM) Network.
Platform: Code4rena
Start Date: 26/05/2023
End Date: 12/06/2023
Period: 17 days
Status: Awarded
Pot Size: $300,000 USDC
Participants: 78
Id: 247
League: ETH
rvierdiiev | 1/78 | $45,347.05 | 4 | 1 | 0 | 2 | 1 | - | 0 | 0 |
AkshaySrivastav | 2/78 | $23,168.46 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 0 |
0xRobocop | 3/78 | $18,910.40 | 6 | 3 | 0 | 2 | 0 | - | 0 | 0 |
Trust | 4/78 | $10,546.00 | 5 | 2 | 0 | 2 | 0 | - | 0 | 0 |
ronnyx2017 | 5/78 | $10,060.60 | 4 | 1 | 0 | 1 | 0 | - | - | 0 |
kutugu | 6/78 | $7,993.60 | 2 | 0 | 0 | 1 | 0 | - | 0 | 0 |
BlockSails | 7/78 | $6,845.78 | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 0 |
MiloTruck | 8/78 | $6,300.97 | 4 | 3 | 0 | 0 | 0 | - | 0 | 0 |
nobody2018 | 9/78 | $5,005.18 | 3 | 1 | 0 | 2 | 0 | 0 | 0 | 0 |
parlayan_yildizlar_takimi | 10/78 | $4,629.53 | 3 | 3 | 0 | 0 | 0 | 0 | 0 | 0 |
Auditor per page
IMPORTANT NOTE: Prior to receiving payment from this audit you MUST become a Certified Warden (successfully complete KYC). This also applies to bot crews. You do not have to complete this process before competing or submitting bugs. You must have started this process within 48 hours after the audit ends, i.e. by June 14, 2023 at 20:00 UTC in order to receive payment.
❗ Please note that outside of the automated findings output, the findings and audit report will remain private to the sponsor team only.
Automated findings output for the audit can be found here. See related note in the C4 Discord.
Note for C4 wardens: Anything included in the automated findings output is considered a publicly known issue and is ineligible for awards.
The Cross-Chain Interoperability Protocol (CCIP) provides a standard for developers to build applications that can send messages and transfer value across multiple blockchains.
A CCIP lane
is a set of contracts and off-chain DONs(Decentralized Oracle Networks) that enable a message to be securely sent from Chain A to Chain B.
Contracts in white are chain specific, contracts in green are lane specific. The purple Dapp
s are customer contracts.
NOTE: This image only shows a simplified view of the message flow, it does not include all CCIP contracts, most notably it omits the ARM and PriceRegistry.
Source Router
ccipSend
and handling token approvals.EVM2EVM OnRamp
CCIPSendRequested
event.TokenPools
DON (Decentralized Oracle Network)
n
participants, up to f
of which can be faulty (e.g. act maliciously).CommitStore
OCR2Base
, entry point for the Committing DON. CommitStore is lane-specific.EVM2EVM OffRamp
OCR2BaseNoChecks
, entry point for the Executing DON. OffRamp is lane-specific.Destination Router
PriceRegistry
ARM
Libraries
[Approvals] Sending dapp, for a given message, must approve at least feeAmount of the feeToken to the router where feeAmount is the amount returned by getFee(uint64 destinationChainSelector, Client.EVM2AnyMessage message) returns (uint256 fee)
. If the dapp is using sending tokens then, before calling ccipSend
the sender must have approved the router to take at least the token amount they want to transfer. Dapp may manage the approval or provide an infinite one should they not want to deal with that.
The sender calls ccipSend(uint64 destinationChainSelector, Client.EVM2AnyMessage memory message) returns (bytes32 messageId)
providing the message they wish to send to destinationChainID
. All relevant information is included in the message and a unique messageId is returned for tracking purposes.
The Committing DON comes to consensus on finalized events emitted from ccipSend
, batches them into a root and writes the root to the destination chain. DON’s signatures are verified on the destination chain.
Members of the ARM (Active Risk Management Network) verify the Committing DON’s root and vote to “bless” it. Once sufficient votes have been acquired, it becomes available for execution.
The Executing DON comes to consensus on a set of executable messages (correct sequencing, within lane rate limits etc.) and submits a batch of executions to the offramp. For each execution the message gets routed through the router on the destination chain to the users specified receiver via ccipReceive(Client.Any2EVMMessage calldata message)
/contracts/interfaces
/contracts/libraries/Client.sol
You can find example application code under /contracts/applications
Example | Description |
---|---|
PingPongDemo.sol | A simple ping-pong contract for demonstrating cross-chain communication |
ImmutableExample.sol | Example of an immutable client example which supports EVM/non-EVM chains |
NOTE: these contracts are examples and not in scope of this audit.
Both Billing and AggregateRateLimiter (Rate limit based on aggregated USD value) requires token price feeds. Token prices are stored in PriceRegistry.
Since tokens can vary in decimals
, prices are stored as USD (with 18 decimals) per 1e18 of the smallest token denomination.
A price of 1e18
represents 1 USD per 1e18 token amount.
Examples:
A single fee is paid on the source chain. The message will be executed on the destination chain with the caveat that execution might be delayed. See section below on execution latency for details.
A cache of recent destination gas prices and feeToken prices, is maintained on the source chain. Updates are done either by piggy backing on commits to that source chain or via timeout. The timeout provides a configurable maximum destination gas price staleness. The fee charged is calculated as follows:
fee(msg) = execution_fee + token_transfer_fee;
Execution Fee
execution_fee = network_fee + feeTokenPerUnitGasOnDestination * (msg.gasLimit + destinationGasOverhead) * (multiplier)
Details on each parameter:
network_fee
: the fee for CCIP node operators and any coordinatorfeeTokenPerUnitGasOnDestination
: the cached destination gas price per unit fee tokenmsg.gasLimit
: the user specified message gas limit, current maximum is 4MdestinationGasOverhead
: the average overhead incurred on the destination chain by CCIP to process the messagemultiplier
: a scaling factor for execution costs, will be tuned according to actual execution costs to avoid CCIP node operators from incurring losses (unusual gas spikes etc.).Token Transfer Fee
token_transfer_fee = sum_each_token_transfer_USD( min( maxFee, max(minFee, bpsValue) ) ).convert_USD_value_to_fee_token()
where bpsValue
is defined as tokenAmount * price * bps ratio
Details on each parameter:
minFee
maxFee
ratio
If the fee paid on source is within an acceptable range of the estimated execution cost, the Executing DON will execute immediately after (sourceChainFinality + root committed + ARM approval of the root). Should the fee be significantly lower than the estimated execution cost (i.e. gas price spike), CCIP will slowly ignore greater portion of the fee gap to eventually ensure execution.
Long delay (> 1hr) scenarios should be exceedingly rare (occurring on the order of once a year) and only apply to specific chains (e.g. eth mainnet), nevertheless, if a user wishes to execute anyway, they will have the option of manually executing the message after a window of time through the CCIP explorer.
The gasLimit
specifies the maximum amount of gas that can be consumed to execute the ccipReceive()
implementation on the destination blockchain. Unspent gas is not refunded.
If you want to transfer tokens directly to an EOA address as receiver
on the destination chain, you should explicitly set gasLimit
to 0
given there is no ccipReceive()
implementation to call (and consume gas).
If extraArgs
is left empty (0 length byte array), a default of 200,000
gas will be set.
Following options might be helpful to estimate the proper gas limit:
eth_estimateGas
, see also https://ethereum.github.io/execution-apis/api-documentation/ and https://docs.alchemy.com/reference/eth-estimategas. To be applied on receiver.ccipReceive()
. Note the limitation if you set the onlyRouter
modifier on ccipReceive()
(see example contract above).ccipReceive()
)Messages from a given sender to a given destination chain will always be executed in the order in which they are sent.
If the strict: true
is set in extraArgs
, then a ccipReceive
revert will
⚠️cause subsequent message from the same sender to be blocked until that message is executed ⚠️.
Use with extreme caution to avoid blocking messages from the sender forever.
Receiver dapps using strict: true
should be prepared to do one of the following:
Furthermore they need to be certain that the receiver is the intended receiver. We recommend testing without strict first.
Manual execution will be available through CL supplied scripts or via the CCIP UI. Anyone can develop their own tooling to assist in manually executing but given the need to construct a valid merkle tree CCIP will be issuing tooling to assist in this process.
Manual execution window begins after permissionLessExecutionThresholdSeconds
has passed since the time the message was included in CommitStore.
CCIP does not use (evm)chainIds as the protocol is chain family agnostic and there is no unified schema that would fit all chain families. This is why CCIP introduces the notion of CCIP Chain Selectors, a randomly chosen uint64 value per unique blockchain. Upon deploying to a new blockchain, if no chainSelector is available yet, one will be issued. These chainSelectors are required for sending txs, as ccipSend requires the destination to be specified with this selector, and not a chainId.
The Active Risk Management Network is a hybrid system comprising offchain and onchain components:
The ARM acts as a secondary validation service (or a lightweight form of n-version programming) that continually monitors the behavior of the primary CCIP system. The ARM implementation aims to be independent from the primary system. This greatly reduces the likelihood that the ARM and the primary system are affected by the same (hypothetical) security vulnerability. The ARM implementation also aims to minimize external dependencies to reduce the risk of supply chain attack. Throughout this document, we generally assume that the ARM is operating correctly unless explicitly otherwise noted.
Note that the ARM only interacts with the various source and destination chains supported by CCIP. It does not interact with the primary system in any other way. Individual ARM nodes also only interact with one another via the chains.
The ARM has two main modes of operations that both run in parallel, Blessing and Cursing.
The ARM continually monitors all Merkle roots of messages that are committed on each destination chain.
Whenever a new Merkle root appears, the ARM will attempt to independently reconstruct it by fetching all messages contained in the Merkle tree from the finalized prefix of the source chain. It will then check that the independently constructed root matches. Due to the Collision-Resistance property of Merkle trees, if the Merkle tree contains any message that does not exactly match what the ARM observes on the source chain, the Merkle root observed on the destination chain will not match the reconstructed Merkle root.
Only if the Merkle root matches, the ARM will bless the root: Upon successfully verifying a match, the ARM node will send a vote to bless the root to the ARM contract. The ARM contract keeps track of the votes. Once a quorum of votes has been reached, it will consider a Merkle root blessed.
The CCIP OffRamp contract enforces that only messages in a Merkle root that is considered blessed by the ARM contract can be executed. Consequently, we are protected from security vulnerabilities in the CCIP offchain system that lead to mistakenly committed Merkle roots. The ARM will never bless such a Merkle root.
The ARM owner can undo a mistaken blessing. This is an escape hatch in case of bugs in the offchain blessing logic leading to false blessings.
The ARM continually monitors all chains for anomalies. If an anomaly is detected by an ARM node, the ARM node will send votes to curse on all appropriate ARM contracts. Once a quorum of such votes has been reached on a chain, the ARM contract will curse and CCIP will be automatically paused on that chain until the situation is assessed by the owner, and the curse is potentially lifted.
Anomalous conditions that would warrant a curse include:
The ARM owner can also directly trigger a curse.
The ARM contract exposes a two-method interface to the other CCIP contracts that corresponds to the two modes of operation
isBlessed(taggedRoot)
returns a boolean indicating whether the root is blessedisCursed()
returns a boolean indicating whether the ARM has cursedThe contract maintains a group of nodes (authenticated by their addresses) that are authorized to participate in the ARM. Each node has an address from which it votes to curse, an address from which it votes to bless, an address from which it can unvote to curse, a curse weight, and a bless weight.
The contract also maintains two thresholds that are used to determine whether a quorum has been reached on blessing/cursing, the blessing threshold and cursing threshold, respectively.
The voting logic for blessings is straightforward: The weights of all votes for a root are added up. If the sum exceeds the blessing threshold, the root is considered blessed.
The voting logic for curses is slightly more complex, because we want to be careful to never accidentally undo a curse: Every vote to curse carries a random 32 byte id assigned by the ARM node that sends the vote. An ARM node may have multiple active votes to curse at any time. An ARM node is considered to have voted to curse if there is at least one active vote to curse by that node. If the sum of the weights of the ARM nodes that have voted to curse exceeds the curse threshold, the ARM contract considers the system cursed.
In case an ARM node erroneously voted to curse (which would imply a faulty RPC or bug in the ARM code) once or multiple times, and while the ARM contract has not cursed, the ARM node has the ability to undo all their curses.
If the ARM contract is cursed, only the owner of the contract can interact with it. The owner has the ability, after becoming satisfied that all issues due to which ARM nodes voted to curse have been addressed, to unvote to curse on behalf of ARM nodes. If as a result of this, enough active votes to curse are inactivated such that the sum of weights of ARM nodes with active votes to curse drops below the curse threshold, the curse is lifted.
Contract | SLOC | Purpose | Libraries used |
---|---|---|---|
Router.sol | 183 | Entry and exit contract for CCIP | @openzeppelin/* |
ARM.sol | 422 | Active Risk Management, a second layer of security for CCIP | - |
PriceRegistry.sol | 165 | Contains pricing information in USD for any CCIP supported token for that chain | @openzeppelin/* |
CommitStore.sol | 137 | Security critical contract that stores the merkle roots for tx batches | - |
AggregateRateLimiter.sol | 52 | Token bucket based rate limiting with token prices | @openzeppelin/* |
OwnerIsCreator.sol | 5 | Simply contracts that enforces the creator becomes the owner | - |
onRamp/EVM2EVMOnRamp.sol | 526 | The chain family specific gateway to a destination chain. Emits the ccipSendRequested event | @openzeppelin/* |
offRamp/EVM2EVMOffRamp.sol | 365 | The chain family specific execution contract on the destination chain. | @openzeppelin/* |
ocr/OCR2Abstract.sol | 72 | Abstract contract for OCR2 logic | - |
ocr/OCR2Base.sol | 169 | Base contract for OCR2 logic, checks transmitters and signatures are valid before calling underlying contracts | - |
ocr/OCR2BaseNoChecks.sol | 137 | Base contract for OCR2 logic, only checks transmitters and not signatures before calling underlying contracts | - |
pools/TokenPool.sol | 89 | Abstract base functionality of a token pool | @openzeppelin/* |
pools/BurnMintTokenPool.sol | 30 | Burn mint implementation of a token pool, required burn and mint privileges from the token | - |
pools/LockReleaseTokenPool.sol | 49 | Lock release implementation of a token pool. Does not require permissions but does require liquidity to release tokens | @openzeppelin/* |
pools/ThirdPartyBurnMintTokenPool.sol | 43 | Example contract for third party owned burn mint pools. Contains extra checks on adding offRamps | @openzeppelin/* |
pools/USDC/USDCTokenPool.sol | 110 | USDC specific implementation of a token pool | @openzeppelin/* |
pools/tokens/ERC677.sol | 20 | Basic ERC677 implementation | @openzeppelin/* |
pools/tokens/BurnMintERC677.sol | 39 | Burn mint ERC677 to be compliant with token pool requirements | @openzeppelin/* |
libraries/Internal.sol | 81 | Internal structs, functions and enum | - |
libraries/Client.sol | 29 | Client facing structs and function to be shared with customers | - |
libraries/RateLimiter.sol | 73 | Basic bucket based rate limiter library | - |
libraries/MerkleMultiProof.sol | 38 | Merkle multi proof to allow multiple leaves to be proven efficiently | - |
libraries/USDPriceWith18Decimals.sol | 9 | Basic math operations to work with our 18 decimal token prices | - |
interfaces/IPriceRegistry.sol | 20 | PriceRegistry interface | - |
interfaces/pools/IPool.sol | 19 | Token pool interface | @openzeppelin/* |
interfaces/IEVM2AnyOnRamp.sol | 18 | Generic onRamp interface | @openzeppelin/* |
interfaces/IRouterClient.sol | 17 | External Router interface | - |
interfaces/IRouter.sol | 11 | Internal Router interface | - |
interfaces/IARM.sol | 9 | ARM interface | - |
interfaces/ICommitStore.sol | 9 | CommitStore interface | - |
interfaces/pools/IBurnMintERC20.sol | 7 | BurnMintERC20 interface | @openzeppelin/* |
interfaces/IERC677.sol | 6 | ERC677 interface | - |
interfaces/IAny2EVMMessageReceiver.sol | 5 | Generic message receiver interface | - |
interfaces/IWrappedNative.sol | 5 | Wrapped native interface | @openzeppelin/* |
interfaces/IAny2EVMOffRamp.sol | 4 | Generic offRamp interface | - |
interfaces/IERC677Receiver.sol | 4 | ERC677 receiver interface | - |
interfaces/ITypeAndVersion.sol | 4 | TypeAndVersion interface | - |
interfaces/automation/ILinkAvailable.sol | 4 | LinkAvailable interface | - |
SUM | 2985 |
Any file not in the contracts
folder is out of scope.
There are two external dependencies: the folder foundry-lib
contains the Foundry dependency forge-std
and the folder vendor
contains OpenZeppelin contracts.
Besides these two folders, there is a libraries
folder containing generic Chainlink contracts that are not specific to CCIP and also out of scope.
/contracts/applications/* /contracts/pools/USDC/{IMessageReceiver.sol, ITokenMessenger.sol} /contracts/test/* /libraries/* /vendor/* /foundry-lib/*
The MerkleMultiProof uses an algorithm to allow solving of multiple leaves, based on figure 7 of Improving Stateless Hash-Based Signatures.
The main points of concern would be
- If you have a public code repo, please share it here: N/A - How many contracts are in scope?: 38 - Total SLoC for these contracts?: With test files: 9833, Without test files: 2985 - How many external imports are there?: 1: Openzeppelin v4.8.0 - How many separate interfaces and struct definitions are there for the contracts within scope?: 15 interfaces and ~50 struct definitions - Does most of your code generally use composition or inheritance?: - How many external calls?: N/A - What is the overall line coverage percentage provided by your tests?: 97% - Is there a need to understand a separate part of the codebase / get context in order to audit this part of the protocol?: No - Please describe required context: N/A - Does it use an oracle?: Yes, Chainlink DON - Does the token conform to the ERC20 standard?: N/A - Are there any novel or unique curve logic or mathematical models?: Merkle multi proof - Does it use a timelock function?: N/A - Is it an NFT?: N/A - Does it have an AMM?: N/A - Is it a fork of a popular project?: No - Does it use rollups?: N/A - Is it multi-chain?: N/A - Does it use a side-chain?: Yes; this is a cross-chain product. EVM compatible.
This repository uses Foundry tests that are located in contracts/test
.
All dependencies have been made part of this repository and no manual installation is required.
If the test produce failures or unexpected results, please ensure your local foundry dependencies are up to date using foundryup
.
All tests have been successfully run with nightly-a26edce5d2e1ad28d833328b22e857ecb7075e63
.
To run the test
forge test
To generate a gas snapshot
forge snapshot
To generate a code coverage report. Please note that Foundry code coverage doesn't appear to work well with libraries, often reporting zero coverage.
forge coverage
To install Foundry
curl -L https://foundry.paradigm.xyz | bash foundryup
To run Slither
slither . --foundry-out-directory artifacts