Impact: High
Likelihood: High
Root + Impact
Description
By design, a ContestManager.sol contract receives 10% reward from pot's remaining rewards after the owner closed a contest. Before that during 90 days the participants are allowed to claim their reward. A contract will receive nothing if all participants decided to claim their reward or if the remaining reward is too small to cover even gas expenses.
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
@> uint256 managerCut = remainingRewards / managerCutPercent;
@> i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
}
Risk
Likelihood:
Impact:
Proof of Concept
Pleade, add the following tests to TestMyCut.t.sol:
test_economicallyImpracticalToCloseContest_whenAllPlayersClaimed shows that when all players claimed their reward, there is nothing left for managerCut.
test_economicallyImpracticalToCloseContest_whenRewardIsLessThanGasUsed shows that managerCut is too small even to cover gas usage for closeContest() transaction.
function test_economicallyImpracticalToCloseContest_whenAllPlayersClaimed() public mintAndApproveTokens {
vm.startPrank(user);
rewards = [500, 500];
totalRewards = 1000;
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), totalRewards);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
uint256 playersAmount = players.length;
for(uint256 i = 0; i < playersAmount; i++){
vm.startPrank(players[i]);
Pot(contest).claimCut();
vm.stopPrank();
}
uint256 conManBalanceBeforeCloseContest = ERC20Mock(weth).balanceOf(conMan);
console.log("conManBalanceBeforeCloseContest ", conManBalanceBeforeCloseContest.toString());
vm.warp(91 days);
vm.startPrank(user);
ContestManager(conMan).closeContest(contest);
vm.stopPrank();
uint256 conManBalanceAfterCloseContest = ERC20Mock(weth).balanceOf(conMan);
console.log("conManBalanceAfterCloseContest ", conManBalanceAfterCloseContest.toString());
assertEq(conManBalanceAfterCloseContest - conManBalanceBeforeCloseContest, 0);
}
function test_economicallyImpracticalToCloseContest_whenRewardIsLessThanGasUsed() public mintAndApproveTokens {
vm.startPrank(user);
rewards = [500, 500];
totalRewards = 1000;
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), totalRewards);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
vm.startPrank(player1);
Pot(contest).claimCut();
vm.stopPrank();
vm.warp(91 days);
uint256 conManBalanceBeforeCloseContest = ERC20Mock(weth).balanceOf(conMan);
uint256 gasStart = gasleft();
vm.txGasPrice(960000000);
vm.prank(user);
ContestManager(conMan).closeContest(contest);
uint256 gasEnd = gasleft();
uint256 gasUsed = (gasStart - gasEnd) * tx.gasprice;
console.log("gas units used ", (gasStart - gasEnd).toString());
console.log("gasUsed ", gasUsed.toString());
uint256 comManBalanceAfterCloseContest = ERC20Mock(weth).balanceOf(conMan);
uint256 managerCut = comManBalanceAfterCloseContest - conManBalanceBeforeCloseContest;
console.log("managerCut ", managerCut.toString());
assertGt(gasUsed, managerCut);
}
Recommended Mitigation
It may be acceptable to allow not all players to claim, so at least 1 reward of one player stays in the protocol.
Specify the minimum reward to cover at least gas expenses by 10% from it (at least 0.0005 ether)
+ uint256 public constant MINIMUM_REWARD_AMOUNT = 0.0005 ether;
+ error Pot__RewardIsLessThanMininumAllowed();
+ error Pot__CannotClaimTheLastReward();
constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
i_players = players;
i_rewards = rewards;
i_token = token;
i_totalRewards = totalRewards;
remainingRewards = totalRewards;
i_deployedAt = block.timestamp;
// i_token.transfer(address(this), i_totalRewards);
for (uint256 i = 0; i < i_players.length; i++) {
+ require(i_rewards[i] >= MINIMUM_REWARD_AMOUNT, Pot__RewardIsLessThanMininumAllowed());
playersToRewards[i_players[i]] = i_rewards[i];
}
}
function claimCut() public {
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
revert Pot__RewardNotFound();
}
playersToRewards[player] = 0;
+ require((remainingRewards - reward) > 0, Pot__CannotClaimTheLastReward());
remainingRewards -= reward;
claimants.push(player);
_transferReward(player, reward);
}