Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- Security Model and Trust Assumptions
- Medium Severity
- Low Severity
- Notes & Additional Information
- Different Pragma Directives Are Used
- Todo Comments in the Code
- Inconsistent Use of Named Returns
- Inconsistent Event Emission
- Use of Magic Constants
- Incorrect or Misleading Docstrings
- Unused Modifier
- SwapAndBridge Contract Could Be Abstract
- Naming Suggestion
- Constants Not Using UPPER_CASE Format
- Lack of Security Contact
- Variables Could Be constant
- Unused Event
- Unnecessary Casts
- Code Duplication
- Permit2Depositor Restricts Exclusive Relayer To msg.sender
- Recommendations
- Conclusion
Summary
- Type
- Bridge
- Timeline
- From 2024-01-08
- To 2024-01-30
- Languages
- Solidity
- Total Issues
- 27 (25 resolved)
- Critical Severity Issues
- 0 (0 resolved)
- High Severity Issues
- 0 (0 resolved)
- Medium Severity Issues
- 4 (3 resolved)
- Low Severity Issues
- 7 (7 resolved)
- Notes & Additional Information
- 16 (15 resolved)
Scope
We audited the diff of the UMAprotocol/across-contracts-v2-private repository from the base at commit b41fcee and the head at commit 8595081.
In scope were the following files:
contracts
├── Arbitrum_SpokePool.sol
├── Base_SpokePool.sol
├── Ethereum_SpokePool.sol
├── Linea_SpokePool.sol
├── MerkleLib.sol
├── Optimism_SpokePool.sol
├── Ovm_SpokePool.sol
├── PolygonZkEVM_SpokePool.sol
├── Polygon_SpokePool.sol
├── Scroll_SpokePool.sol
├── SpokePool.sol
├── Succinct_SpokePool.sol
├── SwapAndBridge.sol
├── ZkSync_SpokePool.sol
├── chain-adapters
│ ├── Arbitrum_Adapter.sol
│ ├── Base_Adapter.sol
│ ├── CrossDomainEnabled.sol
│ ├── Linea_Adapter.sol
│ ├── Optimism_Adapter.sol
│ ├── PolygonZkEVM_Adapter.sol
│ ├── Polygon_Adapter.sol
│ └── Scroll_Adapter.sol
├── external
│ └── interfaces
│ └── CCTPInterfaces.sol
├── interfaces
│ └── SpokePoolInterface.sol
├── libraries
│ └── CircleCCTPAdapter.sol
└── permit2-order
├── Permit2Depositor.sol
├── Permit2Order.sol
└── Permit2OrderLib.sol
We also audited pull request #1 and in scope were the following files:
contracts
├── MerkleLib.sol
├── Ovm_SpokePool.sol
├── Polygon_SpokePool.sol
├── SpokePool.sol
├── SpokePoolVerifier.sol
└── interfaces
├── SpokePoolInterface.sol
└── V3SpokePoolInterface.sol
Additionally, we audited pull request #29 and in scope were the following files:
contracts
├── MerkleLib.sol
├── Polygon_SpokePool.sol
├── SpokePool.sol
└── interfaces
└── V3SpokePoolInterface.sol
System Overview
The Across protocol is a cross-chain bridge that allows for quickly bridging ERC-20 tokens from any supported origin chain to any supported destination chain. Users of the protocol, called depositors, deposit their funds into the protocol on the origin chain. Then, they wait for the relayers to fill the deposits on the destination chain, using their own funds, by sending the specified amount minus fees. The protocol refunds the relayers by sending funds available on the chain of relayers' choice or by tapping into the HubPool on Ethereum if a specific chain does not have enough funds. More information about the protocol can be found in one of our previous reports.
Across V3 Upgrade
The main changes going from V2 to V3 version of the protocol include changes to the smart contract interface, the addition of new features, and the removal of some of the existing features. The input and output tokens can now be different. While the output token can be virtually any token, the input token can only be one whitelisted by the protocol. As a result, depositors are responsible for calculating the value of both tokens and setting the input and output amounts such that they incentivize relayers to fill the deposit. Relayers on their side should assess whether filling a particular deposit is profitable for them.
Both relayer and protocol fees are now implied by the spread between the input and output amounts. The protocol fee is taken as part of the relayer fee. This replaces the realized liquidity provider fee parameter which, however, is still present in the V2 deposits for backwards compatibility. Moreover, two new parameters were added to deposits. The first one is the "fill deadline" after which the deposit is not fillable. If a deposit is not filled and the fill deadline has passed then the refund process is triggered. This process uses the same refund mechanism as relayer refunds. Thus, the relayer refund roots might contain both the depositor refund leaves and relayer refund leaves.
The second one is the "exclusive relayer" which is the only address that can fill the deposit up until the "exclusivity deadline". The exclusivity deadline can be earlier than the fill deadline in which case the deposit can still be filled after the exclusivity deadline and up until the fill deadline. Partial fills are not possible for V3 deposits but still possible for remaining V2 deposits.
We would also like to highlight some of the less obvious consequences of the upgrade:
- Slow fills can happen only if the input and output tokens are equivalent. The equivalence is determined by the configurable mapping in the protocol. For each token, there can be only one equivalent token per chain.
- Regardless of whether the tokens are equivalent or not, the refund process takes place in case a deposit has expired. This ensures that all deposits are eventually either filled or refunded.
- Refunds are issued only in the input tokens which are whitelisted by Across. Both depositor and relayer refunds share the same refund root.
- Slow fills must be explicitly requested via a designated function.
Upgrade Process
The upgrade will happen in two stages. Deployment of the first stage adds the V3 interface and refactors V2 deposit functions. Both interfaces will co-exist with V2 deposits emitting V3 events. This ensures graceful migration from V2 to V3 because no new V2 deposits are created but the existing V2 deposits can still be filled and refunded using V2 functions. Once all the V2 deposits are filled, all V2 refunds are executed, and all parties have migrated to the V3 interface, the second stage will be deployed and the V2 functions will be removed.
Swap and Bridge
Even though V3 deposits allow specifying virtually any token as the output token, the input token can only be one of the tokens whitelisted by the protocol. Some depositors might not have one of the whitelisted tokens but may still want to use the protocol. To overcome this limitation, the SwapAndBridge
periphery contract was added to facilitate bridging from any token to any other token. It transfers a depositor-specified token from the depositor to itself, performs a swap to an input token, and makes an AcrossV3 deposit on behalf of the depositor. The DEX used to perform the swap is set by the deployer at deploy time.
Support for Cross-Chain Transfer Protocol
The Cross-Chain Transfer Protocol (CCTP) is now used to bridge USDC on Arbitrum, Base, Optimism, and Polygon. While this speeds up the USDC bridging between the L2 chains and the Ethereum Mainnet, it makes the protocol rely on the CCTP infrastructure for functioning which introduces new trust assumptions. In particular, rebalancing, which moves USDC from SpokePools to the HubPool and vice versa, is now faster on the aforementioned chains.
Support for Linea, Polygon zkEVM, and Scroll Chains
A pair of SpokePool and adapter contracts was added for Linea, Polygon zkEVM, and Scroll chains to support them in the protocol. Below, we briefly describe the workings of the protocol on each of these chains.
On Linea, WETH is bridged as ETH via the canonical message service. USDC is bridged via its own Linea-specific bridge which is not CCTP. Other tokens are bridged via the canonical token bridge. On Polygon zkEVM, all tokens are bridged via the canonical bridge but WETH has a separate procedure which bridges it as ETH. On Scroll, all tokens are bridged via the canonical gateway router.
Integration with Third-Party Limit Order Protocol and Permit2
The Permit2Depositor
periphery contract was added to translate limit orders of the third-party limit order protocol to cross-chain limit orders. The third-party protocol collects a user's permit2 signatures and makes them available for anyone to deposit on behalf of said user via the Permit2Depositor
contract. In the process, the input tokens are pulled from the user and the one who deposits on behalf of the user via the Permit2 contract. The latter serves as collateral and is returned in the relayer refund process. An Across deposit is then created for the input token amount plus the collateral amount with the user as the depositor and the one who deposits on behalf of the user as the exclusive relayer.
Other Changes
- Some of the SpokePools' storage variables were made immutable for gas savings. In particular, the
_wrappedNativeTokenAddress
and_depositQuoteTimeBuffer
variables are now immutable. - The
_preExecuteLeafHook
internal function was added. It executes a SpokePool-specific hook before a refund or slow fill leaf execution. - Refund leaf execution which bridges funds from the Polygon SpokePool to the HubPool now must be the only function call in the transaction.
Security Model and Trust Assumptions
Here we present changes to the security model and new trust assumptions. You can find more information about the security model, trust assumptions, and privileged roles in one of our previous reports.
Bridge Infrastructure Assumptions
The protocol generally relies on canonical bridges. However, with the introduction of CCTP, USDC bridging now relies on the CCTP infrastructure. This exposes the protocol to an additional risk of infrastructure failure.
ERC-20 Approvals
The SwapAndBridge
contract holds users' approvals. The associated risk, of course, depends on whether the users approve more than needed for a swap or do unlimited approvals which, however, are common use cases.
Protocol Configuration Assumptions
Fills are distinguished on the basis of their hashes. If two fills satisfy the protocol rules and fill the same deposit but have different hashes then the protocol refunds twice for one deposit. In particular, the concept of equivalent tokens introduces such a risk in case more than one equivalent token per chain is defined for a token. For example, USDC and USDC.e are two versions of the USDC token on Arbitrum. If both were defined as an equivalent token for USDC, the aforementioned scenario is possible.
Medium Severity
Deadline Buffer for Fills Is Not Always Respected
A newly added feature to the system is that the fill requests are subject to a deadline after which any fill attempt reverts. The essence of this feature is to limit the maximum necessary lookback for dataworkers and relayer instances.
For backwards compatibility reasons, the _deposit
function will be present for a long time after the system upgrade introducing the new V3 interface takes place. However, note that the _deposit
function sets each fill's deadline to an infinitely large number. As a consequence, even after the system's upgrade, new deposits will remain active practically forever, thereby breaking the assumption of the limited maximum necessary lookback.
Consider setting the default fill deadline equal to the standard deadline buffer in order to enable limited lookbacks.
Update: Resolved in pull request #14 at commit 71383bc. The Risk Labs team stated:
We decided not to implement the suggested fix because the existing integrators that use
deposit()
are likely not prepared to receive expired deposit refunds on the origin chain. Instead, we will enshrineMAX_UINT_256
as a “magic number” in the Across UMIP that is only possible to be set via the soon-to-be deprecateddeposit()
function.This means that deposits sent from this function can never expire, and therefore the dataworker does not need to maintain a longer lookback to look out for these. Moreover, these deposits can always be slow-filled because
outputToken
is hardcoded to 0x0 bydeposit()
, meaning that these deposits will never lock funds.
Refund Leaf Execution Fails if One of the Addresses Is Blacklisted
If an address is blacklisted in the USDC token then both to and from transfers revert for this address. During refund leaf execution, tokens are pushed to the recipients. If one of the recipients is a blacklisted address, the whole execution fails and the blacklisted address as well as other addresses included in the leaf are not refunded. While such issues are inherent to the push pattern as opposed to the pull pattern, in this case, these issues and those similar to them can be remedied off-chain.
Consider adding a fallback option to the off-chain process that can separate problematic addresses to their own refund leaf so that they do not affect legitimate addresses.
Update: Acknowledged, will resolve. The Risk Labs team stated:
We will address this in the upcoming UMIP changes for Across V3. Relayer refund leaves that refund any user on the
l2Token
's blacklist should not be created.
Permit2OrderLib
Is Not Fully Compliant With EIP-712
In the Permit2OrderLib
contract, the witnessTypeString
argument when calling the external permit2WitnessTransferFrom
function does not fully follow the EIP-712 specification.
Specifically:
- Some of the entries have a different type in the struct's declaration than what is included in the typestring. Namely, these entries are:
fillPeriod
,validationContract
, andvalidationData
. - Some entries are included in the witness but are not included in the typestring. Namely,
order.challengerCollateral.token
andorder.challengerCollateral.amount
.
Consider fixing all the inconsistencies described above in order to fully comply with the EIP-712 specification.
Update: Resolved in pull request #15 at commit 446aae6.
Polygon_SpokePool
-Specific Lock Can Be Bypassed
The Polygon_SpokePool
contract has locks that prevent filling deposits with a message hook if a leaf is claimed within the same transaction. This is intended to prevent a situation in which the attacker executes a refund leaf and gets the control flow.
This lock, however, can be bypassed by executing the multicall
function within which a relayer refund leaf is executed and a deposit is filled. The fill does not contain a message hook but during a native token transfer or a malicious outputToken
transfer, it still gives the control flow to the attacker which defeats the purpose of the lock.
Consider adding a contract-wide lock to the executeV3RelayerRefundLeaf
and executeRelayerRefundLeaf
functions of Polygon_SpokePool
so that each of these functions can only be executed if it is the only one in a transaction.
Update: Resolved in pull request #24 at commit d910641 and in pull request #29 at commit 8d6682b. The executeV3RelayerRefundLeaf
function has been completely removed. Additionally, it is now prohibited to call executeRelayerRefundLeaf
combined with other public
function calls within a multicall
transaction.
Low Severity
Unchecked Return Value
In the SwapAndBridge
contract, the _swapAndBridge
function attempts to pull the caller's tokens but does not check the return value of the transferFrom
call.
Consider using the safeTransferFrom
function of the SafeERC20
library in order to assert the call's success.
Update: Resolved in pull request #16 at commit 3a105a2.
Fillers Might Lose Their Collateral
The Permit2Depositor
contract is meant to be used by third-party fillers and requires collateral from their side which is returned during the refund process. Because of this, they are also set as the exclusive relayer for the deposit. This way, they are protected from someone else filling the deposit and claiming the collateral.
However, liquidity providers can bypass the exclusivity restriction and have an incentive to do so by slow-filling the deposit thus leaving the collateral in the protocol. They can achieve this by requesting and executing a slow fill before the exclusivity deadline. This, of course, requires the filler to not to fill for at least the root bundle dispute period because slow fills can happen only after the root bundle dispute period has passed.
Consider either restricting the caller of the requested slow fill function to the depositor for the duration of the exclusivity period or guiding the third-party integrations to set the deposit lifespan to be less than the challenge period. The former approach gives more flexibility in terms of deposit lifespan but forces users to do on-chain calls if they want a slow fill for a deposit with an exclusive relayer. This might not be desirable given that the Permit2Depositor
contract provides a gasless bridging experience to the user. The latter approach provides a better gasless experience but requires the third-party integration to be mindful of this issue.
Update: Resolved in pull request #17 at commit cc6b3cc. The Risk Labs team stated:
We ultimately decided to not only restrict when a slow fill request can be sent during the exclusivity window, but to block it altogether. We think that there is no reason for a depositor to request a slow fill when there is an exclusivity window set since they would have already made an off-chain agreement with an exclusive relayer to fill their deposit. If they had not, then they would set a 0-length exclusivity window. This implementation is the simplest to reason about without affecting projected UX.
Lack of Input Validation
There are some instances of input parameters within the SpokePool
contract that are not sufficiently validated:
fillDeadline
can be set in the past. In such a case, it is not possible to fill the deposit request.exclusiveRelayer
andexclusivityDeadline
may not be consistent with each other. For example, it is possible thatexclusiveRelayer
is set to the0x0
address whileexclusivityDeadline
is a non-zero value. This would create some confusion when filling the deposit request.- In the
payable
depositV3
function,msg.value
should always be zero in case theinputToken
is not thewrappedNativeToken
.
Consider sufficiently validating all of the instances as described above.
Update: Resolved in pull request #18 at commit e1f2492. The Risk Labs team stated:
The new rule for
exclusiveRelayer
andexclusivityDeadline
is that either both must be 0 or both must be non-zero andexclusivityDeadline >= currentTime
.
Insufficient Access Controls
The Linea_SpokePool
contract receives cross-chain messages from the HubPool via the claimMessage
function of the Linea message service. The function sets the sender
for the duration of the call. This might be problematic if the HubPool ever sends a cross-chain message to an attacker-controlled contract on Linea. This is because in this case, the attacker-controlled contract can make calls to Linea_SpokePool
and bypass the access controls because the sender
is set to the HubPool.
Consider checking both that sender
is the HubPool and that the immediate caller is the Linea message service.
Update: Resolved in pull request #19 at commit e6bb797.
Low Level Call to External Exchange Contract With Arbitrary Calldata
The SwapAndBridge
contract enables swapping an amount of tokens via a decentralized exchange service of preference and then bridge the swapped amount via Across. Both swap and bridging actions take place within a single transaction. To execute the swap, the SwapAndBridge
contract performs a low level call to the designated exchange contract. For this call, the calldata parameters are arbitrarily given by the user. The only restriction is that the called function selector must be different than an ERC-20 token's transferFrom
function. Performing a low level call with arbitrary data to an external contract should be avoided as it increases the possible attack surface.
Instead of blacklisting the suspicious calldata parameters, consider whitelisting the allowed ones. More specifically, consider whitelisting the specific swap function selectors of the external exchange in each SwapAndBridge
contract.
Update: Resolved in pull request #20 at commit 2c68c2e.
Inconsistent And Misleading Events Emitted When Bridging To HubPool
A relayer refund leaf contains information about the refund amounts of a specific L2 token that need to be transferred to the relayers. In addition, it may also contain information about a non-zero amount of the L2 token that needs to be bridged back to the HubPool contract. Each L2 SpokePool contract implements the _bridgeTokensToHubPool
function to handle the chain-specific operations for bridging to L1.
There is an inconsistency in the events emitted by the several L2 SpokePools upon bridging to HubPool. More specifically, the base SpokePool
contract emits the TokensBridged
event upon all relevant actions. Apart from this event, most of the L2 SpokePools emit one more event which typically contains 3 parts of information: the address of the L2 token, the receiver address (i.e., HubPool address), and the amount bridged.
However, the following inconsistencies and errors have been identified:
- Instead of emitting the L2 token address,
Arbitrum_SpokePool
emits the L1 token address. The L1 address is also hardcoded as the0x0
address. Ethereum SpokePool
andPolygonZkEVM_SpokePool
emit no event.Polygon_SpokePool
mistakenly emitsaddress(this)
as theHubPool
address.
Consider addressing any faulty or inconsistent event information as listed above. Since most of the information contained in the more specific events is already covered by the event emitted by the base SpokePool
contract, consider emitting a more specific event only when complementary information needs to be logged.
Update: Resolved in pull request #21 at commit d1aa772. The Risk Labs team stated:
We decided to remove all these chain-specific bridge events as they add no additional value to the
TokensBridged
event emitted in the parent contract and they are not currently used in any off-chain infrastructure.
Wrong Constructor Argument Passed
When constructing the UniversalSwapAndBridge
contract, the address of the exchange contract is wrongly passed to SwapAndBridgeBase
's constructor due to a typographical error. Specifically, instead of passing the _exchange
parameter, exchange
is passed, which is an uninitialized inherited variable. Since SwapAndBridgeBase
does not validate _exchange
against address(0)
, the faulty deployment would complete successfully.
Consider fixing the typographical error to avoid faulty deployments.
Update: Resolved in pull request #22 at commit 0a960f1.
Notes & Additional Information
Different Pragma Directives Are Used
Having fixed and the same pragma directives across file imports helps clearly identify the Solidity version with which the contracts will be compiled. However, the Permit2Depositor.sol
file has the pragma directive pragma solidity ^0.8.0;
and imports the Permit2OrderLib.sol
file which has a different pragma directive.
Consider using the same fixed pragma version where possible.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
Changed pragma call in the following contracts:
IPermit2
,Permit2Order
,Permit2OrderLib
, andAddressLibUpgradeable
.
Todo Comments in the Code
During development, having well-described TODO/Fixme comments will make the process of tracking and solving them easier. Without this information, these comments might age and important information for the security of the system might be forgotten by the time it is released to production. These comments should be tracked in the project's issue backlog and resolved before the system is deployed. There is a TODO
comment at line 1221 in SpokePool.sol
.
Consider removing all instances of TODO/Fixme comments and instead tracking them in the issues backlog. Alternatively, consider linking each inline TODO/Fixme to the corresponding issues backlog entry.
Update: Acknowledged, will resolve. The Risk Labs team stated:
We decided to leave in the TODO comments to remind ourselves to fix them in the future.
Inconsistent Use of Named Returns
Contract Arbitrum_Adapter
contract has inconsistent usage of named returns in its functions. More specifically, only the _contractHasSufficientEthBalance
function names its return variable.
To improve the readability of the contract, consider using the same return style in all of its functions.
Update: Resolved in pull request #23 at commit 5571b12.
Inconsistent Event Emission
In the Scroll_SpokePool
contract, the initialize
function directly sets the l2GatewayRouter
and l2ScrollMessenger
variables instead of calling their internal setters. As a consequence, the associated events will not be emitted. Similarly, the initialize
function of the Polygon_SpokePool
contract immediately sets the polygonTokenBridger
and fxChild
variables instead of calling the associated setter functions.
Consider calling the setter functions instead of directly setting the variables so that the associated events are emitted in a consistent manner.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
We refactored
Scroll_SpokePool
andPolygon_SpokePool
to use theinternal
setter functions in the initializer.
Use of Magic Constants
The Arbitrum_SpokePool
, OVM_SpokePool
, and Polygon_SpokePool
contracts use the magic constant 0
within their constructors to denote Circle's CCTP domain id for the Ethereum Mainnet. In addition, hardcoded 0
and address(0)
values are used in the SpokePool
contract which could be defined as constant variables.
Consider using a constant variable for each value of special meaning for better readability and clarity.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
We added the CCTP domain ID's as constants in
CircleCCTPAdapter.sol
. Magic values inArbitrum_SpokePool
,Polygon_SpokePool
, andOVM_SpokePool
have been replaced.
Incorrect or Misleading Docstrings
Several instances of incorrect or misleading docstrings have been identified throughout the codebase:
In SpokePool.sol
:
- At line 683, "plus" should be "less".
- At line 528, "_deposit()" should be "deposit()".
- At line 565, "_deposit()" should be "depositFor".
In Permit2Depositor.sol
, at line 13, "AcrossV2" should be "AcrossV3".
In Permit2Order.sol
, at line 59, "signature" could be more specifically described as "Permit2 signature".
Consider updating the misleading instances of docstrings for improved clarity and readability.
Update: Resolved in pull request #23 at commit 5571b12.
Unused Modifier
The onlyFromCrossDomainAccount
modifier of the CrossDomainEnabled
contract is never used within the entire codebase.
Consider removing the unused modifier for clarity and readability.
Update: Resolved in pull request #23 at commit 5571b12.
SwapAndBridge
Contract Could Be Abstract
The SwapAndBridgeBase
contract could be marked abstract
since it only contains internal
functions and is not intended to be directly deployed as a standalone contract.
Consider marking the SwapAndBridge
contract abstract
for clarity.
Update: Resolved in pull request #23 at commit 5571b12.
Naming Suggestion
The Polygon_SpokePool
contract disallows relayer refunds and fills with a message hook to take place within a single transaction. This is achieved by setting and checking the lock variables. In essence, the _setFunctionLock
function is responsible for locking a designated lock variable, while the _revertIfFunctionCalledAtomically
function checks whether the lock variable is locked, and if so, reverts the execution.
Despite these two functions performing symmetric checks, their names do not represent this fact. As such, to improve the contract's overall clarity and readability, consider renaming _revertIfFunctionCalledAtomically
to a more intuitive name (e.g., _revertIfFunctionLockSet
).
Update: Resolved in pull request #24 at commit d910641. The Risk Labs team stated:
These hooks were completely removed by the fix for the M04 issue.
Constants Not Using UPPER_CASE
Format
Throughout the codebase, there are constants that do not use the UPPER_CASE
format.
According to the Solidity Style Guide, constants should be named with all capital letters with underscores separating words. For better readability, consider following this convention.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
We renamed variables in:
BondToken
,Ovm_SpokePool
,PolygonTokenBridger
,PolygonZkEVM_SpokePool
,Arbitrum_Adapter
,Base_Adapter
,Boba_Adapter
,Linea_Adapter
,Optimism_Adapter
,PolygonZkEVM_Adapter
,Polygon_Adapter
,Scroll_Adapter
, andPermit2Depositor
.
Lack of Security Contact
Providing a specific security contact (such as an email or ENS name) within a smart contract significantly simplifies the process for individuals to communicate if they identify a vulnerability in the code. This practice is quite beneficial as it permits the code owners to dictate the communication channel for vulnerability disclosure, eliminating the risk of miscommunication or failure to report due to a lack of knowledge on how to do so. In addition, if the contract incorporates third-party libraries and a bug surfaces in those, it becomes easier for the maintainers of those libraries to contact the appropriate person about the problem and provide mitigation instructions. Throughout the codebase, there are contracts that do not have a security contact.
Consider adding a NatSpec comment containing a security contact above the contract definitions. Using the @custom:security-contact
convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
Security contact information has been added to
SpokePool.sol
Variables Could Be constant
If a variable is only ever assigned a value when it is declared, then it could be declared as constant
. Throughout the codebase, there are several variables that could be constant
:
- The
l2GasLimit
variable inBase_Adapter.sol
- The
l2GasLimit
variable inOptimism_Adapter.sol
- The
dai
variable inOptimism_Adapter.sol
- The
daiOptimismBridge
variable inOptimism_Adapter.sol
- The
snx
variable inOptimism_Adapter.sol
- The
snxOptimismBridge
variable inOptimism_Adapter.sol
To better convey the intended use of variables and to potentially save gas, consider adding the constant
keyword to variables that are only set when they are declared.
Update: Resolved in pull request #23 at commit 5571b12.
Unused Event
In SpokePool.sol
, the RefundRequested
event is unused.
To improve the overall clarity, intentionality, and readability of the codebase, consider removing the unused event.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
The event has been removed.
Unnecessary Casts
Throughout the codebase, there are multiple unnecessary casts:
- The
uint32(l2GasLimit)
cast in theBase_Adapter
contract - The
IMessageService(l2MessageService)
cast in theLinea_SpokePool
contract - The
IMessageService(l2MessageService)
cast in theLinea_SpokePool
contract - The
IMessageService(l2MessageService)
cast in theLinea_SpokePool
contract - The
uint32(l2GasLimit)
cast in theOptimism_Adapter
contract - The
IPermit2(address(permit2))
cast in thePermit2OrderLib
contract.
To improve the overall clarity, intent, and readability of the codebase, consider removing unnecessary casts.
Update: Resolved in pull request #23 at commit 5571b12 and in pull request #30 at commit 2d90d5b.
Code Duplication
The IMessageService
, ITokenBridge
, and IUSDCBridge
interfaces are defined in both the Linea_Adapter
and the Linea_SpokePool
contracts.
Consider including these interfaces in a separate file and importing them wherever needed in order to avoid duplicating the code which can be error-prone.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
They have been moved to the
LineaInterfaces.sol
contract.
Permit2Depositor
Restricts Exclusive Relayer To msg.sender
The permit2Deposit
function of the Permit2Depositor
contract receives a signed order and makes a V3 deposit on behalf of the signer. The deposit made through the Permit2Depositor
contract can only be filled by the designated exclusive relayer as the entire fill period is set exclusive. The exclusive relayer is always the caller of permit2Depositor
.
If the Permit2 functionality gets supported on zkSync Era, the current version of Permit2Depositor
would not function properly because of the different address derivation algorithm. Moreover, fillers may have different addresses on different chains so they might need more flexibility in specifying the exclusive relayer.
Consider adding the filler's address on the destination chain within the order's argument for flexibility but also to avoid faulty implementations on future upgrades that involve zkSync Era.
Update: Resolved in pull request #23 at commit 5571b12. The Risk Labs team stated:
The
address destinationChainFillerAddress
parameter has been added topermit2Deposit
.
Recommendations
Codebase Can Benefit From Additional Testing
We recommend adding more unit and integration tests for the new components. In particular, tests for the Permit2Depositor
and SwapAndBrdige
contracts were not found. Tests can aid in communicating the intended behaviour and making the codebase more robust.
Conclusion
This audit mainly covered the code upgrade from V2 to V3 version of the protocol. Other changes audited include the additional features supported by the newly introduced peripheral contracts, the integration with the CCTP service for faster cross-chain USDC transfers, and the support of three new L2 chains.
The audited codebase largely involves integration and cooperation with external smart contracts (e.g., canonical bridges, exchange services, CCTP bridges, etc.) and great effort was made to investigate the possible attack vectors introduced by this fact.
The Risk Labs team provided us with extensive documentation about the version upgrade and newly introduced features, all the while being very responsive and engaging in discussions with the auditors.