Description
The current game mechanics allow MEV bots to front-run legitimate declareWinner()
calls by claiming the throne just before legit winner is declared.
This creates an unfair MEV extraction opportunity where bots can monitor the mempool for winner declarations and immediately claim the throne with higher gas fees, preventing legitimate players from ending the game and declaring winners.
Root Cause
The issue stems from the lack of protection against claims after the grace period has expired:
function claimThrone() external payable gameNotEnded nonReentrant {
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."
);
}
Key issues:
claimThrone()
can be called even after grace period expires
MEV bots can monitor mempool for declareWinner()
transactions
Bots front-run with higher gas claimThrone()
calls
This resets lastClaimTime
, making declareWinner()
revert
Creates an MEV auction for throne claims instead of fair game resolution
Risk
Likelihood: High - MEV bots actively monitor Ethereum mempool for profitable opportunities, and this attack is easily automated.
Impact: Medium - While no funds are directly at risk, the game becomes unplayable for regular users and winners are determined by gas bidding rather than game mechanics.
Proof of Concept
This test demonstrates the vulnerability by showing that claimThrone()
can be called after the grace period expires, preventing legitimate winner declaration.
As MEV is not practicaly to demonstrate on foundry this test is just simulating the attack flow
function test_MEVVulnerabilityExists() public {
vm.startPrank(deployer);
game = new Game(0.1 ether, 1 hours, 10, 3);
vm.stopPrank();
vm.deal(player1, 1 ether);
vm.prank(player1);
game.claimThrone{value: 0.1 ether}();
vm.warp(block.timestamp + 1 hours + 1);
assertTrue(
block.timestamp > game.lastClaimTime() + game.gracePeriod(),
"Grace period should be expired"
);
address mevBot = address(0x777);
vm.deal(mevBot, 10 ether);
vm.startPrank(mevBot);
game.claimThrone{value: game.claimFee()}();
vm.stopPrank();
assertEq(game.currentKing(), mevBot, "MEV bot became king after grace period");
assertEq(game.pendingWinnings(player1), 0, "Player1 has no winnings despite waiting full grace period");
}
Real-world attack scenario:
Grace period expires, player1 should win
Alice submits declareWinner()
transaction to mempool
MEV bot detects this and submits claimThrone()
with higher gas fee
Bot's transaction executes first, resetting lastClaimTime
Alice's declareWinner()
reverts due to "Grace period has not expired yet"
Bot becomes king, stealing the victory from the legitimate winner
Recommended Mitigation
Prevent throne claims after the grace period has expired:
function claimThrone() external payable gameNotEnded nonReentrant {
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.");
+ require(
+ block.timestamp <= lastClaimTime + gracePeriod,
+ "Game: Grace period expired, winner must be declared"
+ );
uint256 sentAmount = msg.value;
// ... rest of function remains unchanged
}
Additional recommendation to prevent last-minute MEV battles and spam:
function claimThrone() external payable gameNotEnded nonReentrant {
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.");
+ require(
+ block.timestamp <= lastClaimTime + gracePeriod,
+ "Game: Grace period expired, winner must be declared"
+ );
+
+ // Optional: Prevent the same player from claiming multiple times in succession
+ // This reduces MEV bot spam and creates fairer gameplay
+ require(msg.sender != lastKing, "Game: You were the previous king, wait for another player");
uint256 sentAmount = msg.value;
// ... existing code ...
+ // Track the previous king before updating current king
+ lastKing = currentKing;
currentKing = msg.sender;
// ... rest of function
}
Add the new state variable:
contract Game is Ownable {
address public currentKing;
+ address public lastKing; // Track previous king to prevent immediate reclaims
// ... other state variables
}