MyCut

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

Fee-on-Transfer Tokens Cause Pot Underfunding and Permanent Reward Lock

Description

The MyCut protocol supports arbitrary ERC20 tokens. When a fee-on-transfer token is used, fundContest() calls transferFrom(owner, pot, totalRewards), but the Pot receives less than totalRewards due to the transfer fee deducted en route.

Meanwhile, the Pot constructor sets remainingRewards = totalRewards (the full amount). This creates an accounting mismatch: the Pot believes it holds more tokens than it actually does. When the last player(s) attempt to claimCut(), the Pot has insufficient token balance and the transfer reverts (or returns false with a non-compliant token), permanently locking their rewards.

// src/Pot.sol:27 — @> remainingRewards set to totalRewards (before any fee deduction)
@> remainingRewards = totalRewards;
// src/ContestManager.sol:37 — @> transferFrom delivers less than totalRewards for fee-on-transfer tokens
@> token.transferFrom(msg.sender, address(pot), totalRewards);

The fee is never accounted for in the Pot's accounting. remainingRewards is decremented by the full reward amount per claim, but the Pot only received reward - fee for each transfer.

Risk

Likelihood:

  • The protocol is deployed with or used alongside fee-on-transfer tokens (common in DeFi: SafeMoon, PAXG, USDT on certain L2s, various yield-bearing tokens)

  • A user or integration selects a fee-on-transfer token when creating a contest, either intentionally or because the token list does not distinguish standard vs. fee-on-transfer tokens

Impact:

  • The final claimant(s) cannot call claimCut() because the Pot has fewer tokens than remainingRewards expects. Their rewards are permanently locked with no recovery path.

  • In the PoC: 3 players × 3 ether = 9 ether funded. With a 10% fee, Pot receives 8.1 ether. After 2 claims (each losing 0.3 ether to fee), Pot has 2.1 ether but owes 3 ether. Charlie's claim reverts.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ContestManager} from "../../src/ContestManager.sol";
import {Pot} from "../../src/Pot.sol";
import {Test} from "lib/forge-std/src/Test.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {FeeOnTransferERC20} from "./FeeOnTransferERC20.sol";
/// @title PoC: Fee-on-Transfer Token Underfunds Pot
/// @notice 10% fee-on-transfer token: Charlie's claim reverts due to insufficient Pot balance
contract PocFeeOnTransfer is Test {
ContestManager public cm;
FeeOnTransferERC20 public token;
address public owner = makeAddr("owner");
event Result(string message, uint256 value);
function setUp() public {
vm.startPrank(owner);
cm = new ContestManager();
token = new FeeOnTransferERC20("FEE", "FEE", 10); // 10% fee
token.approve(address(cm), type(uint256).max);
vm.stopPrank();
}
function test_PoC_FeeOnTransferUnderfunds() public {
address[] memory players = new address[](3);
players[0] = makeAddr("alice");
players[1] = makeAddr("bob");
players[2] = makeAddr("charlie");
uint256[] memory rewards = new uint256[](3);
rewards[0] = 3 ether;
rewards[1] = 3 ether;
rewards[2] = 3 ether;
vm.startPrank(owner);
token.mint(owner, 10 ether);
address potAddr = cm.createContest(players, rewards, IERC20(address(token)), 9 ether);
cm.fundContest(0);
vm.stopPrank();
emit Result("Pot balance:", token.balanceOf(potAddr)); // 8.1 ether (90% of 9)
emit Result("remainingRewards:", Pot(potAddr).getRemainingRewards()); // 9 ether (full amount)
vm.prank(makeAddr("alice"));
Pot(potAddr).claimCut(); // Alice gets 2.7 ether (3 - 10% fee)
vm.prank(makeAddr("bob"));
Pot(potAddr).claimCut(); // Bob gets 2.7 ether
vm.prank(makeAddr("charlie"));
try Pot(potAddr).claimCut() {
revert("Charlie should not succeed");
} catch {
emit Result("Charlie claim reverted - Pot underfunded!", 0);
}
emit Result("Charlie balance:", token.balanceOf(makeAddr("charlie")));
assertEq(token.balanceOf(makeAddr("charlie")), 0, "Charlie lost his reward");
assertEq(Pot(potAddr).getRemainingRewards(), 3 ether, "3 ether remaining but Pot has less");
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
// FeeOnTransferERC20.sol — ERC20 mock that deducts a fee on every transfer
contract FeeOnTransferERC20 {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
uint256 public feePercent;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory _name, string memory _symbol, uint256 _feePercent) {
name = _name;
symbol = _symbol;
feePercent = _feePercent;
}
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
uint256 fee = amount * feePercent / 100;
uint256 netAmount = amount - fee;
balanceOf[msg.sender] -= amount;
balanceOf[to] += netAmount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
if (allowance[from][msg.sender] < amount) {
return false;
}
allowance[from][msg.sender] -= amount;
uint256 fee = amount * feePercent / 100;
uint256 netAmount = amount - fee;
balanceOf[from] -= amount;
balanceOf[to] += netAmount;
return true;
}
}

Recommended Mitigation

constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
i_players = players;
i_rewards = rewards;
i_token = token;
i_totalRewards = totalRewards;
- remainingRewards = totalRewards;
+ // Use actual balance as the source of truth, not the passed-in totalRewards
i_deployedAt = block.timestamp;
for (uint256 i = 0; i < i_players.length; i++) {
playersToRewards[i_players[i]] = i_rewards[i];
}
+ remainingRewards = i_token.balanceOf(address(this));
}
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 balanceBefore = token.balanceOf(address(pot));
+ token.transferFrom(msg.sender, address(pot), totalRewards);
+ uint256 balanceAfter = token.balanceOf(address(pot));
+ require(balanceAfter - balanceBefore == totalRewards, "ContestManager: fee-on-transfer not supported");
}
Updates

Lead Judging Commences

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