Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: low
Valid

Unbounded Token Inflation via _finishGame and _handleTie

Summary

The RockPaperScissors::_finishGame and RockPaperScissors::_handleTie functions in token-based games mint new WinningToken tokens as rewards or refunds, but do not return or burn the tokens that were originally staked via transferFrom.

This leads to two critical issues:

  • Permanent lock of staked tokens inside the contract.

  • Unbounded inflation of the token supply due to redundant minting.

As players can repeatedly create and win or tie games, they can farm tokens without real economic input, severely diluting the value of WinningToken.


Vulnerability Details

// RockPaperScissors::_createGameWithToken and joinGameWithToken
// @audit-issue Staked tokens are transferred to the contract but never returned or burned
@> winningToken.transferFrom(msg.sender, address(this), 1);
// RockPaperScissors::_finishGame
if (game.bet == 0) {
// @audit-issue Mints 2 new tokens even though 2 are already locked inside the contract
@> winningToken.mint(_winner, 2);
}
// RockPaperScissors::_handleTie
if (game.bet == 0) {
// @audit-issue Mints new tokens instead of returning or burning the staked tokens
@> winningToken.mint(game.playerA, 1);
@> winningToken.mint(game.playerB, 1);
}

Issues Identified

  1. Permanent Token Lock

    • Players’ staked tokens (2 per game) are transferred into the contract and never returned or burned.

  2. Unbounded Token Inflation & Value Dilution

    • Every token‑based game outcome mints new tokens without reusing stakes.

    • Circulating supply grows uncontrollably, diluting the token’s economic value and breaking scarcity.


Impact

  • Total supply grows linearly with gameplay volume.

  • Staked tokens are stuck forever, clogging contract state.

  • WinningToken's economic model becomes meaningless, breaking trust and reducing incentive integrity.


Proof of Concept (PoC)

PoC Explanation

This PoC simulates five 1-turn token-based games where Player A always wins. Each time:

  • Player A and Player B each stake 1 WinningToken via transferFrom.

  • The contract mints 2 new tokens to Player A upon winning (_finishGame).

  • The 2 staked tokens remain locked in the contract and are never returned or burned.

After 5 rounds:

  • Player A gains 5 net tokens.

  • The contract holds 10 permanently locked tokens.

  • The total token supply increases by 10 (5 rounds × 2 minted).

This confirms the vulnerability: token inflation and unrecoverable stake lock.

function test_TokenInflationByWinLoop() public {
uint256 initialPlayerBalance = token.balanceOf(playerA);
uint256 initialTotalSupply = token.totalSupply();
for (uint256 i = 0; i < 5; i++) {
// Create and join token-based game
vm.startPrank(playerA);
token.approve(address(game), 1);
uint256 id = game.createGameWithToken(1, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(id);
vm.stopPrank();
// Player A always wins (Paper vs Rock)
bytes32 saltA = keccak256(abi.encodePacked("A", i));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA));
vm.prank(playerA); game.commitMove(id, commitA);
bytes32 saltB = keccak256(abi.encodePacked("B", i));
bytes32 commitB = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB));
vm.prank(playerB); game.commitMove(id, commitB);
vm.prank(playerA); game.revealMove(id, uint8(RockPaperScissors.Move.Paper), saltA);
vm.prank(playerB); game.revealMove(id, uint8(RockPaperScissors.Move.Rock), saltB);
}
assertEq(token.balanceOf(playerA), initialPlayerBalance + 5);
assertEq(token.balanceOf(address(game)), 10);
assertEq(token.totalSupply(), initialTotalSupply + 10);
}

Tools Used

  • Manual Review

  • Foundry Unit Testing


Recommendations

  1. Return Deposited Tokens Instead of Minting

    // In _finishGame
    winningToken.transfer(_winner, 2);
    // In _handleTie
    winningToken.transfer(game.playerA, 1);
    winningToken.transfer(game.playerB, 1);
  2. Burn Deposits Before Minting (if return is not feasible)

    winningToken.burn(2);
    winningToken.mint(_winner, 2);

Updates

Appeal created

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

Minting Instead of Transferring Staked Tokens

Mints new tokens upon game completion or cancellation for token-based games

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

Minting Instead of Transferring Staked Tokens

Mints new tokens upon game completion or cancellation for token-based games

Support

FAQs

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