Table of Contents
Summary
- Type
- Timeline
- From 2025-03-03
- To 2025-03-19
- Languages
- Solidity
- Total Issues
- 18 (6 resolved)
- Critical Severity Issues
- 3 (2 resolved)
- High Severity Issues
- 1 (1 resolved)
- Medium Severity Issues
- 2 (2 resolved)
- Low Severity Issues
- 4 (1 resolved)
- Notes & Additional Information
- 8 (0 resolved)
Scope
We audited the 0xFFoundation/fchain-contracts repository at commit 11ffd45.
In scope were the following files:
fchain-contracts/
└── src/
├── StakeManager.sol
└── ValidatorRegistry.sol
System Overview
The audited codebase manages the validator set for F Foundation's "FCHAIN", a subnet following the ACP-77 specification in the Avalanche ecosystem. The contracts in scope are a fork of Ava Lab's ICM contracts. The codebase utilizes the Warp Messenger, a standard that uses precompiles to send messages across a subnet and the Avalanche P-chain. Messages can be signed by P-chain validators to manage the official validator set of ACP-77 subnets, like the FCHAIN.
The setup of the validation scheme involves "licenses", which are NFTs that will be sold or distributed when the FCHAIN is first launched. These licenses will give a user the ability to become a validator on the FCHAIN. In order to do this, the user will need to lock up their license NFT. Once this is done, the validator can begin earning rewards for each "epoch" in which they have sufficient uptime. Rewards will be paid out in the native token for the FCHAIN and automatically re-staked. Delegators can also stake on validators, earning some rewards for both themselves and the validator, in exchange for not having to do the work of validating. Staking for both validators and delegators requires at least one staked license, but rewards can be increased by staking additional licenses or native tokens.
To prevent manipulation of the validator scheme, there is a time delay for joining and exiting the validator or delegator set. This gives P-chain validators the time to sign any changes to the FCHAIN validator set and prevents validators from receiving rewards for partial epochs. This is implemented generally by having "initialize" and "complete" functions for most state transitions, where, between the two phases, the P-chain generates a signed message verifying the transition.
In addition, to stabilize the system, there is a 1-year period during which delegators cannot withdraw their stakes. This period is the same for everyone and ends 1 year after the beginning of the FCHAIN. Notably, this period is not applicable to validators, allowing them to remove their stakes as soon as they stop validating. While delegators cannot withdraw, they are allowed to change their selected validator during this time and deposit more stakes.
Security Model and Trust Assumptions
During the audit, the following trust assumptions were made:
- It is assumed that the Warp messenger precompiles and Warp messaging protocol function as stated in the ACP-77 specification.
- It is assumed that the
updateBalances
orupdateMultipleBalances
functions will be called after the grace period of every epoch, but before the epoch ends, for everystakeID
actively validating the system. - It is assumed that uptime proofs will be signed by the P-chain validators for each
stakeID
during the grace period for each epoch. It is similarly assumed thatsubmitUptimeProof
orsubmitMultipleUptimeProofs
will be called during the grace period for each epoch, for everystakeID
that is registered. - It is assumed that P-chain validators will sign Warp messages in a timely manner as described by ACP-77.
While the uptime and rewards accrual scheme is fragile, it has been incentivized to function properly. The functions for tabulating uptime and for accruing rewards are openly callable, capable of being called for more than one epoch at a time, and may not be called for every validator or delegator for every epoch. However, we understand that the F Foundation team intends to automate calls to the applicable functions. As such, we make the following assumptions as part of our security assessment:
- It is assumed that an automated service will submit uptime proofs for all validators, for all epochs, before the end of the grace period for that epoch.
- It is assumed that uptime proofs will only be created once per epoch, per validator.
- It is assumed that balance updates will be submitted after the grace period of each epoch and before the beginning of the next epoch, for each validator and for each delegator, once per epoch.
- The P-chain Validator set has the ability to send Warp messages, which can be used to affect the validator and delegator set of the FCHAIN. It is further assumed that the P-chain Validators will only sign Warp messages which are valid, and that they will check the state of the FCHAIN before signing messages pertaining to it.
Privileged Roles
Throughout the codebase, the following privileged roles were identified:
- Within the
StakeManager
contract, only theDEFAULT_ADMIN_ROLE
is authorized to upgrade the contract. - Within the
ValidatorRegistry
contract, theVALIDATOR_ADMIN_ROLE
can:- initialize validator registration.
- complete validator registration.
- initialize end validation.
- complete end validation.
- mint native tokens.
- set validator weight.
- Within the
ValidatorRegistry
contract, theDEFAULT_ADMIN_ROLE
can grant or revokeVALIDATOR_ADMIN_ROLE
role.
ICM Contracts Under Development
It should be noted that the ICM contracts, which the audited codebase has been forked from, are still under active development. Thus, it is recommended that the F Foundation team keep an eye on new developments in the ICM contracts codebase, and review any changes for integration into the audited codebase. The F Foundation team should inspect new releases of the ICM contracts and their associated discussions. Moreover, they should monitor the discussions associated with ACP-77 and ACP-99.
Critical Severity
Locked-In Licenses Can Be Transferred
Possession of an FNode
license is a requirement to become a validator on FCHAIN. Validators must lock their ERC-721-compliant FNode
tokens into the StakeManager
contract to be recognized as active validators and receive incentives, while delegators can also lock and delegate their FNode
tokens to active validators to earn rewards.
Within the StakeManager
contract, both validators and delegators lock their FNode
licenses by invoking the internal
_lockLicenses
function during the initiation phases of validator registration, delegator registration, or when adding new stakes. This function confirms that the caller (_msgSender
) is the owner of the ERC-721 token and then “locks” the token by updating the tokenLockedBy[tokenID]
mapping. Notably, only the internal
mapping is modified the token itself is not transferred to the contract.
This allows the owner of the FNode
token to be able to transfer ownership of their "locked" license by invoking the transferFrom
or safeTransferFrom
functions of the IERC721
interface, which effectively cancels their eligibility as a network validator. Nevertheless, the original stake in StakeManager
continues to earn rewards since the license remains counted in the validator’s overall weight. In addition, the new owner of the license cannot restake the token because it is already recorded in the tokenLockedBy
mapping.
Consider transferring the FNode
token to the StakeManager
contract during the locking process and returning it to the validator or delegator upon unlocking. Alternatively, consider making the FNode
tokens non-transferrable as long as they are part of an active validator's weight.
Update: Acknowledged, will resolve. The team stated:
NFTs are going to be non-transferable for a year, so we are not going to change this part at the moment.
Deposited Stakes Can Be Locked in StakeManager
if the Validator Is Inactive
The initializeDeposit
function is designed to accept licenses and staking amounts as stakes for a validating node within the system. This function can be called by the validators to increase their own weight or by delegators to increase the weight of their delegated validator. However, the function does not validate the active status of a validator before proceeding with the deposit, allowing the stakers to inadvertently deposit resources into validators that are inactive. The function then locks the deposited licenses and value within the StakeManager
contract, adds the newly added weight to the validator's existing weight, and sends the corresponding set weight message to the P-Chain. It also creates an entry in the pendingDeposits
mapping such that when completeDeposit
function is called, these stakes are added to the nextEpochDeposit
mapping, which is processed when the staker's balances are updated.
Given that the validator is already inactive, the P-Chain will reject the message to add weight and there will be no corresponding messageIndex
available to call the completeDeposit
function. Therefore, pendingDeposits
are never counted as part of the staker's balances. Furthermore, given that the locked stakes are not a part of staker's balance, the staker will not be able to withdraw these stakes due to the revert in the createWithdrawalRequest
function, thereby locking these stakes inevitably within the StakeManager
contract.
Consider checking that the validator is active in the initializeDeposit
function before accepting deposits.
Update: Resolved in pull request #46. The initializeDeposit
function correctly checks if the validator is active before accepting stakes. Additionally, a cancelDeposit
function has been introduced that allows users to withdraw any deposited stakes if the validator becomes inactive before the deposit is complete.
Validators Can Skip createEndRequest
and Quickly Re-Register
Within the ACP-77 specification, it is identified that a validator can only be registered once:
When it is known that a given validationID is not and never will be registered, the P-Chain must be willing to sign an L1ValidatorRegistrationMessage for the validationID with registered set to false. This could be the case if the expiry time of the message has passed prior to the message being delivered in a RegisterL1ValidatorTx, or if the validator was successfully registered and then later removed
However, it is possible for validators to re-register only on the FCHAIN, which creates a discrepancy between the validator sets recorded on the FCHAIN and those on the P-chain. This is because once a validator is de-registered, the P-chain is obligated to refuse to register it again.
To do this, a validator must first call initializeValidatorRegistration
. Once the L1ValidatorRegistrationMessage
is received from the P-chain, the validator can call completeValidatorRegistration
. After this, the validator can quickly call initializeEndValidation
and completeEndValidation
, then once again call initializeValidatorRegistration
and completeValidatorRegistration
, using the same messageIndex
as the original registration. This must be performed without calling _updateBalances
for that validator before initializeEndValidation
is called, as doing so will affect the stake's fNodesTokenIDs
mapping.
Additionally, this must all be done before the registrationExpiry
time is reached. After this has occurred, the validator's isValidationEnded
boolean will be set to true
. This will have the effect of preventing that validator's ending permanently, preventing the processing of uptime proofs, and resulting in a mismatch between the validator sets recorded on the P-chain and on the FCHAIN. Note that another property of this vulnerability is validators are able to skip calling createEndRequest
entirely. This is clearly not the intended functionality.
Consider checking isValidationEnded
within the validator registration flow. Also, consider enforcing that in all cases, _updateBalances
is called at least once before a validator can initialize the ending validation. It may be possible to enforce this by flagging any new validators as "needing balance updates", or by checking the startEpoch
and nextEpochDeposit
values for that validator.
Update: Resolved in pull request #43.
High Severity
Insufficient Checks on Warp Messages
Within the completeValidatorChange
and completeDelegatorRegistration
functions, Warp messages are ingested and utilized to validate logic. However, these Warp messages are insufficiently checked.
In completeValidatorChange
, any value can be specified for oldNonceTarget
and newNonceTarget
, but these values are only used once, for checks against oldNonceMessage
and newNonceMessage
. Because of this, any message indices can be specified, allowing the function to easily execute without having the expected Warp messages. This may allow some delegator to quickly change their validator without having the approval of the P-chain. This can also be leveraged to trigger changes for validators that the caller does not control, potentially affecting other users' rewards.
In completeDelegatorRegistration
, the only value in the Warp message that is used is the validationID
. Thus, any incorrect message with that same validationID
may be used to make the function execute successfully. This may allow any delegator to register with a validator without having the approval of the P-chain.
In both cases, the amount of rewards accrued by this delegator can be easily manipulated. Thus, consider recording nonces for validator changes within initiateValidatorChange
and using these in completeValidatorChange
to ensure that the messages used are newer than the initial request. This may also be used to prevent users from changing the validator of delegators that they do not know. Similarly, consider storing the nonce
obtained in initializeDelegatorRegistration
and recording the nonce
obtained from the Warp message parsed in completeDelegatorRegistration
. These values should be compared with each other to ensure that the P-Chain Warp message is newer than the request to initialize delegation.
Update: Resolved in pull request #45.
Medium Severity
Potential Stake Lock and Inconsistency Due to Validator State Transitions
The StakeManager
contract facilitates the registration process for delegators to stake on active validators. It also includes a createEndRequest
function, enabling validators to initiate their withdrawal from the network. A problem arises when a delegator initiates staking on a validator that has begun the exit process through createEndRequest
but has not yet updated their balance, leaving their isValidationActive
flag set to true
. This flag is only set to false
upon a balance update after the creation of the validation end request.
During this period, a delegator can execute the initializeDelegatorRegistration
function, which adds the weight of the stake to the validator, broadcasts the message to P-Chain, and locks the stakes within the contract through the _lockStakesAndLicensesSetTotalStats
internal
function. These stakes are then supposed to be reflected in the staker's balance upon calling completeDelegatorRegistration
in the subsequent epoch, following a balance update.
However, if the validator proceeds to update their balances and initiate the end of their validation before the delegator completes their registration, the validator's weight could be reduced to zero. This sequence of actions introduces two significant issues:
- If the weight update message from
initializeDelegatorRegistration
is processed after the validator's exit due to delayed pickup from the relayer or out-of-order processing by the P-Chain, it may be rejected if the validator is marked inactive on the P-Chain. This means that there will be nomessageIndex
for completing the delegator's registration, effectively locking the staked funds in theStakeManager
contract without recourse. - In cases where the
initializeDelegatorRegistration
message is processed in time, providing a validmessageIndex
, but the validator exits beforecompleteDelegatorRegistration
is executed, the system may still finalize the stake registration. This creates a discrepancy as the validator's weight is zero, yet the system acknowledges a stake registration. Consequently, delegators are unable to earn rewards due to the inability to verify the validator's uptime, nor can they unlock their tokens unless thedelegationLockDeadline
has elapsed, forcing them to reallocate their delegation.
This vulnerability extends to the initializeDeposit
and completeDeposit
functions, presenting a broader attack vector within the staking mechanism.
Consider adding a PendingRemoved
status state for validators undergoing an exit and implementing a conditional check in the completeDelegatorRegistration
and completeDeposit
functions to release stakes if a validator is in this transitional state. Doing this would help safeguard against unintended stake lock and ensure consistency in validator and delegator states.
Update: Resolved in pull request #47.
Resending Failed Set Weight Message Functionality is Needed
It is possible for Warp messages sent to the P-chain to be considered invalid if they are not signed by at least 67% of the FCHAIN validators. Many flows exist in StakeManager
that depend on L1ValidatorWeightMessage
. However, if these messages are dropped by the P-chain, such flows could be frozen, causing problems for users. For example, the completeWithdrawal
function may be unusable if an L1ValidatorWeightMessage
is never sent, leaving tokens locked in the StakeManager
contract.
Consider creating a function similar to resendRegisterValidatorMessage
and resendEndValidatorMessage
, but for the L1ValidatorWeightMessage
. This will allow action to be taken in the case where an important validator weight message is dropped by the P-chain.
Update: Resolved in pull request #44.
Low Severity
Incomplete Docstrings
Throughout the codebase, multiple instances of incomplete docstrings were identified:
-
In
StakeManager.sol
, in thecreateWithdrawalRequest
function docstring, thestakeID
,amount
,fNodesTokenIDs
parameters are not documented. -
In
ValidatorRegistry.sol
, in theaddValidatorAdmin
function docstring, theaccount
parameter is not documented. -
In
ValidatorRegistry.sol
, in theremoveValidatorAdmin
function docstring, theaccount
parameter is not documented.
Consider thoroughly documenting all functions/events (and their parameters or return values) that are part of a contract's public API. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Acknowledged, will resolve.
Insufficient Validation in initializeDeposit
The initializeDeposit
function allows deposits of both native tokens and lockups for licenses. However, in the case where fNodesTokenIDs
is empty and msg.value == 0
, the function will execute successfully without making any changes to the validator or delegator's stake, thereby, causing unnecessary gas consumption.
Following the "fail early and loudly" principle, consider adding a check for the length of fNodesTokenIDs
and msg.value
, reverting with a descriptive error if both of these values are 0.
Update: Resolved in pull request #48.
Lack of In-line Documentation
The StakeManager
and ValidatorRegistry
contracts are observed to have a significant deficiency in in-line documentation, particularly the absence of docstrings across numerous functions and their input variables. This shortfall not only hampers the readability and maintainability of the codebase but also poses substantial challenges in ensuring its security and functionality.
Consider adding in-line documentation throughout the codebase to ensure that all functions, especially those that are public or external, are accompanied by detailed docstrings. These should include descriptions of the function's purpose, parameters, return values, emitted events, and any side effects or important notes regarding its behavior. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Acknowledged, will resolve.
Update initialize...
Function Names to initiate...
Within the StakeManager
contract, there are 5 functions that follow the naming convention of initialize...
, such as:
initializeValidatorRegistration
initializeDelegatorRegistration
initializeDeposit
initializeEndValidation
initializeWithdraw
These functions all have corresponding complete...
functions. However, there is also a function with a similar naming that does not follow this convention: initiateValidatorChange
, and it has a similar complete...
function.
Consider changing the names of these 5 identified functions to initiate...
. This will help keep the codebase consistent and easier to understand. Moreover, doing this will help separate these functions from the initialize
function and any associated functions related to initialization. Finally, it will help match the most recent version of icm-contracts
ValidatorManager.sol
which has made the same change.
Update: Acknowledged, will resolve.
Notes & Additional Information
Unaddressed TODO Comments
During development, having well-described TODO comments will make the process of tracking and solving them easier. However, left unaddressed, these comments might age and important information for the security of the system might be forgotten by the time it is released to production. Thus, TODO comments should be tracked in the project's issue backlog and resolved before the system is deployed.
A TODO comment was identified in line 543 of StakeManager.sol
.
Consider addressing all instances of TODO comments and tracking them in the issues backlog.
Update: Acknowledged, will resolve.
Inconsistent Function Ordering
The StakeManager
and ValidatorRegistry
contracts deviate from the Solidity Style Guide due to having an inconsistent ordering of functions. For instance, in the StakeManager
contract, the submitUptimeProof
external function is placed after the _processUptimeProof
internal function.
To improve the project's overall legibility, consider standardizing ordering throughout the codebase as recommended by the Solidity Style Guide (Order of Functions).
Update: Acknowledged, will resolve.
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.
Within StakeManager.sol
, the tokenExists
mapping does not have named parameters.
Consider adding named parameters to mappings in order to improve the readability and maintainability of the codebase.
Update: Acknowledged, will resolve.
Unused Named Return Variables
Named return variables are a way to declare variables that are meant to be used within a function's body for the purpose of being returned as that function's output. They are an alternative to explicit in-line return
statements.
Within StakeManager.sol
, multiple instances of unused named return variables were identified:
- The
validationID
return variable of the_processPendingValidatorChange
function - The
result
return variable of the_getEpochRewardForDelegator
function
Consider either using or removing any unused named return variables.
Update: Acknowledged, will resolve.
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 their maintainers to contact the appropriate person about the problem and provide mitigation instructions.
Both the StakeManager
and the ValidatorRegistry
contracts do not have a security contact. In addition, the ValidatorManager
contract currently specifies a security contact for ava-labs/icm-contracts
instead of specifying one for 0xFFoundation/fchain-contracts
.
Consider adding a NatSpec comment containing the correct 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.
Update: Acknowledged, will resolve.
Typographical Errors
Throughout the StakeManager
contract, multiple instances of typographical errors were identified:
- In line 1301, "calalble" should be "callable".
- In line 1095, "decays by 82.5%" should be "decays by 17.5%".
Consider correcting any instances of typographical errors to improve the clarity and readability of the codebase.
Update: Acknowledged, will resolve.
_disableInitializers
Not Being Called From Initializable Contract Constructors
An implementation contract in a proxy pattern allows anyone to call its initialize
function. While not a direct security concern, preventing the implementation contract from being initialized is important, as this could allow an attacker to take over the contract. This would not affect the proxy contract's functionality, as only the implementation contract's storage would be affected.
Throughout the codebase, multiple instances of initializable contracts, where _disableInitializers()
is not called in the constructors, were identified:
- The initializable contract
StakeManager
within theStakeManager.sol
file - The initializable contract
ValidatorRegistry
within theValidatorRegistry.sol
file
Consider calling _disableInitializers()
inside the constructors of initializable contracts to prevent malicious actors from front-running initialization.
Update: Acknowledged, will resolve.
Function Visibility Overly Permissive
Within the codebase, there are various functions with unnecessarily permissive visibility:
-
The
_validateEpoch
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_processUptimeProof
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_hasGracePeriodPassed
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getEpochEndTime
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getPChainWarpMessage
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getWeight
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getWeightSetLosses
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_lockStakesAndLicensesSetTotalStats
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_lock
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_lockLicenses
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_validateStakeAmount
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_unlock
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_unlockLicenses
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_processPendingValidatorChange
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_processPendingBalanceChanges
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getEpochReward
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getEpochRewardForValidator
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_getEpochRewardForDelegator
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_updateBalances
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
_revertIfDeadlineNotPassed
function inStakeManager.sol
withinternal
visibility could be limited toprivate
. -
The
completeValidatorRegistration
function inValidatorRegistry.sol
withpublic
visibility could be limited toexternal
.
To better convey the intended use of functions and to potentially realize some additional gas savings, consider changing a function's visibility to be only as permissive as required.
Update: Acknowledged, will resolve.
Conclusion
The audited codebase manages the validator set for F Foundation's FCHAIN, a subnet operating under the ACP-77 specification in the Avalanche ecosystem. The audited code interacts with a fork of Ava Lab's ICM contracts ValidatorManager
contract, which was out of scope of this audit.
Several critical and high-severity issues were identified, primarily related to the validation of key parameters used for registering validators and delegators. While some documentation is available in the docs folder of the audited codebase, adding more in-line documentation within the contracts could further enhance clarity. Since the ICM contracts are still under active development by Ava Labs, we encourage the F Foundation team to stay informed about any updates that may impact this codebase and to integrate relevant changes as needed.
The client was very responsive during the audit, addressed doubts promptly, and helped triage potential issues.