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;
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;
@>
@>
token.safeTransfer(account, _amountToUnqueue);
@>
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;
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);
}
}
@>
@>
if (toWithdraw != 0) {
IERC20Upgradeable(address(stakingPool)).safeTransferFrom(
account,
address(this),
toWithdraw
);
toWithdraw = _withdraw(account, toWithdraw, _shouldQueueWithdrawal);
}
@>
@>
@>
@>
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.