Liquid Staking

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

Users can complete withdrawals with any batchId previous to their withdrawal and enjoy the best assets-shares ratio

Summary

In the WithdrawalPool.sol contract, users with withdrawals that have been partially withdrawn, when they call the WithdrawalPool::withdraw function they can input any previous batch they want and get the best assets-shares ratio for the rest of their shares.

Vulnerability Details

If a user calls PriorityPool::withdraw and there isn't enough liquidity to give him immediately, he can choose to queue his withdrawal in the WithdrawalPool.

function withdraw(
uint256 _amountToWithdraw,
uint256 _amount,
uint256 _sharesAmount,
bytes32[] calldata _merkleProof,
bool _shouldUnqueue,
@> bool _shouldQueueWithdrawal
) external {
.
.
.
if (toWithdraw != 0) {
IERC20Upgradeable(address(stakingPool)).safeTransferFrom(account, address(this), toWithdraw);
@> toWithdraw = _withdraw(account, toWithdraw, _shouldQueueWithdrawal);
}
token.safeTransfer(account, _amountToWithdraw - toWithdraw);
}
function _withdraw(address _account, uint256 _amount, bool _shouldQueueWithdrawal) internal returns (uint256) {
.
.
.
if (toWithdraw != 0) {
if (!_shouldQueueWithdrawal) revert InsufficientLiquidity();
@> withdrawalPool.queueWithdrawal(_account, toWithdraw);
}
emit Withdraw(_account, _amount - toWithdraw);
return toWithdraw;
}

In WithdrawalPool the withdrawal gets a unique withdrawalId

function queueWithdrawal(address _account, uint256 _amount) external onlyPriorityPool {
if (_amount < minWithdrawalAmount) revert AmountTooSmall();
lst.safeTransferFrom(msg.sender, address(this), _amount);
uint256 sharesAmount = _getSharesByStake(_amount);
queuedWithdrawals.push(Withdrawal(uint128(sharesAmount), 0));
totalQueuedShareWithdrawals += sharesAmount;
@> uint256 withdrawalId = queuedWithdrawals.length - 1;
queuedWithdrawalsByAccount[_account].push(withdrawalId);
withdrawalOwners[withdrawalId] = _account;
emit QueueWithdrawal(_account, _amount);
}

Now for the user to be able to complete his withdrawal, WithdrawalPool::_finalizeWithdrawals must be called, either by performUpkeep or by other users depositing. Everytime _finalizeWithdrawals gets called, it finalizes a batch of withdrawals. This batch gets saved along with the last withdrawal if finalized and the assets-share ratio at that time.

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;
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);
}

Withdrawals can get fully finalized or partially finalized, depending if sharesToWithdraw can cover the whole withdrawal or not.

Now users can call WithdrawalPool::withdraw on their finalized withdrawals to complete the withdrawing process. They are requiered to input their withdrawalId along with the batches they got finalized at. However, if a user's withdrawlId got partially withdrawn at some point, they can enter any batch previous to that withdrawal and enjoy a potentially better assets-shares ratio for the rest of their shares that were left when it got partially withdrawn.

function withdraw(uint256[] calldata _withdrawalIds, uint256[] calldata _batchIds) external {
address owner = msg.sender;
uint256 amountToWithdraw;
for (uint256 i = 0; i < _withdrawalIds.length; ++i) {
uint256 withdrawalId = _withdrawalIds[i];
Withdrawal memory withdrawal = queuedWithdrawals[_withdrawalIds[i]];
uint256 batchId = _batchIds[i];
WithdrawalBatch memory batch = withdrawalBatches[batchId];
if (withdrawalOwners[withdrawalId] != owner) revert SenderNotAuthorized();
if (batchId != 0 && withdrawalId <= withdrawalBatches[batchId - 1].indexOfLastWithdrawal) {
revert InvalidWithdrawalId();
}
// if withdrawal.partiallyWithdrawableAmount != 0 they can input any previous batch
@> if (batchId != 0 && withdrawalId > batch.indexOfLastWithdrawal
&& withdrawal.partiallyWithdrawableAmount == 0) revert InvalidWithdrawalId();
if (withdrawalId <= batch.indexOfLastWithdrawal) {
amountToWithdraw += withdrawal.partiallyWithdrawableAmount
@> + (uint256(batch.stakePerShares) * uint256(withdrawal.sharesRemaining)) / 1e18;
delete queuedWithdrawals[withdrawalId];
delete withdrawalOwners[withdrawalId];
} else {
amountToWithdraw += withdrawal.partiallyWithdrawableAmount;
queuedWithdrawals[withdrawalId].partiallyWithdrawableAmount = 0;
}
}
token.safeTransfer(owner, amountToWithdraw);
emit Withdraw(owner, amountToWithdraw);
}

Impact

