MyCut

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

Pot starts its 90-day claim clock at deployment (createContest), not at funding (fundContest), so when funding is delayed the pot can be closed after fewer than 90 claimable days, or in the funding block itself

Claim clock starts at deployment + effective claim window shortened or removed when funding is delayed

Description

  • The README gives claimants "90 days to claim before the manager takes a cut". Players can only claim from a funded Pot, so the 90 days should be counted from the moment the Pot can actually pay.

  • The Pot constructor sets i_deployedAt = block.timestamp during createContest. Funding is a separate, later onlyOwner call (fundContest) that records no timestamp. The line that would have funded the Pot in the constructor is commented out.

  • closePot counts the 90 days from deployment. Until funding, every claimCut reverts inside the token transfer with ERC20InsufficientBalance; the declared Pot__InsufficientFunds error is never used. So every day between creation and funding is subtracted from the claim window.

// src/Pot.sol
@> 28: i_deployedAt = block.timestamp;
29:
30: // i_token.transfer(address(this), i_totalRewards);
// ...
49: function closePot() external onlyOwner {
@> 50: if (block.timestamp - i_deployedAt < 90 days) {
51: revert Pot__StillOpenForClaim();
// src/ContestManager.sol
28: function fundContest(uint256 index) public onlyOwner {
29: Pot pot = Pot(contests[index]);
30: IERC20 token = pot.getToken();
31: uint256 totalRewards = contestToTotalRewards[address(pot)];
32:
33: if (token.balanceOf(msg.sender) < totalRewards) {
34: revert ContestManager__InsufficientFunds();
35: }
36:
37: token.transferFrom(msg.sender, address(pot), totalRewards);
38: }

Risk

Likelihood:

  • It occurs when the trusted owner funds a pot well after creating it and still closes it at the earliest allowed moment, deployment + 90 days. Both steps are owner actions, so it needs an operational slip by a trusted role. closePot has no upper deadline, so an owner who closes later restores the full window.

  • The contract gives the owner no signal that the window has been shortened.

Impact:

  • Players get 90 days minus the funding delay. In the PoC, funding on day 60 leaves 30 effective days. p2's claims on days 1 and 59 revert, and p2 is excluded at the day-90 close. p1 ends with 725, and the 225 left in the Pot is less than p2's 500, so p2 cannot claim late either.

  • Funding after day 90 lets fundContest and closeContest run in the same block. The effective claim window is 0 seconds, and 100 is taken as the manager cut from rewards that nobody had a chance to claim.

Proof of Concept

The test deploys ContestManager and the repo's test/ERC20Mock.sol. All four tests pass:

  • test_baseline_fundedImmediately_full90DayWindow (control): when create and fund happen back to back, a close at 90 days minus 1 second reverts with Pot__StillOpenForClaim. The earliest close is 7,776,000 s (90 days) after funding.

  • test_lateFunding_day60_only30EffectiveDays: claims on days 1 and 59 revert with ERC20InsufficientBalance(pot, 0, 500). The close succeeds on day 90, exactly 30 days after funding. p2 ends with 0.

  • test_fundingAfterDay90_closeSameBlock_zeroWindow: funding and closing happen in the same block, with a window of 0 seconds and a manager cut of 100.

  • test_context_adminCanRestoreWindowByClosingLater (mitigating context): an owner who funds on day 60 and closes on day 150 gives players the full 90 days.

Save the file as test/PoC_MC-09.t.sol and run from the repository root:

FOUNDRY_FFI=false forge test --match-path test/PoC_MC-09.t.sol -vv
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
​
import {Test, console} from "lib/forge-std/src/Test.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {IERC20Errors} from "lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
import {ERC20Mock} from "./ERC20Mock.sol";
​
/// MC-09: the 90-day claim clock starts at Pot deployment (createContest), not at funding
/// (fundContest), so a delay between the two shortens or removes the effective claim window.
contract PoC_MC09 is Test {
address admin = makeAddr("admin");
address p1 = makeAddr("p1");
address p2 = makeAddr("p2");
​
ContestManager conMan;
ERC20Mock token;
​
uint256 constant REWARD = 500;
uint256 constant TOTAL = 1000;
​
function setUp() public {
vm.warp(1_700_000_000);
vm.startPrank(admin);
conMan = new ContestManager();
token = new ERC20Mock("TKN", "TKN", admin, 0);
token.mint(admin, 1_000_000);
token.approve(address(conMan), type(uint256).max);
vm.stopPrank();
}
​
function _create() internal returns (Pot pot, uint256 idx) {
address[] memory players = new address[](2);
players[0] = p1;
players[1] = p2;
uint256[] memory rewards = new uint256[](2);
rewards[0] = REWARD;
rewards[1] = REWARD;
vm.prank(admin);
address potAddr = conMan.createContest(players, rewards, IERC20(address(token)), TOTAL);
idx = conMan.getContests().length - 1;
pot = Pot(potAddr);
}
​
function _fund(uint256 idx) internal {
vm.prank(admin);
conMan.fundContest(idx);
}
​
/// Baseline: create + fund back to back gives players the full 90 days.
function test_baseline_fundedImmediately_full90DayWindow() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
_fund(idx);
​
// Players can claim from the first moment.
vm.prank(p1);
pot.claimCut();
assertEq(token.balanceOf(p1), REWARD);
​
// Closing before 90 days after funding reverts.
vm.warp(t0 + 90 days - 1);
vm.prank(admin);
vm.expectRevert(Pot.Pot__StillOpenForClaim.selector);
conMan.closeContest(address(pot));
​
vm.warp(t0 + 90 days);
vm.prank(admin);
conMan.closeContest(address(pot));
console.log("baseline: seconds between funding and earliest close =", block.timestamp - t0);
}
​
/// Limbs 1 + 3: funding on day 60 leaves only 30 effective days; claims before funding
/// revert with the token's ERC20InsufficientBalance (Pot__InsufficientFunds is never used).
function test_lateFunding_day60_only30EffectiveDays() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
​
// Day 0..59: pot exists but is unfunded; every claim reverts inside the token transfer.
vm.warp(t0 + 1 days);
vm.prank(p2);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, REWARD)
);
pot.claimCut();
// The revert rolled back state: p2 still has their cut recorded, but received nothing.
assertEq(pot.checkCut(p2), REWARD);
assertEq(token.balanceOf(p2), 0);
​
vm.warp(t0 + 59 days);
vm.prank(p2);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, REWARD)
);
pot.claimCut();
​
// Day 60: admin funds.
vm.warp(t0 + 60 days);
uint256 fundedAt = block.timestamp;
_fund(idx);
assertEq(token.balanceOf(address(pot)), TOTAL);
​
// p1 claims after funding.
vm.prank(p1);
pot.claimCut();
assertEq(token.balanceOf(p1), REWARD);
​
// Day 90 since deployment = only 30 days since funding: close is already allowed.
vm.warp(t0 + 90 days);
vm.prank(admin);
conMan.closeContest(address(pot));
uint256 effectiveWindow = block.timestamp - fundedAt;
console.log("late funding: effective claim window (days) =", effectiveWindow / 1 days);
assertEq(effectiveWindow, 30 days);
assertLt(effectiveWindow, 90 days);
​
// p2, who could not claim for the first 60 days, is excluded from the closing bonus:
// p1 (the only claimant) got REWARD + bonus, p2 got nothing at close.
assertGt(token.balanceOf(p1), REWARD);
assertEq(token.balanceOf(p2), 0);
console.log("p1 balance after close =", token.balanceOf(p1));
console.log("pot balance after close =", token.balanceOf(address(pot)));
}
​
/// Limb 2: funding after day 90 allows closing in the same block; zero effective window.
function test_fundingAfterDay90_closeSameBlock_zeroWindow() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
​
// Before funding, claims are impossible.
vm.warp(t0 + 45 days);
vm.prank(p1);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, REWARD)
);
pot.claimCut();
​
// Day 91: fund, then close in the same block.
vm.warp(t0 + 91 days);
uint256 fundedAt = block.timestamp;
_fund(idx);
uint256 adminBefore = token.balanceOf(admin);
vm.prank(admin);
conMan.closeContest(address(pot)); // does NOT revert: no time measured from funding
assertEq(block.timestamp - fundedAt, 0);
​
// No one claimed in time: manager took 10% of the full pot (to ContestManager, the Pot owner),
// no claimant received a bonus.
assertEq(token.balanceOf(address(conMan)), TOTAL / 10);
assertEq(token.balanceOf(admin), adminBefore);
assertEq(token.balanceOf(p1), 0);
assertEq(token.balanceOf(p2), 0);
console.log("fund-after-90d: window seconds =", block.timestamp - fundedAt);
console.log("ContestManager cut =", token.balanceOf(address(conMan)));
console.log("pot balance after close =", token.balanceOf(address(pot)));
}
​
/// Mitigating context (narrows the claim): closePot has no upper deadline, so the trusted admin
/// can restore a full 90 days after late funding by simply closing later.
function test_context_adminCanRestoreWindowByClosingLater() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
vm.warp(t0 + 60 days);
uint256 fundedAt = block.timestamp;
_fund(idx);
​
vm.warp(fundedAt + 90 days); // day 150
vm.prank(p2);
pot.claimCut();
assertEq(token.balanceOf(p2), REWARD);
vm.prank(admin);
conMan.closeContest(address(pot));
assertEq(block.timestamp - fundedAt, 90 days);
}
}

