Liquid Staking

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

`PriorityPool::withdraw()` does not decrease shares and burn tokens when directly unqueued, leading to DoS the deposit function.

Description

PriorityPool::withdraw() allows users to withdraw their tokens.
When there are enough tokens in the queue, it directly provides those for the unstake.
However, it does not call StakingPool::withdraw, leading to no tokens being burned and StakingPool::totalStake not decreasing.

function withdraw(
uint256 _amountToWithdraw,
uint256 _amount,
uint256 _sharesAmount,
bytes32[] calldata _merkleProof,
bool _shouldUnqueue,
bool _shouldQueueWithdrawal
) external {
if (_amountToWithdraw == 0) revert InvalidAmount();
uint256 toWithdraw = _amountToWithdraw;
address account = msg.sender;
// attempt to unqueue tokens before withdrawing if flag is set
if (_shouldUnqueue == true) {
_requireNotPaused();
if (_merkleProof.length != 0) {
bytes32 node = keccak256(
bytes.concat(keccak256(abi.encode(account, _amount, _sharesAmount)))
);
if (!MerkleProofUpgradeable.verify(_merkleProof, merkleRoot, node))
revert InvalidProof();
} else if (accountIndexes[account] < merkleTreeSize) {
revert InvalidProof();
}
uint256 queuedTokens = getQueuedTokens(account, _amount);
uint256 canUnqueue = queuedTokens <= totalQueued ? queuedTokens : totalQueued;
uint256 amountToUnqueue = toWithdraw <= canUnqueue ? toWithdraw : canUnqueue;
if (amountToUnqueue != 0) {
accountQueuedTokens[account] -= amountToUnqueue;
totalQueued -= amountToUnqueue;
toWithdraw -= amountToUnqueue;
emit UnqueueTokens(account, amountToUnqueue);
}
}
// attempt to withdraw if tokens remain after unqueueing
if (toWithdraw != 0) {
@> IERC20Upgradeable(address(stakingPool)).safeTransferFrom(
@> account,
@> address(this),
@> toWithdraw
@> );
@> toWithdraw = _withdraw(account, toWithdraw, _shouldQueueWithdrawal);
}
@> token.safeTransfer(account, _amountToWithdraw - toWithdraw);
}

For every withdraw taking the queued tokens, it will increase LST supply and StakingPool::totalStake, leading to DoS StakingPool::canDeposit(), preventing the protocol from staking new tokens, even vaults are not full.

function getMaxDeposits() public view returns (uint256) {
uint256 max;
for (uint256 i = 0; i < strategies.length; i++) {
uint strategyMax = IStrategy(strategies[i]).getMaxDeposits();
if (strategyMax >= type(uint256).max - max) {
return type(uint256).max;
}
max += strategyMax;
}
return max;
}
function canDeposit() external view returns (uint256) {
@> uint256 max = getMaxDeposits();
@> if (max <= totalStaked) {
@> return 0;
} else {
return max - totalStaked;
}
}

Risk

Likelyhood: High

  • Everytime someone uses PriorityPool::withdraw() and directly withdraws queued tokens in the PriorityPool.

Impact: High

  • LST tokens accumulate in the PriorityPool without any way to collect them.

  • StakingPool::totalStake will indefinitely grow and DoS StakingPool::canDeposit() when getMaxDeposits() is reached.

Proof of Concept

function testWithdrawPriorityPoolBurnLstToken() public {
// Transfer enough LINKs to Alice to fill 8 vaults.
linkToken.transfer(alice, 15000*8 ether);
// Approver le PriorityPool
vm.startPrank(alice);
linkToken.approve(address(priorityPool), 15000*8 ether);
linkToken.approve(address(stakingPool), 15000*8 ether);
stakingPool.approve(address(priorityPool), 15000*8 ether);
// In the setUp (scripts/test/deploy/modules/deploy-link-staking.ts), there is 6 vaults (3 Operator and 3 Community)
bytes[] memory strategyData = new bytes[]();
strategyData[0] = abi.encode(uint64(0));
strategyData[1] = abi.encode(uint64(1));
strategyData[2] = abi.encode(uint64(2));
strategyData[3] = abi.encode(uint64(3));
strategyData[4] = abi.encode(uint64(4));
strategyData[5] = abi.encode(uint64(5));
// Alice deposit 15000*7 LINKs in the PriorityPool to fill 6 vaults and queued enough token for 1 more vault.
priorityPool.deposit(15000*7 ether, true, strategyData);
uint256 initialTotalStakedAfterDeposit = stakingPool.totalStaked();
uint256 sharesAmount = stakingPool.getSharesByStake(15000 ether);
bytes32[] memory emptyProof = new bytes32[]();
emptyProof[0] = bytes32(0);
// Alice withdraw 10000 LINKs from the PriorityPool, directly from the priority queue.
uint256 withdrawAmount = 10000 ether;
priorityPool.withdraw(withdrawAmount, 0, sharesAmount, emptyProof, false, true);
vm.stopPrank();
uint256 stakingPoolStakedAfterWithdraw = stakingPool.totalStaked();
// will revert because tokens are still considered as staked !
assertEq(stakingPoolStakedAfterWithdraw, initialTotalStakedAfterDeposit - withdrawAmount);
}

Recommended Mitigation

Call StakingPool::withdraw when queued tokens are withdrawn to update totalStake and burn LST tokens.

Updates

Lead Judging Commences

inallhonesty Lead Judge
11 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.