pragma solidity ^0.8.20;
import {Test, console} from "lib/forge-std/src/Test.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {IERC20Errors} from "lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
import {ERC20Mock} from "./ERC20Mock.sol";
contract FalseReturnToken {
string public name = "FalseReturn";
string public symbol = "FRT";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
emit Transfer(address(0), to, amount);
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
if (balanceOf[msg.sender] < amount) return false;
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
if (balanceOf[from] < amount || allowance[from][msg.sender] < amount) return false;
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
contract PoC_MC07 is Test {
address admin = makeAddr("admin");
address p1 = makeAddr("p1");
address p2 = makeAddr("p2");
ContestManager conMan;
address[] players;
uint256[] rewards;
uint256 constant TOTAL = 1_000;
function setUp() public {
vm.prank(admin);
conMan = new ContestManager();
players.push(p1);
players.push(p2);
rewards.push(400);
rewards.push(600);
}
function test_baseline_OZToken_revertsAndPreservesEntitlement() public {
ERC20Mock oz = new ERC20Mock("TKN", "TKN", admin, 0);
oz.mint(admin, TOTAL);
vm.prank(admin);
Pot pot = Pot(conMan.createContest(players, rewards, IERC20(address(oz)), TOTAL));
vm.prank(p1);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, 400)
);
pot.claimCut();
assertEq(pot.checkCut(p1), 400, "baseline: entitlement preserved");
assertEq(pot.getRemainingRewards(), TOTAL, "baseline: remainingRewards unchanged");
vm.prank(admin);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(conMan), 0, TOTAL)
);
conMan.fundContest(0);
}
function test_limb1_claimCutErasesEntitlementWithoutPayment() public {
FalseReturnToken tok = new FalseReturnToken();
tok.mint(admin, TOTAL);
vm.prank(admin);
Pot pot = Pot(conMan.createContest(players, rewards, IERC20(address(tok)), TOTAL));
assertEq(pot.checkCut(p1), 400);
vm.prank(p1);
pot.claimCut();
console.log("p1 token balance after claim:", tok.balanceOf(p1));
console.log("p1 checkCut after claim:", pot.checkCut(p1));
console.log("pot remainingRewards after claim:", pot.getRemainingRewards());
assertEq(tok.balanceOf(p1), 0, "p1 was paid nothing");
assertEq(pot.checkCut(p1), 0, "p1 entitlement erased");
assertEq(pot.getRemainingRewards(), TOTAL - 400, "remainingRewards decremented as if paid");
vm.startPrank(admin);
tok.approve(address(conMan), TOTAL);
conMan.fundContest(0);
vm.stopPrank();
assertEq(tok.balanceOf(address(pot)), TOTAL, "pot fully funded");
vm.prank(p1);
vm.expectRevert(Pot.Pot__RewardNotFound.selector);
pot.claimCut();
vm.prank(p2);
pot.claimCut();
assertEq(tok.balanceOf(p2), 600);
assertEq(pot.getRemainingRewards(), 0, "accounting says nothing is left");
assertEq(tok.balanceOf(address(pot)), 400, "p1's 400 stranded in pot");
console.log("pot balance with remainingRewards == 0:", tok.balanceOf(address(pot)));
}
function test_limb2_fundContestSucceedsWhileFundingNothing() public {
FalseReturnToken tok = new FalseReturnToken();
tok.mint(admin, TOTAL);
vm.prank(admin);
Pot pot = Pot(conMan.createContest(players, rewards, IERC20(address(tok)), TOTAL));
vm.prank(admin);
conMan.fundContest(0);
console.log("pot balance after 'successful' fundContest:", tok.balanceOf(address(pot)));
assertEq(tok.balanceOf(address(pot)), 0, "pot funded with nothing");
assertEq(tok.balanceOf(admin), TOTAL, "admin still holds all tokens");
assertEq(conMan.getContestRemainingRewards(address(pot)), TOTAL, "accounting claims full pot");
vm.prank(p1);
pot.claimCut();
vm.prank(p2);
pot.claimCut();
assertEq(tok.balanceOf(p1), 0);
assertEq(tok.balanceOf(p2), 0);
assertEq(pot.checkCut(p1), 0);
assertEq(pot.checkCut(p2), 0);
assertEq(pot.getRemainingRewards(), 0, "pot reports everything paid out");
}
function test_limb3_closePotTransfersFailSilently() public {
FalseReturnToken tok = new FalseReturnToken();
tok.mint(admin, TOTAL);
vm.prank(admin);
Pot pot = Pot(conMan.createContest(players, rewards, IERC20(address(tok)), TOTAL));
vm.prank(admin);
conMan.fundContest(0);
vm.prank(p1);
pot.claimCut();
vm.warp(block.timestamp + 91 days);
vm.prank(admin);
conMan.closeContest(address(pot));
assertEq(tok.balanceOf(address(conMan)), 0, "manager cut not paid");
assertEq(tok.balanceOf(p1), 0, "claimant bonus not paid");
console.log("conMan balance after close:", tok.balanceOf(address(conMan)));
console.log("p1 balance after close:", tok.balanceOf(p1));
}
}
@@ -2,9 +2,12 @@
pragma solidity ^0.8.20;
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
+import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
contract Pot is Ownable(msg.sender) {
+ using SafeERC20 for IERC20;
+
error Pot__RewardNotFound();
error Pot__InsufficientFunds();
error Pot__StillOpenForClaim();
@@ -52,7 +55,7 @@
}
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
- i_token.transfer(msg.sender, managerCut);
+ i_token.safeTransfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
@@ -62,7 +65,7 @@
}
function _transferReward(address player, uint256 reward) internal {
- i_token.transfer(player, reward);
+ i_token.safeTransfer(player, reward);
}
function getToken() public view returns (IERC20) {
@@ -4,8 +4,11 @@
import {Pot} from "./Pot.sol";
import {Ownable} from "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
+import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
contract ContestManager is Ownable {
+ using SafeERC20 for IERC20;
+
address[] public contests;
mapping(address => uint256) public contestToTotalRewards;
@@ -34,7 +37,7 @@
revert ContestManager__InsufficientFunds();
}
- token.transferFrom(msg.sender, address(pot), totalRewards);
+ token.safeTransferFrom(msg.sender, address(pot), totalRewards);
}
function getContests() public view returns (address[] memory) {