MyCut

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

`fundContest()` Allows Repeated Funding of the Same Contest Without State Guard

fundContest() Allows Repeated Funding of the Same Contest Without State Guard

Description

ContestManager.fundContest() transfers totalRewards into a pot every time the function is called for a given index.

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);
}

There is no funded-state guard, no per-contest funding cap, and no check that the pot is still in a funding phase. As a result, the same contest can be funded repeatedly with full totalRewards each time.

While this is an admin-only action, it is still a lifecycle integrity issue. The funding action is effectively unbounded and can accidentally or operationally over-capitalize a single contest.


Risk

Likelihood: Medium

Only the owner can trigger this path, so it is not externally exploitable by arbitrary users. Still, repeated calls are straightforward and can occur due to retries or incorrect operator scripts.

Impact: Medium

Repeated full funding breaks expected per-contest budgeting and can lock or misallocate protocol treasury tokens. In combination with weak close-finalization semantics, overfunded balances can amplify downstream payout errors.


Proof of Concept

The behavior is demonstrated by the following PoC test:

function testFundContest_CanBeCalledMultipleTimes() public {
address contest = manager.getContests()[0];
vm.startPrank(owner);
manager.fundContest(0);
uint256 balanceAfterFirstFunding = token.balanceOf(contest);
// The second call succeeds again because no funded/finalized guard exists.
manager.fundContest(0);
uint256 balanceAfterSecondFunding = token.balanceOf(contest);
vm.stopPrank();
assertEq(balanceAfterFirstFunding, 4);
assertEq(balanceAfterSecondFunding, 8);
}

Run:

forge test --match-test testFundContest_CanBeCalledMultipleTimes -vv

Output:

[PASS] testFundContest_CanBeCalledMultipleTimes() (gas: 81008)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.60ms (185.54µs CPU time)
Ran 1 test suite in 10.08ms (1.60ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

The test confirms the same contest balance moves from 4 to 8 after two consecutive fundContest(0) calls.


Recommended Mitigation

Add per-contest funding state so full funding can happen only once unless an explicit top-up flow is designed.

+mapping(address => bool) public contestFunded;
function fundContest(uint256 index) public onlyOwner {
Pot pot = Pot(contests[index]);
+ require(!contestFunded[address(pot)], "Contest already funded");
IERC20 token = pot.getToken();
uint256 totalRewards = contestToTotalRewards[address(pot)];
token.transferFrom(msg.sender, address(pot), totalRewards);
+ contestFunded[address(pot)] = true;
}

If top-ups are intended by design, implement an explicit topUpContest() path with separate accounting and events, instead of reusing initial funding logic.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 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!