Last Man Standing

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

Previous kings do not receive expected rewards

Contract doesn’t reward the previous king, reducing incentive.

Description

  • Normally, in throne-style games, when a new player claims the throne, a portion of their payment is redistributed to the previous king as a reward for holding the position.

  • However, in this implementation, a portion of the claim fee goes to the platform, while the rest is added to the pot leaving previous kings with no reward. This removes financial incentives for participation and weakens the competitiveness and sustainability of the game economy.

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."
);
uint256 sentAmount = msg.value;
uint256 previousKingPayout = 0;
uint256 currentPlatformFee = 0;
uint256 amountToPot = 0;
// Calculate platform fee
currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
// Defensive check to ensure platformFee doesn't exceed available amount after previousKingPayout
if (currentPlatformFee > (sentAmount - previousKingPayout)) {
currentPlatformFee = sentAmount - previousKingPayout;
}
platformFeesBalance = platformFeesBalance + currentPlatformFee;
// Remaining amount goes to the pot
amountToPot = sentAmount - currentPlatformFee;
pot = pot + amountToPot;
// Update game state
currentKing = msg.sender;
lastClaimTime = block.timestamp;
playerClaimCount[msg.sender] = playerClaimCount[msg.sender] + 1;
totalClaims = totalClaims + 1;
// Increase the claim fee for the next player
claimFee = claimFee + (claimFee * feeIncreasePercentage) / 100;
emit ThroneClaimed(
msg.sender,
sentAmount,
claimFee,
pot,
block.timestamp
);
}

Risk

Likelihood:

  • This issue will occur whenever a new player claims the throne and the contract fails to send the designated percentage of the claim fee to the previous king.

  • Since the contract lacks the logic to transfer the payout to the previous king, this scenario will consistently happen in every claim after the first.

Impact:

  • The previous king does not receive the promised share of the next claim fee, breaking the intended economic incentive structure.

  • This undermines trust in the contract and may discourage player participation, reducing the overall activity and growth of the game.

Proof of Concept

This PoC simulates two players interacting with the contract. Player One becomes king first. Then Player Two claims the throne by paying a higher amount. The contract is expected to send a percentage of Player Two's claim fee to Player One (the previous king), but it fails to do so.

Deploy the Game contract.

  • Player One claims the throne with 0.1 ETH.

  • Record Player One’s balance after claiming.

  • Player Two claims the throne with 0.11 ETH.

  • Check Player One’s balance again.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {Game} from "../src/Game.sol";
contract GameTest is Test {
Game public game;
address public deployer;
address public player1;
address public player2;
address public player3;
address public maliciousActor;
// Initial game parameters for testing
uint256 public constant INITIAL_CLAIM_FEE = 0.1 ether; // 0.1 ETH
uint256 public constant GRACE_PERIOD = 1 days; // 1 day
uint256 public constant FEE_INCREASE_PERCENTAGE = 10; // 10%
uint256 public constant PLATFORM_FEE_PERCENTAGE = 5; // 5%
function setUp() public {
deployer = makeAddr("deployer");
player1 = makeAddr("player1");
player2 = makeAddr("player2");
player3 = makeAddr("player3");
maliciousActor = makeAddr("maliciousActor");
vm.deal(deployer, 10 ether);
vm.deal(player1, 10 ether);
vm.deal(player2, 10 ether);
vm.deal(player3, 10 ether);
vm.deal(maliciousActor, 10 ether);
vm.startPrank(deployer);
game = new Game(
INITIAL_CLAIM_FEE,
GRACE_PERIOD,
FEE_INCREASE_PERCENTAGE,
PLATFORM_FEE_PERCENTAGE
);
vm.stopPrank();
}
function testPreviousKingGetsNothing() public {
// Player1 claims the throne
vm.prank(player1);
game.claimThrone{value: INITIAL_CLAIM_FEE}();
// Time passes
vm.warp(block.timestamp + 1);
// Calculate next claim fee (with 10% increase)
uint256 secondClaimFee = INITIAL_CLAIM_FEE +
(INITIAL_CLAIM_FEE * FEE_INCREASE_PERCENTAGE) /
100;
// Player2 claims throne
vm.prank(player2);
game.claimThrone{value: secondClaimFee}();
// Check if Player1 got any payout — they should not!
uint256 player1Winnings = game.pendingWinnings(player1);
assertEq(player1Winnings, 0, "Player1 should NOT have any earnings");
// Try to withdraw winnings (should fail)
vm.prank(player1);
vm.expectRevert("Game: No winnings to withdraw.");
game.withdrawWinnings();
}
}

