MyCut

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

ERC20 return values are not checked — failed transfers silently corrupt accounting state

Root + Impact

Description

All i_token.transfer() and i_token.transferFrom() calls across Pot.sol and ContestManager.sol ignore the boolean return value. The ERC20 standard does not require reverting on failure — tokens such as USDT return false instead. In claimCut(), state changes (playersToRewards zeroed, remainingRewards decremented, claimants pushed) occur before the transfer. A silent false leaves the contract believing the player was paid when they received nothing, and the player has no recourse to re-claim.

// Pot.sol :: claimCut()
playersToRewards[player] = 0; // @> state mutated first
remainingRewards -= reward; // @> state mutated first
claimants.push(player); // @> state mutated first
_transferReward(player, reward); // @> silent failure possible
// Pot.sol :: _transferReward()
function _transferReward(address player, uint256 reward) internal {
// @> return value discarded — false == silent failure
i_token.transfer(player, reward);
}
// Pot.sol :: closePot()
i_token.transfer(msg.sender, managerCut); // @> return value discarded
// ContestManager.sol :: fundContest()
token.transferFrom(msg.sender, address(pot), totalRewards); // @> same issue

Risk

Likelihood:

  • The protocol explicitly targets "Standard ERC20 Tokens Only" — USDT is the most widely used ERC20 and returns false instead of reverting on failure.

  • Any future token added to a contest that follows the pre-EIP-20 return-value pattern will silently fail on every transfer.

Impact:

  • A player's reward is zeroed out and they are added to claimants, but they receive nothing — the tokens remain locked in the Pot with no re-claim path.

  • The manager can appear to have closed and paid out a contest while players received nothing, making the bug invisible to on-chain state inspection.

Proof of Concept

// Using USDT-style token that returns false on transfer failure
// Player calls claimCut() with reward = 500e6
// State changes execute:
// playersToRewards[player] = 0
// remainingRewards -= 500e6
// claimants.push(player)
// _transferReward called → token.transfer returns false (no revert)
// Player balance: unchanged (received 0 tokens)
// Player cannot re-claim: reward already zeroed
// 500e6 locked in Pot forever

Recommended Mitigation

+ import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
contract Pot is Ownable(msg.sender) {
+ using SafeERC20 for IERC20;
function _transferReward(address player, uint256 reward) internal {
- i_token.transfer(player, reward);
+ i_token.safeTransfer(player, reward);
}
// closePot()
- i_token.transfer(msg.sender, managerCut);
+ i_token.safeTransfer(msg.sender, managerCut);
}
// ContestManager.sol
+ using SafeERC20 for IERC20;
- token.transferFrom(msg.sender, address(pot), totalRewards);
+ token.safeTransferFrom(msg.sender, address(pot), totalRewards);
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!