The normal behavior should allow the game to start when the first player claims the throne, or provide a mechanism for the owner to reset if no interest occurs. However, the constructor immediately starts the grace period timer (lastClaimTime = block.timestamp
) even though no one has claimed the throne yet (currentKing = address(0)
). If the grace period expires without any claims, declareWinner()
reverts because there's no king, and resetGame()
reverts because gameEnded = false
, leaving the contract permanently stuck
pragma solidity ^0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {Game} from "../src/Game.sol";
Game stalls indefinitely if no one claims the throne initially
contract Finding9POC is Test {
Game public game;
Game public expensiveGame;
address public deployer;
address public player1;
address public player2;
uint256 public constant INITIAL_CLAIM_FEE = 0.1 ether;
uint256 public constant GRACE_PERIOD = 1 days;
uint256 public constant FEE_INCREASE_PERCENTAGE = 10;
uint256 public constant PLATFORM_FEE_PERCENTAGE = 5;
function setUp() public {
deployer = makeAddr("deployer");
player1 = makeAddr("player1");
player2 = makeAddr("player2");
vm.deal(deployer, 100 ether);
vm.deal(player1, 10 ether);
vm.deal(player2, 10 ether);
vm.startPrank(deployer);
game = new Game(
INITIAL_CLAIM_FEE,
GRACE_PERIOD,
FEE_INCREASE_PERCENTAGE,
PLATFORM_FEE_PERCENTAGE
);
vm.stopPrank();
}
* @notice POC: Demonstrates timer starting immediately on deployment
*/
function testPOC_TimerStartsImmediatelyOnDeployment() public {
console2.log("=== FINDING #9 POC: TIMER STARTS IMMEDIATELY ON DEPLOYMENT ===");
console2.log("");
uint256 deploymentTime = block.timestamp;
vm.startPrank(deployer);
Game newGame = new Game(
INITIAL_CLAIM_FEE,
GRACE_PERIOD,
FEE_INCREASE_PERCENTAGE,
PLATFORM_FEE_PERCENTAGE
);
vm.stopPrank();
console2.log("DEPLOYMENT ANALYSIS:");
console2.log("- Deployment time:", deploymentTime);
console2.log("- Game lastClaimTime:", newGame.lastClaimTime());
console2.log("- Timer started immediately:", newGame.lastClaimTime() == deploymentTime);
console2.log("- Current king:", newGame.currentKing());
console2.log("- Total claims:", newGame.totalClaims());
console2.log("");
assertEq(newGame.lastClaimTime(), deploymentTime);
assertEq(newGame.currentKing(), address(0));
assertEq(newGame.totalClaims(), 0);
console2.log("IMMEDIATE COUNTDOWN:");
console2.log("- Remaining time starts counting down immediately");
console2.log("- Grace period will expire even with no participation");
console2.log("- No mechanism to pause timer until first claim");
console2.log("");
vm.warp(deploymentTime + GRACE_PERIOD / 4);
console2.log("After 25% of grace period:");
console2.log("- Remaining time:", newGame.getRemainingTime());
console2.log("- Still no king:", newGame.currentKing() == address(0));
vm.warp(deploymentTime + GRACE_PERIOD / 2);
console2.log("After 50% of grace period:");
console2.log("- Remaining time:", newGame.getRemainingTime());
console2.log("- Still no king:", newGame.currentKing() == address(0));
console2.log("");
console2.log("PROBLEM IDENTIFIED:");
console2.log("- Timer runs regardless of participation");
console2.log("- Grace period can expire with zero activity");
console2.log("- No pause mechanism for empty games");
}
* @notice POC: Demonstrates permanent stall with high fees and no claims
*/
function testPOC_PermanentStallWithHighFeesNoClaims() public {
console2.log("=== PERMANENT STALL WITH HIGH FEES - NO CLAIMS ===");
console2.log("");
uint256 prohibitiveFee = 50 ether;
vm.startPrank(deployer);
expensiveGame = new Game(
prohibitiveFee,
GRACE_PERIOD,
FEE_INCREASE_PERCENTAGE,
PLATFORM_FEE_PERCENTAGE
);
vm.stopPrank();
uint256 deployTime = block.timestamp;
console2.log("HIGH-FEE GAME SETUP:");
console2.log("- Claim fee (ETH):", prohibitiveFee / 1 ether);
console2.log("- Player1 balance (ETH):", player1.balance / 1 ether);
console2.log("- Player2 balance (ETH):", player2.balance / 1 ether);
console2.log("- Fee is unaffordable for most players");
console2.log("");
console2.log("INITIAL STATE:");
console2.log("- Current king:", expensiveGame.currentKing());
console2.log("- Game started:", !expensiveGame.gameEnded());
console2.log("- Timer started:", expensiveGame.lastClaimTime() == deployTime);
console2.log("- Remaining time:", expensiveGame.getRemainingTime());
console2.log("");
console2.log("ATTEMPTING CLAIMS (WILL FAIL):");
vm.startPrank(player1);
vm.expectRevert("Game: Insufficient ETH sent to claim the throne.");
expensiveGame.claimThrone{value: player1.balance}();
vm.stopPrank();
vm.startPrank(player2);
vm.expectRevert("Game: Insufficient ETH sent to claim the throne.");
expensiveGame.claimThrone{value: player2.balance}();
vm.stopPrank();
console2.log("- Player1 claim failed: insufficient funds");
console2.log("- Player2 claim failed: insufficient funds");
console2.log("- No one can afford the claim fee");
console2.log("");
console2.log("FAST FORWARD TO GRACE PERIOD EXPIRATION:");
vm.warp(deployTime + GRACE_PERIOD + 1);
console2.log("- Grace period expired");
console2.log("- Remaining time:", expensiveGame.getRemainingTime());
console2.log("- Current king:", expensiveGame.currentKing());
console2.log("- Game ended:", expensiveGame.gameEnded());
console2.log("");
console2.log("ATTEMPTING TO DECLARE WINNER:");
vm.expectRevert("Game: No one has claimed the throne yet.");
expensiveGame.declareWinner();
console2.log("- declareWinner() FAILED: No one has claimed the throne yet");
console2.log("");
console2.log("ATTEMPTING TO RESET GAME:");
vm.startPrank(deployer);
vm.expectRevert("Game: Game has not ended yet.");
expensiveGame.resetGame();
vm.stopPrank();
console2.log("- resetGame() FAILED: Game has not ended yet");
console2.log("");
console2.log("PERMANENT DEADLOCK CONFIRMED:");
console2.log("- Grace period expired:", expensiveGame.getRemainingTime() == 0);
console2.log("- No current king:", expensiveGame.currentKing() == address(0));
console2.log("- Game not ended:", !expensiveGame.gameEnded());
console2.log("- Cannot declare winner: requires currentKing != address(0)");
console2.log("- Cannot reset game: requires gameEnded = true");
console2.log("- NO RECOVERY MECHANISM EXISTS");
console2.log("");
assertEq(expensiveGame.getRemainingTime(), 0);
assertEq(expensiveGame.currentKing(), address(0));
assertFalse(expensiveGame.gameEnded());
assertEq(expensiveGame.pot(), 0);
assertEq(expensiveGame.totalClaims(), 0);
console2.log("CONTRACT COMPLETELY STUCK:");
console2.log("- Contract becomes unusable");
console2.log("- Deployment resources wasted");
console2.log("- Must deploy new contract");
console2.log("- Platform reputation damaged");
}
* @notice POC: Show normal behavior with affordable fees for comparison
*/
function testPOC_NormalBehaviorWithAffordableFees() public {
console2.log("=== COMPARISON: NORMAL BEHAVIOR WITH AFFORDABLE FEES ===");
console2.log("");
console2.log("AFFORDABLE FEE SCENARIO:");
console2.log("- Claim fee: 0.1 ETH (affordable)");
console2.log("- Player1 balance (ETH):", player1.balance / 1 ether);
console2.log("- Player can afford to participate");
console2.log("");
console2.log("NOTE: This test demonstrates how the game SHOULD work");
console2.log("when fees are affordable and players can participate.");
console2.log("The timer starting immediately is only a problem when");
console2.log("nobody can or wants to claim the throne.");
}
* @notice POC: Demonstrate the exact timing vulnerability
*/
function testPOC_TimingVulnerabilityExact() public {
console2.log("=== EXACT TIMING VULNERABILITY DEMONSTRATION ===");
console2.log("");
uint256 startTime = block.timestamp;
uint256 shortGracePeriod = 1 hours;
vm.startPrank(deployer);
Game timedGame = new Game(
100 ether,
shortGracePeriod,
10,
5
);
vm.stopPrank();
console2.log("TIMING ANALYSIS:");
console2.log("- Contract deployed at:", startTime);
console2.log("- Timer immediately set to:", timedGame.lastClaimTime());
console2.log("- Grace period (seconds):", shortGracePeriod);
console2.log("- Expiration time:", timedGame.lastClaimTime() + shortGracePeriod);
console2.log("");
uint256[] memory checkpoints = new uint256[](5);
checkpoints[0] = 15 minutes;
checkpoints[1] = 30 minutes;
checkpoints[2] = 45 minutes;
checkpoints[3] = 59 minutes;
checkpoints[4] = 61 minutes;
for (uint i = 0; i < checkpoints.length; i++) {
vm.warp(startTime + checkpoints[i]);
console2.log("At minutes:", checkpoints[i] / 60);
console2.log("- Remaining time:", timedGame.getRemainingTime());
console2.log("- Current king:", timedGame.currentKing());
console2.log("- Can declare winner:",
timedGame.getRemainingTime() == 0 && timedGame.currentKing() != address(0));
}
console2.log("");
console2.log("VULNERABILITY CONFIRMED:");
console2.log("- Grace period expired with no king");
console2.log("- Contract enters unrecoverable state");
console2.log("- No mechanism to handle this scenario");
}
}