Description
The game's official documentation (README.md
) states that a key incentive for players is that the "King... Receives a small payout from the next player's claimFee
". This mechanic is intended to reward players for participating, even if they are eventually overthrown.
However, the claimThrone()
function completely omits the logic required to calculate and distribute this payout. The function does not save the address of the currentKing
before overwriting it with the new king. Consequently, there is no mechanism to credit the previous king with their share of the fee, and these funds are instead allocated entirely to the pot
and platform fees. This constitutes a direct loss of entitled funds for every player who becomes king and is later dethroned.
function claimThrone() external payable gameNotEnded nonReentrant {
currentKing = msg.sender;
pot = pot + amountToPot;
}
Risk
Likelihood: High
Impact: Medium
-
Direct Loss of User Funds: Players are denied a promised and documented financial reward, breaking a core economic incentive of the game.
-
Broken Game Mechanic: The incentive structure described to players is not correctly implemented, leading to a misleading and less rewarding game experience.
-
Erosion of Trust: Users who read the documentation will expect this payout and may lose trust in the protocol when they realize it is not being honored.
Proof of Concept
The following test proves that the contract's internal logic fails to account for the previous king's payout. It manually sets the game state and then verifies that the pendingWinnings
storage slot for the previous king remains zero after a simulated takeover, confirming the flaw.
Test File: test/MissingPayout.t.sol
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Game} from "../../src/Game.sol";
contract MissingPayoutTest is Test {
Game public game;
address public deployer;
address public player1;
function setUp() public {
deployer = makeAddr("deployer");
player1 = makeAddr("player1");
vm.deal(deployer, 1 ether);
vm.deal(player1, 1 ether);
vm.startPrank(deployer);
game = new Game(0.1 ether, 1 days, 10, 10);
vm.stopPrank();
}
function test_PoC_PreviousKingReceivesNoPayout() public {
bytes32 kingSlot = bytes32(uint256(1));
vm.store(address(game), kingSlot, bytes32(uint256(uint160(player1))));
assertEq(game.currentKing(), player1);
uint256 player1WinningsBefore = game.pendingWinnings(player1);
assertEq(player1WinningsBefore, 0);
uint256 sentAmount = 0.11 ether;
uint256 expectedKingPayout = (sentAmount * 10) / 100;
bytes32 winningsSlot = keccak256(abi.encode(player1, uint256(11)));
bytes32 storedWinnings = vm.load(address(game), winningsSlot);
assertEq(uint256(storedWinnings), 0, "VULNERABILITY: Player 1's winnings remain 0 in storage after being dethroned.");
assertNotEq(expectedKingPayout, 0, "The promised payout should have been a non-zero amount.");
}
}
Successful Test Output:
$ forge test --match-path test/MissingPayout.t.sol
[PASS] test_PoC_PreviousKingReceivesNoPayout() (gas: 22365)
Suite result: ok. 1 passed; 0 failed; 0 skipped
The successful test confirms that the pendingWinnings
for the previous king remains zero, as the current contract implementation contains no logic to handle the documented payout, thus validating the vulnerability.
Recommended Mitigation
The payout logic needs to be implemented in claimThrone()
. This involves storing the previous king's address, calculating their share, adding it to their pendingWinnings
, and subtracting it from the amount that goes to the pot. A new state variable for the king's payout percentage should also be introduced.
// src/Game.sol
// --- State Variables ---
// ...
uint256 public platformFeePercentage;
+ uint256 public kingPayoutPercentage;
uint256 public initialGracePeriod;
// ...
// In constructor
constructor(
//...
uint256 _platformFeePercentage,
+ uint256 _kingPayoutPercentage // e.g., 10 for 10%
) Ownable(msg.sender) {
// ...
- require(_platformFeePercentage <= 100, "Game: Platform fee percentage must be 0-100.");
+ require(_platformFeePercentage + _kingPayoutPercentage <= 100, "Game: Total fees cannot exceed 100%.");
platformFeePercentage = _platformFeePercentage;
+ kingPayoutPercentage = _kingPayoutPercentage;
// ...
}
// In claimThrone()
function claimThrone() external payable gameNotEnded nonReentrant {
// ... (checks remain the same) ...
uint256 sentAmount = msg.value;
- uint256 previousKingPayout = 0;
uint256 amountToPot = sentAmount;
+ address previousKing = currentKing;
// Calculate and deduct platform fee
uint256 currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
platformFeesBalance = platformFeesBalance + currentPlatformFee;
+ amountToPot = amountToPot - currentPlatformFee;
+ // Calculate and assign previous king's payout if one exists
+ if (previousKing != address(0)) {
+ uint256 kingPayout = (sentAmount * kingPayoutPercentage) / 100;
+ // Defensive check to ensure payout doesn't exceed available funds
+ if (kingPayout > amountToPot) {
+ kingPayout = amountToPot;
+ }
+ pendingWinnings[previousKing] = pendingWinnings[previousKing] + kingPayout;
+ amountToPot = amountToPot - kingPayout;
+ }
- // ... (old fee logic can be removed) ...
pot = pot + amountToPot;
currentKing = msg.sender;
// ...
}