Last Man Standing

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

Game.sol - L2 Sequencer Timestamp Manipulation Allows Instant Winner Declaration

Description

The contract relies heavily on block.timestamp for its core game mechanics (setting lastClaimTime and checking grace periods). On Layer 2 networks like Arbitrum and Optimism, timestamp values are controlled by the sequencer and can be updated within protocol allowed bounds. This creates a severe vulnerability where players can claim the throne and potentially be declared winners in the very next block due to timestamp jumps, completely breaking the intended grace period mechanics.

Root Cause

The vulnerability stems from L2 sequencers' control over block timestamps and the contract's reliance on these unreliable values:

// Line 210 - Sets timing based on sequencer-controlled timestamp
lastClaimTime = block.timestamp;
// Lines 239-242 - Grace period check uses unreliable timestamp
require(
block.timestamp > lastClaimTime + gracePeriod,
"Game: Grace period has not expired yet."
);

L2 Timestamp Behavior:

  • Arbitrum documentation explicitly states: "timing assumptions about block numbers and timestamps should be considered... unreliable in the shorter term (minutes)"

  • Optimism: "block.timestamp reflects the timestamp of the last L1 block ingested by the Sequencer"

  • Sequencers can make timestamps jump forward suddenly when catching up to L1

Risk

Likelihood: High - This is not a theoretical attack but documented behavior of L2 sequencers. Timestamp jumps happen regularly during normal L2 operation.

Impact: Critical - Players can win instantly after claiming the throne, bypassing the entire grace period mechanism that defines the game.

Impact

High severity because:

  • Core game mechanic (grace period) can be completely bypassed

  • Players can claim throne and win in consecutive blocks

  • Frontend/UI will show incorrect remaining time, confusing users

  • Breaks fundamental fairness - winner determined by L2 sequencer timing, not gameplay

  • Documented issue acknowledged by major L2 protocols (Arbitrum, Optimism)

  • No way for users to predict or defend against timestamp jumps

Proof of Concept

This scenario demonstrates how L2 timestamp behavior breaks the game:

function test_L2SequencerTimestampManipulation() public {
// Deploy game with 1 hour grace period
vm.startPrank(deployer);
game = new Game(0.1 ether, 1 hours, 10, 3);
vm.stopPrank();
// Block 1: Player claims throne at timestamp 1000
vm.warp(1000);
vm.deal(player1, 1 ether);
vm.prank(player1);
game.claimThrone{value: 0.1 ether}();
assertEq(game.lastClaimTime(), 1000);
assertEq(game.currentKing(), player1);
// Block 2: Sequencer catches up to L1, timestamp jumps to 5000 (4000 seconds jump)
// This is documented L2 behavior when sequencer syncs with L1
vm.warp(5000);
// Grace period (1 hour = 3600 seconds) has "passed" due to timestamp jump
assertTrue(block.timestamp > game.lastClaimTime() + game.gracePeriod());
// Player can immediately declare themselves winner in the next block!
vm.prank(player1);
game.declareWinner();
// Player won instantly after claiming throne due to L2 timestamp behavior
assertTrue(game.gameEnded());
assertEq(game.pendingWinnings(player1), game.pot());
}

Real-world L2 Attack Scenario:

  1. Attacker monitors L1 for periods of high timestamp gaps

  2. When L2 sequencer is behind, attacker claims throne

  3. Sequencer catches up to L1 in next block, timestamp jumps forward

  4. Attacker immediately calls declareWinner() in same transaction

  5. Attacker wins entire pot without waiting actual grace period

Recommended Mitigation

Enforce minimum grace periods per L2 documentation:

contract Game is Ownable {
+ uint256 public constant MIN_GRACE_PERIOD = 12 hours; // Arbitrum: "reliable in longer term (several hours)"
constructor(...) {
+ require(_gracePeriod >= MIN_GRACE_PERIOD, "Game: Grace period too short for L2 deployment");
// ... rest of constructor
}
function updateGracePeriod(uint256 _newGracePeriod) external onlyOwner {
require(_newGracePeriod > 0, "Game: New grace period must be greater than zero.");
+ require(_newGracePeriod >= MIN_GRACE_PERIOD, "Game: Grace period too short for L2 deployment");
gracePeriod = _newGracePeriod;
emit GracePeriodUpdated(_newGracePeriod);
}

Alternative: Use block numbers instead of timestamps:

contract Game is Ownable {
- uint256 public lastClaimTime;
+ uint256 public lastClaimBlock;
+ uint256 public graceBlocks; // e.g., 300 blocks ≈ 1 hour on Arbitrum
function claimThrone() external payable gameNotEnded nonReentrant {
// ... existing checks ...
- lastClaimTime = block.timestamp;
+ lastClaimBlock = block.number;
// ... rest of function
}
function declareWinner() external gameNotEnded {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
require(
- block.timestamp > lastClaimTime + gracePeriod,
+ block.number > lastClaimBlock + graceBlocks,
"Game: Grace period has not expired yet."
);
// ... rest of function
}
Updates

Appeal created

inallhonesty Lead Judge about 2 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.