Rock Paper Scissors

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

Permanent Lock of Staked Tokens in Token-Based Games

Summary

The RockPaperScissors.sol contract implements a Rock Paper Scissors game allowing bets in ETH or a specific ERC20 token (WinningToken). While the contract correctly takes custody of WinningToken stakes via transferFrom when players create or join token-based games, it fails to return these specific tokens upon game completion (win, tie, or cancellation). Instead, it attempts to mint new tokens for the players using winningToken.mint(). This results in the original staked tokens being permanently locked within the RockPaperScissors contract balance, leading to a direct loss of assets for users participating in token-based games.

Vulnerability Details

  1. Staking Mechanism: When a player initiates or joins a token-based game, the createGameWithToken and joinGameWithToken functions correctly use winningToken.transferFrom(player, address(this), 1). This transfers ownership of one WinningToken from the player to the RockPaperScissors contract (address(this)), placing the token under the contract's custody as the game stake.

  2. Payout/Refund Mechanism: Upon game conclusion for token-based games (game.bet == 0), the internal functions _finishGame (for a win), _handleTie (for a tie), and _cancelGame (for cancellation) are responsible for returning the stakes.

  3. Incorrect Function Call: Instead of transferring the tokens held by the contract back to the relevant player(s) using winningToken.transfer(player, amount), these functions incorrectly call winningToken.mint(player, amount).

    • _finishGame: Calls winningToken.mint(_winner, 2).

    • _handleTie: Calls winningToken.mint(game.playerA, 1) and winningToken.mint(game.playerB, 1).

    • _cancelGame: Calls winningToken.mint(player, 1) for existing players.

  4. Discrepancy: The mint function creates entirely new tokens and assigns them to the recipient. It does not interact with or reduce the existing token balance held by the RockPaperScissors contract.

  5. Consequence: The original tokens transferred to the RockPaperScissors contract during the staking phase are never transferred out. They remain in the contract's balance indefinitely with no function available to withdraw them.

Proof Of Concept

The test cases below demonstrates the flaw.

// ==================== LOCKED TOKEN FLAW TESTS ====================
/**
* @notice Tests the locked token flaw after a token game win.
* The contract should transfer held tokens, but mints new ones instead,
* leaving the original staked tokens locked in the contract.
*/
function testLockedTokensAfterTokenGameWin() public {
// --- Setup: Create and Join 1-Turn Token Game ---
uint256 singleTurn = 1;
// Player A creates game with token
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(singleTurn, TIMEOUT);
vm.stopPrank();
// Player B joins with token
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// --- Check Balances After Joining ---
uint256 initialBalanceA = token.balanceOf(playerA); // Should be 9
uint256 initialBalanceB = token.balanceOf(playerB); // Should be 9
uint256 contractBalanceAfterJoin = token.balanceOf(address(game)); // Should be 2
assertEq(initialBalanceA, 9, "Player A balance after stake incorrect");
assertEq(initialBalanceB, 9, "Player B balance after stake incorrect");
assertEq(contractBalanceAfterJoin, 2, "Contract balance after join incorrect");
// --- Play Turn: Player A Wins (Paper vs Rock) ---
playTurn(gameId, RockPaperScissors.Move.Paper, RockPaperScissors.Move.Rock);
// --- Check Final Balances ---
uint256 finalBalanceA = token.balanceOf(playerA);
uint256 finalBalanceB = token.balanceOf(playerB);
uint256 finalContractBalance = token.balanceOf(address(game));
// --- Assertions ---
// Winner (A) should have initial balance + 2 (minted)
assertEq(finalBalanceA, initialBalanceA + 2, "Winner A final balance incorrect");
// Loser (B) should have initial balance (no change as tokens weren't returned)
assertEq(finalBalanceB, initialBalanceB, "Loser B final balance incorrect");
// !!! THE FLAW: Contract balance should be 0 if tokens were transferred back.
// !!! Instead, it remains 2 because it minted new tokens.
assertEq(finalContractBalance, 2, "Contract still holds the original 2 staked tokens");
// Verify game state is Finished
(,,,,,,,,,,,,,,, RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Finished));
}
/**
* @notice Tests the locked token flaw after a token game tie.
* The contract should transfer held tokens back, but mints new ones instead,
* leaving the original staked tokens locked in the contract.
*/
function testLockedTokensAfterTokenGameTie() public {
// --- Setup: Create and Join 1-Turn Token Game ---
uint256 singleTurn = 1;
// Player A creates game with token
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(singleTurn, TIMEOUT);
vm.stopPrank();
// Player B joins with token
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// --- Check Balances After Joining ---
uint256 initialBalanceA = token.balanceOf(playerA); // Should be 9
uint256 initialBalanceB = token.balanceOf(playerB); // Should be 9
uint256 contractBalanceAfterJoin = token.balanceOf(address(game)); // Should be 2
assertEq(initialBalanceA, 9, "Player A balance after stake incorrect");
assertEq(initialBalanceB, 9, "Player B balance after stake incorrect");
assertEq(contractBalanceAfterJoin, 2, "Contract balance after join incorrect");
// --- Play Turn: Tie (Rock vs Rock) ---
playTurn(gameId, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Rock);
// --- Check Final Balances ---
uint256 finalBalanceA = token.balanceOf(playerA);
uint256 finalBalanceB = token.balanceOf(playerB);
uint256 finalContractBalance = token.balanceOf(address(game));
// --- Assertions ---
// Player A should have initial balance + 1 (minted)
assertEq(finalBalanceA, initialBalanceA + 1, "Player A final balance incorrect after tie");
// Player B should have initial balance + 1 (minted)
assertEq(finalBalanceB, initialBalanceB + 1, "Player B final balance incorrect after tie");
// !!! THE FLAW: Contract balance should be 0 if tokens were transferred back.
// !!! Instead, it remains 2 because it minted new tokens.
assertEq(finalContractBalance, 2, "Contract still holds the original 2 staked tokens after tie");
// Verify game state is Finished
(,,,,,,,,,,,,,,, RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Finished));
}

