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.
@> remainingRewards = totalRewards;
@> 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
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";
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);
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));
emit Result("remainingRewards:", Pot(potAddr).getRemainingRewards());
vm.prank(makeAddr("alice"));
Pot(potAddr).claimCut();
vm.prank(makeAddr("bob"));
Pot(potAddr).claimCut();
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");
}
}
pragma solidity 0.8.20;
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");
}