MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

`Pot::remainingRewards` should be equal to a balance of `Pot.sol` as it reflects the internal accounting of a protocol

Impact: High
Likelihood: High

Root + Impact

Description

  • The protocol's invariant remainingRewards should be equal to pot balance proves a protocol solvency.

  • When a Pot is created, the value of remainingRewards is assigned equal to totalRewards. When a Pot is funded, exactly totalRewards amount is transferred to Pot.sol. This means that remainingRewards reflects balanceOf(pot) and these two values should be synchronized.

  • A pot may be funded several times, but remainingRewards is not synchronized with a new balance.

  • remainingRewards value is not assigned to zero or synchronized with a pot balance when Pot::closePot() function is called.

Pot.sol constructo assignes remainingRewards equal to totalRewards in constructor:

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++) {
playersToRewards[i_players[i]] = i_rewards[i];
}
}

ContestManager::fundContest transfers totalRewards and sets a Pot.sol balance:

function fundContest(uint256 index) public onlyOwner {
Pot pot = Pot(contests[index]);
IERC20 token = pot.getToken();
uint256 totalRewards = contestToTotalRewards[address(pot)];
if (token.balanceOf(msg.sender) < totalRewards) {
revert ContestManager__InsufficientFunds();
}
@> token.transferFrom(msg.sender, address(pot), totalRewards);
}

remainingRewards is not zeroed or synchronized with a pot balance, allowing it to be more than balance and causing a protocol insolvency:

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:

  • The issue occurs at the very beginning of the contest, when not-funded pot already has remainingRewards which means that a contest is ready to be started, but it was not funded yet and it is insolvent.

  • Once a pot is funded, the invariant remainingRewards should be equal to pot balance holds till a Pot::closePot() function is called - remainingRewards is never zeroed or synchronized with a pot balance.

Impact:

  • The issue breaks invariant remainingRewards should be equal to pot balance and reveals a protocol insolvency when remainingRewards exceed balance, which mean that it will not be possible to pay claimants cuts.

Proof of Concept

Values of remainingRewards and Pot.sol balance are not equal during a protocol lifecycle.
Please, add the following test to TestMyCut.t.sol:

function test_remainingRewardsIsNotEqualToPotBalance() public mintAndApproveTokens {
vm.startPrank(user);
rewards = [500, 500];
totalRewards = 1000;
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), totalRewards);
uint256 potBalInitial = ERC20Mock(weth).balanceOf(contest);
uint256 remainingRewardsInitial = Pot(contest).getRemainingRewards();
// remainingRewards != pot balance
assertGt(remainingRewardsInitial, potBalInitial);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
uint256 potBalFunded = ERC20Mock(weth).balanceOf(contest);
uint256 remainingRewardsFunded = Pot(contest).getRemainingRewards();
// remainingRewards == pot balance
assertEq(potBalFunded, remainingRewardsFunded);
vm.startPrank(player1);
Pot(contest).claimCut();
vm.stopPrank();
uint256 potBalClaimed = ERC20Mock(weth).balanceOf(contest);
uint256 remainingRewardsClaimed = Pot(contest).getRemainingRewards();
// remainingRewards == pot balance
assertEq(potBalClaimed, remainingRewardsClaimed);
vm.warp(91 days);
vm.startPrank(user);
ContestManager(conMan).closeContest(contest);
vm.stopPrank();
uint256 potBalFinal = ERC20Mock(weth).balanceOf(contest);
uint256 remainingRewardsFinal = Pot(contest).getRemainingRewards();
// remainingRewards != pot balance
assertGt(remainingRewardsFinal, potBalFinal);
}

Recommended Mitigation

It is recommended to synchronize remainingRewards with a pot balance and account additionally transferred funds in Pot::remainingRewards:

-uint256 private remainingRewards;
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++) {
playersToRewards[i_players[i]] = i_rewards[i];
}
}
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
+ uint256 remainingRewards = i_token.balanceOf(address(this));
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);
}
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!