A user may want to withdraw his funds and notice a bad assets-shares ratio. User can queue the withdrawal and deposit just 1 wei to call the _finalizeWithdrawals function and partially finalize his withdrawal. Now when his withdrawal gets actually fully finalized, he can enter any previous batchId and enjoy the assets-shares ratio at that time for the rest of his shares.

This breaks the whole accounting of the protocol.

Proof of Concept

Create a new tests.test.ts file in the test/core/priorityPool folder and paste the following code:

import { assert, expect } from 'chai'
import {
toEther,
deploy,
fromEther,
deployUpgradeable,
getAccounts,
setupToken,
} from '../../utils/helpers'
import {
ERC677,
SDLPoolMock,
StakingPool,
PriorityPool,
StrategyMock,
WithdrawalPool,
} from '../../../typechain-types'
import { ethers } from 'hardhat'
import { StandardMerkleTree } from '@openzeppelin/merkle-tree'
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'
describe.only('PriorityPool', () => {
async function deployFixture() {
const { accounts, signers } = await getAccounts()
const adrs: any = {}
const token = (await deploy('contracts/core/tokens/base/ERC677.sol:ERC677', [
'Chainlink',
'LINK',
1000000000,
])) as ERC677
adrs.token = await token.getAddress()
await setupToken(token, accounts, true)
const stakingPool = (await deployUpgradeable('StakingPool', [
adrs.token,
'Staked LINK',
'stLINK',
[],
toEther(10000),
])) as StakingPool
adrs.stakingPool = await stakingPool.getAddress()
const strategy = (await deployUpgradeable('StrategyMock', [
adrs.token,
adrs.stakingPool,
toEther(1000),
toEther(100),
])) as StrategyMock
adrs.strategy = await strategy.getAddress()
const sdlPool = (await deploy('SDLPoolMock')) as SDLPoolMock
adrs.sdlPool = await sdlPool.getAddress()
const pp = (await deployUpgradeable('PriorityPool', [
adrs.token,
adrs.stakingPool,
adrs.sdlPool,
toEther(100),
toEther(1000),
])) as PriorityPool
adrs.pp = await pp.getAddress()
const withdrawalPool = (await deployUpgradeable('WithdrawalPool', [
adrs.token,
adrs.stakingPool,
adrs.pp,
toEther(1),
0,
])) as WithdrawalPool
adrs.withdrawalPool = await withdrawalPool.getAddress()
await stakingPool.addStrategy(adrs.strategy)
await stakingPool.setPriorityPool(adrs.pp)
await stakingPool.setRebaseController(accounts[0])
await pp.setDistributionOracle(accounts[0])
await pp.setWithdrawalPool(adrs.withdrawalPool)
for (let i = 0; i < signers.length; i++) {
await token.connect(signers[i]).approve(adrs.pp, ethers.MaxUint256)
}
return { signers, accounts, adrs, token, stakingPool, strategy, sdlPool, pp, withdrawalPool }
}
it('should withdraw with any previous batchId', async () => {
const { signers, accounts, adrs, pp, token, strategy, stakingPool, withdrawalPool } = await loadFixture(
deployFixture
)
// bunch of deposits
for (let i = 0; i < signers.length; i++) {
await pp.connect(signers[i]).deposit(toEther(1), false, ['0x'])
}
// fully finalize withdrawlId = 1
await stakingPool.connect(signers[1]).approve(adrs.pp, ethers.MaxUint256)
await pp.connect(signers[1]).withdraw(toEther(1), 0, 0, [], false, true)
await pp.connect(signers[2]).deposit(toEther(1), false, ['0x'])
// fully finalize withdrawalId = 2
await stakingPool.connect(signers[6]).approve(adrs.pp, ethers.MaxUint256)
await pp.connect(signers[6]).withdraw(toEther(1), 0, 0, [], false, true)
await pp.connect(signers[2]).deposit(toEther(1), false, ['0x'])
// partially finalize withdrawlId = 3
await stakingPool.connect(signers[3]).approve(adrs.pp, ethers.MaxUint256)
await pp.connect(signers[3]).withdraw(toEther(1), 0, 0, [], false, true)
await pp.connect(signers[2]).deposit(toEther(0.2), false, ['0x'])
// fully finalize withdrawlId = 3
await pp.connect(signers[2]).deposit(toEther(1), false, ['0x'])
// complete withdrawlId = 3 with batchId = 1
await withdrawalPool.connect(signers[3]).withdraw([3], [1])
})
})

Tools Used

Manual Review

Recommendations

On the Withdrawal struct, consider adding a partiallyWithdrawnAt variable which is the batchId of when the withdrawal got partially withdrawn. Then compare this variable to the batchId entered by the users and don't let them be earlier than that.

Updates

Lead Judging Commences

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

[INVALID]Users can complete withdrawals with any batchId previous to their withdrawal and enjoy the best assets-shares ratio

Support

FAQs

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