Liquid Staking

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

All queued withdrawals are stuck in the protocol with users having no way to withdraw their tokens

Summary

The Protocol is designed in such a way that a user can withdraw their tokens from the Protocol or queue them for withdrawal when there is no available liquidity at the time of the withdrawal request by setting the _shouldQueueWithdrawal parameter as true in the PriorityPool::withdraw function. The Protocol is then supposed to fulfill the queued withdrawals anytime there is liquidity in the Protocol e.g. when a deposit is made by some other user.
Unfortunately, the Protocol's code is not designed to fulfull queued withdrawals whenever the Protocol receives some liquidity.

Vulnerability Details

The problem lies in the fact that there is no way the Protocol can fulfull queued withdrawals. In fact, the function that looks as though it was supposed to fulfill this role, has no way of sending queued withdrawal tokens to the users who queued for withdrawal. The WithdrawalPool::_finalizeQueuedWithdrawal function only finalizes accounting after withdrawals are finalized but does not send any tokens to a user. The function is given in the code snipet below:

function _finalizeWithdrawals(uint256 _amount) internal {
uint256 sharesToWithdraw = _getSharesByStake(_amount);
uint256 numWithdrawals = queuedWithdrawals.length;
totalQueuedShareWithdrawals -= sharesToWithdraw;
for (uint256 i = indexOfNextWithdrawal; i < numWithdrawals; ++i) {
uint256 sharesRemaining = queuedWithdrawals[i].sharesRemaining;
if (sharesRemaining < sharesToWithdraw) {
// fully finalize withdrawal
@> sharesToWithdraw -= sharesRemaining; // @audit-comment withdrawal is fully finalized but no token is transfered to the user and the Withdrawal struct is not updated
continue;
}
if (sharesRemaining > sharesToWithdraw) {
// partially finalize withdrawal
queuedWithdrawals[i] = Withdrawal(
uint128(sharesRemaining - sharesToWithdraw),
uint128(
queuedWithdrawals[i].partiallyWithdrawableAmount +
_getStakeByShares(sharesToWithdraw)
)
);
indexOfNextWithdrawal = i;
withdrawalBatches.push(
WithdrawalBatch(uint128(i - 1), uint128(_getStakeByShares(1 ether)))
);
} else {
// fully finalize withdrawal
indexOfNextWithdrawal = i + 1;
withdrawalBatches.push(
WithdrawalBatch(uint128(i), uint128(_getStakeByShares(1 ether)))
);
}
sharesToWithdraw = 0;
break;
}
// entire amount must be accounted for
assert(sharesToWithdraw == 0);
emit WithdrawalsFinalized(_amount);
}

Here is the github link to the above function https://github.com/Cyfrin/2024-09-stakelink/blob/f5824f9ad67058b24a2c08494e51ddd7efdbb90b/contracts/core/priorityPool/WithdrawalPool.sol#L422-L466

Two things to note about the above function:

  1. When withdrawals are fully finalized, no tokens are sent to the user who queued withdrawals initially and the Withdrawal struct containing the queued withdrawal information is not updated

  2. When withdrawals are partially finalized, no tokens are sent to the user who queued withdrawals initially but the Withdrawal struct containing the queued withdrawal information is updated

Either ways, no tokens are sent to the user.

Impact

The impact is that user funds that are queued for withdrawal are stuck in the Protocol and the user loses their funds.
To further explain this, there are two ways intuitively how the Protocol could fulfill queued withdrawals. One way is when another user deposits tokens into the Protocol and the second way is when the Protocol performs an upkeep. Now lets examine the two possible ways:

  1. When a User deposits some tokens into the protocol i.e. user1 -> PriorityPool::deposit. The amount is transfered from the user to the Priority Pool; thereafter the Priority Pool calls the _deposit function i.e. PriorityPool::deposit -> PriorityPool::_deposit.
    The Priority Pool calls the deposit function in the Withdrawal Pool i.e. PriorityPool::_deposit -> WithdrawalPool::deposit. The required tokens are then transferred from the Priority Pool to the Withdrawal Pool while the same amount of lst (liquid staking tokens) are transfered from the Withdrawal Pool to the Priority Pool. Then the _finalizeWithrawals function is called i.e. WithdrawalPool::deposit -> WithdrawalPool::_finalizeWithrawals. At this point, the entire path sums up as
    user -> PriorityPool::deposit -> PriorityPool::_deposit -> WithdrawalPool::deposit -> WithdrawalPool::_finalizeWithrawals
    The _finalizeWithdrawals function finalizes withdrawal accounting after withdrawals have been executed. However, in the execution path above, no tokens were transfered to users who had their withdrawals queued and yet, withdrawal accounting was finalized. In summary, the Protocol now has liquidity but users who had their withdrawals queued still have their token stuck in the Protocol

  2. When the WithdrawalPool performs an upkeep by calling the WithdrawalPool::performUpkeep function, the WithdrawalPool calls the PriorityPool::executeQueuedWithdrawals function i.e. WithdrawalPool::performUpkeep -> PriorityPool::executeQueuedWithdrawals which sends tokens from the Priority Pool to the Withdrawal Pool. Thereafter, the perform upkeep function calls the _finalizeWithdrawals function i.e. WithdrawalPool::performUpkeep -> WithdrawalPool::_finalizeWithdrawals. Again, no tokens were transfered to users who had their withdrawals queued and yet, withdrawal accounting was finalized. As a result, users who had their withdrawals queued still have their token stuck in the Protocol

Tools Used

