Summary
In turbulent market conditions or in case of whale attack, fees accrued by strategies might be bigger than StakingPool#totalStaked
storage variable. In this case, fees are completely skipped, making fees funds sent fro strategy locked in the contract.
Vulnerability Details
When strategy rewards are being processed, strategy returns the amount of fees it accrued. It is used then to calculate how much fees to send. However, there is an edge case when totalFeeAmounts
are bigger than totalStaked
. In this case the code skips fees:
StakingPool.sol
function _updateStrategyRewards(uint256[] memory _strategyIdxs, bytes memory _data) private {
address[][] memory receivers = new address[][]();
uint256[][] memory feeAmounts = new uint256[][]();
for (uint256 i = 0; i < _strategyIdxs.length; ++i) {
IStrategy strategy = IStrategy(strategies[_strategyIdxs[i]]);
(
int256 depositChange,
address[] memory strategyReceivers,
uint256[] memory strategyFeeAmounts
@> ) = strategy.updateDeposits(_data);
@> totalRewards += depositChange;
if (totalRewards > 0) {
receivers[receivers.length - 1] = new address[]();
feeAmounts[feeAmounts.length - 1] = new uint256[]();
totalFeeCount += fees.length;
for (uint256 i = 0; i < fees.length; i++) {
receivers[receivers.length - 1][i] = fees[i].receiver;
feeAmounts[feeAmounts.length - 1][i] =
(uint256(totalRewards) * fees[i].basisPoints) /
10000;
totalFeeAmounts += feeAmounts[feeAmounts.length - 1][i];
}
}
if (totalFeeAmounts >= totalStaked) {
totalFeeAmounts = 0;
}
@> if (totalFeeAmounts > 0) {
uint256 sharesToMint = (totalFeeAmounts * totalShares) /
(totalStaked - totalFeeAmounts);
_mintShares(address(this), sharesToMint);
if (totalFeeAmounts > 0) {
uint256 sharesToMint = (totalFeeAmounts * totalShares) /
(totalStaked - totalFeeAmounts);
_mintShares(address(this), sharesToMint);
uint256 feesPaidCount;
for (uint256 i = 0; i < receivers.length; i++) {
for (uint256 j = 0; j < receivers[i].length; j++) {
if (feesPaidCount == totalFeeCount - 1) {
@> transferAndCallFrom(
address(this),
receivers[i][j],
balanceOf(address(this)),
"0x"
);
} else {
@> transferAndCallFrom(address(this), receivers[i][j], feeAmounts[i][j], "0x");
feesPaidCount++;
}
}
}
}
So incase there would be a large withdrawal by a single entity or organically by honest users due to turbulent market conditions, the totalStaked
variable will diminish badly and remain less than the totalFeeAmounts
.
However, the problem is that the strategy sends the funds either way
function updateDeposits(
bytes calldata _data
)
external
override
onlyStakingPool
returns (int256 depositChange, address[] memory receivers, uint256[] memory amounts)
{
if (balance != 0) {
@> token.safeTransfer(address(stakingPool), balance);
newTotalDeposits -= balance;
}
So, the fees are locked in the contract in this case. Technically, they can be treated as "unused deposit" that anyone can put into strategies, because of the logic in deposit that allows to put tokens that StakingPool
is having into strategies.
If this won't happen, the fees can be taken by the last fee receiver on the next call:
if (feesPaidCount == totalFeeCount - 1) {
transferAndCallFrom(
address(this),
receivers[i][j],
@> balanceOf(address(this)),
"0x"
);
This might actually have broader consequences, because last fee receiver will get whatever balance of the tokens that the protocol has.
Impact
Loss of rewards
Tools Used
Manual review
Recommendations
Consider queueing fees for later in case that it's bigger than StakingPool#totalStaked
.