Impact

  • Permanent Token Lock: The original tokens staked by the players remain permanently locked in the RockPaperScissors contract's balance with no mechanism for recovery.

  • Direct Financial Loss: Users participating in token-based games (game.bet == 0) will permanently lose the WinningToken they staked to create or join the game. The tokens are irrecoverable as there is no withdrawal mechanism for them.

  • Incorrect Token Supply Inflation: The total supply of WinningToken increases unexpectedly every time a token-based game concludes (win, tie, or cancel), as new tokens are minted instead of existing ones being circulated. This can disrupt the token's intended economics.

  • User Trust Erosion: Discovering that staked assets are not returned correctly will severely damage user trust in the application.

Tools Used

  • Manual Code Review

  • Foundry/Forge (for Test Execution and PoC verification)

Recommendations

The core recommendation is to replace the incorrect mint calls with transfer calls when handling token payouts or refunds for token-based games (game.bet == 0).

  1. Modify _finishGame:

    • Replace winningToken.mint(_winner, 2);

    • With: winningToken.transfer(_winner, 2);

  2. Modify _handleTie:

    • Replace winningToken.mint(game.playerA, 1); and winningToken.mint(game.playerB, 1);

    • With: winningToken.transfer(game.playerA, 1); and winningToken.transfer(game.playerB, 1);

  3. Modify _cancelGame:

    • Replace winningToken.mint(game.playerA, 1); (inside the if)

    • With: winningToken.transfer(game.playerA, 1);

    • Replace winningToken.mint(game.playerB, 1); (inside the if)

    • With: winningToken.transfer(game.playerB, 1);

  4. Consider SafeERC20: For enhanced robustness, consider using OpenZeppelin's SafeERC20 library and its safeTransfer function instead of the standard transfer. This protects against potential issues with certain ERC20 token implementations that might not return booleans or could behave unexpectedly.

    // Example using SafeERC20
    import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
    // ... inside contract ...
    using SafeERC20 for WinningToken;
    // ... inside _handleTie ...
    token.safeTransfer(game.playerA, 1);
    token.safeTransfer(game.playerB, 1);

By implementing these changes, the contract will correctly transfer the tokens it holds back to the players, resolving the locked tokens vulnerability.

Updates

Appeal created

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