Platform: Code4rena
Start Date: 02/06/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 75
Period: 7 days
Judge: Picodes
Total Solo HM: 5
Id: 249
League: ETH
Rank: 66/75
Findings: 1
Award: $18.57
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: RaymondFam
Also found by: 0xWaitress, 0xhacksmithh, ChrisTina, DadeKuma, LaScaloneta, Rolezn, SAAJ, Sathish9098, T1MOH, bin2chen, btk, catellatech, ernestognw, fatherOfBlocks, hals, hunter_w3b, jaraxxus, matrix_0wl, mgf15, naman1778, niser93, solsaver, turvy_fuzz
18.5651 USDC - $18.57
The purpose of the ETHx protocol is to create a multi-pool architecture for node operations that emphasizes decentralization, scalability, and resilience. It aims to democratize node operations and adapt to increasing demand. The protocol includes both a permissionless pool, open to anyone who wants to participate and operate nodes, and a permissioned pool consisting of high-performing validators. The design focuses on enabling widespread participation and ensuring consistent performance. By combining these pools, ETHx aims to provide a robust and scalable infrastructure for node operations in a decentralized manner.
Issues Summarize |
---|
Risk | Issues Details |
---|---|
[QA-01] | MIN_AUCTION_DURATION will not be accurate due to ethereum average block time volatility |
[QA-02] | setCommissionFees() is missing sanity checks |
[QA-03] | WithdrawRequestInfo is not enforced in the code |
[QA-04] | BURNER_ROLE can burn any arbitrary amount of ETHx token from any account |
[QA-05] | Potential temporary griefing attack when REWARD_THRESHOLD is lowered |
[QA-06] | VaultFactory.deployWithdrawVault() : deterministic address may cause issues |
MIN_AUCTION_DURATION
will not be accurate due to ethereum average block time volatilityThe MIN_AUCTION_DURATION
value may not accurately represent 24 Hours in blocks. The value is currently calculated based on a fixed 12-second Ethereum block time, but Ethereum's average block time can vary over time, causing potential discrepancies between the calculated block count and actual elapsed time.
MIN_AUCTION_DURATION
is defined as follow:
uint256 public constant MIN_AUCTION_DURATION = 7200; // 24 hours
MIN_AUCTION_DURATION
should represent 24 hours
in blocks which may differ based on this site:
Ethereum Average Block Time is at a current level of 12.13, up from 12.12 yesterday and down from 14.30 one year ago. This is a change of 0.08% from yesterday and -15.17% from one year ago.
To demonstrate this potential Risk, here is 2 scenarios based on the Highest and the Lowest block time value in the past two months:
Manual Review
We recommend using the block.timestamp
and Time Units
instead of block.numbe
r to calculate time intervals. This approach is more reliable than using a fixed block count, which can be affected by Ethereum's average block time variability.
setCommissionFees()
is missing sanity checkssetCommissionFees()
function is used to set the commission fees for the protocol. However, the current implementation lacks sanity checks, specifically a minimum bound check, which makes it vulnerable to human error and potential loss of funds for either the protocol or the operator.
function setCommissionFees(uint256 _protocolFee, uint256 _operatorFee) external { UtilLib.onlyManagerRole(msg.sender, staderConfig); if (_protocolFee + _operatorFee > MAX_COMMISSION_LIMIT_BIPS) { revert InvalidCommission(); } protocolFee = _protocolFee; operatorFee = _operatorFee; emit UpdatedCommissionFees(_protocolFee, _operatorFee); }
Manual Review
To address this issue, introduce a MIN_COMMISSION_LIMIT_BIPS
constant that represents the minimum allowed commission limit in basis points.
WithdrawRequestInfo
is not enforced in the codeThe WithdrawRequestInfo
struct in the code represents a withdrawal delay to prevent instant withdrawal of the operator's SD token. However, the code currently lacks enforcement of this withdrawal delay, and the operator receives the requested amount in the same tx as you can see in this line:
if (!IERC20(staderConfig.getStaderToken()).transfer(payable(operator), _requestedSD)) {
/// @notice for operator to request withdraw of sd /// @dev it does not transfer sd tokens immediately /// operator should come back after withdrawal-delay time to claim /// this requested sd is subject to slashes function withdraw(uint256 _requestedSD) external override { address operator = msg.sender; uint256 opSDBalance = operatorSDBalance[operator]; if (opSDBalance < getOperatorWithdrawThreshold(operator) + _requestedSD) { revert InsufficientSDToWithdraw(opSDBalance); } operatorSDBalance[operator] -= _requestedSD; // cannot use safeERC20 as this contract is an upgradeable contract if (!IERC20(staderConfig.getStaderToken()).transfer(payable(operator), _requestedSD)) { revert SDTransferFailed(); } emit SDWithdrawn(operator, _requestedSD); }
Manual Review
To address this issue, we recommend one of the following :
Enforce Withdrawal Delay
Update NatSpec Documentation and remove the WithdrawRequestInfo
struct from ISDCollateral
BURNER_ROLE
can burn any arbitrary amount of ETHx token from any accountETHx.burnFrom()
allow the burner role to burn any arbitrary amount from any arbitrary account which will provides less guarantees and reduces the level of trust required, thus increasing risk for users, and it will make the protocol more centralized which conflicts with what the Stader protocol is aiming to provide:
ETHx aims to provide stakers, a decentralized and scalable solution with diverse DeFi integrations.
function burnFrom(address account, uint256 amount) external onlyRole(BURNER_ROLE) whenNotPaused { _burn(account, amount); }
Manual Review
We recommend updating the function to:
function burnFrom(uint256 amount) external whenNotPaused { _burn(msg.sender, amount); }
REWARD_THRESHOLD
is loweredThere is a potential temporary griefing attack when the REWARD_THRESHOLD
is set or updated to a lower value. This attack can cause the distributeRewards()
function to revert due to the following check:
if (!staderConfig.onlyOperatorRole(msg.sender) && totalRewards > staderConfig.getRewardsThreshold()) { emit DistributeRewardFailed(totalRewards, staderConfig.getRewardsThreshold()); revert InvalidRewardAmount(); }
The purpose of this check is to distinguish whether the ETH balance belongs to rewards or staked ETH as per of the sponsor:
Both consensus layer rewards and "32 bonded ETH" is sent back to the same address (validatorWithdrawalVault). So it becomes important to distinguish whether ETH balance belongs to rewards or staked ETH (that's now withdrawn) We use rewards threshold to make that distinction.
However, this check makes the contract vulnerable to temporary griefing attacks. An attacker can exploit this vulnerability by sending ETH to the contract and causing totalRewards > staderConfig.getRewardsThreshold()
to be true. The likelihood of the attack depends on the value of REWARD_THRESHOLD
— it is more expensive when REWARD_THRESHOLD
is high and cheaper when REWARD_THRESHOLD
is low, thus introducing a MIN_LIMIT_REWARD_THRESHOLD
will make the contract more robust.
function distributeRewards() external override { uint8 poolId = VaultProxy(payable(address(this))).poolId(); uint256 validatorId = VaultProxy(payable(address(this))).id(); IStaderConfig staderConfig = VaultProxy(payable(address(this))).staderConfig(); uint256 totalRewards = address(this).balance; if (!staderConfig.onlyOperatorRole(msg.sender) && totalRewards > staderConfig.getRewardsThreshold()) { emit DistributeRewardFailed(totalRewards, staderConfig.getRewardsThreshold()); revert InvalidRewardAmount(); } if (totalRewards == 0) { revert NotEnoughRewardToDistribute(); } (uint256 userShare, uint256 operatorShare, uint256 protocolShare) = IPoolUtils(staderConfig.getPoolUtils()) .calculateRewardShare(poolId, totalRewards); // Distribute rewards IStaderStakePoolManager(staderConfig.getStakePoolManager()).receiveWithdrawVaultUserShare{value: userShare}(); UtilLib.sendValue(payable(staderConfig.getStaderTreasury()), protocolShare); IOperatorRewardsCollector(staderConfig.getOperatorRewardsCollector()).depositFor{value: operatorShare}( getOperatorAddress(poolId, validatorId, staderConfig) ); emit DistributedRewards(userShare, operatorShare, protocolShare); }
function updateRewardsThreshold(uint256 _rewardsThreshold) external onlyRole(MANAGER) { setVariable(REWARD_THRESHOLD, _rewardsThreshold); }
Manual Review
We recommend introducing a MIN_LIMIT_REWARD_THRESHOLD
and check that the new REWARD_THRESHOLD
is not below the MIN_LIMIT_REWARD_THRESHOLD
to protect against Dos, griefing attacks in the furture.
VaultFactory.deployWithdrawVault()
: deterministic address may cause issuesThe VaultFactory.deployWithdrawVault()
function is currently using the lonesUpgradeable.cloneDeterministic
function to create a new withdrawal vault. However, the address of the newly created vault depends on the vaultProxyImplementation
and the salt
parameter provided. Once the tx is broadcasted, the salt
parameter can be viewed by anyone watching the public mempool and the public readability of salt
parameter may cause issues in the future.
function deployNodeELRewardVault(uint8 _poolId, uint256 _operatorId) public override onlyRole(NODE_REGISTRY_CONTRACT) returns (address) { bytes32 salt = sha256(abi.encode(_poolId, _operatorId)); address nodeELRewardVaultAddress = ClonesUpgradeable.cloneDeterministic(vaultProxyImplementation, salt); VaultProxy(payable(nodeELRewardVaultAddress)).initialise(false, _poolId, _operatorId, address(staderConfig)); emit NodeELRewardVaultCreated(nodeELRewardVaultAddress); return nodeELRewardVaultAddress; }
Manual Review
We recommend including the msg.sender
address as part of the salt hash:
bytes32 salt = sha256(abi.encode(_poolId, _operatorId, _validatorCount, msg.sender));
#0 - c4-judge
2023-06-14T06:38:41Z
Picodes marked the issue as grade-b