Last Man Standing

First Flight #45
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Severity: low
Valid

Incomplete State Reset in `declareWinner` & `resetGame` Leading to Game-Freezing Scenarios

Root + Impact

Root Cause: Essential state variables reset in wrong function or missing -> Impact: Owner cannot restart game, players locked out, rounds stall indefinitely - echoing real-world DeFi freezes

WHY WOULD ANYONE TRUST THE OWNER: It's a Game something similar to a gamble. So we can't expect the owner to be fair. The owner can change the rules at any time, so we can't expect the game to be fair.

Description

  • The Game contract implements two key functions for ending and restarting rounds-declareWinner() and resetGame()-but their state-reset logic is inconsistently placed and omits important updates:

  1. declareWinner()

    • Sets gameEnded = true, awards pot, resets pot to 0.

    • Missing: should also reset lastClaimTime, increment gameRound, and clear transient flags.

  2. resetGame()

    • Resets currentKing, lastClaimTime, pot, claimFee, gracePeriod, gameEnded, and increments gameRound.

    • Issues:

      • lastClaimTime reset here is too late (should occur on declareWinner()).

      • pot reset is redundant (already zeroed).

      • gameRound is incremented here, but only allowed after game end, making a gap where no one can start next round.

  • Because the timing and placement of these resets are mismatched:

  • The owner cannot call resetGame() until after a winner is declared.

  • A winner cannot be declared unless prior round’s conditions are met (valid king & expired grace).

  • No way to resume or restart if something goes wrong mid-round (e.g., nobody claims, or a player crashes the flow).

declareWinner()

function declareWinner() external gameNotEnded {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
require(block.timestamp > lastClaimTime + gracePeriod, "Game: Grace period has not expired yet.");
gameEnded = true;
pendingWinnings[currentKing] += pot;
pot = 0; // Reset pot after assigning to winner's pending winnings
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
}
  • ✅ Properly marks the game as ended and awards the pot.

  • Does not reset lastClaimTime - next round’s reference timestamp.

  • Does not increment gameRound - round number remains stale.

  • Does not reset flags that may impact other functions (e.g., a locked flag if added).

resetGame()

function resetGame() external onlyOwner gameEndedOnly {
currentKing = address(0);
// @info: this should have been reset in declareWinner()
@> lastClaimTime = block.timestamp;
// @info: pot was already zeroed in declareWinner()
pot = 0;
claimFee = initialClaimFee;
gracePeriod = initialGracePeriod;
gameEnded = false;
// @info: incrementing here leaves declareWinner without round bump
@> gameRound = gameRound + 1;
// totalClaims is cumulative across rounds, not reset here, but could be if desired.
emit GameReset(gameRound, block.timestamp);
}
  • ✅ Clears currentKing, resets fees & periods.

  • Round counter increment belongs in declareWinner().

  • lastClaimTime only set here, causing a window where neither declareWinner() nor resetGame() can be called.

  • Inflexible flow: if declareWinner() fails (e.g., edge-case revert), resetGame() is blocked until owner manually triggers a proper end.

Risk

Likelihood: Medium

  • This effectively freezes the game until the exact sequence of events occurs, with no manual override. Similar patterns have led to major DeFi protocol freezes, where missing epoch increments or mis-ordered resets halted rewards, trading, or governance.

Impact: High

    • Total Game Freeze: Neither declareWinner() nor resetGame() can progress if state deviates—owner and players are locked out.

  • Revenue & Engagement Loss: Stalled rounds mean no fees collected, no gameplay.

  • Trust Erosion: Players lose confidence knowing a single mistake halts the entire system.

  • Operational Risk: No emergency exit or admin rescue path increases governance overhead and complexity.

Real-World DeFi Analogues:

  1. Olympus DAO Epoch Freeze (Mar 2021)

    • The staking contract forgot to increment epoch after each rebase. As a result, no rewards could be distributed for multiple cycles, effectively freezing all stakers until an off-chain intervention fixed the state.

  2. Yam Finance Rebase Lock-up (Aug 2020)

    • A logic error caused the rebasing schedule to skip a critical state update, leading to an irreversible state where rebases never triggered, halting token supply adjustments and collapsing community confidence.

These examples underscore how misplaced or missing state updates in critical functions can halt entire DeFi protocols-sometimes for days—requiring complex governance or manual fixes.

