Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: medium
Invalid

ETH Prize Distribution on Tie

Security Report

Vulnerability Details

The smart contract deducts a 10% protocol fee from the total ETH pot even when a game ends in a tie. This design results in players receiving less ETH than they contributed, even though there is no winner. It violates fairness expectations and undermines user trust.

Instead, the current implementation imposes a 10% fee even though no player won, effectively punishing both participants for playing to a draw.

Code Analysis

The issue lies in the _handleTie function:

uint256 totalPot = game.bet * 2; // Total ETH in the pot
uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT) / 100; // 10% fee
uint256 refundPerPlayer = (totalPot - fee) / 2; // Players share the reduced pot

This logic deducts the fee regardless of the game outcome, including draws.

What Should Happen

On a tie, since no winner emerges, the entire pot should be returned equally to both players without any fee. Charging a fee in this case disincentivizes honest gameplay.

Impact

  • ** Financial Loss**: Both players lose ETH despite the absence of a winner.

  • ** Fairness Violation**: It creates a perception of unfairness and could be interpreted as exploitative behavior by the protocol.

  • ** User Distrust**: Players may avoid playing or recommend against using the game, especially if their balance decreases after a draw.

  • ** Misaligned Incentives**: Protocol fees should reward engagement or outcomes, not penalize players arbitrarily.

Tools Used

  • Manual Review

Recommended Mitigation Steps

  1. Conditionally Apply the Fee

    • The protocol fee should be charged only when a winner exists.

    • On a tie, refund both players in full.

  2. Update the _handleTie Function
    Modify the logic to:

    uint256 refundPerPlayer = game.bet; // Full refund, no fee
    payable(game.playerA).transfer(refundPerPlayer);
    payable(game.playerB).transfer(refundPerPlayer);
  3. Document Edge Cases Clearly

    • Document that ties incur no fees.

    • This improves transparency for users and developers.

Proof of Concept

function testTieGameFeeDeduction() public {
// Create a game and join
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(1, TIMEOUT);
vm.prank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// Record initial balances
uint256 playerABalanceBefore = playerA.balance;
uint256 playerBBalanceBefore = playerB.balance;
// Players make the same move (tie)
playTurn(gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Rock);
// Ensure game ended in tie
(,,,,,,,,,,,,, uint8 scoreA, uint8 scoreB, RockPaperScissors.GameState state) = game.games(gameId);
assertEq(scoreA, 0);
assertEq(scoreB, 0);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Finished));
// Calculate player refunds
uint256 playerARefund = playerA.balance - playerABalanceBefore;
uint256 playerBRefund = playerB.balance - playerBBalanceBefore;
// Expect fee was deducted incorrectly
uint256 totalPot = BET_AMOUNT * 2;
uint256 fee = (totalPot * 10) / 100;
uint256 expectedRefundPerPlayer = (totalPot - fee) / 2;
// Assert refund matches flawed logic
assertEq(playerARefund, expectedRefundPerPlayer);
assertEq(playerBRefund, expectedRefundPerPlayer);
// Assert refund is less than original bet (proving economic loss)
assertLt(playerARefund, BET_AMOUNT);
assertLt(playerBRefund, BET_AMOUNT);
}
Updates

Appeal created

m3dython Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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