We audited the Fantom-foundation/Bridge repository at commit 558465d.
In scope were the following files:
Bridge-1.0.0-rc.1
├── cmd
│ ├── relay
│ │ ├── main.go
│ │ ├── relay.go
│ │ └── sender.go
│ ├── voter
│ │ ├── main.go
│ │ └── voter.go
├── contracts
│ ├── interfaces
│ │ ├── IActivityMonitor.sol
│ │ ├── IMintedBurnableERC20.sol
│ │ ├── IProofVerifier.sol
│ │ ├── IProvingContract.sol
│ │ ├── ISFC.sol
│ │ ├── IStateOracle.sol
│ │ ├── ITokenDeposit.sol
│ │ ├── ITokenPairs.sol
│ │ └── IUpdateVerifier.sol
│ ├── ActivityMonitor.sol
│ ├── Bridge.sol
│ ├── DirectExitAdministrator.sol
│ ├── ExitAdministrator.sol
│ ├── MPTProofVerifier.sol
│ ├── MessageBus.sol
│ ├── MintedERC20.sol
│ ├── SFCUpdateVerifier.sol
│ ├── StateOracle.sol
│ ├── TokenDeposit.sol
│ ├── TokenPairs.sol
│ ├── UpdateManager.sol
│ └── ValidatorsRegistry.sol
└── internal
├── collector
│ ├── collector.go
│ └── signature.go
├── rpc
│ ├── chainlistener.go
│ └── transact.go
├── signing
│ ├── keystore.go
│ └── signing.go
├── validators
│ └── validators.go
└── voting
└── voting.go
Sonic Gateway is a protocol designed to enable users to transfer approved ERC-20 tokens between the Ethereum network and the Sonic mainnet. The protocol consists of two main components:
Users initiate a transfer by locking their ERC-20 tokens in the TokenDeposit
Bridge contract on Ethereum. This action creates a pending transfer that will later be completed on the Sonic chain. The IBC Relay network then updates the Merkle Root on the Sonic chain's StateOracle
contract to reflect the state that includes this pending transfer. Once the Merkle Root is updated, users can generate a Merkle Proof of their deposit and submit it to the Bridge contract on the Sonic chain. Upon verification, the Bridge contract mints the IOU tokens on Sonic, completing the transfer.
To transfer assets back to Ethereum, users burn their IOU tokens on the Sonic chain, creating a pending withdrawal. The IBC Relay network updates the Merkle Root on the Ethereum StateOracle
contract that includes the state with this pending withdrawal. Users then generate a Merkle Proof of their burn tokens and submit it to the TokenDeposit
contract on Ethereum. Upon successful verification, the contract releases the locked ERC-20 tokens back to the users' address on Ethereum.
The security of the Sonic Gateway protocol relies on a combination of properly functioning smart contracts, multisignature wallets controlling administrative roles, and the honest operation of the IBC Relay network:
UpdateManager
Contract: Acts as the central point for updating the parameters and smart contracts of the bridge.
Multisignature Wallets: Hold critical administrative roles across various contracts. Compromise of these wallets could lead to unauthorized modifications, including the ability to update the smart contracts.
IBC Relay Network: Validators in the relay network are responsible for accurately updating Merkle Roots across chains. They must operate honestly and maintain consensus to prevent incorrect state updates, which could lead to fund losses or denial of service. The Sonic Gateway protocol involves several smart contracts with specific administrative roles and permissions. Proper management of these roles is crucial for the security and integrity of the bridge. The following are some of their administrative roles alongside their associated capabilities:
ActivityMonitor
has an admin role that will be held by the UpdateManager
contract. This allows for the update
function to call markActivity
for active validators.Bridge
has an admin role held by a multisig. They are able to initialize the DepositToken
address after deployment via setTokenDepositAddress
function. This address is not known upon deployment and this function can only be called once. The admin is also able to update the proofVerifier
address via the setProofVerifier
function.ExitAdministrator
has two roles, the DEFAULT_ADMIN_ROLE
held by a multisig and the IMPLEMENTATION_ROLE
held by the current implementation of the DirectExitAdministrator
contract. The DEFAULT_ADMIN_ROLE
will be able to grant or revoke other roles, while the IMPLEMENTATION_ROLE
is able to call the withdrawWhileDead
. This raises a trust concern whereby if the DEFAULT_ADMIN_ROLE
is compromised, they will be able to grant the IMPLEMENTATION_ROLE
to any address, allowing the compromised address to withdraw all funds locked in the TokenDeposit
. Great care should be taken with the multisig holding the DEFAULT_ADMIN_ROLE
to prevent such an attack from materializing.StateOracle
has an admin role held by the UpdateManager
contract. This allows the update
function to call the update
function of the StateOracle
.TokenDeposit
has an admin role held by a multisig that allows them to update the proofVerifier
address via the setProofVerifier
function.TokenPairs
has two roles, the DEFAULT_ADMIN_ROLE
and the REGISTER_ROLE
, both held by a multisig. The DEFAULT_ADMIN_ROLE
will be able to grant or revoke other roles, while the REGISTER_ROLE
will be able to call the register
function to register Ethereum ERC-20 tokens to L2 tokens minted by the bridge.UpdateManager
has a few different roles held by a multisig. The DEFAULT_ADMIN_ROLE
will be able to grant or revoke other roles. HEARTBEAT_ROLE
will be able to call both the setHeartbeat
and the setFastLanePadding
functions. FEE_SETTER_ROLE
has the ability to update the fast lane fee via the setFastLaneFee
function. Lastly, the MONITOR_SETTER_ROLE
will be able to call setActivityMonitor
to update the address of the ActivityMonitor
contract.ValidatorsRegistry
has an admin held by the UpdateManager
contract, allowing the update
function to call the setValidators
function.UpdateManager
In the UpdateManager contract
, the update function
incorrectly calls setValidators
on the previous updateVerifier
instance instead of calling it on the newly updated one. This results in the new verifier contract being set without valid validators. Consequently, the UpdateManager contract
could get bricked because when verifyUpdate
is called on the new verifier, it would revert due to the absence of validators.
Consider setting the new verified address before calling setValidators
to ensure that the correct instance is used.
Update: Resolved in pull request #62.
The Bridge
and TokenDeposit
contracts rely on users to provide unique IDs for claiming and withdrawal operations, as they lack an internal mechanism to generate unique IDs. This design can result in multiple users submitting a transaction with the same ID, either by mistake or even on purpose by malicious users, who then monitor the pending transactions and submit a transaction with the same ID before the original transaction is processed. Since the contract requires each ID to be unique, after the first transaction with a specific ID, all other transactions with the same ID will fail. This can be abused to cause a denial of service for a particular account by front-running its transaction with the same ID.
Consider implementing an internal unique ID generation mechanism to prevent ID collisions and mitigate front-running vulnerabilities.
When using account abstraction wallets, users may have different addresses across chains for the same account. If a user using account abstraction deposits tokens via the bridge and later attempts to claim them on the other chain while having a different address, the transaction will revert due to an address mismatch.
When the user deposits tokens on L1 using the deposit
function of the TokenDeposit
contract, the deposit is recorded using a hash of the L1 address msg.sender
, the token address, and the amount. On L2, when attempting to claim the tokens via the claim
function of the Bridge contract, the L2 address derived from the account abstraction wallet could differ from the L1 address, causing the proof verification to fail in the verifyProof
. As a result, the only way to retrieve the deposited tokens would be for the bridge to be marked as dead so that the user could call cancelDepositWhileDead
. However, this would be an extremely unlikely scenario, and the user would most likely experience a permanent loss of funds.
Consider clearly documenting the fact that the bridge requires the same user address on both networks for proper functionality, which may not be the case with account abstraction wallets.
Update: Resolved in pull request #63. The Sonic team stated:
We fixed it by including
msg.sender
in the internal unique ID.
payFastLane
Could Be Continuously Blocked by Exploiting selfdestruct
A malicious contract could exploit the selfdestruct
opcode to send ETH directly to the UpdateManager
contract, blocking calls to the payFastLane
function until the next call to update
. This occurs due to the strict equality check of the contract's balance having to be equal to the msg.value
. Since the contract balance only returns to zero after a successful call to update
, an attacker could even front-run legitimate calls to update and recover their ETH, and keep repeating the process to block the payFastLane
functionality.
Consider implementing an alternative mechanism to determine whether the fast lane is in use.
TokenPairs
The TokenPairs contract
lacks a mechanism to remove registered token pairs. Without the ability to remove token pairs, the bridge may continue supporting tokens that have been renamed, are no longer relevant, have been found to have security vulnerabilities, or no longer meet the acceptance criteria for being used in the bridge.
Consider implementing a de-registration function to allow for the removal of token pairs.
Throughout the codebase, multiple instances of floating pragma directives were identified. Pragma directives should be fixed to clearly identify the Solidity version with which the contracts will be compiled.
Consider using fixed pragma directives.
When assigning an address from a user-provided parameter, it is crucial to ensure that the address is not set to zero. Setting an address to zero is problematic because it has special burn/renounce connotations. As such, this action should be handled by a separate function to prevent accidental loss of access during value or ownership transfers.
Throughout the codebase, multiple instances of assignment operations missing zero-address checks were identified. As such, consider adding a zero address check before assigning a state variable.
Solidity's integer division truncates the result. Thus, performing division before multiplication can lead to precision loss. Throughout the codebase, multiple instances of multiplications that can lead to precision loss were identified:
*
within the SFCUpdateVerifier.sol
file.*
within the ValidatorsRegistry.sol
file.Performing multiplication before division is generally better to avoid loss of precision. As such, consider performing multiplication before division.
extendValidatorsArray
Can Create Gaps in validatorAddress
ArrayIn the ValidatorsRegistry
contract, the extendValidatorsArray
function used in setValidators
can introduce gaps in the validatorAddress
array. When setValidators
is called by the update
function in the UpdateManager
contract, it invokes the first if
condition when validatorAddress.length
is less than or equal to the newValidators.id
. In such cases, extendValidatorsArray
is called with newValidators.id
+ 1. This can lead to an issue where, for example, if the array currently holds 5 addresses with IDs 1–5, and a new validator has ID 10, the array will be extended to include the new validator but will leave uninitialized address(0)
gaps in between.
Consider modifying the array extension mechanism to prevent uninitialized gaps in the validatorAddress
array.
In the Listen
function of ChainWatcher
, when a subscription error occurs, it immediately attempts to resubscribe without any delay or back-off mechanism. If there is a network error or any other persistent error, it can lead to a loop of unsubscribing and attempting to subscribe. Continuous retrying without delay can lead to unnecessary CPU usage and network strain, potentially degrading performance.
Consider implementing an exponential back-off strategy for retry attempts.
In the getQuorumSignatures
function, the quorum is calculated as two-thirds of the total validator weight. However, the calculation uses integer division which rounds down any fractional result. When totalWeight
is not divisible by three, this rounding down leads to a quorum that is lower than two-thirds of the totalWeight
. For example, if the totalWeight
is 10, two-thirds would be approximately 6.66, which should round up to 7. However, the current implementation rounds it down to 6.
Consider adjusting the quorum calculation to ensure that it correctly rounds up to the nearest integer when necessary.
The LookupValidatorsChanges
function is responsible for returning a list of validators whose addresses or weights have changed between two blocks. This list is then used to create a transaction to update the validators.
If a lot of validators are modified between blocks, the size of the transaction required to process these updates may exceed the gas limit, resulting in an out-of-gas error and transaction failure. Such a failure could prevent the validator set from being updated correctly, potentially causing delays or disruptions in network operation.
To avoid exceeding gas limits, consider implementing a cap on the number of validators processed per update transaction. Alternatively, consider introducing a mechanism to batch updates across multiple transactions.
MPTProofVerifier
The MPTProofVerifier contract
does not perform a length check on proofItems
before accessing its elements. The verifyProof
function assumes that the proofItems
array contains at least two elements to retrieve the account and storage proofs. Without validating the length of the proofItems
array, an empty or insufficiently populated proof could result in an out-of-bounds array access error.
Consider adding a check with a custom error to ensure that the proof array has the correct structure before proceeding with verification.
SFCUpdateVerifier
In the SFCUpdateVerifier
contract, the validatorAddress
function does not validate that the public key retrieved from ISFC(sfc).getValidatorPubkey(index)
is at least 66 bytes long. If the length of the public key is less than 66 bytes, this access results in an out-of-bounds memory error, leading to a revert or undefined behavior.
Consider adding a check to ensure that the public key is at least 66 bytes long before proceeding with the substring extraction. This will help prevent out-of-bounds access and improve contract robustness.
MerkleTrie
LibraryThe MerkleTrie
library from Optimism that is utilized by the MPTProofVerifier contract
deviates from the Ethereum Merkle Patricia trie specification in the way that it handles node IDs for nodes with RLP-encoded lengths greater than or equal to 32 bytes. Specifically, the _getNodeID
function returns the decoded bytes instead of the Keccak-256 hash of the RLP encoding for such nodes.
While this implementation error could theoretically allow an attacker to craft node ID collisions and forge inclusion proofs, exploiting this vulnerability in practice is currently computationally infeasible due to the collision resistance of the Keccak-256 hash function.
To ensure correctness and future-proofing, consider raising this issue with the library maintainers to align the implementation with the Ethereum specification.
Many of the contracts and functions in the Sonic codebase lack documentation. This hinders reviewers' understanding of the code's intention, which is fundamental to correctly assessing not only security, but also correctness of the code. In addition, docstrings improve readability and ease maintenance. They should explicitly explain the purpose or intention of the functions, the scenarios under which they can fail, the roles allowed to call them, and the values returned and the events emitted.
Consider thoroughly documenting all functions (and their parameters) that are part of the contracts' public API. Functions implementing sensitive functionality, even if not public, should be clearly documented as well. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
A few functions have checks that could be performed earlier in the function body. This is so that in case of check failure, the function fails early and users save gas. Two checks were identified to have this issue:
withdraw
function can be moved to just after getting the withdrawHash
.mintedToken
function can be moved to the start of the function.Consider implementing the aforementioned suggestions so that the functions fail earlier and save users gas.
Throughout the codebase, there are contracts that do not have a 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 their maintainers to contact the appropriate person about the problem and provide mitigation instructions.
Consider adding a NatSpec comment containing a security contact above each contract definition. Using the @custom:security-contact
convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.
Since Solidity 0.8.18, developers can utilize named parameters in mappings. This means mappings can take the form of mapping(KeyType KeyName? => ValueType ValueName?)
. This updated syntax provides a more transparent representation of a mapping's purpose.
Throughout the codebase, multiple instances of mappings without named parameters were identified:
validatorLastActivity
state variable in the ActivityMonitor
contractwithdrawals
state variable in the Bridge
contractclaims
state variable in the Bridge
contracttokenWithdrawn
state variable in the DirectExitAdministrator
contractdeposits
state variable in the TokenDeposit
contractclaims
state variable in the TokenDeposit
contracttokenWithdrawn
state variable in the TokenDeposit
contractoriginalToMinted
state variable in the TokenPairs
contractmintedToOriginal
state variable in the TokenPairs
contractvalidatorWeight
state variable in the ValidatorsRegistry
contractvalidatorId
state variable in the ValidatorsRegistry
contractConsider adding named parameters to mappings in order to improve the readability and maintainability of the codebase.
The use of non-explicit imports throughout the codebase decreases code clarity and may create naming conflicts between locally defined and imported variables. This is particularly relevant when multiple contracts exist within the same Solidity file or when inheritance chains are long.
Following the principle that clearer code is better code, consider using the named import syntax (import {A, B, C} from "X"
) to explicitly declare which contracts are being imported.
Throughout the codebase, multiple instances of unnecessary casts were identified:
ITokenDeposit(deposit)
cast in the DirectExitAdministrator
contractIProvingContract(provingContract)
cast in the UpdateManager
contractTo improve the overall clarity and intent of the codebase, consider removing any unnecessary casts.
immutable
If a variable is only ever assigned a value from within the constructor
of a contract or during compile time, then it could be declared as immutable
. Throughout the codebase, multiple instances of variables that could be made immutable
were identified:
tokenPairs
state variable in Bridge.sol
stateOracle
state variable in Bridge.sol
dec
state variable in MintedERC20.sol
minter
state variable in MintedERC20.sol
sfc
state variable in SFCUpdateVerifier.sol
chainId
state variable in StateOracle.sol
exitAdministrator
state variable in TokenDeposit.sol
bridge
state variable in TokenDeposit.sol
tokenPairs
state variable in TokenDeposit.sol
stateOracle
state variable in TokenDeposit.sol
To better convey the intended use of variables and to potentially save gas, consider adding the immutable
keyword to variables that are only set in the constructor.
Throughout the codebase, multiple instances of events missing indexed parameters were identified:
Withdrawal
event in Bridge.sol
Claim
event in Bridge.sol
Vote
event in MessageBus.sol
Deposit
event in TokenDeposit.sol
Claim
event in TokenDeposit.sol
WithdrawnWhileDead
event in TokenDeposit.sol
CancelledWhileDead
event in TokenDeposit.sol
FastLaneRequest
event in UpdateManager.sol
To improve the ability of off-chain services to search and filter for specific events, consider indexing event parameters.
calldata
Instead of memory
When dealing with the parameters of external
functions, it is more gas-efficient to read their arguments directly from calldata
instead of storing them to memory
. calldata
is a read-only region of memory that contains the arguments of incoming external
function calls. This makes using calldata
as the data location for such parameters cheaper and more efficient compared to memory
. Thus, using calldata
in such situations will generally save gas and improve the performance of a smart contract.
Throughout the codebase, multiple instances where function parameters should use calldata
instead of memory
were identified:
SFCUpdateVerifier.sol
, the None
parameterValidatorsRegistry.sol
, the newValidators
parameterConsider using calldata
as the data location for the parameters of external
functions to optimize gas usage.
Throughout the codebase, multiple instances of functions updating the state without an event emission were identified:
markActivity
function in ActivityMonitor.sol
setTokenDepositAddress
function in Bridge.sol
setProofVerifier
function in Bridge.sol
setTokenDeposit
function in ExitAdministrator.sol
update
function in StateOracle.sol
getDeadStatus
function in TokenDeposit.sol
setProofVerifier
function in TokenDeposit.sol
register
function in TokenPairs.sol
update
function in UpdateManager.sol
setHeartbeat
function in UpdateManager.sol
setFastLanePadding
function in UpdateManager.sol
setFastLaneFee
function in UpdateManager.sol
setActivityMonitor
function in UpdateManager.sol
setValidators
function in ValidatorsRegistry.sol
Consider emitting events whenever there are state changes to make the codebase less error-prone and improve its readability.
The audited codebase, which includes both on-chain smart contracts and off-chain components, allows bridging specific ERC-20 tokens between the Ethereum network and the Sonic mainnet. Validators play a critical role by voting on the next state root hash and block height to be transmitted between the blockchains, ensuring the security of cross-chain operations.
Throughout the audit, we identified several security issues and provided actionable recommendations to address them. In addition, we suggested best practices to enhance the overall robustness and readability of the codebase.
The Sonic Gateway team was responsive and collaborative throughout the audit process. The project documentation was clear and comprehensive, which greatly facilitated our understanding of the system's architecture and functionalities. However, we note that the codebase would benefit from more detailed inline documentation to assist future developers and auditors in navigating the code.