MyCut

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

Unchecked transferFrom Return in fundContest — Contest Appears Funded With Zero Tokens

Unchecked transferFrom Return in fundContest — Contest Appears Funded With Zero Tokens

Description

The ContestManager.fundContest() function calls token.transferFrom(msg.sender, address(pot), totalRewards) without checking the boolean return value. The function only validates that the caller has sufficient balance via token.balanceOf(msg.sender) < totalRewards, but does not verify that the transfer actually succeeded. For non-reverting ERC20 tokens that return false on failure (e.g., insufficient allowance), the contest transitions to a "funded" state in the protocol's view while the Pot contract holds zero tokens.

This creates a particularly dangerous inconsistency: the contestToTotalRewards mapping shows the expected amount, getRemainingRewards() returns the full value, but the actual token balance of the Pot is zero. When players call claimCut(), they will either receive nothing (non-reverting token) or trigger a revert (standard ERC20). In both cases, the contest is rendered non-functional despite appearing properly funded.

// ContestManager.sol
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); // @> return value unchecked
}

Risk

Likelihood

Medium

Impact

High

Severity

Medium

Proof of Concept

The contest owner creates a contest with a non-reverting ERC20 token. They call fundContest() without first calling token.approve(). The balanceOf check passes (owner has tokens), but transferFrom returns false (no allowance). The function completes without error, but the Pot has no tokens. When a player tries claimCut(), the transfer fails silently.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract NonRevertingToken {
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
function mint(address to, uint256 amt) external { balanceOf[to] += amt; }
function approve(address spender, uint256 amt) external returns (bool) {
allowance[msg.sender][spender] = amt;
return true;
}
function transfer(address to, uint256 amt) external returns (bool) {
if (balanceOf[msg.sender] < amt) return false;
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function transferFrom(address from, address to, uint256 amt) external returns (bool) {
if (allowance[from][msg.sender] < amt) return false; // silent fail — no revert
balanceOf[from] -= amt;
balanceOf[to] += amt;
allowance[from][msg.sender] -= amt;
return true;
}
}
contract FundContestTest is Test {
function test_fundWithoutApproval_silentFail() public {
NonRevertingToken token = new NonRevertingToken();
ContestManager cm = new ContestManager();
token.mint(address(this), 100 ether);
// NOTE: no approve() call
address[] memory players = new address[](1);
players[0] = address(0xBEEF);
uint256[] memory rewards = new uint256[](1);
rewards[0] = 100 ether;
address potAddr = cm.createContest(players, rewards, IERC20(address(token)), 100 ether);
cm.fundContest(0);
// Contest "funded" but pot has 0 tokens
assertEq(token.balanceOf(potAddr), 0);
assertEq(cm.getContestTotalRewards(potAddr), 100 ether); // shows funded
}
}

Recommended Mitigation

Use SafeERC20.safeTransferFrom() which reverts on false return. Additionally, verify the Pot's token balance increased by the expected amount to defend against fee-on-transfer tokens.

// ContestManager.sol
+import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
contract ContestManager is Ownable {
+ using SafeERC20 for IERC20;
// ...
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);
+ uint256 balBefore = token.balanceOf(address(pot));
+ token.safeTransferFrom(msg.sender, address(pot), totalRewards);
+ require(token.balanceOf(address(pot)) - balBefore == totalRewards, "transfer mismatch");
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days 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!