Manual Review and Hardhat

Proof of Concept:

  1. User A deposits 100 tokens into the protocol. User A now have 100 tokens less than their initial balance

  2. User A attempts to withdraw 20 tokens out of the 100 tokens they deposited earlier into the protocol. User A sets the _shouldQueueWithdrawal option as true.

  3. Now because the Protocol does not have liquidity, the withdrawal request of user A is queued

  4. Another User B deposits 15 tokens into the Protocol. User A's withrawal request is still not fulfilled

  5. Another User C deposits 5000 tokens into the Protocol. This deposit provides more liquidity to the Protocol but User A's withdrawal request is still not fulfilled. MEanwhile the Priority Pool holds 4095 tokens

  6. User C withdraws 2000 tokens out of their initial deposit leaving 2095 tokens in the Priority Pool

  7. User B also withdraws the 15 tokens they deposited earlier into the protocol.

  8. User A attempts to withdraw their 100 tokens since the queued withdrawal was not fulfilled but the call reverts with "Transfer amount exceeds balance" message

  9. User A then attempts to withdraw 80 tokens and the call succeeds. Now, User A has 20 tokens less than their initial balance.

PoC Place the following code in `priority-pool.test.ts`.
it.only('user tokens are stuck in the protocol when they queue withdrawals', async () => {
const { signers, accounts, adrs, pp, token, stakingPool, withdrawalPool } = await loadFixture(deployFixture)
await stakingPool.approve(adrs.pp, ethers.MaxUint256)
await token.connect(signers[1]).approve(adrs.pp, ethers.MaxUint256)
const initBalance1 = fromEther(await token.balanceOf(signers[1]))
const initBalance10 = fromEther(await token.balanceOf(signers[10]))
console.log(signers[1].address, 'Signer_1 Initial Balance is: ', initBalance1)
console.log(signers[10].address, 'Signer_10 Initial Balance is: ', initBalance10)
await pp.connect(signers[1]).deposit(toEther(100), true, ['0x'])
console.log(signers[1].address, 'deposits', toEther(100), 'tokens')
const balanceAfter1 = fromEther(await token.balanceOf(signers[1]))
console.log(signers[1].address, 'Signer_1 Balance After is: ', balanceAfter1)
const stakingPoolBal1 = fromEther(await stakingPool.balanceOf(signers[1]))
console.log(signers[1].address, 'Signer_1 Staking Pool Balance is: ', stakingPoolBal1)
await stakingPool.connect(signers[1]).approve(adrs.pp, ethers.MaxUint256)
await pp.connect(signers[1]).withdraw(toEther(20), 0, 0, [], false, true)
console.log(signers[1].address, 'queues', toEther(20), 'tokens for withdrawal')
assert.equal(fromEther(await token.balanceOf(signers[1])), balanceAfter1,
'Balance of Signer_1 has changed')
await pp.connect(signers[2]).deposit(toEther(15), true, ['0x'])
assert.equal(fromEther(await stakingPool.balanceOf(accounts[2])), 15)
assert.equal(fromEther(await stakingPool.balanceOf(adrs.withdrawalPool)), 5)
assert.equal(fromEther(await token.balanceOf(adrs.withdrawalPool)), 15)
assert.equal(fromEther(await token.balanceOf(adrs.pp)), 0)
await pp.deposit(toEther(5000), true, ['0x'])
assert.equal(fromEther(await token.balanceOf(adrs.pp)), 4095)
await pp.withdraw(toEther(2000), 0, 0, [], true, false)
assert.equal(fromEther(await token.balanceOf(adrs.pp)), 2095)
await stakingPool.connect(signers[2]).approve(adrs.pp, ethers.MaxUint256)
await pp.connect(signers[2]).withdraw(toEther(15), 0, 0, [], true, false)
assert.equal(fromEther(await token.balanceOf(signers[2])), 10_000,
'Signer_2 could not withdraw tokens')
// user tries to withdraw all their funds including the queued withdrawal
// since they have not yet received the tokens
// the transaction reverts
await expect(
pp.connect(signers[1]).withdraw(toEther(100), 0, 0, [], false, false)
).to.be.revertedWith("Transfer amount exceeds balance")
// user is only able to withdraw their remaining balance excluding the
// queued withdrawal amount
await pp.connect(signers[1]).withdraw(toEther(80), 0, 0, [], true, false)
assert.equal(fromEther(await token.balanceOf(signers[1])), 9980,
'Signer_1 could not withdraw tokens')
})

Run: yarn test

Output:

PriorityPool
0x2228bdc8584595DfefA75597C96B13c00a2D88C2 Signer_1 Initial Balance is: 10000
0x10050810372A166386a402e317e8514C824c1fd1 Signer_10 Initial Balance is: 10000
0x2228bdc8584595DfefA75597C96B13c00a2D88C2 deposits 100000000000000000000n tokens
0x2228bdc8584595DfefA75597C96B13c00a2D88C2 Signer_1 Balance After is: 9900
0x2228bdc8584595DfefA75597C96B13c00a2D88C2 Signer_1 Staking Pool Balance is: 100
0x2228bdc8584595DfefA75597C96B13c00a2D88C2 queues 20000000000000000000n tokens for withdrawal
✔ user tokens are stuck in the protocol when they queue withdrawals (3815ms)
1 passing (4s)
Done in 8.23s.

Recommendations

The WithdrawalPool::_finalizeWithdrawals function should be modified to transfer the required amount to the user and update the Withdrawal struct as necessary

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.