Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: medium
Valid

Principal amount of removed operator get's stuck in Chainlink's Staking Contract forever

Summary

When an operator is removed by Chainlink's Staking Contract , it needs to call unbound function of Chainlink's OperatorStakingPool.sol before wthdrawing it's principal amounts using unstakeRemovedPrincipal function , but in Stakelink protocol , the Operators misses to call the unbound function , so the unstakeRemovedPrincipal function always reverts causing lock of funds of removed operator in Chainlink's Staking Contract .

Vulnerability Details

As it can be seen in Chainlink's OperatorStakingPool.sol at #233 -
https://etherscan.io/address/0xBc10f2E862ED4502144c7d632a3459F49DFCDB5e#code

/// @inheritdoc StakingPoolBase
/// @dev Removed operators need to go through the unbonding period before they can withdraw. This
/// function will check if the operator has removed principal they can unstake.
function unbond() external override {
Staker storage staker = s_stakers[msg.sender];
uint224 history = staker.history.latest();
uint112 stakerPrincipal = uint112(history >> 112);
if (stakerPrincipal == 0 && s_operators[msg.sender].removedPrincipal == 0) {
revert StakeNotFound(msg.sender);
}
_unbond(staker);
}

removed operators need to go through unbound period and thereby needs to call this function before unstaking from Chainlink's OperatorStakingPool.sol .

In normal condition , when an Operator is not removed the unbound is being called by invoking FundFlowController.sol#updateVaultGroups which gets the vaults for which unbound needs to be called , the flow looks like FundFlowController.sol#updateVaultGroups -> FundFlowController.sol#_getVaultUpdateData -> FundFlowController.sol#_getTotalDepositRoom to get the nonEmptyVaultsFormatted which is further used as curGroupOpVaultsToUnbond in updateVaultGroups function of FundFlowController.sol .
The problem is , while getting the nonEmptyVaultsFormatted from FundFlowController.sol#_getTotalDepositRoom , the vaults for which operator's are removed are avoided as can be seen here -
https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/FundFlowController.sol#L413C8-L415C1

for (uint256 i = _vaultGroup; i < _depositIndex; i += _numVaultGroups) {
if (IVault(_vaults[i]).isRemoved()) continue;

So when FundFlowController.sol#updateVaultGroups goes further to call operatorVCS.updateVaultGroups which is VaultControllerStrategy.sol#updateVaultGroups as seen below -
https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/base/VaultControllerStrategy.sol#L471C4-L487C1

function updateVaultGroups(
uint256[] calldata _curGroupVaultsToUnbond,
uint256 _curGroupTotalDepositRoom,
uint256 _nextGroup,
uint256 _nextGroupTotalUnbonded
) external onlyFundFlowController {
for (uint256 i = 0; i < _curGroupVaultsToUnbond.length; ++i) {
@> vaults[_curGroupVaultsToUnbond[i]].unbond();
}
vaultGroups[globalVaultState.curUnbondedVaultGroup].totalDepositRoom = uint128(
_curGroupTotalDepositRoom
);
globalVaultState.curUnbondedVaultGroup = uint64(_nextGroup);
totalUnbonded = _nextGroupTotalUnbonded;
}

The removed vaults/operators do not call the unbound function OperatorStakingPool.sol , so the unbound period is never started for Removed operators . So when the attempt is done to remove the vault here -
https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/OperatorVCS.sol#L310C2-L312C1

_updateStrategyRewards();
(uint256 principalWithdrawn, uint256 rewardsWithdrawn) = IOperatorVault(vault).exitVault();

and thereby withdraw the principle amount from Chainlink's Staking contract here -

https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/OperatorVault.sol#L225C2-L243C8

function exitVault() external onlyVaultController returns (uint256, uint256) {
if (!isRemoved()) revert OperatorNotRemoved();
uint256 opRewards = getUnclaimedRewards();
if (opRewards != 0) _withdrawRewards();
uint256 rewards = getRewards();
if (rewards != 0) rewardsController.claimReward();
uint256 principal = getPrincipalDeposits();
@> stakeController.unstakeRemovedPrincipal();
uint256 balance = token.balanceOf(address(this));
token.safeTransfer(vaultController, balance);
return (principal, rewards);
}
/**

it would revert and the funds of removed operator is stuck in Chainlink's OperatorStakingPool.sol .

Impact

Funds of removed operator is stuck in Chainlink's OperatorStakingPool.sol.

Tools Used

Manual Review ,
Chainlink staking contracts collection - https://ipfs.io/ipfs/QmUWDupeN4D5vHNWH6dEbNuoiZz9bnbqTHw61L27RG6tE2

Recommendations

Create a mechanism to call the stakeController.unbound so that Removed operators go through the unbonding period before they can withdraw.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Non-Grouped Vaults cannot be exited because there is no means to unbond them

Appeal created

angrymustacheman Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
angrymustacheman Submitter
about 1 year ago
angrymustacheman Submitter
about 1 year ago
angrymustacheman Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Non-Grouped Vaults cannot be exited because there is no means to unbond them

Support

FAQs

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