A few weeks ago we reviewed a project that was integrating with LayerZero Omnichain Tokens. We were not able to find an all-in-one security checklist for that and that is why we decided to write this article.
Foreword
During a security review for a client, we do everything in our power to secure their contracts. This requires not only experience and knowledge but also effective use of time. Unable to find the sources that interested us, we contacted the LayerZero Labs team directly.
Therefore, we would like to give a big kudos for their interest in a project that wanted to integrate with their contracts and thank them for the time they took to answer our questions. Such team involvement and cooperation have a significant impact on the safety of the ecosystem.
Thank you LayerZero team!
LayerZero 101
LayerZero is a messaging protocol allowing arbitrary contract invocation across chains with an included payload. In short it is an omnichain interoperability protocol.
Basically, a developer deploys contracts on multiple chains that are integrated with LayerZero contracts. Whenever a call (by users) is made to those deployed contracts (called User Application) it contains a message that should be passed to the deployed contract on the destination chain. This job is done by the LayerZero infrastructure.
Technically, when a call is executed on User Application (UA) in the source chain, the contract interacts with LayerZero Endpoint and then a few things happen. The endpoint then notifies the Oracle and Relayer (selected by UA) of the message and its destination chain.
The Oracle waits for the number of block confirmations (specified by UA) and forwards the block header to the endpoint on the destination chain. The Relayer then submits the transaction proof. The proof is validated by the ProofLibrary (selected by UA) on the destination chain and the message is forwarded to the destination address.
One of the specific message types that can be bridged using LayerZero bridge are tokens and the team has created the Omnichain Fungible and Non-Fungible Tokens (OFT, ONFT) to make it very easy. They are ready-to-use contracts that need minimal customization (like ERC20s) without requiring to dig deep into LayerZero infrastructure and contract.
However, the devil’s in the details and that is why this security list has been prepared.
Security checks
The checklist is based on some resources linked below and things that we identified as we dug deeper. The checks are ordered by the potential impact on risk and are mainly (but not only!) focused on the Omnichain Fungible and Non-Fungible Tokens (OFT, ONFT).
Authorization of the sender
If you are using the OFT or ONFT contracts, make sure you authorize the sender correctly.
The _debitFrom function in OFT must verify whether the sender is allowed to transfer tokens from the address specified in the _from parameter.
The _debitFrom function in ONFT must verify whether the specified owner is the owner of the tokenId passed in the parameters and whether the sender is allowed to transfer this token.
Production ready code
It is important to reuse existing base contracts created by the LayerZero team. You can find them in the following package: solidity-examples.
WARNING: Do not copy source code examples directly from LayerZero repositories as those may include new, not yet fully tested, and production-ready code.
In the case of token bridging it is important to use the correct type of base contract. If you want to create a new token, use the OFT contract as the base contract and if you plan to bridge the existing token, use ProxyOFT contract.
The advantage of using production ready code provided is that you will also avoid the unwanted practice of hardcoding parameters in your UA that should be sent as parameters in the call to the endpoint: zroPaymentAddress, adapterParams, refundAddress, useZro.
However, if you inherit from the LzApp contract, remember to:
- use _lzSend function instead of directly calling lzEndpoint.send function,
- not add require statements that have already been covered (e.g., verification whether the message sender is LayerZero endpoint and the scrAddress is a trusted remote).
Failed messages
Even though the function call reverts on the destination chain, the message is considered delivered by LayerZero.
That means you are supposed to try-catch it, store it and later allow it to retry the call. It is much cheaper and easier for your programs to recover from the last state at the destination chain.
Best practice is to keep the state of the function call in a mapping with the hash of the payload as the key.
Note: Such behavior is implemented in NonblockingLzApp contract, therefore if you inherit from it, you have that covered.
Gas for function calls on destination chain
The function call on the destination chain requires a specific amount of gas or, otherwise, it will revert with out-of-gas exception.
It is the User Application’s responsibility to make sure that there are correct limits set that will instruct relayers to specify the correct amount of gas at the source chain to prevent users from inputting too low the value for gas.
Especially, when the application supports multiple message types it is recommended to specify the minimum amount of gas for each of them. The minimum amounts of gas can be set using the setMinDstGas function in LzApp contract.
Additionally, you can use custom adapter parameters (_adapterParams) to specify the gas limits for each call.
When specifying the gas, one must not forget about fees that cover that. You can get the fees using estimateFees function from the endpoint contract. Additionally, the OFTCore contract has another helper function: estimateSendFee.
Supporting force resume
The User Application should implement the ILayerZeroUserApplicationConfig interface, including the forceResumeReceive function which, in the worst case can allow the owner/multisig to unblock the queue of messages if something unexpected happens.
Configuration of User Application
The default configuration of LayerZero applications delegates security to the LayerZero team. The default contracts are upgradeable and therefore, their logic can be changed.
If you don't want to outsource your security, it is recommended to configure the applications to not use the default configuration.
The application can be configured using the following functions defined in the LzApp contract:
- setConfig,
- setSendVersion,
- setReceiveVersion,
- setTrustedRemote,
- setMinDstGasLookup, and
- setUseCustomAdapterParams
- functions in OFTCore contract.
More specifically, here is the list of configs you can set as UA’s owner using setConfig function in the current version (3):
Using the LzApp base contract that implements the afore-mentioned functions you are able to change the chain ID which is also recommended.
WARNING: UAs that opt-in to LayerZero defaults accept LayerZero's future changes to default configurations (i.e. best practice changes to block confirmations & proof libraries etc.).
Oracle and Relayer
Two crucial components for your UA are: Oracle and Relayer. That is why we have created a separate subcategory for them, even though they are configurable using the functions described in the previous one.
These components are so important because if they both get compromised (or are vulnerable), the attacker is able to cross-chain any call on behalf of your UA. That said, you must make sure you are using the trusted and secure Oracle and Relayer. By default, your protocol would use both Relayer provided and controlled by the LayerZero team and Oracle selected by LayerZero team, but provided by external party (e.g. Chainlink).
If you plan to use your own Oracle on Relayer, make sure that those have been security reviewed, not only their on-chain source code (if there is any) but also their network and key management security.
WARNING: LayerZero's default oracle will be updated to Google Cloud Oracle as of 9/19/23, which means that you will have to change the Oracle for your UA if you are not OK with the new one, by Google.
Correct confirmations number
The User Application can be configured to require a certain number of blocks before a cross-chained function call is considered confirmed on the source and destination chains.
The number varies for different chains and must be adjusted depending on reorgs that have occurred in the chain - the deeper reorgs, the bigger number of required confirmations.
Taking Polygon as an example, due to its unique block production mechanism, the 32-depth reorg can happen with the same probability as 1-depth reorg on most other chains. The depth has reached such numbers as 120 blocks (4 minutes).
Messages Encoding
The payload specified on the source chain and sent to the destination contract is of a generic bytes type. It means that the payload data must be encoded. It is important to remember to use safe-encoding (e.g. abi.encode).
Use custom encoding only if you need deep optimization and you know what you are doing. Especially, remember that abi.encodePacked is dangerous for encoding types of dynamic size (e.g. bytes, string).
Correct circulating supply
The circulatingSupply function in ProxyOFT contract calculates the circulating supply of the bridged token on the specific chain. It should be noted that the circulating supply should not include the tokens that were cross-transferred. Those should be either burnt (in case of the token created and managed by the project) or saved in the variable and its value should be subtracted from the total supply of wrapped tokens.
The simple subtraction of the OFT contract’s balance is not correct, because any user can transfer tokens to the token contract and it will lower the circulating supply even though those tokens are not cross-transferred. However, it is worth noting that the user will lose the control over transferred tokens.
Address sanity check
The _debitFrom function should check the size of the destination address passed in the parameter and forwarded as payload. In the case of EVM chains, if the address is shorter than 20 bytes, it will be properly encoded and then decoded to a zero-padded address. This creates the possibility of transferring to the wrong address.
Cross-transfers to zero address
The _mint function in OZ’s ERC20 contract reverts when the receiver of the minting is the zero address. This behavior does not allow to transfer of tokens between chains to this address as it will revert on the destination address.
Best Practices
Tracking the Nonce
If you are building a UA that directly calls the endpoint and want to match the sending call and receiving call on source and destination chains, respectively, you can use nonces to achieve that.
Correct version of OFT
There are two versions of OFT tokens. The V1 version is recommended to be used if you plan to bridge tokens between EVM compatible chains.
On the other hand, if you plan to bridge the token between EVM compatible and EVM non-compatible chains, use OFTV2 version.
SCSVS category
Based on this article we have also prepared a new integration category in SCSVS! Say hello to the I4: Cross-Chain category.
- Planning to integrate with LayerZero smart contracts? Contact us and schedule a free security consultation.
Composable Security 🇵🇱⛓️ is a Polish company specializing in increasing the security of projects based on smart contracts written in Solidity. Examples of projects that have trusted us are market leaders such as FujiDAO, Enjin, Volmex Finance, DIVA Protocol or Tellor. We are creators of the Smart Contract Security Verification Standard. Speakers at various conferences such as EthCC, ETHWarsaw, or OWASP AppSec EU. Authors of numerous publications on DeFi security. Experienced auditors operating in the IT Security space since 2016.
If you need support in the field of security or auditing smart contracts do not hesitate to contact us.
About the author
PhD, Speaker, Co-Author of SCSVS and White Hat. Professionally dealing with security since 2009, contributing to the crypto space since 2017. Smart contract security research lead.