Root + Impact
The ContestManager contract fails to track whether a contest has already been funded and ignores the return value of transferFrom. Consequently, an owner can accidentally call fundContest multiple times for the same contest, and if an ERC20 token returns false instead of reverting on failure (such as when a prior approve call is missing), the contract will statefully treat the contest as active despite receiving no funding.
Description
The contract is designed to allow the owner to deploy individual Pot contracts for contests and subsequently fund them with the required ERC20 tokens using the fundContest function.
However, fundContest lacks state validation to check if a specific contest index has already been processed, allowing the transfer logic to be executed repeatedly for the same Pot. Furthermore, the function executes transferFrom without verifying the boolean return value required by the ERC20 standard, meaning failures in standard token transfers pass unnoticed by the execution flow.
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);
trnasfer is made before approve is made which can result in a failed txn
and the bool is ignored
*/
}
Risk
Likelihood: Medium
-
The owner executes fundContest multiple times due to operational error, network lag, or script retries.
-
The contract interacts with standard ERC20 tokens that return false on failed transfers rather than reverting, combined with the owner omitting the necessary prerequisite approve transaction.
Impact: High
-
User funds are double-spent or locked redundantly within a single Pot contract, draining the owner's balance.
-
The contest enters a state where it is considered functionally valid by the manager while holding a zero token balance, leading to failed prize distributions for players.
Proof of Concept
The owner calls createContest to initialize a new contest requiring 1,000 tokens.
The owner calls fundContest(0) twice due to a duplicate transaction submission.
The owner's balance decreases by 2,000 tokens while the Pot receives double its required allocation.
Alternatively, if the owner calls fundContest(0) without executing token.approve(ContestManager, 1000) beforehand on a non-reverting ERC20 token, transferFrom returns false, no tokens are moved, but the transaction completes successfully.
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract MockERC20isNonReverting is ERC20 {
constructor() ERC20("MockToken", "MTK") {
_mint(msg.sender, 100_000 * 10**18);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
if (allowance(from, msg.sender) < amount && from != msg.sender) {
return false;
}
if (balanceOf(from) < amount) {
return false;
}
return super.transferFrom(from, to, amount);
}
}
contract ContestManagerPoCTest is Test {
ContestManager public manager;
MockERC20isNonReverting public token;
address public owner = address(0x1);
address public player = address(0x2);
function setUp() public {
vm.startPrank(owner);
manager = new ContestManager();
token = new MockERC20isNonReverting();
vm.stopPrank();
}
function test_PoC_DoubleFundingAndSilentFailure() public {
address[] memory players = new address[](1);
players[0] = player;
uint256[] memory rewards = new uint256[](1);
rewards[0] = 1000;
vm.startPrank(owner);
address potAddress = manager.createContest(players, rewards, token, 1000);
manager.fundContest(0);
assertEq(token.balanceOf(potAddress), 0);
console.log("Pot Balance after unapproved fundContest:", token.balanceOf(potAddress));
token.approve(address(manager), 5000);
manager.fundContest(0);
uint256 balanceAfterFirstFund = token.balanceOf(potAddress);
assertEq(balanceAfterFirstFund, 1000);
manager.fundContest(0);
uint256 balanceAfterSecondFund = token.balanceOf(potAddress);
assertEq(balanceAfterSecondFund, 2000);
console.log("Pot Balance after accidental double funding:", balanceAfterSecondFund);
vm.stopPrank();
}
}
Recommended Mitigation
Implement a state tracking mapping to prevent multiple funding phases and switch to OpenZeppelin's SafeERC20 wrapper library to handle token approvals and return values securely.
Solidity
+import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
contract ContestManager is Ownable {
using SafeERC20 for IERC20;
mapping(address => bool) public isContestFunded;
error ContestManager__AlreadyFunded();
function fundContest(uint256 index) public onlyOwner {
address contestAddress = contests[index];
if (isContestFunded[contestAddress]) {
revert ContestManager__AlreadyFunded();
}
Pot pot = Pot(contestAddress);
IERC20 token = pot.getToken();
uint256 totalRewards = contestToTotalRewards[contestAddress];
isContestFunded[contestAddress] = true;
token.safeTransferFrom(msg.sender, address(pot), totalRewards);
}
}