Recommended Mitigation

Modify the claimThrone() function to reward the previous king with a percentage (e.g., 10%) of the new claim amount before updating the currentKing.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
contract Game is Ownable {
// --- State Variables ---
// Game Core State
address public currentKing; // The address of the current "King"
uint256 public lastClaimTime; // Timestamp when the throne was last claimed
uint256 public gracePeriod; // Time in seconds after which a winner can be declared (e.g., 24 hours)
uint256 public pot; // Total ETH accumulated for the winner
uint256 public claimFee; // Current ETH fee required to claim the throne
bool public gameEnded; // True if a winner has been declared for the current round
// Game Parameters (Configurable by Owner)
uint256 public initialClaimFee; // The starting fee for a new game round
uint256 public feeIncreasePercentage; // Percentage by which the claimFee increases after each successful claim (e.g., 10 for 10%)
uint256 public platformFeePercentage; // Percentage of the claimFee that goes to the contract owner (deployer)
uint256 public initialGracePeriod; // The grace period set at the start of a new game round
+ uint256 public previousKingRewardPercent; // Percentage of the claim fee that goes to the previous owner
// Payouts and Balances
mapping(address => uint256) public pendingWinnings; // Stores ETH owed to the declared winner (pot + prev king payouts)
uint256 public platformFeesBalance; // Accumulated platform fees for the contract owner
// Game Analytics/History
uint256 public gameRound; // Current round number of the game
uint256 public totalClaims; // Total number of throne claims across all rounds
mapping(address => uint256) public playerClaimCount; // How many times an address has claimed the throne in total
+ mapping(address => uint256) public kingEarnings; // Earnings from previous Kings
// Manual Reentrancy Guard
bool private _locked; // Flag to prevent reentrant calls
// --- Events ---
/**
* @dev Emitted when a new player successfully claims the throne.
* @param newKing The address of the new king.
* @param claimAmount The ETH amount sent by the new king.
* @param newClaimFee The updated claim fee for the next claim.
* @param newPot The updated total pot for the winner.
* @param timestamp The block timestamp when the claim occurred.
*/
event ThroneClaimed(
address indexed newKing,
uint256 claimAmount,
uint256 newClaimFee,
uint256 newPot,
uint256 timestamp
);
/**
* @dev Emitted when the game ends and a winner is declared.
* @param winner The address of the declared winner.
* @param prizeAmount The total prize amount won.
* @param timestamp The block timestamp when the winner was declared.
* @param round The game round that just ended.
*/
event GameEnded(
address indexed winner,
uint256 prizeAmount,
uint256 timestamp,
uint256 round
);
/**
* @dev Emitted when a winner successfully withdraws their prize.
* @param to The address that withdrew the winnings.
* @param amount The amount of ETH withdrawn.
*/
event WinningsWithdrawn(address indexed to, uint256 amount);
/**
* @dev Emitted when the contract owner withdraws accumulated platform fees.
* @param to The address that withdrew the fees (owner).
* @param amount The amount of ETH withdrawn.
*/
event PlatformFeesWithdrawn(address indexed to, uint256 amount);
/**
* @dev Emitted when a new game round is started.
* @param newRound The number of the new game round.
* @param timestamp The block timestamp when the game was reset.
*/
event GameReset(uint256 newRound, uint256 timestamp);
/**
* @dev Emitted when the grace period is updated by the owner.
* @param newGracePeriod The new grace period in seconds.
*/
event GracePeriodUpdated(uint256 newGracePeriod);
/**
* @dev Emitted when the claim fee parameters are updated by the owner.
* @param newInitialClaimFee The new initial claim fee.
* @param newFeeIncreasePercentage The new fee increase percentage.
*/
event ClaimFeeParametersUpdated(
uint256 newInitialClaimFee,
uint256 newFeeIncreasePercentage
);
/**
* @dev Emitted when the platform fee percentage is updated by the owner.
* @param newPlatformFeePercentage The new platform fee percentage.
*/
event PlatformFeePercentageUpdated(uint256 newPlatformFeePercentage);
// --- Modifiers ---
/**
* @dev Throws if the game has already ended.
*/
modifier gameNotEnded() {
require(
!gameEnded,
"Game: Game has already ended. Reset to play again."
);
_;
}
/**
* @dev Throws if the game has not yet ended.
*/
modifier gameEndedOnly() {
require(gameEnded, "Game: Game has not ended yet.");
_;
}
/**
* @dev Throws if the provided percentage is not between 0 and 100 (inclusive).
* @param _percentage The percentage value to validate.
*/
modifier isValidPercentage(uint256 _percentage) {
require(_percentage <= 100, "Game: Percentage must be 0-100.");
_;
}
/**
* @dev Prevents reentrant calls to a function.
* This is a manual implementation of a reentrancy guard.
*/
modifier nonReentrant() {
require(!_locked, "ReentrancyGuard: reentrant call");
_locked = true;
_;
_locked = false;
}
/**
* @dev Initializes the game contract.
* @param _initialClaimFee The starting fee to claim the throne.
* @param _gracePeriod The initial grace period in seconds (e.g., 86400 for 24 hours).
* @param _feeIncreasePercentage The percentage increase for the claim fee (0-100).
* @param _platformFeePercentage The percentage of claim fee for the owner (0-100).
*/
constructor(
uint256 _initialClaimFee,
uint256 _gracePeriod,
uint256 _feeIncreasePercentage,
uint256 _platformFeePercentage,
+ uint256 _rewardPercent
) Ownable(msg.sender) {
// Set deployer as owner
require(
_initialClaimFee > 0,
"Game: Initial claim fee must be greater than zero."
);
require(
_gracePeriod > 0,
"Game: Grace period must be greater than zero."
);
require(
_feeIncreasePercentage <= 100,
"Game: Fee increase percentage must be 0-100."
);
require(
_platformFeePercentage <= 100,
"Game: Platform fee percentage must be 0-100."
);
+ require(
+ _rewardPercent <= 100,
+ "Game: Previous King Payout percentage must be 0-100."
+ );
initialClaimFee = _initialClaimFee;
initialGracePeriod = _gracePeriod;
feeIncreasePercentage = _feeIncreasePercentage;
platformFeePercentage = _platformFeePercentage;
+ previousKingRewardPercent = _rewardPercent;
// Initialize game state for the first round
claimFee = initialClaimFee;
gracePeriod = initialGracePeriod;
lastClaimTime = block.timestamp; // Game starts immediately upon deployment
gameRound = 1;
gameEnded = false;
// currentKing starts as address(0) until first claim
}
/**
* @dev Allows a player to claim the throne by sending the required claim fee.
* If there's a previous king, a small portion of the new claim fee is sent to them.
* A portion also goes to the platform owner, and the rest adds to the pot.
*/
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."
);
uint256 sentAmount = msg.value;
uint256 previousKingPayout = 0;
uint256 currentPlatformFee = 0;
uint256 amountToPot = 0;
// Calculate platform fee
currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
+ // Calculate payout for previous King
+ previousKingPayout = (sentAmount * previousKingRewardPercent) / 100;
+ kingEarnings[currentKing] += previousKingPayout;
+ emit PreviousKingRewarded(currentKing, previousKingPayout);
// Defensive check to ensure platformFee doesn't exceed available amount after previousKingPayout
if (currentPlatformFee > (sentAmount - previousKingPayout)) {
currentPlatformFee = sentAmount - previousKingPayout;
}
platformFeesBalance = platformFeesBalance + currentPlatformFee;
// Remaining amount goes to the pot
amountToPot = sentAmount - currentPlatformFee;
pot = pot + amountToPot;
// Update game state
currentKing = msg.sender;
lastClaimTime = block.timestamp;
playerClaimCount[msg.sender] = playerClaimCount[msg.sender] + 1;
totalClaims = totalClaims + 1;
// Increase the claim fee for the next player
claimFee = claimFee + (claimFee * feeIncreasePercentage) / 100;
emit ThroneClaimed(
msg.sender,
sentAmount,
claimFee,
pot,
block.timestamp
);
}
/**
* @dev Allows anyone to declare a winner if the grace period has expired.
* The currentKing at the time the grace period expires becomes the winner.
* The pot is then made available for the winner to withdraw.
*/
function declareWinner() external gameNotEnded {
require(
currentKing != address(0),
"Game: No one has claimed the throne yet."
);
require(
block.timestamp > lastClaimTime + gracePeriod, //checks that the grace period has expired and no one can claim the throne
"Game: Grace period has not expired yet."
);
gameEnded = true; //affects the global variable gameEnded
pendingWinnings[currentKing] = pendingWinnings[currentKing] + pot;
pot = 0; // Reset pot after assigning to winner's pending winnings
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
}
/**
* @dev Allows the declared winner to withdraw their prize.
* Uses a secure withdraw pattern with a manual reentrancy guard.
*/
function withdrawWinnings() external nonReentrant {
uint256 amount = pendingWinnings[msg.sender];
require(amount > 0, "Game: No winnings to withdraw.");
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Game: Failed to withdraw winnings.");
pendingWinnings[msg.sender] = 0;
emit WinningsWithdrawn(msg.sender, amount);
}
+ function withdrawEarnings() external {
+ uint256 amount = kingEarnings[msg.sender];
+ require(amount > 0, "No earnings to withdraw");
+ kingEarnings[msg.sender] = 0; // Avoid reentrancy
+ payable(msg.sender).transfer(amount);
+ emit KingEarningsWithdrawn(msg.sender, amount);
+}
/**
* @dev Allows the contract owner to reset the game for a new round.
* Can only be called after a winner has been declared.
*/
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);
}
/**
* @dev Allows the contract owner to update the grace period.
* @param _newGracePeriod The new grace period in seconds.
*/
function updateGracePeriod(uint256 _newGracePeriod) external onlyOwner {
require(
_newGracePeriod > 0,
"Game: New grace period must be greater than zero."
);
gracePeriod = _newGracePeriod;
emit GracePeriodUpdated(_newGracePeriod);
}
/**
* @dev Allows the contract owner to update the initial claim fee and fee increase percentage.
* @param _newInitialClaimFee The new initial claim fee.
* @param _newFeeIncreasePercentage The new fee increase percentage (0-100).
*/
function updateClaimFeeParameters(
uint256 _newInitialClaimFee,
uint256 _newFeeIncreasePercentage
) external onlyOwner isValidPercentage(_newFeeIncreasePercentage) {
require(
_newInitialClaimFee > 0,
"Game: New initial claim fee must be greater than zero."
);
initialClaimFee = _newInitialClaimFee;
feeIncreasePercentage = _newFeeIncreasePercentage;
emit ClaimFeeParametersUpdated(
_newInitialClaimFee,
_newFeeIncreasePercentage
);
}
/**
* @dev Allows the contract owner to update the platform fee percentage.
* @param _newPlatformFeePercentage The new platform fee percentage (0-100).
*/
function updatePlatformFeePercentage(
uint256 _newPlatformFeePercentage
) external onlyOwner isValidPercentage(_newPlatformFeePercentage) {
platformFeePercentage = _newPlatformFeePercentage;
emit PlatformFeePercentageUpdated(_newPlatformFeePercentage);
}
/**
* @dev Allows the contract owner to withdraw accumulated platform fees.
* Uses a secure withdraw pattern with a manual reentrancy guard.
*/
function withdrawPlatformFees() external onlyOwner nonReentrant {
uint256 amount = platformFeesBalance;
require(amount > 0, "Game: No platform fees to withdraw.");
platformFeesBalance = 0;
(bool success, ) = payable(owner()).call{value: amount}("");
require(success, "Game: Failed to withdraw platform fees.");
emit PlatformFeesWithdrawn(owner(), amount);
}
/**
* @dev Returns the time remaining until the grace period expires and a winner can be declared.
* Returns 0 if the grace period has already expired or the game has ended.
*/
function getRemainingTime() public view returns (uint256) {
if (gameEnded) {
return 0; // Game has ended, no remaining time
}
uint256 endTime = lastClaimTime + gracePeriod;
if (block.timestamp >= endTime) {
return 0; // Grace period has expired
}
return endTime - block.timestamp;
}
/**
* @dev Returns the current balance of the contract (should match the pot plus platform fees unless payouts are pending).
*/
function getContractBalance() public view returns (uint256) {
return address(this).balance;
}
receive() external payable {}
}
Updates

Appeal created

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

Missing Previous King Payout Functionality

Support

FAQs

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