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
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!