Multichain Auditor
Observations and tips for auditing protocols on multiple chains π§
βοΈ Open to Contributions
If you see some error, or want to add an observation, please create an issue or a PR. References are greatly appreciated. You can also contact me on Twitter at @0xJuancito.
π Disclaimer
Take the observations in this repository as a guideline and kickstarter to your findings. Judge the actual impact independently, and please do not use them as a tool to spam audit contests. Do your own research.
Index
- General Observations
- Block time is not the same on different chains
- Block production may not be constant
- L2 Sequencer Uptime Feeds in Chainlink
- Chainlink Price Feeds
- AMM pools token0 and token1 order
- Modified Opcodes
- Support for the push0 opcode
- Address Aliasing - tx.origin / msg.sender
- tx.origin == msg.sender
- transfer, send and fixed gas operations
- Gas fees
- Signature replay
- Frontrunning
- Hardcoded Contract Addresses
- ERC20 decimals
- Contracts Interface
- Contracts Upgradability
- Contracts may behave differently
- Precompiles
- zkSync Era
- Differences from Ethereum
- TODO / Planned
General Observations
Block time is not the same on different chains
Block time refers to the time separating blocks. The average block time in Ethereum is 12s, but this value is different on different chains.
Example:
// 1 block every 12 sec -> 5 blocks / min
uint256 auctionDuration = 7200; // Auction duration lasts for one day (5 * 60 * 24 = 7200)
π‘ Look for hardcoded time values dependent on the block.number
that may only be valid on Mainnet.
Block production may not be constant
For some chains, block.number
is NOT a reliable source of timing information. Especially in L2 like Optimism for example.
block.number
as a time reference, especially on L2.
L2 Sequencer Uptime Feeds in Chainlink
From Chainlink documentation:
Optimistic rollup protocols have a sequencer that executes and rolls up the L2 transactions by batching multiple transactions into a single transaction.
If a sequencer becomes unavailable, it is impossible to access read/write APIs that consumers are using and applications on the L2 network will be down for most users.
This means that if the project does not check if the sequencer is down, it can return stale results.
Mitigations can be found on Handling Arbitrum outages and Handling outages on Optimism and Metis.
Chainlink Price Feeds
Chainlink Data Feeds provide data that is aggregated from many data sources by a decentralized set of independent node operators.
Chainlink provides more price feeds for some chains like Ethereum than others like Base for example. On other chains, no feed may be supported. Also, the same feed like AAVE/USD may have one address on a chain like Ethereum, and another one on Moonriver.
token0
and token1
order
AMM pools In Uniswap and derived AMMs: token0
is the token with the lower sort order, while token1
is the token with the higher sort order, as described on Uniswap documentation. This is valid for both v2 and v3 pools.
The order is important because that determines which one is the base token, and which one is the quote token. In other words, if the price is WETH/USDC or USDC/WETH.
As contracts may have different addresses on different chains, the token order can change. That is the case for example on Optimism, where the pair is WETH/USDC while on Polygon it is USDC/WETH.
π‘ Verify that the token orders is taking into account, and it is not assumed to be the same on all chains.
Modified Opcodes
Some chains implement opcodes with some modification compared to Ethereum, or are not supported.
Optimism for example, has a different implementation of opcodes like block.coinbase
, block.difficulty
, block.basefee
. tx.origin
may also behave different if the it is an L1 => L2 transaction. It also implements some new opcode [L1BLOCKNUMBER](Chains may also implement new opcodes).
Arbitrum also has some differences in some operations/opcodes like: blockhash(x)
, block.coinbase
, block.difficulty
, block.number
. msg.sender
may also behave different for L1 => L2 "retryable ticket" transactions.
push0
opcode
Support for the push0
is an instruction which pushes the constant value 0 onto the stack. This opcode is still not supported by many chains, like Arbitrum and might be problematic for projects compiled with a version of Solidity >= 0.8.20
(when it was introduced).
π‘ Pay attention to projects using a Solidity version >= 0.8.20
and check if it is supported on the deployed chains.
tx.origin
/ msg.sender
Address Aliasing - On some chains like Optimism, because of the behaviour of the CREATE opcode, it is possible for a user to create a contract on L1 and on L2 that share the same address but have different bytecode.
This can break trust assumptions, because one contract may be trusted and another be untrusted. To prevent this problem the behaviour of the ORIGIN and CALLER opcodes (tx.origin and msg.sender) differs slightly between L1 and L2.
π‘ Verify that the expected behaviour of tx.origin
and msg.sender
holds on all deployed chains
tx.origin == msg.sender
From Optimism documentation:
On L1 Ethereum tx.origin is equal to msg.sender only when the smart contract was called directly from an externally owned account (EOA). However, on Optimism tx.origin is the origin on Optimism. It could be an EOA. However, in the case of messages from L1, it is possible for a message from a smart contract on L1 to appear on L2 with tx.origin == msg.sender. This is unlikely to make a significant difference, because an L1 smart contract cannot directly manipulate the L2 state. However, there could be edge cases we did not think about where this matters.
π‘ Verify that the expected behavior of tx.origin
and msg.sender
holds on all deployed chains
transfer
, send
and fixed gas operations
transfer
and send
forward a hardcoded amount of gas and are discouraged as gas costs can change. On certain chains that cost can be higher than in Mainnet, and can result in issues, like in zkSync Era.
transfer
or send
.
Gas fees
Transactions on Ethereum mainnet are much more expensive than on other chains. Chains with very low fees may open the possibility to implement attacks that require a large amount of transactions, or where the cost-benefit of the attack would now be profitable.
Examples:
- DOS on unbound arrays
- DOS by filling bound arrays
- Spamming that can incur in extra processing costs for the protocol
- An attack that only drains smaller amounts of wei that wouldn't be profitable with high gas fees
- Frontrunning operations to prevent txns to be executed during a time frame (liquidations, complete auctions, etc.)
- Greifing attacks against the protocol
Although cheaper, each case should be analyzed to check if it is economically viable to actually be considered an attack.
Signature replay across chains
If a contract is deployed on multiple chains and uses signatures, it may be possible to reuse a signature used on one chain and execute the same transaction on another chain.
To prevent that, it is important that the signed data contains the chain id where it should be executed.
Frontrunning
Frontrunning is possible on chains that have a mempool or a way to read proposed transactions before they are executed.
It is possible on some chains like Ethereum, although expensive because of gas costs. It is possible at a cheaper cost on other chains like Polygon.
But it may be very difficult on chains like Optimism where the sequencer has no mempool.
Hardcoded Contract Addresses
Projects sometimes deploy their contracts on the same addresses over different chains but that is not always the case.
Take WETH as an example. Its address on Ethereum is 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, but 0x7ceb23fd6bc0add59e62ac25578270cff1b9f619 on Polygon.
π‘ Verify external contract addresses for the chains where the contracts are deployed
ERC20 decimals
Some ERC20 tokens have different decimals
on different chains. Even some popular ones like USDT and USDC have 6 decimals on Ethereum, and 18 decimals on BSC for example:
- USDT on Ethereum - 6 decimals
- USDC on Ethereum - 6 decimals
- USDT on BSC - 18 decimals
- USDC on BSC - 18 decimals
decimals
are set for the deployed chains if the token values are hardcoded.
Contracts Interface
Some contracts have a slightly different interface on different chains, which may break compatibility.
USDT for example is missing its return value on Ethereum as the ERC20 specification suggests, but it is compliant on that aspect on Polygon. This may lead to some vulnerabilities on some chains, while not on others.
function transfer(address _to, uint _value) public whenNotPaused {
USDT Implementation | USDT Proxy on Polygon:
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
function transfer(address _to, uint256 _value) public returns (bool success)
Contracts Upgradability
Some contracts are immutable on a chain but upgradeable on others, like USDT in Ethereum vs USDT in Polygon.
π‘ Double-check the upgradability of contracts on different chains and evaluate their implications.
Contracts may behave differently
Contracts deployed on different chains may behave differently.
On the XDai chain, USDC, WBTC, and WETH contained post-transfer callback procedures, as opposed to their traditional ERC20 implementations on other chains with no callback.
That enabled the possibility of a re-entrancy attack that was exploited and ultimately derived on the fork of the chain.
Precompiles
Chains have precompiled contracts on different addresses like Arbitrum or Optimism. Care has to be taken if some is used that is not available, works differently or is on a different address.
Cross-chain message vulnerabilities
Some protocols work by sending cross-chain messages to their counterpart contracts on the other chains. This can lead to vulnerabilities like authorization issues, or issues with relayers.
zkSync Era
zkSync Era has many differences from Ethereum on EVM instructions like CREATE
, CREATE2
, CALL
, STATICCALL
, DELEGATECALL
, MSTORE
, MLOAD
, CALLDATALOAD,
CALLDATACOPY
, etc. The full list can be checked here as well as other differences.
Differences from Ethereum
Some blockchains have articles explaining their differences with Ethereum or other EVM chains. Here's a list of official docs:
- Arbitrum/Ethereum Differences
- Differences between Ethereum and Optimism
- zkSync Era: Differences from Ethereum
- Differences Between Moonbeam and Ethereum
- Differences between Ethereum and Base
- BNB Smart Chain vs Polygon - Comparing the Differences
- BSC Token Standard Comparison
- Filecoin EVM: Differences with Ethereum
- Differences between Gnosis and Ethereum
- Tron TVM: Differences from EVM
EVM Compatible Chains Diff
Check evm-diff repository and the website evmdiff.com to diff EVM-compatible chains in a friendly format. It's an amazing tool created by @mds1
TODO / Planned
If you'd like to contribute, I would greatly appreciate the following:
- Add examples from public audits
- Add documentation of differences from other chains
- Add more observations
- Add cases where contracts behave different (like the mentioned decimals on ERC20 tokens, or the token0 and token1 on Uniswap)
- Add documentation on how different chains behave for the current observations