Last Man Standing

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

block.timestamp-Based Grace Period Allows Miners to Front-Run and Steal Victory

Description

The Game contract allows players to claim the throne by paying a claimFee, becoming the currentKing, with the game ending when the gracePeriod expires, awarding the pot to the currentKing via declareWinner. A miner participating as a player can front-run a winning player’s claimThrone transaction just before the gracePeriod expires by reordering transactions in a block they mine, stealing the pot. The contract’s reliance on block.timestamp for the gracePeriod check in declareWinner enables this, as miners control transaction inclusion and can slightly manipulate timestamps.

function claimThrone() external payable gameNotEnded nonReentrant {
@> // No check to prevent late claims exploitable by miner front-running
require(msg.value >= claimFee, "Game: Insufficient ETH sent to claim the throne.");
require(msg.sender == currentKing, "Game: You are already the king. No need to re-claim.");
}
function declareWinner() external gameNotEnded {
@> require(block.timestamp > lastClaimTime + gracePeriod, "Game: Grace period has not expired yet.");
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
// ... rest of the function
}
function resetGame() external onlyOwner gameEndedOnly {
currentKing = address(0);
@>lastClaimTime = block.timestamp;
pot = 0;

Risk

Likelihood:

Miners who are players can observe pending claimThrone transactions in the mempool near the gracePeriod end (e.g., block.timestamp ≈ lastClaimTime + gracePeriod).
Occurs when a miner mines a block and prioritizes their own claimThrone transaction over others.

Impact:

Miners can consistenly steal the pot by becoming currentKing just before declareWinner, undermining game fairness.
Discourages player participation due to perceived unfairness, potentially stalling the game.

Proof of Concept

Scenario:

  1. Grace period ends at block.timestamp = lastClaimTime + gracePeriod (e.g., 1086400).

  2. Player A submits claimThrone at block.timestamp = 1086399 (1 second before expiration).

  3. The miner, seeing Player A’s transaction, includes their own claimThrone in the same block, ordered first, becoming the currentKing.

  4. When declareWinner is called at block.timestamp = 1086401, the miner wins the pot.

Recommended Mitigation

Use block.number instead of block.timestamp to track the gracePeriod, as block numbers are less susceptible to miner manipulation and provide a more deterministic measure. Convert gracePeriod to a number of blocks (e.g., assuming 12 seconds per block, 86400 seconds ≈ 7200 blocks) and update relevant functions.

Alternatvely,Consider using commit-reveal, VRF, or other forms of finality that can't be manipulated by ordering

// State variables
- uint256 public gracePeriod;
- uint256 public lastClaimTime;
+ uint256 public gracePeriodBlocks;
+ uint256 public lastClaimBlock;
function claimThrone() external payable gameNotEnded nonReentrant {
+ require(block.number <= lastClaimBlock + gracePeriodBlocks, "Game: Grace period expired, declare winner instead.");
require(msg.value >= claimFee, "Game: Insufficient ETH sent to claim the throne.");
require(msg.sender == currentKing, "Game: You are already the king. No need to re-claim.");
// ...
- lastClaimTime = block.timestamp;
+ lastClaimBlock = block.number;
// ...
}
function declareWinner() external gameNotEnded {
- require(block.timestamp > lastClaimTime + gracePeriod, "Game: Grace period has not expired yet.");
+ require(block.number > lastClaimBlock + gracePeriodBlocks, "Game: Grace period has not expired yet.");
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
// ...
}
// Constructor
- constructor(uint256 _initialClaimFee, uint256 _gracePeriod, uint256 _feeIncreasePercentage, uint256 _platformFeePercentage)
+ constructor(uint256 _initialClaimFee, uint256 _gracePeriodBlocks, uint256 _feeIncreasePercentage, uint256 _platformFeePercentage)
// ...
- gracePeriod = _gracePeriod;
+ gracePeriodBlocks = _gracePeriodBlocks;
- lastClaimTime = block.timestamp;
+ lastClaimBlock = block.number;
// ...
}
function resetGame() external onlyOwner gameEndedOnly {
currentKing = address(0);
- lastClaimTime = block.timestamp;
+ lastClaimBlock = block.number; // replaces lastClaimTime
Updates

Appeal created

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

block.timestamp in L2's

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

block.timestamp in L2's

Support

FAQs

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

Give us feedback!