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 PoC_MC09 is Test {
address admin = makeAddr("admin");
address p1 = makeAddr("p1");
address p2 = makeAddr("p2");
ContestManager conMan;
ERC20Mock token;
uint256 constant REWARD = 500;
uint256 constant TOTAL = 1000;
function setUp() public {
vm.warp(1_700_000_000);
vm.startPrank(admin);
conMan = new ContestManager();
token = new ERC20Mock("TKN", "TKN", admin, 0);
token.mint(admin, 1_000_000);
token.approve(address(conMan), type(uint256).max);
vm.stopPrank();
}
function _create() internal returns (Pot pot, uint256 idx) {
address[] memory players = new address[](2);
players[0] = p1;
players[1] = p2;
uint256[] memory rewards = new uint256[](2);
rewards[0] = REWARD;
rewards[1] = REWARD;
vm.prank(admin);
address potAddr = conMan.createContest(players, rewards, IERC20(address(token)), TOTAL);
idx = conMan.getContests().length - 1;
pot = Pot(potAddr);
}
function _fund(uint256 idx) internal {
vm.prank(admin);
conMan.fundContest(idx);
}
function test_baseline_fundedImmediately_full90DayWindow() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
_fund(idx);
vm.prank(p1);
pot.claimCut();
assertEq(token.balanceOf(p1), REWARD);
vm.warp(t0 + 90 days - 1);
vm.prank(admin);
vm.expectRevert(Pot.Pot__StillOpenForClaim.selector);
conMan.closeContest(address(pot));
vm.warp(t0 + 90 days);
vm.prank(admin);
conMan.closeContest(address(pot));
console.log("baseline: seconds between funding and earliest close =", block.timestamp - t0);
}
function test_lateFunding_day60_only30EffectiveDays() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
vm.warp(t0 + 1 days);
vm.prank(p2);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, REWARD)
);
pot.claimCut();
assertEq(pot.checkCut(p2), REWARD);
assertEq(token.balanceOf(p2), 0);
vm.warp(t0 + 59 days);
vm.prank(p2);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, REWARD)
);
pot.claimCut();
vm.warp(t0 + 60 days);
uint256 fundedAt = block.timestamp;
_fund(idx);
assertEq(token.balanceOf(address(pot)), TOTAL);
vm.prank(p1);
pot.claimCut();
assertEq(token.balanceOf(p1), REWARD);
vm.warp(t0 + 90 days);
vm.prank(admin);
conMan.closeContest(address(pot));
uint256 effectiveWindow = block.timestamp - fundedAt;
console.log("late funding: effective claim window (days) =", effectiveWindow / 1 days);
assertEq(effectiveWindow, 30 days);
assertLt(effectiveWindow, 90 days);
assertGt(token.balanceOf(p1), REWARD);
assertEq(token.balanceOf(p2), 0);
console.log("p1 balance after close =", token.balanceOf(p1));
console.log("pot balance after close =", token.balanceOf(address(pot)));
}
function test_fundingAfterDay90_closeSameBlock_zeroWindow() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
vm.warp(t0 + 45 days);
vm.prank(p1);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(pot), 0, REWARD)
);
pot.claimCut();
vm.warp(t0 + 91 days);
uint256 fundedAt = block.timestamp;
_fund(idx);
uint256 adminBefore = token.balanceOf(admin);
vm.prank(admin);
conMan.closeContest(address(pot));
assertEq(block.timestamp - fundedAt, 0);
assertEq(token.balanceOf(address(conMan)), TOTAL / 10);
assertEq(token.balanceOf(admin), adminBefore);
assertEq(token.balanceOf(p1), 0);
assertEq(token.balanceOf(p2), 0);
console.log("fund-after-90d: window seconds =", block.timestamp - fundedAt);
console.log("ContestManager cut =", token.balanceOf(address(conMan)));
console.log("pot balance after close =", token.balanceOf(address(pot)));
}
function test_context_adminCanRestoreWindowByClosingLater() public {
uint256 t0 = block.timestamp;
(Pot pot, uint256 idx) = _create();
vm.warp(t0 + 60 days);
uint256 fundedAt = block.timestamp;
_fund(idx);
vm.warp(fundedAt + 90 days);
vm.prank(p2);
pot.claimCut();
assertEq(token.balanceOf(p2), REWARD);
vm.prank(admin);
conMan.closeContest(address(pot));
assertEq(block.timestamp - fundedAt, 90 days);
}
}
Start the claim window when the Pot is funded, not when it is deployed. fundContest calls a new onlyOwner hook on the Pot right after the transfer. The hook also puts the unused Pot__InsufficientFunds error to work and, as a side effect, blocks a second fundContest.
@@ -8,12 +8,13 @@
error Pot__RewardNotFound();
error Pot__InsufficientFunds();
error Pot__StillOpenForClaim();
+ error Pot__ClaimPeriodAlreadyStarted();
address[] private i_players;
uint256[] private i_rewards;
address[] private claimants;
uint256 private immutable i_totalRewards;
- uint256 private immutable i_deployedAt;
+ uint256 private s_claimStart;
IERC20 private immutable i_token;
mapping(address => uint256) private playersToRewards;
uint256 private remainingRewards;
@@ -25,7 +26,6 @@
i_token = token;
i_totalRewards = totalRewards;
remainingRewards = totalRewards;
- i_deployedAt = block.timestamp;
// i_token.transfer(address(this), i_totalRewards);
@@ -34,7 +34,21 @@
}
}
+ /// @notice Starts the 90-day claim window once the Pot holds the full reward amount.
+ function startClaimPeriod() external onlyOwner {
+ if (s_claimStart != 0) {
+ revert Pot__ClaimPeriodAlreadyStarted();
+ }
+ if (i_token.balanceOf(address(this)) < i_totalRewards) {
+ revert Pot__InsufficientFunds();
+ }
+ s_claimStart = block.timestamp;
+ }
+
function claimCut() public {
+ if (s_claimStart == 0) {
+ revert Pot__InsufficientFunds();
+ }
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
@@ -47,7 +61,7 @@
}
function closePot() external onlyOwner {
- if (block.timestamp - i_deployedAt < 90 days) {
+ if (s_claimStart == 0 || block.timestamp - s_claimStart < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
@@ -35,6 +35,8 @@
}
token.transferFrom(msg.sender, address(pot), totalRewards);
+ // the 90-day claim window starts only now that the Pot can pay
+ pot.startClaimPeriod();
}
function getContests() public view returns (address[] memory) {