Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: high
Invalid

Triggering of `WithdrawalPool::performUpkeep` `Function Blocked Due to Incorrect Parameter Passing

Summary

In the current logic of WithdrawalPool, when it calls the PriorityPool::canWithdraw function, the _account parameter passed is the address of WithdrawalPool. However, the system calculates the balance based on shares allocated in StakingPool. Since WithdrawalPool itself has not earned any shares, the balance query result is always zero, preventing the correct determination of withdrawal amounts and blocking the normal execution of WithdrawalPool::performUpkeep function.

Vulnerability Details

In WithdrawalPool, two key functions: checkUpkeep and performUpkeep both call the PriorityPool::canWithdraw function to determine if withdrawal conditions are met. When calling PriorityPool::canWithdraw, the _account parameter passed is the address of WithdrawalPool, which is then passed to the StakingPool::balanceOf function.

/**
* @notice Returns whether withdrawals should be executed based on available withdrawal space
* @return true if withdrawal should be executed, false otherwise
*/
// @audit: priorityPool.canWithdraw:will always be 0
function checkUpkeep(bytes calldata) external view returns (bool, bytes memory) {
if (
_getStakeByShares(totalQueuedShareWithdrawals) != 0 &&
@> priorityPool.canWithdraw(address(this), 0) != 0 &&
block.timestamp > timeOfLastWithdrawal + minTimeBetweenWithdrawals
) {
return (true, "");
}
return (false, "");
}
/**
* @notice Executes withdrawals if there is sufficient available withdrawal space
* @param _performData encoded list of withdrawal data passed to staking pool strategies
*/
function performUpkeep(bytes calldata _performData) external {
@> uint256 canWithdraw = priorityPool.canWithdraw(address(this), 0);
uint256 totalQueued = _getStakeByShares(totalQueuedShareWithdrawals);
if (
totalQueued == 0 ||
canWithdraw == 0 ||
block.timestamp <= timeOfLastWithdrawal + minTimeBetweenWithdrawals
) revert NoUpkeepNeeded();
timeOfLastWithdrawal = uint64(block.timestamp);
uint256 toWithdraw = totalQueued > canWithdraw ? canWithdraw : totalQueued;
bytes[] memory data = abi.decode(_performData, (bytes[]));
priorityPool.executeQueuedWithdrawals(toWithdraw, data);
_finalizeWithdrawals(toWithdraw);
}
function canWithdraw(
address _account,
uint256 _distributionAmount
) external view returns (uint256) {
uint256 canUnqueue = paused()
? 0
@> : MathUpgradeable.min(getQueuedTokens(_account, _distributionAmount), totalQueued);
uint256 stLINKCanWithdraw = MathUpgradeable.min(
@> stakingPool.balanceOf(_account),
stakingPool.canWithdraw() + totalQueued - canUnqueue
);
return canUnqueue + stLINKCanWithdraw;
}

The inheritance structure of StakingPool is as follows:

StakingPool <- StakingRewardsPool <- ERC677Upgradeable <- ERC20Upgradeable

The balanceOf function is defined in the base contract ERC20Upgradeable:

/**
* @dev See {IERC20-balanceOf}.
*/
function balanceOf(address account) public view virtual override returns (uint256) {
return _balances[account];
}

This function is overridden in the derived contract StakingRewardsPool:

/**
* @notice Returns an account's LST balance
* @param _account account address
* @return account's balance
*/
function balanceOf(address _account) public view override returns (uint256) {
uint256 balance = getStakeByShares(shares[_account]);
if (balance < 100) {
return 0;
} else {
return balance;
}
}

Thus, the stakingPool.balanceOf function called in PriorityPool::canWithdraw is actually the one from StakingRewardsPool. This version calculates the balance based on shares[_account]. However, since WithdrawalPool itself has not participated in the staking process, its corresponding shares are always zero (shares[withdrawalPool.address] == 0). This results in a balance query for the WithdrawalPool address returning zero.

Additionally, the PriorityPool::getQueuedTokens function returns the amount of tokens queued for staking by the user. In this function, the _account parameter passed is also the address of WithdrawalPool, resulting in a return value of zero. Therefore, due to these parameter-passing errors, the PriorityPool::canWithdraw function always returns zero, preventing the normal triggering of the WithdrawalPool::performUpkeep function, making withdrawal operations inoperable.

Impact

Due to this issue, the performUpkeep function in WithdrawalPool cannot be triggered normally, preventing users from withdrawing funds from the pool, which can hinder normal user operations and potentially impact the overall stability of the staking and withdrawal process.

Tools Used

Manual audit.

Recommendations

Enhance Withdrawal Logic: Implement logic in WithdrawalPool to accurately track user shares and balances, allowing for correct determination of withdrawal amounts based on actual user contributions and staked shares.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.