Recommended Mitigation

Start the claim window when the Pot is funded, not when it is deployed. fundContest calls a new onlyOwner hook on the Pot right after the transfer. The hook also puts the unused Pot__InsufficientFunds error to work and, as a side effect, blocks a second fundContest.

--- a/src/Pot.sol
+++ b/src/Pot.sol
@@ -8,12 +8,13 @@
error Pot__RewardNotFound();
error Pot__InsufficientFunds();
error Pot__StillOpenForClaim();
+ error Pot__ClaimPeriodAlreadyStarted();
address[] private i_players;
uint256[] private i_rewards;
address[] private claimants;
uint256 private immutable i_totalRewards;
- uint256 private immutable i_deployedAt;
+ uint256 private s_claimStart;
IERC20 private immutable i_token;
mapping(address => uint256) private playersToRewards;
uint256 private remainingRewards;
@@ -25,7 +26,6 @@
i_token = token;
i_totalRewards = totalRewards;
remainingRewards = totalRewards;
- i_deployedAt = block.timestamp;
// i_token.transfer(address(this), i_totalRewards);
@@ -34,7 +34,21 @@
}
}
+ /// @notice Starts the 90-day claim window once the Pot holds the full reward amount.
+ function startClaimPeriod() external onlyOwner {
+ if (s_claimStart != 0) {
+ revert Pot__ClaimPeriodAlreadyStarted();
+ }
+ if (i_token.balanceOf(address(this)) < i_totalRewards) {
+ revert Pot__InsufficientFunds();
+ }
+ s_claimStart = block.timestamp;
+ }
+
function claimCut() public {
+ if (s_claimStart == 0) {
+ revert Pot__InsufficientFunds();
+ }
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
@@ -47,7 +61,7 @@
}
function closePot() external onlyOwner {
- if (block.timestamp - i_deployedAt < 90 days) {
+ if (s_claimStart == 0 || block.timestamp - s_claimStart < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
--- a/src/ContestManager.sol
+++ b/src/ContestManager.sol
@@ -35,6 +35,8 @@
}
token.transferFrom(msg.sender, address(pot), totalRewards);
+ // the 90-day claim window starts only now that the Pot can pay
+ pot.startClaimPeriod();
}
function getContests() public view returns (address[] memory) {
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!