diff --git a/contracts/src/v2/SimpleTriviaGameV2.sol b/contracts/src/v2/SimpleTriviaGameV2.sol new file mode 100644 index 0000000..f6c31f8 --- /dev/null +++ b/contracts/src/v2/SimpleTriviaGameV2.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title SimpleTriviaGame + * @dev Gas-optimized trivia game with reward distribution + */ +contract SimpleTriviaGameV2 is Ownable { + using SafeERC20 for IERC20; + + error InvalidTokenAddress(); + error InvalidOptions(); + error InvalidCorrectOption(); + error QuestionNotActive(); + error InvalidOption(); + error InsufficientBalance(); + error Unauthorized(); + + struct Question { + string questionText; + string[] options; + uint8 correctOption; + uint256 rewardAmount; + bool isActive; + } + + struct QuestionData { + string questionText; + string[] options; + uint256 rewardAmount; + } + + IERC20 public immutable usdcToken; + uint256 public questionCounter; + + mapping(uint256 => Question) public questions; + mapping(address => uint256) public userScores; + mapping(uint256 => mapping(address => bool)) public hasAnswered; + + event QuestionAdded(uint256 indexed questionId, string questionText, uint256 reward); + event AnswerSubmitted(address indexed user, uint256 indexed questionId, bool isCorrect, uint256 reward); + event QuestionDeactivated(uint256 indexed questionId); + + constructor(address _usdcToken) Ownable(msg.sender) { + if (_usdcToken == address(0)) revert InvalidTokenAddress(); + usdcToken = IERC20(_usdcToken); + } + + function addQuestion( + string calldata questionText, + string[] calldata options, + uint8 correctOption, + uint256 rewardAmount + ) external onlyOwner returns (uint256) { + if (options.length < 2 || options.length > 255) revert InvalidOptions(); + if (correctOption >= options.length) revert InvalidCorrectOption(); + + uint256 qId = ++questionCounter; + questions[qId] = Question({ + questionText: questionText, + options: options, + correctOption: correctOption, + rewardAmount: rewardAmount, + isActive: true + }); + + emit QuestionAdded(qId, questionText, rewardAmount); + return qId; + } + + function submitAnswer(uint256 questionId, uint8 selectedOption) external { + Question storage question = questions[questionId]; + + if (!question.isActive) revert QuestionNotActive(); + if (selectedOption >= question.options.length) revert InvalidOption(); + if (hasAnswered[questionId][msg.sender]) revert Unauthorized(); + + hasAnswered[questionId][msg.sender] = true; + bool isCorrect = selectedOption == question.correctOption; + + if (isCorrect) { + userScores[msg.sender]++; + if (question.rewardAmount > 0) { + usdcToken.safeTransfer(msg.sender, question.rewardAmount); + } + emit AnswerSubmitted(msg.sender, questionId, true, question.rewardAmount); + } else { + emit AnswerSubmitted(msg.sender, questionId, false, 0); + } + } + + function deactivateQuestion(uint256 questionId) external onlyOwner { + if (questions[questionId].isActive) { + questions[questionId].isActive = false; + emit QuestionDeactivated(questionId); + } + } + + function withdrawTokens(uint256 amount) external onlyOwner { + uint256 balance = usdcToken.balanceOf(address(this)); + if (balance < amount) revert InsufficientBalance(); + usdcToken.safeTransfer(msg.sender, amount); + } + + function getQuestion(uint256 questionId) external view returns (QuestionData memory) { + Question storage q = questions[questionId]; + return QuestionData({ + questionText: q.questionText, + options: q.options, + rewardAmount: q.rewardAmount + }); + } + + function isQuestionActive(uint256 questionId) external view returns (bool) { + return questions[questionId].isActive; + } + + function hasUserAnswered(uint256 questionId, address user) external view returns (bool) { + return hasAnswered[questionId][user]; + } + + function getUserScore(address user) external view returns (uint256) { + return userScores[user]; + } +} \ No newline at end of file diff --git a/contracts/src/v2/TriviaGamev2.sol b/contracts/src/v2/TriviaGamev2.sol new file mode 100644 index 0000000..7fefe3d --- /dev/null +++ b/contracts/src/v2/TriviaGamev2.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// ----------------------------------------------------------------------- +/// Custom Errors (cheaper + safer than strings) +/// ----------------------------------------------------------------------- +error ZeroAddress(); +error InvalidGameState(); +error GameFull(); +error AlreadyJoined(); +error InsufficientAllowance(); +error InvalidWinnerCount(); +error InvalidWinner(); +error DuplicateWinner(); +error NothingToRefund(); + +/// ----------------------------------------------------------------------- +/// Trivia Game (Secure Version) +/// ----------------------------------------------------------------------- +contract TriviaGame is Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + IERC20 public immutable cUSD; + + uint256 public constant ENTRY_FEE = 0.1 ether; + + uint256 public constant FIRST_SHARE = 80; + uint256 public constant SECOND_SHARE = 15; + uint256 public constant THIRD_SHARE = 5; + uint256 private constant TOTAL_SHARE = 100; + + enum GameState { + Open, + InProgress, + Completed, + Cancelled + } + + struct Game { + uint256 id; + string title; + uint256 prizePool; + uint256 maxPlayers; + uint256 startTime; + uint256 endTime; + GameState state; + address[] players; + address[] winners; + mapping(address => bool) joined; + } + + uint256 public gameCounter; + mapping(uint256 => Game) private games; + + /// ----------------------------------------------------------------------- + /// Events + /// ----------------------------------------------------------------------- + event GameCreated(uint256 indexed gameId, string title, uint256 maxPlayers); + event PlayerJoined(uint256 indexed gameId, address indexed player); + event GameStarted(uint256 indexed gameId); + event GameCompleted(uint256 indexed gameId, address[] winners); + event GameCancelled(uint256 indexed gameId); + event PrizePaid(uint256 indexed gameId, address indexed winner, uint256 amount); + event RefundIssued(uint256 indexed gameId, address indexed player, uint256 amount); + + /// ----------------------------------------------------------------------- + /// Constructor + /// ----------------------------------------------------------------------- + constructor(address _cUSD) Ownable(msg.sender) { + if (_cUSD == address(0)) revert ZeroAddress(); + cUSD = IERC20(_cUSD); + } + + /// ----------------------------------------------------------------------- + /// Game Creation + /// ----------------------------------------------------------------------- + function createGame(string calldata title, uint256 maxPlayers) external onlyOwner { + if (maxPlayers == 0) revert InvalidWinnerCount(); + + uint256 gameId = ++gameCounter; + Game storage g = games[gameId]; + + g.id = gameId; + g.title = title; + g.maxPlayers = maxPlayers; + g.state = GameState.Open; + + emit GameCreated(gameId, title, maxPlayers); + } + + /// ----------------------------------------------------------------------- + /// Join Game + /// ----------------------------------------------------------------------- + function joinGame(uint256 gameId) external nonReentrant { + Game storage g = games[gameId]; + + if (g.state != GameState.Open) revert InvalidGameState(); + if (g.joined[msg.sender]) revert AlreadyJoined(); + if (g.players.length >= g.maxPlayers) revert GameFull(); + + uint256 allowance = cUSD.allowance(msg.sender, address(this)); + if (allowance < ENTRY_FEE) revert InsufficientAllowance(); + + cUSD.safeTransferFrom(msg.sender, address(this), ENTRY_FEE); + + g.players.push(msg.sender); + g.joined[msg.sender] = true; + g.prizePool += ENTRY_FEE; + + emit PlayerJoined(gameId, msg.sender); + } + + /// ----------------------------------------------------------------------- + /// Start Game + /// ----------------------------------------------------------------------- + function startGame(uint256 gameId) external onlyOwner { + Game storage g = games[gameId]; + + if (g.state != GameState.Open) revert InvalidGameState(); + if (g.players.length == 0) revert InvalidWinnerCount(); + + g.state = GameState.InProgress; + g.startTime = block.timestamp; + + emit GameStarted(gameId); + } + + /// ----------------------------------------------------------------------- + /// Complete Game & Distribute Prizes + /// ----------------------------------------------------------------------- + function completeGame( + uint256 gameId, + address[] calldata winners + ) external onlyOwner nonReentrant { + Game storage g = games[gameId]; + + if (g.state != GameState.InProgress) revert InvalidGameState(); + if (winners.length == 0 || winners.length > 3) revert InvalidWinnerCount(); + + // validate winners + for (uint256 i = 0; i < winners.length; i++) { + if (!g.joined[winners[i]]) revert InvalidWinner(); + for (uint256 j = i + 1; j < winners.length; j++) { + if (winners[i] == winners[j]) revert DuplicateWinner(); + } + } + + g.state = GameState.Completed; + g.endTime = block.timestamp; + g.winners = winners; + + uint256 pool = g.prizePool; + + if (winners.length >= 1) { + _pay(gameId, winners[0], (pool * FIRST_SHARE) / TOTAL_SHARE); + } + if (winners.length >= 2) { + _pay(gameId, winners[1], (pool * SECOND_SHARE) / TOTAL_SHARE); + } + if (winners.length == 3) { + _pay(gameId, winners[2], (pool * THIRD_SHARE) / TOTAL_SHARE); + } + + emit GameCompleted(gameId, winners); + } + + function _pay(uint256 gameId, address to, uint256 amount) internal { + if (amount == 0) return; + cUSD.safeTransfer(to, amount); + emit PrizePaid(gameId, to, amount); + } + + /// ----------------------------------------------------------------------- + /// Cancel Game & Refund + /// ----------------------------------------------------------------------- + function cancelGame(uint256 gameId) external onlyOwner nonReentrant { + Game storage g = games[gameId]; + + if ( + g.state != GameState.Open && + g.state != GameState.InProgress + ) revert InvalidGameState(); + + if (g.players.length == 0) revert NothingToRefund(); + + g.state = GameState.Cancelled; + g.endTime = block.timestamp; + + for (uint256 i = 0; i < g.players.length; i++) { + address player = g.players[i]; + cUSD.safeTransfer(player, ENTRY_FEE); + emit RefundIssued(gameId, player, ENTRY_FEE); + } + + emit GameCancelled(gameId); + } + + /// ----------------------------------------------------------------------- + /// View Helpers + /// ----------------------------------------------------------------------- + function getPlayers(uint256 gameId) external view returns (address[] memory) { + return games[gameId].players; + } + + function getWinners(uint256 gameId) external view returns (address[] memory) { + return games[gameId].winners; + } + + function hasJoined(uint256 gameId, address player) external view returns (bool) { + return games[gameId].joined[player]; + } + + function getGameState(uint256 gameId) external view returns (GameState) { + return games[gameId].state; + } + + function getPrizePool(uint256 gameId) external view returns (uint256) { + return games[gameId].prizePool; + } +} diff --git a/contracts/test.bak/v2/TriviaGame.t.sol b/contracts/test.bak/v2/TriviaGame.t.sol new file mode 100644 index 0000000..a6090d2 --- /dev/null +++ b/contracts/test.bak/v2/TriviaGame.t.sol @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/TriviaGame.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +// Mock cUSD token for testing +contract MockcUSD is ERC20 { + constructor() ERC20("cUSD", "cUSD") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract TriviaGameTest is Test { + TriviaGame game; + MockcUSD cUSD; + + address owner = address(0x1); + address player1 = address(0x2); + address player2 = address(0x3); + address player3 = address(0x4); + address player4 = address(0x5); + + function setUp() public { + vm.startPrank(owner); + cUSD = new MockcUSD(); + game = new TriviaGame(address(cUSD)); + vm.stopPrank(); + + // Mint tokens to players + cUSD.mint(player1, 100 ether); + cUSD.mint(player2, 100 ether); + cUSD.mint(player3, 100 ether); + cUSD.mint(player4, 100 ether); + } + + // ----------------------------------------------------------------------- + // Constructor Tests + // ----------------------------------------------------------------------- + function test_ConstructorWithValidAddress() public view { + assertEq(address(game.cUSD()), address(cUSD)); + } + + function test_ConstructorWithZeroAddress() public { + vm.expectRevert(ZeroAddress.selector); + new TriviaGame(address(0)); + } + + // ----------------------------------------------------------------------- + // Game Creation Tests + // ----------------------------------------------------------------------- + function test_CreateGameAsOwner() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + assertEq(game.gameCounter(), 1); + assertEq(game.getGameState(1), TriviaGame.GameState.Open); + } + + function test_CreateGameWithZeroPlayers() public { + vm.prank(owner); + vm.expectRevert(InvalidWinnerCount.selector); + game.createGame("Game 1", 0); + } + + function test_CreateGameAsNonOwner() public { + vm.prank(player1); + vm.expectRevert(); + game.createGame("Game 1", 4); + } + + // ----------------------------------------------------------------------- + // Join Game Tests + // ----------------------------------------------------------------------- + function test_JoinGameSuccessfully() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + assertEq(game.hasJoined(1, player1), true); + assertEq(game.getPlayers(1).length, 1); + assertEq(game.getPrizePool(1), 0.1 ether); + } + + function test_JoinGameWithoutApproval() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + vm.expectRevert(InsufficientAllowance.selector); + game.joinGame(1); + } + + function test_JoinGameAlreadyJoined() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.2 ether); + game.joinGame(1); + + vm.expectRevert(AlreadyJoined.selector); + game.joinGame(1); + } + + function test_JoinGameWhenGameFull() public { + vm.prank(owner); + game.createGame("Game 1", 2); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player2); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player3); + cUSD.approve(address(game), 0.1 ether); + vm.expectRevert(GameFull.selector); + game.joinGame(1); + } + + function test_JoinGameNotOpen() public { + vm.prank(owner); + game.createGame("Game 1", 4); + game.startGame(1); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + vm.expectRevert(InvalidGameState.selector); + game.joinGame(1); + } + + function test_MultiplePlayersJoin() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + for (uint256 i = 0; i < 3; i++) { + address player = address(uint160(0x2 + i)); + vm.prank(player); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + } + + assertEq(game.getPlayers(1).length, 3); + assertEq(game.getPrizePool(1), 0.3 ether); + } + + // ----------------------------------------------------------------------- + // Start Game Tests + // ----------------------------------------------------------------------- + function test_StartGameSuccessfully() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + assertEq(uint256(game.getGameState(1)), uint256(TriviaGame.GameState.InProgress)); + } + + function test_StartGameWithoutPlayers() public { + vm.prank(owner); + game.createGame("Game 1", 4); + vm.expectRevert(InvalidWinnerCount.selector); + game.startGame(1); + } + + function test_StartGameTwice() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + vm.expectRevert(InvalidGameState.selector); + game.startGame(1); + } + + // ----------------------------------------------------------------------- + // Complete Game Tests + // ----------------------------------------------------------------------- + function test_CompleteGameWithOneWinner() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player2); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](1); + winners[0] = player1; + + uint256 balanceBefore = cUSD.balanceOf(player1); + vm.prank(owner); + game.completeGame(1, winners); + + uint256 expectedReward = (0.2 ether * 80) / 100; + assertEq(cUSD.balanceOf(player1), balanceBefore + expectedReward); + } + + function test_CompleteGameWithThreeWinners() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player2); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player3); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player4); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](3); + winners[0] = player1; + winners[1] = player2; + winners[2] = player3; + + uint256 pool = 0.4 ether; + uint256 firstReward = (pool * 80) / 100; + uint256 secondReward = (pool * 15) / 100; + uint256 thirdReward = (pool * 5) / 100; + + vm.prank(owner); + game.completeGame(1, winners); + + assertEq(cUSD.balanceOf(player1), 100 ether - 0.1 ether + firstReward); + assertEq(cUSD.balanceOf(player2), 100 ether - 0.1 ether + secondReward); + assertEq(cUSD.balanceOf(player3), 100 ether - 0.1 ether + thirdReward); + } + + function test_CompleteGameWithInvalidWinnerCount() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](4); + vm.prank(owner); + vm.expectRevert(InvalidWinnerCount.selector); + game.completeGame(1, winners); + } + + function test_CompleteGameWithNonMember() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](1); + winners[0] = player4; + + vm.prank(owner); + vm.expectRevert(InvalidWinner.selector); + game.completeGame(1, winners); + } + + function test_CompleteGameWithDuplicateWinner() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](2); + winners[0] = player1; + winners[1] = player1; + + vm.prank(owner); + vm.expectRevert(DuplicateWinner.selector); + game.completeGame(1, winners); + } + + // ----------------------------------------------------------------------- + // Cancel Game Tests + // ----------------------------------------------------------------------- + function test_CancelGameAndRefund() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player2); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + uint256 balanceBefore1 = cUSD.balanceOf(player1); + uint256 balanceBefore2 = cUSD.balanceOf(player2); + + vm.prank(owner); + game.cancelGame(1); + + assertEq(cUSD.balanceOf(player1), balanceBefore1 + 0.1 ether); + assertEq(cUSD.balanceOf(player2), balanceBefore2 + 0.1 ether); + assertEq(uint256(game.getGameState(1)), uint256(TriviaGame.GameState.Cancelled)); + } + + function test_CancelGameWithNoPlayers() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(owner); + vm.expectRevert(NothingToRefund.selector); + game.cancelGame(1); + } + + function test_CancelCompletedGame() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](1); + winners[0] = player1; + game.completeGame(1, winners); + + vm.expectRevert(InvalidGameState.selector); + game.cancelGame(1); + } + + // ----------------------------------------------------------------------- + // Reentrancy Tests + // ----------------------------------------------------------------------- + function test_JoinGameReentrancyProtected() public { + vm.prank(owner); + game.createGame("Game 1", 2); + + vm.prank(player1); + cUSD.approve(address(game), 0.2 ether); + game.joinGame(1); + + vm.prank(player2); + cUSD.approve(address(game), 0.2 ether); + game.joinGame(1); + + assertEq(game.getPlayers(1).length, 2); + } + + // ----------------------------------------------------------------------- + // View Functions Tests + // ----------------------------------------------------------------------- + function test_GetPlayers() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(player2); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + address[] memory players = game.getPlayers(1); + assertEq(players.length, 2); + assertEq(players[0], player1); + assertEq(players[1], player2); + } + + function test_GetWinners() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + vm.prank(player1); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + + vm.prank(owner); + game.startGame(1); + + address[] memory winners = new address[](1); + winners[0] = player1; + game.completeGame(1, winners); + + address[] memory returnedWinners = game.getWinners(1); + assertEq(returnedWinners.length, 1); + assertEq(returnedWinners[0], player1); + } + + function test_PrizePoolAccumulation() public { + vm.prank(owner); + game.createGame("Game 1", 4); + + for (uint256 i = 0; i < 3; i++) { + address player = address(uint160(0x2 + i)); + vm.prank(player); + cUSD.approve(address(game), 0.1 ether); + game.joinGame(1); + } + + assertEq(game.getPrizePool(1), 0.3 ether); + } +} \ No newline at end of file