Liquid Staking

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

Depositors which got LINK deposited after queued can steal from other stakers and/or users

Vulnerability Details

This problem arises in 2 parts of the code at PriorityPool: withdraw() and unqueueTokens().

The problem is that the code will give LINK to the user without taking from him the corresponding stLINK.

Merkle proofs represent the right to claim shares which represent stLINK which is meant to be in a 1:1 ratio with the LINK managed by the protocol. And those shares are given to you because some of your queued LINK got eventually staked. So you have the right to claim with stLINK in a 1:1 ratio the LINK that was staked and its rewards.

Yet here you get the LINK without giving the stLINK back. Effectively swapping at a ratio of 0:1 and stealing from other users staking LINK deposits and rewards.

This can be seen clearer in the unqueueTokens() function, there is a merkle proof verification to see if any of your LINK got deposited yet no transfer of stLINK is made:

function unqueueTokens(
uint256 _amountToUnqueue,
uint256 _amount,
uint256 _sharesAmount,
bytes32[] calldata _merkleProof
) external whenNotPaused {
if (_amountToUnqueue == 0) revert InvalidAmount();
if (_amountToUnqueue > totalQueued) revert InsufficientQueuedTokens();
address account = msg.sender;
// verify merkle proof only if sender is included in tree
if (accountIndexes[account] < merkleTreeSize) {
bytes32 node = keccak256(
bytes.concat(keccak256(abi.encode(account, _amount, _sharesAmount)))
);
if (!MerkleProofUpgradeable.verify(_merkleProof, merkleRoot, node))
revert InvalidProof();
}
if (_amountToUnqueue > getQueuedTokens(account, _amount)) revert InsufficientBalance();
accountQueuedTokens[account] -= _amountToUnqueue;
totalQueued -= _amountToUnqueue;
@> // 👁️🔴⏬ Only LINK is transferred, no stLINK.
@> // 👁️🔴⏬ Notice this is fine if the user has no shares, but it is not fine if the user does hold shares represented in stLINK.
token.safeTransfer(account, _amountToUnqueue);
@> // 👁️🔴⏬ Now here the user took LINK from the queue for free.
emit UnqueueTokens(account, _amountToUnqueue);
}

Imagine someone: PriorityPool::deposit(), but the staking room is full and they get queued. Let's say he queues 10 LINK. Now if the user who got its LINK staked after a queuing period and got shares wants to, and if those shares are worth at least 10 LINK, he can just take the LINK in the queue without even spending its shares. In this example effectively stealing the LINK in queued by a user.

At the withdraw() we can see that:

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);
}
}
@> // 👁️🔴⏬ Transfer of stLINK only happens if `toWithdraw != 0`, which won't be if the user
@> // 👁️🔴⏬ inputs the exact amount the merkle proof allows him to unqueue and `_shouldUnqueue==true`.
// attempt to withdraw if tokens remain after unqueueing
if (toWithdraw != 0) {
IERC20Upgradeable(address(stakingPool)).safeTransferFrom(
account,
address(this),
toWithdraw
);
toWithdraw = _withdraw(account, toWithdraw, _shouldQueueWithdrawal);
}
@> // 👁️🔴⏬ Only LINK is transferred, no stLINK.
@> // 👁️🔴⏬ The staker can call this twice, one leveraging `_shouldUnqueue==true` as said and
@> // 👁️🔴⏬ getting LINK without transferring tokens and another one transferring his current claimed LST
@> // 👁️🔴⏬ and with `_shouldUnqueue==false`
token.safeTransfer(account, _amountToWithdraw - toWithdraw);
}

Impact

You break the 1:1 relationship between LINK <-> stLINK withdrawing more LINK for free. This means that someone will eventually receive < 1 LINK per stLINK. Effectively stealing from the stakers and/or users.

Recommendations

At both functions mentioned, after proper merkle proof verification, make the user transfer the corresponding stLINK to the contract before giving him the LINK.

Updates

Lead Judging Commences

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

Support

FAQs

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