Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- High Severity
- Medium Severity
- Low Severity
- Lack of Deregistration Mechanism for TokenPairs
- Floating Pragma
- Missing Zero-Address Checks
- Possible Precision Loss Due to Division Before Multiplication
- extendValidatorsArray Can Create Gaps in validatorAddress Array
- Infinite Loop in Subscription Retry Mechanism
- Quorum Calculation Rounds Down, Allowing Insufficient Validator Approval
- Updating Too Many Validator in a Single Update May Cause Out-of-Gas Error
- Notes & Additional Information
- Lack of Length Check on Proof Items in MPTProofVerifier
- Missing Length Check for Public Key in SFCUpdateVerifier
- Improper Node ID Handling in MerkleTrie Library
- Missing Docstrings
- Move Checks to Fail Earlier
- Lack of Security Contact
- Missing Named Parameters in Mappings
- Non-Explicit Imports
- Unnecessary Casts
- Variables Could Be immutable
- Lack of Indexed Event Parameters
- Use calldata Instead of memory
- Functions Updating State Without Event Emissions
- Conclusion
Summary
- Type
- DeFi
- Timeline
- From 2024-09-24
- To 2024-10-21
- Languages
- Solidity, Golang
- Total Issues
- 25 (2 resolved)
- Critical Severity Issues
- 0 (0 resolved)
- High Severity Issues
- 1 (1 resolved)
- Medium Severity Issues
- 3 (1 resolved)
- Low Severity Issues
- 8 (0 resolved)
- Notes & Additional Information
- 13 (0 resolved)
Scope
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
System Overview
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:
- On-Chain Smart Contracts: Deployed on both Ethereum and Sonic networks, these contracts handle the token locking, burning, and verification processes required for asset transfers.
- Off-Chain Inter-Blockchain Communication (IBC) Relay Network: A network of validators responsible for updating the state information between the two blockchains. They ensure that Merkle Roots that represent the current state of the bridge are accurately relayed and updated across both chains.
Bridging from Ethereum to Sonic
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.
Bridging from Sonic to Ethereum
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.
Privileged Roles and Trust Assumptions
Security Model
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 theUpdateManager
contract. This allows for theupdate
function to callmarkActivity
for active validators.Bridge
has an admin role held by a multisig. They are able to initialize theDepositToken
address after deployment viasetTokenDepositAddress
function. This address is not known upon deployment and this function can only be called once. The admin is also able to update theproofVerifier
address via thesetProofVerifier
function.ExitAdministrator
has two roles, theDEFAULT_ADMIN_ROLE
held by a multisig and theIMPLEMENTATION_ROLE
held by the current implementation of theDirectExitAdministrator
contract. TheDEFAULT_ADMIN_ROLE
will be able to grant or revoke other roles, while theIMPLEMENTATION_ROLE
is able to call thewithdrawWhileDead
. This raises a trust concern whereby if theDEFAULT_ADMIN_ROLE
is compromised, they will be able to grant theIMPLEMENTATION_ROLE
to any address, allowing the compromised address to withdraw all funds locked in theTokenDeposit
. Great care should be taken with the multisig holding theDEFAULT_ADMIN_ROLE
to prevent such an attack from materializing.StateOracle
has an admin role held by theUpdateManager
contract. This allows theupdate
function to call theupdate
function of theStateOracle
.TokenDeposit
has an admin role held by a multisig that allows them to update theproofVerifier
address via thesetProofVerifier
function.TokenPairs
has two roles, theDEFAULT_ADMIN_ROLE
and theREGISTER_ROLE
, both held by a multisig. TheDEFAULT_ADMIN_ROLE
will be able to grant or revoke other roles, while theREGISTER_ROLE
will be able to call theregister
function to register Ethereum ERC-20 tokens to L2 tokens minted by the bridge.UpdateManager
has a few different roles held by a multisig. TheDEFAULT_ADMIN_ROLE
will be able to grant or revoke other roles.HEARTBEAT_ROLE
will be able to call both thesetHeartbeat
and thesetFastLanePadding
functions.FEE_SETTER_ROLE
has the ability to update the fast lane fee via thesetFastLaneFee
function. Lastly, theMONITOR_SETTER_ROLE
will be able to callsetActivityMonitor
to update the address of theActivityMonitor
contract.ValidatorsRegistry
has an admin held by theUpdateManager
contract, allowing theupdate
function to call thesetValidators
function.
High Severity
Incorrect Update Order in 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.
Medium Severity
User-Provided Deposit and Withdraw IDs Can Cause Collisions
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.
Users with Account Abstraction Wallets May Lose Bridged Funds Due to Address Mismatch
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.
Low Severity
Lack of Deregistration Mechanism for 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.
Floating Pragma
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.
Missing Zero-Address Checks
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.
Possible Precision Loss Due to Division Before Multiplication
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:
- The multiplication
*
within theSFCUpdateVerifier.sol
file. - The multiplication
*
within theValidatorsRegistry.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
Array
In 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.
Infinite Loop in Subscription Retry Mechanism
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.
Quorum Calculation Rounds Down, Allowing Insufficient Validator Approval
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.
Updating Too Many Validator in a Single Update May Cause Out-of-Gas Error
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.
Notes & Additional Information
Lack of Length Check on Proof Items in 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.
Missing Length Check for Public Key in 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.
Improper Node ID Handling in MerkleTrie
Library
The 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.
Missing Docstrings
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).
Move Checks to Fail Earlier
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:
- The "Already withdrawn" check performed in the
withdraw
function can be moved to just after getting thewithdrawHash
. - The "No minted token for given token" check performed in the
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.
Lack of Security Contact
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.
Missing Named Parameters in Mappings
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:
- The
validatorLastActivity
state variable in theActivityMonitor
contract - The
withdrawals
state variable in theBridge
contract - The
claims
state variable in theBridge
contract - The
tokenWithdrawn
state variable in theDirectExitAdministrator
contract - The
deposits
state variable in theTokenDeposit
contract - The
claims
state variable in theTokenDeposit
contract - The
tokenWithdrawn
state variable in theTokenDeposit
contract - The
originalToMinted
state variable in theTokenPairs
contract - The
mintedToOriginal
state variable in theTokenPairs
contract - The
validatorWeight
state variable in theValidatorsRegistry
contract - The
validatorId
state variable in theValidatorsRegistry
contract
Consider adding named parameters to mappings in order to improve the readability and maintainability of the codebase.
Non-Explicit Imports
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.
Unnecessary Casts
Throughout the codebase, multiple instances of unnecessary casts were identified:
- The
ITokenDeposit(deposit)
cast in theDirectExitAdministrator
contract - The
IProvingContract(provingContract)
cast in theUpdateManager
contract
To improve the overall clarity and intent of the codebase, consider removing any unnecessary casts.
Variables Could Be 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:
- The
tokenPairs
state variable inBridge.sol
- The
stateOracle
state variable inBridge.sol
- The
dec
state variable inMintedERC20.sol
- The
minter
state variable inMintedERC20.sol
- The
sfc
state variable inSFCUpdateVerifier.sol
- The
chainId
state variable inStateOracle.sol
- The
exitAdministrator
state variable inTokenDeposit.sol
- The
bridge
state variable inTokenDeposit.sol
- The
tokenPairs
state variable inTokenDeposit.sol
- The
stateOracle
state variable inTokenDeposit.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.
Lack of Indexed Event Parameters
Throughout the codebase, multiple instances of events missing indexed parameters were identified:
- The
Withdrawal
event inBridge.sol
- The
Claim
event inBridge.sol
- The
Vote
event inMessageBus.sol
- The
Deposit
event inTokenDeposit.sol
- The
Claim
event inTokenDeposit.sol
- The
WithdrawnWhileDead
event inTokenDeposit.sol
- The
CancelledWhileDead
event inTokenDeposit.sol
- The
FastLaneRequest
event inUpdateManager.sol
To improve the ability of off-chain services to search and filter for specific events, consider indexing event parameters.
Use 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:
- In
SFCUpdateVerifier.sol
, theNone
parameter - In
ValidatorsRegistry.sol
, thenewValidators
parameter
Consider using calldata
as the data location for the parameters of external
functions to optimize gas usage.
Functions Updating State Without Event Emissions
Throughout the codebase, multiple instances of functions updating the state without an event emission were identified:
- The
markActivity
function inActivityMonitor.sol
- The
setTokenDepositAddress
function inBridge.sol
- The
setProofVerifier
function inBridge.sol
- The
setTokenDeposit
function inExitAdministrator.sol
- The
update
function inStateOracle.sol
- The
getDeadStatus
function inTokenDeposit.sol
- The
setProofVerifier
function inTokenDeposit.sol
- The
register
function inTokenPairs.sol
- The
update
function inUpdateManager.sol
- The
setHeartbeat
function inUpdateManager.sol
- The
setFastLanePadding
function inUpdateManager.sol
- The
setFastLaneFee
function inUpdateManager.sol
- The
setActivityMonitor
function inUpdateManager.sol
- The
setValidators
function inValidatorsRegistry.sol
Consider emitting events whenever there are state changes to make the codebase less error-prone and improve its readability.
Conclusion
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.