Tools Used:

  • Foundry Test Suite

  • Chat-GPT AI Assistance (Report Grammar Check & Improvements)

  • Manual Review

Proof of Concept

function test_deployerOrOwnerCannotResetEssentialParameterCausesTotalGameFreezeUntilGameEnds() public {
// 10 ethers as new initialGameFee
uint256 newInitialGameFee = 10 ether;
uint256 newFeeIncreasePercentage = 100;
// deployer deploys The Game
vm.startPrank(deployer);
Game newGame = new Game(newInitialGameFee, GRACE_PERIOD, newFeeIncreasePercentage, PLATFORM_FEE_PERCENTAGE);
vm.stopPrank();
// same oversight in updateClaimFeeParameters
vm.startPrank(deployer);
newGame.updateClaimFeeParameters(newInitialGameFee, newFeeIncreasePercentage);
vm.stopPrank();
// nobody is willing to play the game with exhaustive entry or game fee
// owner or deployer decides to decrease the initialFees or
// maybe owner or deployer decides to deduct or give up more grace period to participants
// but due to the current implementation design choice deployer or owner can't
// change these essential parameters
vm.startPrank(deployer);
vm.expectRevert("Game: Game has not ended yet.");
newGame.resetGame();
vm.stopPrank();
// okay the deployer can't reset the game until game ends
// So the deployer or winner has another option declareWinner function
// however, declareWinner function can't be done until there's a valid winner
// as well as grace interval has expired
// declare winner function can be called by anyone
vm.startPrank(player1);
vm.expectRevert("Game: No one has claimed the throne yet.");
newGame.declareWinner();
vm.stopPrank();
// Moreover, if it was possible, owner or deployer then has one more problem...
// reset function also contains the logic to increase the round on game reset.
// however increasing the round on resetting game with no participants is illogical
// similarily, lastClaimTime and gameEnded also worth noticing that they should be
// resetted on winner declaration as pot was resetted in the declareWinner function and that is a good choice
}

step 1: go to test/Game.t.sol file

step 2: paste the above code ⬆️

step 3: run the test suite

forge test --mt test_deployerOrOwnerCannotResetEssentialParameterCausesTotalGameFreezeUntilGameEnds -vv

step 4: See the Output

Scenario:

  1. New Round Never Starts

    • Owner/players follow protocol: claimThrone(), wait grace, declareWinner().

    • Suppose an off-by-one error in grace check causes declareWinner() revert or.

    • Let's say, Due to high entry or game fees, no one claims the throne.

    • Game is not marked ended, so resetGame() cannot be called - stuck forever.

  2. Force-End Needed But Impossible

    • No admin override exists to forcibly end a round or restart.

These gaps in state transitions lock the contract’s progression.

Recommended Mitigation

Consolidate State Resets in declareWinner()

function declareWinner() external gameNotEnded {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
require(block.timestamp > lastClaimTime + gracePeriod, "Game: Grace period has not expired yet.");
gameEnded = true;
+ lastClaimTime = block.timestamp; // reset here
+ gameRound++; // increment round
pendingWinnings[currentKing] = pendingWinnings[currentKing] + pot;
pot = 0; // Reset pot after assigning to winner's pending winnings
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
}

Simplify resetGame()

function resetGame() external onlyOwner gameEndedOnly {
currentKing = address(0);
- lastClaimTime = block.timestamp;
- pot = 0;
claimFee = initialClaimFee;
gracePeriod = initialGracePeriod;
gameEnded = false;
- gameRound = gameRound + 1;
// totalClaims is cumulative across rounds, not reset here, but could be if desired.
emit GameReset(gameRound, block.timestamp);
}

Add Emergency Admin Function

function emergencyReset() external onlyOwner {
// Force-reset all critical state, marking previous round forfeited
// Logs a special event for transparency
}

Call the resetGame function inside declareWinner function

- function declareWinner() external gameNotEnded {
+ function declareWinner() external onlyOwner gameNotEnded {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
require(block.timestamp > lastClaimTime + gracePeriod, "Game: Grace period has not expired yet.");
gameEnded = true;
pendingWinnings[currentKing] = pendingWinnings[currentKing] + pot;
- pot = 0; // Reset pot after assigning to winner's pending winnings
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
+ resetGame();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
4 months ago

Appeal created

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Game gets stuck if no one claims

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!