MyCut

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

Broken Funding Workflow Due to Missing Token Approval Step

Root + Impact

The ContestManager contract attempts to pull tokens directly from the owner's address via transferFrom within the fundContest function, but it provides no mechanism to grant an allowance to itself beforehand. Consequently, unless the owner manually interacts with the external ERC20 contract to approve the ContestManager in a separate, out-of-band transaction, any standard compliant ERC20 token transfer will immediately revert (or return false), creating a broken, non-functional funding flow.

Description

To execute transferFrom(msg.sender, recipient, amount), the calling contract (ContestManager) must have a sufficient allowance allocated to it by the owner (msg.sender).

The current contract architecture assumes that calling fundContest is a one-step process. However, because the contract does not implement a helper function to set approvals or coordinate the approval step internally, it introduces an implicit, external dependency. If the owner has not executed an independent token.approve(address(ContestManager), amount) transaction prior to calling fundContest, the transaction will fail to pull the rewards.

function fundContest(uint256 index) public onlyOwner {
Pot pot = Pot(contests[index]);
IERC20 token = pot.getToken();
uint256 totalRewards = contestToTotalRewards[address(pot)];
//may fund the constst more than once so there is no
//keeping track of which pots have yet to receive funds or
//which ones have already received funds
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: High

  • The owner attempts to use the protocol as written without realizing that a separate approval transaction is required on the underlying ERC20 contract.

Impact: Medium

  • Denial of Service (DoS) / Broken UX: The funding workflow is fundamentally broken for standard-compliant tokens (which revert when allowance is insufficient) unless the owner executes manual, external operations.

Proof of Concept

  1. The owner deploys the ContestManager and calls createContest, deploying a new Pot requiring 1,000 standard ERC20 tokens.

  2. The owner calls fundContest(0) directly.

  3. Because the owner has not called token.approve(address(ContestManager), 1000) prior to this transaction, the ERC20 token's transferFrom function detects an allowance of 0.

  4. The transaction reverts (or returns false), preventing the contest from ever receiving its funds.

// SPDX-License-Identifier: MIT
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";
// A standard ERC20 token that strictly reverts when allowance is insufficient
contract StandardERC20 is ERC20 {
constructor() ERC20("StandardToken", "STK") {
_mint(msg.sender, 100_000 * 10**18);
}
}
contract ContestManagerApprovalPoCTest is Test {
ContestManager public manager;
StandardERC20 public token;
address public owner = address(0x1);
address public player = address(0x2);
function setUp() public {
vm.startPrank(owner);
manager = new ContestManager();
token = new StandardERC20();
vm.stopPrank();
}
function test_PoC_FundingFailsDueToMissingApproval() public {
address[] memory players = new address[](1);
players[0] = player;
uint256[] memory rewards = new uint256[](1);
rewards[0] = 1000;
vm.startPrank(owner);
// 1. The owner deploys the ContestManager and calls createContest,
// deploying a new Pot requiring 1,000 standard ERC20 tokens.
address potAddress = manager.createContest(players, rewards, token, 1000);
// Verify current allowance given to ContestManager is 0
uint256 allowanceBefore = token.allowance(owner, address(manager));
assertEq(allowanceBefore, 0);
console.log("Allowance before funding attempt:", allowanceBefore);
// 2. The owner calls fundContest(0) directly without calling token.approve() first.
// 3. The ERC20 token's transferFrom function detects an allowance of 0.
// 4. The transaction reverts, preventing the contest from ever receiving its funds.
// Expect a revert from the ERC20 token contract due to insufficient allowance
vm.expectRevert();
manager.fundContest(0);
// Verify the Pot contract still has 0 tokens
uint256 potBalance = token.balanceOf(potAddress);
assertEq(potBalance, 0);
console.log("Pot Balance remains at 0 due to transaction revert:", potBalance);
vm.stopPrank();
}
}

Recommended Mitigation

Depending on the desired user experience, there are two standard ways to resolve this missstep:

Option A: Use a Two-Step Process (Documented and Handled on Frontend)

Keep the transferFrom logic, but ensure that the frontend or operator script explicitly executes an approve transaction before calling fundContest. No contract changes are required for this, but the logic must be secured with safeTransferFrom to prevent silent failures:

// Ensure the UI/Script executes this first:
// token.approve(address(ContestManager), totalRewards);
// Then call:
// ContestManager.fundContest(index);

Option B: Implement ERC2612 permit (Gasless Approval)

If the target token supports the ERC2612 standard (like USDC, DAI, or many modern ERC20s), you can pass a cryptographic signature to fundContest to approve and transfer tokens in a single transaction.

import {IERC20Permit} from "lib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
contract ContestManager is Ownable {
using SafeERC20 for IERC20;
// Single-step transaction using Permit signatures
function fundContestWithPermit(
uint256 index,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public onlyOwner {
Pot pot = Pot(contests[index]);
IERC20 token = pot.getToken();
uint256 totalRewards = contestToTotalRewards[address(pot)];
// Execute the approval via signature on behalf of the owner
IERC20Permit(address(token)).permit(msg.sender, address(this), totalRewards, deadline, v, r, s);
// Safe transfer
token.safeTransferFrom(msg.sender, address(pot), totalRewards);
}
}
Updates

Lead Judging Commences

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