1 Executive Summary
This report presents the results of our engagement with ConsenSys/Kilnfi to review the Staking Contracts.
The review was conducted over two weeks, from Aug 28, 2023 to Sept 08, 2023. A total of 2x10 person-days were spent.
The examined system encompasses a suite of staking contracts designed to facilitate registered node operators’ deposits of validator keys funded by external users. This architecture streamlines the staking process for end users, enabling them to stake with one or multiple validators using a single transaction.
Distinct roles within the system include the EndUser
, responsible for providing funds for validator node staking; the Operator
, tasked with managing and operating a set of validator nodes, including their associated keys; and Administrative accounts
(system and proxy) responsible for system maintenance.
The system is equipped with intricate logic and data structures to accommodate multiple operators, introducing a significant level of complexity into the contracts. However, it’s worth noting that the system currently permits only one operator, as per the client’s intention to retain this role exclusively, with no plans to involve external operators. Although there exists the potential for deposit contract front-running by operators, it is easily detectable. Furthermore, there is also potential for operators to not comply with their staking users requests to exit from staking or operators changing/presetting beacon chain credentials. However, given that the system is not open to untrusted external operators and the client is an officially recognized legal entity, the likelihood of such events leading to legal disputes or compliance issues is minimal.
The contracts are designed with pausability and upgradeability features. The proxy administrator retains the authority to execute system upgrades at any point in time, and parameters can be modified without prior notice to users, a centralization aspect that warrants consideration. WithdrawCredentials for nodes are determined and linked to a deterministic address generated using the create2
method. This address is indirectly controlled by the EndUser
, acting as the “withdrawer.”
It’s important to highlight that the contract deployed via create2
functions as a clone proxy, pointing to a designated FeeRecipientImplementation
. While there is presently no inherent logic to alter the FeeRecipientImplementation
, an internal setter is in place, but it is exclusively utilized during the initialization phase.
It is crucial to note that making changes to the FeeRecipientImplementation
while users have active stakes could result in a withdrawal credential mismatch, rendering funds unwithdrawable. This scenario may occur when user stakes are associated with withdrawal credentials configured for their create2
rewards recipient. Should the contract administrator decide to upgrade the contracts and modify the FeeRecipientImplementation
, users attempting to withdraw rewards may face complications, as the create2
deployment would direct to a different address due to the altered implementation.
2 Scope
Our review focused on the commit hash kilnfi/staking-contracts@f33eb8dc37fab40217dbe1e69853ca3fcd884a2d. The list of files in scope can be found in the Appendix. The following official documents were provided to the review team:
2.1 Objectives
Together with the client, we identified the following priorities for our review:
- Correctness of the implementation, consistent with the intended functionality and without unintended edge cases.
- Identify known vulnerabilities particular to smart contract systems, as outlined in our Smart Contract Best Practices, and the Smart Contract Weakness Classification Registry.
3 System Overview
This section describes the top-level/deployable contracts, their inheritance structure and interfaces, actors, permissions and important contract interactions of the system under review.
Contracts are depicted as boxes. Public reachable interface methods are outlined as rows in the box. The 🔍 icon indicates that a method is declared as non-state-changing (view/pure) while other methods may change state. A row at the top of the contract shows inherited contracts. A green row at the top of the contract indicates that that contract is used in a usingFor declaration. Modifiers used as ACL are connected as bubbles in front of methods.
4 Findings
Each issue has an assigned severity:
- Minor issues are subjective in nature. They are typically suggestions around best practices or readability. Code maintainers should use their own judgment as to whether to address such issues.
- Medium issues are objective in nature but are not security vulnerabilities. These should be addressed unless there is a clear reason not to.
- Major issues are security vulnerabilities that may not be directly exploitable or may require certain conditions in order to be exploited. All major issues should be addressed.
- Critical issues are directly exploitable security vulnerabilities that need to be fixed.
4.1 Incorrect Priviliges setOperatorAddresses
Major Acknowledged
Description
The function setOperatorAddresses
instead of allowing the Operator to update its own, as well as the Fee Recipient address, incorrectly provides the privileges to the Fee Recipient. As a result, the Fee Recipient can modify the operator address as and when needed, to DoS the operator and exploit the system. Additionally, upon reviewing the documentation, we found that there are no administrative rights defined for the Fee Recipient, hence highlighting the incorrect privilege allocation.
src/contracts/StakingContract.sol:L412-L424
function setOperatorAddresses(
uint256 _operatorIndex,
address _operatorAddress,
address _feeRecipientAddress
) external onlyActiveOperatorFeeRecipient(_operatorIndex) {
_checkAddress(_operatorAddress);
_checkAddress(_feeRecipientAddress);
StakingContractStorageLib.OperatorsSlot storage operators = StakingContractStorageLib.getOperators();
operators.value[_operatorIndex].operator = _operatorAddress;
operators.value[_operatorIndex].feeRecipient = _feeRecipientAddress;
emit ChangedOperatorAddresses(_operatorIndex, _operatorAddress, _feeRecipientAddress);
}
Recommendation
The modifier should be onlyActiveOperatorOrAdmin
allowing only the operator itself or admin of the system, to update the necessary addresses.
Also, for transferring crucial privileges from one address to another, the operator’s address should follow a 2-step approach like transferring ownership.
4.2 Unconstrained Snapshot While Setting Operator Limit Medium
Description
Function setOperatorLimit
as the name says, allows the SYS_ADMIN
to set/update the staking limit for an operator. The function ensures that if the limit is being increased, the _snapshot
must be ahead of the last validator edit(block.number
at which the last validator edit occurred). However, the parameter _snapshot
is unconstrained and can be any number. Also, the functions addValidators
and removeValidators
update the block.number
signifying the last validator edit, but never constrain the new edits with it. Since there are no publicly available functions to access this value, makes the functionality even more confusing and may be unnecessary.
src/contracts/StakingContract.sol:L468-L473
if (
operators.value[_operatorIndex].limit < _limit &&
StakingContractStorageLib.getLastValidatorEdit() > _snapshot
) {
revert LastEditAfterSnapshot();
}
Recommendation
If the functionality is not needed, consider removing it. Otherwise, add some necessary logic to either constrain the last validator edit or add public functions for the users to access it.
4.3 Missing Input Validation Medium
Description
- There is no zero address check in function
addOperator
for the operator address and fee recipient. Also, the function doesn’t check whether the operator already exists.
src/contracts/StakingContract.sol:L392-L405
function addOperator(address _operatorAddress, address _feeRecipientAddress) external onlyAdmin returns (uint256) {
StakingContractStorageLib.OperatorsSlot storage operators = StakingContractStorageLib.getOperators();
StakingContractStorageLib.OperatorInfo memory newOperator;
if (operators.value.length == 1) {
revert MaximumOperatorCountAlreadyReached();
}
newOperator.operator = _operatorAddress;
newOperator.feeRecipient = _feeRecipientAddress;
operators.value.push(newOperator);
uint256 operatorIndex = operators.value.length - 1;
emit NewOperator(_operatorAddress, _feeRecipientAddress, operatorIndex);
return operatorIndex;
}
- No zero address check in function
setTreasury
for updating the treasury address.
src/contracts/StakingContract.sol:L214-L217
function setTreasury(address _newTreasury) external onlyAdmin {
emit ChangedTreasury(_newTreasury);
StakingContractStorageLib.setTreasury(_newTreasury);
}
- Functions
activateOperator
anddeactivateOperator
as the name hints, allow the admin to activate or deactivate an operator. However, the functions don’t check for already activated/deactivated operators, and may still emitDeactivatedOperator
/ActivatedOperator
events for an operator. It may trigger false alarms for off-chain monitoring tools and create unnecessary panic.
src/contracts/StakingContract.sol:L479-L502
/// @notice Deactivates an operator and changes the fee recipient address and the staking limit
/// @param _operatorIndex Operator Index
/// @param _temporaryFeeRecipient Temporary address to receive funds decided by the system admin
function deactivateOperator(uint256 _operatorIndex, address _temporaryFeeRecipient) external onlyAdmin {
StakingContractStorageLib.OperatorsSlot storage operators = StakingContractStorageLib.getOperators();
operators.value[_operatorIndex].limit = 0;
emit ChangedOperatorLimit(_operatorIndex, 0);
operators.value[_operatorIndex].deactivated = true;
emit DeactivatedOperator(_operatorIndex);
operators.value[_operatorIndex].feeRecipient = _temporaryFeeRecipient;
emit ChangedOperatorAddresses(_operatorIndex, operators.value[_operatorIndex].operator, _temporaryFeeRecipient);
_updateAvailableValidatorCount(_operatorIndex);
}
/// @notice Activates an operator, without changing its 0 staking limit
/// @param _operatorIndex Operator Index
/// @param _newFeeRecipient Sets the fee recipient address
function activateOperator(uint256 _operatorIndex, address _newFeeRecipient) external onlyAdmin {
StakingContractStorageLib.OperatorsSlot storage operators = StakingContractStorageLib.getOperators();
operators.value[_operatorIndex].deactivated = false;
emit ActivatedOperator(_operatorIndex);
operators.value[_operatorIndex].feeRecipient = _newFeeRecipient;
emit ChangedOperatorAddresses(_operatorIndex, operators.value[_operatorIndex].operator, _newFeeRecipient);
}
4.4 Hardcoded Operator Limit Logic Medium
Description
The contract defines some hardcoded limits which is not the right approach for upgradeable contracts and opens doors for accidental mistakes, if not handled with care.
The operators for the current version are limited to 1. If the auditee team decides to open the system to work with more operators but fails to change the limit while upgrading, the upgraded contract will have no effect, and will still disallow any more operators to be added.
src/contracts/StakingContract.sol:L392-L398
function addOperator(address _operatorAddress, address _feeRecipientAddress) external onlyAdmin returns (uint256) {
StakingContractStorageLib.OperatorsSlot storage operators = StakingContractStorageLib.getOperators();
StakingContractStorageLib.OperatorInfo memory newOperator;
if (operators.value.length == 1) {
revert MaximumOperatorCountAlreadyReached();
}
Also, the function _depositOnOneOperator
hardcodes the operator Index as 0 since the contract only supports one operator.
src/contracts/StakingContract.sol:L893-L896
function _depositOnOneOperator(uint256 _depositCount, uint256 _totalAvailableValidators) internal {
StakingContractStorageLib.setTotalAvailableValidators(_totalAvailableValidators - _depositCount);
_depositValidatorsOfOperator(0, _depositCount);
}
Recommendation
A better approach could be to constrain the limit of operators that can be added with a storage variable or constant, provided at the time of contract initialization. The contract should also consider supporting dynamic operator deposits for future versions instead of the default hardcoded index.
4.5 StakingContract - PubKey Length Checks Not Always Enforced Medium
Description
addValidators
checks that the provided bytes pubKey
is a multiple of the expected pubkey length while functions like setWithdrawer
do not enforce similar length checks. This is an inconsistency that should be avoided.
addValidators
enforcing input length checks
src/contracts/StakingContract.sol:L530-L542
function addValidators(
uint256 _operatorIndex,
uint256 _keyCount,
bytes calldata _publicKeys,
bytes calldata _signatures
) external onlyActiveOperator(_operatorIndex) {
if (_keyCount == 0) {
revert InvalidArgument();
}
if (_publicKeys.length % PUBLIC_KEY_LENGTH != 0 || _publicKeys.length / PUBLIC_KEY_LENGTH != _keyCount) {
revert InvalidPublicKeys();
}
setWithdrawer
accepting any length for apubKey
. Note that_getPubKeyRoot
will take any input provided and concat it the zero bytes.
src/contracts/StakingContract.sol:L426-L445
/// @notice Set withdrawer for public key
/// @dev Only callable by current public key withdrawer
/// @param _publicKey Public key to change withdrawer
/// @param _newWithdrawer New withdrawer address
function setWithdrawer(bytes calldata _publicKey, address _newWithdrawer) external {
if (!StakingContractStorageLib.getWithdrawerCustomizationEnabled()) {
revert Forbidden();
}
_checkAddress(_newWithdrawer);
bytes32 pubkeyRoot = _getPubKeyRoot(_publicKey);
StakingContractStorageLib.WithdrawersSlot storage withdrawers = StakingContractStorageLib.getWithdrawers();
if (withdrawers.value[pubkeyRoot] != msg.sender) {
revert Unauthorized();
}
emit ChangedWithdrawer(_publicKey, _newWithdrawer);
withdrawers.value[pubkeyRoot] = _newWithdrawer;
}
src/contracts/StakingContract.sol:L775-L777
function _getPubKeyRoot(bytes memory _publicKey) internal pure returns (bytes32) {
return sha256(abi.encodePacked(_publicKey, bytes16(0)));
}
- similarly, the withdraw family of functions does not enforce a pubkey length either. However, it is unlikely that someone finds a pubkey that matches a root for the attackers address.
src/contracts/StakingContract.sol:L696-L702
/// @notice Withdraw the Execution Layer Fee for a given validator public key
/// @dev Funds are sent to the withdrawer account
/// @param _publicKey Validator to withdraw Execution Layer Fees from
function withdrawELFee(bytes calldata _publicKey) external {
_onlyWithdrawerOrAdmin(_publicKey);
_deployAndWithdraw(_publicKey, EXECUTION_LAYER_SALT_PREFIX, StakingContractStorageLib.getELDispatcher());
}
Nevertheless, the methods should be hardened so as not to give a malicious actor the freedom to use an unexpected input size for the pubKey
argument.
Recommendation
Enforce pubkey length checks when accepting a single pubkey as bytes similar to the batch functions that check for a multiple of ´PUBLIC_KEY_LENGTH´. Alternatively, declare the function argument as bytes48
(however, in this case inputs may be auto-padded to fit the expected length, pot. covering situations that otherwise would throw an error)
4.6 Unpredictable Behavior Due to Admin Front Running or General Bad Timing Medium
Description
In a number of cases, administrators of contracts can update or upgrade things in the system without warning. This has the potential to violate a security goal of the system.
Specifically, privileged roles could use front running to make malicious changes just ahead of incoming transactions, or purely accidental negative effects could occur due to the unfortunate timing of changes.
Some instances of this are more important than others, but in general, users of the system should have assurances about the behavior of the action they’re about to take.
Examples
-
Upgradeable TU proxy
-
Fee changes take effect immediately
src/contracts/StakingContract.sol:L504-L512
/// @notice Change the Operator fee
/// @param _operatorFee Fee in Basis Point
function setOperatorFee(uint256 _operatorFee) external onlyAdmin {
if (_operatorFee > StakingContractStorageLib.getOperatorCommissionLimit()) {
revert InvalidFee();
}
StakingContractStorageLib.setOperatorFee(_operatorFee);
emit ChangedOperatorFee(_operatorFee);
}
src/contracts/StakingContract.sol:L513-L522
/// @notice Change the Global fee
/// @param _globalFee Fee in Basis Point
function setGlobalFee(uint256 _globalFee) external onlyAdmin {
if (_globalFee > StakingContractStorageLib.getGlobalCommissionLimit()) {
revert InvalidFee();
}
StakingContractStorageLib.setGlobalFee(_globalFee);
emit ChangedGlobalFee(_globalFee);
}
Recommendation
The underlying issue is that users of the system can’t be sure what the behavior of a function call will be, and this is because the behavior can change at any time.
We recommend giving the user advance notice of changes with a time lock. For example, make all upgrades require two steps with a mandatory time window between them. The first step merely broadcasts to users that a particular change is coming, and the second step commits that change after a suitable waiting period.
4.7 Potentially Uninitialized Implementations Medium
Description
Most contracts in the system are meant to be used with a proxy pattern. First, the implementations are deployed, and then proxies are deployed that delegatecall into the respective implementations following an initialization call (hardhat, with same transaction). However, the implementations are initialized explicitly nor are they protected from other actors claiming/initializing them. This allows anyone to call initialization functions on implementations for use with phishing attacks (i.e. contract implementation addresses are typically listed on the official project website as valid contracts) which may affect the reputation of the system.
None of the implementations allow unprotected delegatecalls or selfdesturcts. lowering the severity of this finding.
Examples
src/contracts/StakingContract.sol:L151-L162
function initialize_1(
address _admin,
address _treasury,
address _depositContract,
address _elDispatcher,
address _clDispatcher,
address _feeRecipientImplementation,
uint256 _globalFee,
uint256 _operatorFee,
uint256 globalCommissionLimitBPS,
uint256 operatorCommissionLimitBPS
) external init(1) {
src/contracts/AuthorizedFeeRecipient.sol:L21-L32
/// @notice Initializes the receiver
/// @param _dispatcher Address that will handle the fee dispatching
/// @param _publicKeyRoot Public Key root assigned to this receiver
function init(address _dispatcher, bytes32 _publicKeyRoot) external {
if (initialized) {
revert AlreadyInitialized();
}
initialized = true;
dispatcher = IFeeDispatcher(_dispatcher);
publicKeyRoot = _publicKeyRoot;
stakingContract = msg.sender; // The staking contract always calls init
}
src/contracts/FeeRecipient.sol:L18-L27
/// @param _publicKeyRoot Public Key root assigned to this receiver
function init(address _dispatcher, bytes32 _publicKeyRoot) external {
if (initialized) {
revert AlreadyInitialized();
}
initialized = true;
dispatcher = IFeeDispatcher(_dispatcher);
publicKeyRoot = _publicKeyRoot;
}
Recommendation
Petrify contracts in the constructor and disallow other actors from claiming/initializing the implementations.
4.8 Operator May DoS the Withdrawal or Make It More Expensive Medium
Description
While collecting fees, the operator may:
-
cause DoS for the funds/rewards withdrawal by reverting the call, thus reverting the whole transaction. By doing this, it won’t be receiving any rewards, but so the treasury and withdrawer.
-
make the withdrawal more expensive by sending a huge chunk of
returndata
. As the returndata is copied into memory in the caller’s context, it will add an extra gas overhead for the withdrawer making it more expensive. -
or mint gas token
src/contracts/ConsensusLayerFeeDispatcher.sol:L105-L110
if (operatorFee > 0) {
(status, data) = operator.call{value: operatorFee}("");
if (status == false) {
revert FeeRecipientReceiveError(data);
}
}
Recommendation
A possible solution could be to make a low-level call in an inline assembly block, restricting the returndata
to a couple of bytes, and instead of reverting on the failed call, emit an event, flagging the call that failed.
4.9 ProxyAdmin May Cause DoS for SYS_ADMIN Medium
Description
As the TransparentUpgradeableProxy
doesn’t allow the ProxyAdmin to delegate calls to the implementation, it means the SYS_ADMIN
can’t be the same as ProxyAdmin. Now, talking about the design, the proxy defines a system-wide feature to pause or unpause. If the proxyAdmin pauses the staking contract, it implies no one can interact with it, not even the SYS_ADMIN
, which might not be what the auditee team wants. There may be multiple scenarios where the auditee team wants to intervene manually in the system even if the system is paused, for instance, withdrawing funds while restricting the withdrawer.
if (msg.sender == _getAdmin()) {
bytes memory ret;
bytes4 selector = msg.sig;
if (selector == ITransparentUpgradeableProxy.upgradeTo.selector) {
ret = _dispatchUpgradeTo();
} else if (selector == ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
ret = _dispatchUpgradeToAndCall();
} else if (selector == ITransparentUpgradeableProxy.changeAdmin.selector) {
ret = _dispatchChangeAdmin();
} else if (selector == ITransparentUpgradeableProxy.admin.selector) {
ret = _dispatchAdmin();
} else if (selector == ITransparentUpgradeableProxy.implementation.selector) {
ret = _dispatchImplementation();
} else {
revert("TransparentUpgradeableProxy: admin cannot fallback to proxy target");
}
assembly {
return(add(ret, 0x20), mload(ret))
}
Recommendation
The system-wide pause feature should allow the SYS_ADMIN to call the staking functions if the system is paused. Something like:
function _beforeFallback() internal override {
if (StorageSlot.getBooleanSlot(_PAUSE_SLOT).value == false || msg.sender == stakingContract.getAdmin() || msg.sender == address(0)) {
super._beforeFallback();
}
4.10 Ambiguous/Misleading Return Data Minor
Description
The NATSPEC comment says that the getters return address(0)
or boolean false
if the key doesn’t exist in the contract. However, the functions don’t check for any such logic. As a result, for any disabled validator, the functions may still return existing values besides the expected null values, conveying incorrect information to the users.
src/contracts/StakingContract.sol:L258-L263
/// @notice Retrieve withdrawer of public key
/// @notice In case the validator is not enabled, it will return address(0)
/// @param _publicKey Public Key to check
function getWithdrawer(bytes calldata _publicKey) external view returns (address) {
return _getWithdrawer(_getPubKeyRoot(_publicKey));
}
src/contracts/StakingContract.sol:L265-L270
/// @notice Retrieve withdrawer of public key root
/// @notice In case the validator is not enabled, it will return address(0)
/// @param _publicKeyRoot Hash of the public key
function getWithdrawerFromPublicKeyRoot(bytes32 _publicKeyRoot) external view returns (address) {
return _getWithdrawer(_publicKeyRoot);
}
src/contracts/StakingContract.sol:L272-L277
/// @notice Retrieve whether the validator exit has been requested
/// @notice In case the validator is not enabled, it will return false
/// @param _publicKeyRoot Public Key Root to check
function getExitRequestedFromRoot(bytes32 _publicKeyRoot) external view returns (bool) {
return _getExitRequest(_publicKeyRoot);
}
src/contracts/StakingContract.sol:L279-L284
/// @notice Return true if the validator already went through the exit logic
/// @notice In case the validator is not enabled, it will return false
/// @param _publicKeyRoot Public Key Root of the validator
function getWithdrawnFromPublicKeyRoot(bytes32 _publicKeyRoot) external view returns (bool) {
return StakingContractStorageLib.getWithdrawnMap().value[_publicKeyRoot];
}
src/contracts/StakingContract.sol:L286-L290
/// @notice Retrieve the enabled status of public key root, true if the key is in the contract
/// @param _publicKeyRoot Hash of the public key
function getEnabledFromPublicKeyRoot(bytes32 _publicKeyRoot) external view returns (bool) {
return StakingContractStorageLib.getOperatorIndexPerValidator().value[_publicKeyRoot].enabled;
}
4.11 ConsensusLayerFeeDispatcher/ExecutionLayerFeeDispatcher - Should Hardcode autoPetrify With Highest Initializable Version Instead of User Provided Argument Minor
Description
The version to auto-initialize is not hardcoded with the constructor. On deployment, the deployer may accidentally use the wrong version, allowing anyone to call initialize
on the contract.
Examples
src/contracts/ConsensusLayerFeeDispatcher.sol:L47-L50
/// @notice Constructor method allowing us to prevent calls to initCLFR by setting the appropriate version
constructor(uint256 _version) {
VERSION_SLOT.setUint256(_version);
}
src/contracts/ExecutionLayerFeeDispatcher.sol:L47-L57
/// @notice Constructor method allowing us to prevent calls to initCLFR by setting the appropriate version
constructor(uint256 _version) {
VERSION_SLOT.setUint256(_version);
}
/// @notice Initialize the contract by storing the staking contract and the public key in storage
/// @param _stakingContract Address of the Staking Contract
function initELD(address _stakingContract) external init(1) {
STAKING_CONTRACT_ADDRESS_SLOT.setAddress(_stakingContract);
}
Recommendation
Similar to the init(1)
modifier, it is suggested to track the highest version as a const int
with the contract and auto-initialize to the highest version in the constructor instead of taking the highest version as a deployment argument.
4.12 StakingContract - Misleading Comment Minor
Description
The comment notes that the expected caller is admin
while the modifier checks that msg.sender
is an active operator.
src/contracts/StakingContract.sol:L109-L114
/// @notice Ensures that the caller is the admin
modifier onlyActiveOperator(uint256 _operatorIndex) {
_onlyActiveOperator(_operatorIndex);
_;
}
Recommendation
Rectify the comment to accurately describe the intention of the method/modifier.
4.13 Impractical Checks for Global/Operator Fees and the Commission Limits Minor
Description
The contract initialization sets up the global and operator fees and also their commission limits. However, the checks just make sure that the fees or commission limit is up to 100% which is not a very practical check. Any unusual value, for instance, if set to 100% will mean the whole rewards/funds will be non-exempted and taxed as global fees, which we believe will never be a case practically.
src/contracts/StakingContract.sol:L168-L175
if (_globalFee > BASIS_POINTS) {
revert InvalidFee();
}
StakingContractStorageLib.setGlobalFee(_globalFee);
if (_operatorFee > BASIS_POINTS) {
revert InvalidFee();
}
StakingContractStorageLib.setOperatorFee(_operatorFee);
src/contracts/StakingContract.sol:L188-L197
function initialize_2(uint256 globalCommissionLimitBPS, uint256 operatorCommissionLimitBPS) public init(2) {
if (globalCommissionLimitBPS > BASIS_POINTS) {
revert InvalidFee();
}
StakingContractStorageLib.setGlobalCommissionLimit(globalCommissionLimitBPS);
if (operatorCommissionLimitBPS > BASIS_POINTS) {
revert InvalidFee();
}
StakingContractStorageLib.setOperatorCommissionLimit(operatorCommissionLimitBPS);
}
src/contracts/StakingContract.sol:L516-L522
function setGlobalFee(uint256 _globalFee) external onlyAdmin {
if (_globalFee > StakingContractStorageLib.getGlobalCommissionLimit()) {
revert InvalidFee();
}
StakingContractStorageLib.setGlobalFee(_globalFee);
emit ChangedGlobalFee(_globalFee);
}
src/contracts/StakingContract.sol:L506-L512
function setOperatorFee(uint256 _operatorFee) external onlyAdmin {
if (_operatorFee > StakingContractStorageLib.getOperatorCommissionLimit()) {
revert InvalidFee();
}
StakingContractStorageLib.setOperatorFee(_operatorFee);
emit ChangedOperatorFee(_operatorFee);
}
Recommendation
The fees should be checked with a more practical limit. For instance, checking against a min - max limit, like 20% - 40%.
4.14 Proxy Design Inconsistencies Minor
Description
-
The feature to pause/unpause the contract is available in the proxy, however, via the inherited
isAdmin
modifier the functions still delegate calls to the implementation, if the caller is not the Admin and the system is not paused. This can produce unintended results (including the pause function being callable in the delegate target or its fallback potentially being executable). -
Function
isPaused()
is a state-changing function but should beview
. -
If the
returndata
for the call to functionisPaused()
is less than 32 bytes, the function will still succeed, but with a decoding error, since the function expects an abi encoded boolean. -
The documentation outlines that “no non-view functions can be called,” but even the view functions will become uncallable by other contracts in the paused state.
-
It should be avoided to implement a “hack” to allow off-chain view function calls to bypass pause mode (
msg.sender == address(0)
). This is highly client/library specific, and implementing bypasses for off-chain components on-chain may introduce inconsistencies and unwanted side effects.
Examples
src/contracts/TUPProxy.sol:L23-L47
function isPaused() external ifAdmin returns (bool) {
return StorageSlot.getBooleanSlot(_PAUSE_SLOT).value;
}
/// @dev Pauses system
function pause() external ifAdmin {
StorageSlot.getBooleanSlot(_PAUSE_SLOT).value = true;
}
/// @dev Unpauses system
function unpause() external ifAdmin {
StorageSlot.getBooleanSlot(_PAUSE_SLOT).value = false;
}
/// @dev Overrides the fallback method to check if system is not paused before
/// @dev Address Zero is allowed to perform calls even if system is paused. This allows
/// view functions to be called when the system is paused as rpc providers can easily
/// set the sender address to zero.
function _beforeFallback() internal override {
if (StorageSlot.getBooleanSlot(_PAUSE_SLOT).value == false || msg.sender == address(0)) {
super._beforeFallback();
} else {
revert CallWhenPaused();
}
}
4.15 Contracts Should Inherit From Their Interfaces Minor
Description
The following contracts should enforce correct interface implementation by inheriting from the interface declarations.
Examples
src/contracts/StakingContract.sol:L10-L15
/// @title Ethereum Staking Contract
/// @author Kiln
/// @notice You can use this contract to store validator keys and have users fund them and trigger deposits.
contract StakingContract {
using StakingContractStorageLib for bytes32;
src/contracts/interfaces/IStakingContractFeeDetails.sol:L3-L20
interface IStakingContractFeeDetails {
function getWithdrawerFromPublicKeyRoot(bytes32 _publicKeyRoot) external view returns (address);
function getTreasury() external view returns (address);
function getOperatorFeeRecipient(bytes32 pubKeyRoot) external view returns (address);
function getGlobalFee() external view returns (uint256);
function getOperatorFee() external view returns (uint256);
function getExitRequestedFromRoot(bytes32 _publicKeyRoot) external view returns (bool);
function getWithdrawnFromPublicKeyRoot(bytes32 _publicKeyRoot) external view returns (bool);
function toggleWithdrawnFromPublicKeyRoot(bytes32 _publicKeyRoot) external;
}
src/contracts/FeeRecipient.sol:L4-L6
import "./interfaces/IFeeDispatcher.sol";
contract FeeRecipient {
src/contracts/interfaces/IFeeRecipient.sol:L3-L8
interface IFeeRecipient {
function init(address _dispatcher, bytes32 _publicKeyRoot) external;
function withdraw() external;
}
Recommendation
Inherit from interface.
4.16 Misleading Error Statements Minor
Description
The contracts define custom errors to revert transactions on failed operations or invalid input, however, they convey little to no information, making it difficult for the off-chain monitoring tools to track relevant updates.
src/contracts/StakingContract.sol:L28-L51
error Forbidden();
error InvalidFee();
error Deactivated();
error NoOperators();
error InvalidCall();
error Unauthorized();
error DepositFailure();
error DepositsStopped();
error InvalidArgument();
error UnsortedIndexes();
error InvalidPublicKeys();
error InvalidSignatures();
error InvalidWithdrawer();
error InvalidZeroAddress();
error AlreadyInitialized();
error InvalidDepositValue();
error NotEnoughValidators();
error InvalidValidatorCount();
error DuplicateValidatorKey(bytes);
error FundedValidatorDeletionAttempt();
error OperatorLimitTooHigh(uint256 limit, uint256 keyCount);
error MaximumOperatorCountAlreadyReached();
error LastEditAfterSnapshot();
error PublicKeyNotInContract();
For instance, the init
modifier is used to initialize the contracts with the current Version. The Version initialization ensures that the provided version must be an increment of the previous version, if not, it reverts with an error as AlreadyInitialized()
. However, the error doesn’t convey an appropriate message correctly, as any version other than the expected version will signify that the version has already been initialized.
src/contracts/ConsensusLayerFeeDispatcher.sol:L37-L40
modifier init(uint256 _version) {
if (_version != VERSION_SLOT.getUint256() + 1) {
revert AlreadyInitialized();
}
src/contracts/ExecutionLayerFeeDispatcher.sol:L37-L40
modifier init(uint256 _version) {
if (_version != VERSION_SLOT.getUint256() + 1) {
revert AlreadyInitialized();
}
src/contracts/StakingContract.sol:L81-L84
modifier init(uint256 _version) {
if (_version != StakingContractStorageLib.getVersion() + 1) {
revert AlreadyInitialized();
}
Recommendation
Use a more meaningful statement with enough information to track off-chain for all the custom errors in every contract in scope.
For instance, add the current and supplied versions as indexed parameters, like:
IncorrectVersionInitialization(current version, supplied version)
;
Also, the function can be simplified as
function initELD(address _stakingContract) external init(VERSION_SLOT.getUint256() + 1) {
STAKING_CONTRACT_ADDRESS_SLOT.setAddress(_stakingContract);
}
4.17 Inconsistent Storage Slot Design
Description
Some storage slots are constructed by hashing a string as:
src/contracts/libs/StakingContractStorageLib.sol:L110-L111
bytes32 internal constant OPERATORS_SLOT = keccak256("StakingContract.operators");
while others subtract 1 from the hashed string as:
src/contracts/libs/StakingContractStorageLib.sol:L351-L352
bytes32 internal constant WITHDRAWN_MAPPING_SLOT = bytes32(uint256(keccak256("StakingContract.withdrawn")) - 1);
Recommendation
It is recommended that a consistent design should be adopted.
4.18 Opportunities for Code Improvements
Description
- If
operatorFee
is 100% ofglobalFee
, the check will still be satisfied, even though there is no reward for the treasury.
src/contracts/ConsensusLayerFeeDispatcher.sol:L99-L104
if (globalFee > 0) {
(status, data) = treasury.call{value: globalFee - operatorFee}("");
if (status == false) {
revert TreasuryReceiveError(data);
}
}
Instead the check should be if ((globalFee - operatorFee) > 0)
- Both the conditional branches can be combined in a single check with a logical
or
operator to increase readability
src/contracts/StakingContract.sol:L100-L107
modifier onlyActiveOperatorOrAdmin(uint256 _operatorIndex) {
if (msg.sender == StakingContractStorageLib.getAdmin()) {
_;
} else {
_onlyActiveOperator(_operatorIndex);
_;
}
}
- Instead of referencing the outer struct, the functions should reference the nested struct. For instance,
StakingContractStorageLib.OperatorsSlot
struct, the functions can reference theStakingContractStorageLib.OperatorInfo
based on the operator’s index. This will increase the code readability, asOperatorInfo
is what actually stores the operator’s information.
src/contracts/StakingContract.sol:L460-L474
StakingContractStorageLib.OperatorsSlot storage operators = StakingContractStorageLib.getOperators();
if (operators.value[_operatorIndex].deactivated) {
revert Deactivated();
}
uint256 publicKeyCount = operators.value[_operatorIndex].publicKeys.length;
if (publicKeyCount < _limit) {
revert OperatorLimitTooHigh(_limit, publicKeyCount);
}
if (
operators.value[_operatorIndex].limit < _limit &&
StakingContractStorageLib.getLastValidatorEdit() > _snapshot
) {
revert LastEditAfterSnapshot();
}
operators.value[_operatorIndex].limit = _limit;
- Solidity provides the feature to index calldata arrays. Hence, instead of using an external library
src/contracts/StakingContract.sol:L553-L554
bytes memory publicKey = BytesLib.slice(_publicKeys, i * PUBLIC_KEY_LENGTH, PUBLIC_KEY_LENGTH);
bytes memory signature = BytesLib.slice(_signatures, i * SIGNATURE_LENGTH, SIGNATURE_LENGTH);
the byte slicing can be done as :
bytes calldata publicKey =_publicKeys[i * PUBLIC_KEY_LENGTH:PUBLIC_KEY_LENGTH];
bytes calldata signature = _signatures[i * SIGNATURE_LENGTH:SIGNATURE_LENGTH];
Appendix 1 - Files in Scope
This audit covered the following files:
File | SHA-1 hash |
---|---|
src/contracts/AuthorizedFeeRecipient.sol | 5ef5304884508024c35242de6c1ba0944c9d17db |
src/contracts/ConsensusLayerFeeDispatcher.sol | b829573b4957dffbec7f8e816a95a5b3e3fe742b |
src/contracts/ExecutionLayerFeeDispatcher.sol | d442298c236ecb2814b21004c94cbc71dda338fd |
src/contracts/FeeRecipient.sol | 80ff6067645d7eb9ee41cbaa31e1be84fbcb49b7 |
src/contracts/StakingContract.sol | a2887bf364aed54e96a5d0223b61f2ddfcddcc82 |
src/contracts/TUPProxy.sol | e73fdd8194d8849162fe229ad083d9b18b3263a0 |
src/contracts/interfaces/IDepositContract.sol | bc451bf5184a0325ca51a901ab67d601e277cd3b |
src/contracts/interfaces/IFeeDispatcher.sol | f987bafbfddbae39a4a13c7d52959b909c41db5e |
src/contracts/interfaces/IFeeRecipient.sol | 1b85e916b93e26b9b14a402abba0c4e13c830f13 |
src/contracts/interfaces/IStakingContractFeeDetails.sol | 628d14e6e853d0965620d158b9da3c8f37f7d492 |
src/contracts/libs/BytesLib.sol | ab4c22e18868faae26d895b6152c407203759878 |
src/contracts/libs/DispatchersStorageLib.sol | 9ec31af30a59860b0aaa232a34e3da72e64e54b5 |
src/contracts/libs/StakingContractStorageLib.sol | 563ee4555517391c4a95bdbc9b43ceb037e576fa |
Appendix 2 - Disclosure
Consensys Diligence (“CD”) typically receives compensation from one or more clients (the “Clients”) for performing the analysis contained in these reports (the “Reports”). The Reports may be distributed through other means, including via Consensys publications and other distributions.
The Reports are not an endorsement or indictment of any particular project or team, and the Reports do not guarantee the security of any particular project. This Report does not consider, and should not be interpreted as considering or having any bearing on, the potential economics of a token, token sale or any other product, service or other asset. Cryptographic tokens are emergent technologies and carry with them high levels of technical risk and uncertainty. No Report provides any warranty or representation to any third party in any respect, including regarding the bug-free nature of code, the business model or proprietors of any such business model, and the legal compliance of any such business. No third party should rely on the Reports in any way, including for the purpose of making any decisions to buy or sell any token, product, service or other asset. Specifically, for the avoidance of doubt, this Report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. CD owes no duty to any third party by virtue of publishing these Reports.
A.2.1 Purpose of Reports
The Reports and the analysis described therein are created solely for Clients and published with their consent. The scope of our review is limited to a review of code and only the code we note as being within the scope of our review within this report. Any Solidity code itself presents unique and unquantifiable risks as the Solidity language itself remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond specified code that could present security risks. Cryptographic tokens are emergent technologies and carry with them high levels of technical risk and uncertainty. In some instances, we may perform penetration testing or infrastructure assessments depending on the scope of the particular engagement.
CD makes the Reports available to parties other than the Clients (i.e., “third parties”) on its website. CD hopes that by making these analyses publicly available, it can help the blockchain ecosystem develop technical best practices in this rapidly evolving area of innovation.
A.2.2 Links to Other Web Sites from This Web Site
You may, through hypertext or other computer links, gain access to web sites operated by persons other than Consensys and CD. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such web sites’ owners. You agree that Consensys and CD are not responsible for the content or operation of such Web sites, and that Consensys and CD shall have no liability to you or any other person or entity for the use of third party Web sites. Except as described below, a hyperlink from this web Site to another web site does not imply or mean that Consensys and CD endorses the content on that Web site or the operator or operations of that site. You are solely responsible for determining the extent to which you may use any content at any other web sites to which you link from the Reports. Consensys and CD assumes no responsibility for the use of third-party software on the Web Site and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.
A.2.3 Timeliness of Content
The content contained in the Reports is current as of the date appearing on the Report and is subject to change without notice unless indicated otherwise, by Consensys and CD.