Last Man Standing

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

Missing Previous-King Payout in `claimThrone()` Causes Funds Lockup

Root + Impact

Root Cause: No transfer or accounting for the dethroned king’s share on each new claim -> Impact: Early participants never recoup their stake until end, breaking expected pay-out flow and enabling griefing.

Description

  • The claimThrone() function fails to distribute any payout to the previous king when a new claimant usurps the throne. Instead, it simply:

  1. Collects the full msg.value.

  2. Deducts a platform fee.

  3. Adds the remainder entirely to the pot.

  4. Updates currentKing and related state.

  • Because previousKingPayout is declared but never used, dethroned players receive zero immediate compensation and must wait until game end (via declareWinner()) to recover their funds plus the pot. This is likely contrary to the intended design—each dethronement should pay out the outgoing king a portion of the stake. Omitting this logic:

  • Locks up user funds in the pot.

  • Forces all but the final winner to endure unbounded delay before recovering their stake.

  • Exposes the game to griefers who can repeatedly claim with minimal increments, ballooning the pot and withholding payouts.

Affected Function: Game.sol::claimThrone()

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;
@> // ❌ previousKingPayout is never assigned or used
uint256 previousKingPayout = 0;
uint256 currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
@> // This `if` is meaningless because previousKingPayout == 0
if (currentPlatformFee > (sentAmount - previousKingPayout)) {
currentPlatformFee = sentAmount - previousKingPayout;
}
platformFeesBalance += currentPlatformFee;
uint256 amountToPot = sentAmount - currentPlatformFee;
pot += amountToPot;
currentKing = msg.sender;
lastClaimTime = block.timestamp;
playerClaimCount[msg.sender]++;
totalClaims++;
claimFee += (claimFee * feeIncreasePercentage) / 100;
emit ThroneClaimed(msg.sender, sentAmount, claimFee, pot, block.timestamp);
}

Risk

Likelihood: High

  • The local variable previousKingPayout is initialized to 0 and never updated.

  • No code pays or credits the outgoing currentKing.

  • All incoming ETH beyond the platform fee goes into pot, not to the dethroned player.

Impact: High

  • Fund Lockup: Dethroned players’ ETH remains inaccessible until very late (end of game), harming UX and introducing unnecessary risk.

  • Liquidity Risk: Early participants lose liquidity; they cannot reuse or withdraw their stake in a timely manner.

  • Griefing Potential: Attackers can repeatedly dethrone with minimal increments-each time withholding payouts-and force players into a “hold until ruin” scenario.

  • Economic Disincentive: Rational players may avoid participation if payouts are uncertain or excessively delayed.

  • Real-World Parallel: In the Fomo3D game, a similar design led to massive pot growth and delayed payouts-culminating in a single winner taking ~$2.1 M after days of lockup and speculation.

Tools Used:

  • Foundry Test Suite

  • Chat-GPT AI Assistance (Report Grammar Check & Improvements)

  • Manual Review

Proof of Concept

function test_previousKingGetsNoPayoutOnSubsequentClaim() public {
// 1. Player1 claims the throne
vm.startPrank(player1);
game.claimThrone{value: INITIAL_CLAIM_FEE}();
vm.stopPrank();
// 2. Capture Player1’s pending winnings (should be zero)
uint256 pendingAfterFirst = game.pendingWinnings(player1);
assertEq(pendingAfterFirst, 0, "Player1 should have no pending winnings yet");
// 3. Player2 claims the throne
vm.startPrank(player2);
uint256 nextFee = game.claimFee();
game.claimThrone{value: nextFee}();
vm.stopPrank();
// 4. Player1 still has no payout after being dethroned
uint256 pendingAfterSecond = game.pendingWinnings(player1);
assertEq(pendingAfterSecond, 0, "Player1 should have been paid out but got nothing");
}

Step-by-step:

  1. Open test/Game.t.sol.

  2. Paste the above test function.

  3. Run:

forge test --mt test_previousKingGetsNoPayoutOnSubsequentClaim
  1. Observe that Player1 never receives compensation after being replaced.

Scenario:

  1. Player1 pays the initial claimFee (e.g., 1 ETH) to become king.

  2. Player2 pays the next, higher claimFee (e.g., 1.1 ETH) to dethrone Player1.

  3. Expected: Player1 should immediately receive at least their original 1 ETH (or some share).

  4. Actual: Player1’s balance or pendingWinnings[player1] remains zero.

Repeated dethronements exacerbate this: early participants have funds tied up with no interim liquidity.

Recommended Mitigation

Implement Previous-King Payout Logic:

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;
+ uint256 previousKingPayout = (claimFee * 50) / 100;
+ pendingWinnings[currentKing] += previousKingPayout;
+ uint256 remaining = sentAmount - previousKingPayout;
// Calculate platform fee
- currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
+ currentPlatformFee = (remaining * platformFeePercentage) / 100;
- if (currentPlatformFee > (sentAmount - previousKingPayout)) {
+ if (currentPlatformFee > remaining) {
- currentPlatformFee = sentAmount - previousKingPayout;
+ currentPlatformFee = remaining;
}
platformFeesBalance = platformFeesBalance + currentPlatformFee;
// Remaining amount goes to the pot
- amountToPot = sentAmount - currentPlatformFee;
+ amountToPot = remaining - 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);
}

Before deducting platform fees, calculate and transfer (or credit) the outgoing king’s share:

uint256 payout = calculatePayout(currentKing, sentAmount);
pendingWinnings[currentKing] += payout;
uint256 remaining = sentAmount - payout;
// Then deduct platform fee from remaining
Updates

Appeal created

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

Give us feedback!