diff --git a/contracts/contracts/MathBlocGame.sol b/contracts/contracts/MathBlocGame.sol index 72642ec..07339ed 100644 --- a/contracts/contracts/MathBlocGame.sol +++ b/contracts/contracts/MathBlocGame.sol @@ -3,13 +3,24 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; /** * @title MathBlocGame * @notice Educational math game contract deployed on Celo. * Tracks daily activity, scores, streaks, and distributes CELO rewards. + * + * Security model: + * - Session results require an EIP-712 signature from a trusted signer + * (when sessionSigner != address(0)), preventing forged on-chain submissions. + * - Daily base reward is awarded at most once per player per day. + * - Input bounds enforce sane score/attempt ranges. + * - Per-player nonces prevent replay attacks. + * - Emergency pause halts all activity recording and claims. */ -contract MathBlocGame is Ownable, ReentrancyGuard { +contract MathBlocGame is Ownable, ReentrancyGuard, Pausable, EIP712 { // --- Structs --- @@ -40,24 +51,46 @@ contract MathBlocGame is Ownable, ReentrancyGuard { uint256 streak; } - // --- State --- - - mapping(address => Player) public players; - mapping(address => DailyActivity[]) public activityHistory; - mapping(uint256 => address[]) public dailyActivePlayers; // day => players active that day - - address[] public registeredPlayers; + // ─── Constants ────────────────────────────────────────────────────────── uint256 public constant DAILY_REWARD_COINS = 10; uint256 public constant STREAK_BONUS_COINS = 5; // extra per streak milestone (every 7 days) uint256 public constant PERFECT_SCORE_BONUS = 20; uint256 public constant CELO_REWARD_THRESHOLD = 100; // coins needed to claim CELO uint256 public constant CELO_REWARD_AMOUNT = 0.001 ether; // 0.001 CELO per claim + uint256 public constant MAX_ATTEMPTS_PER_SESSION = 100; + uint256 public constant MAX_SCORE_PER_SESSION = 1000; + uint256 public constant MAX_SESSIONS_PER_DAY = 10; + + // EIP-712 type hash for session attestation + bytes32 public constant SESSION_TYPEHASH = keccak256( + "Session(address player,uint256 score,uint256 correct,uint256 attempts,string topic,uint256 nonce,uint256 deadline)" + ); + + // ─── State ────────────────────────────────────────────────────────────── + + mapping(address => Player) public players; + mapping(address => DailyActivity[]) public activityHistory; + mapping(uint256 => address[]) public dailyActivePlayers; // day => players active that day + + address[] public registeredPlayers; uint256 public totalActiveDays; // global stat uint256 public rewardPool; // CELO deposited by owner for rewards - // --- Events --- + /// @notice Trusted signer for session attestations. When address(0), signatures are not required. + address public sessionSigner; + + /// @notice Per-player nonce for replay protection. + mapping(address => uint256) public nonces; + + /// @notice Tracks whether the daily base reward has been claimed for a player on a given day. + mapping(address => mapping(uint256 => bool)) public dailyRewardClaimed; + + /// @notice Number of sessions recorded per player per day. + mapping(address => mapping(uint256 => uint256)) public dailySessionCount; + + // ─── Events ───────────────────────────────────────────────────────────── event PlayerRegistered(address indexed player, string username, uint256 timestamp); event ActivityRecorded(address indexed player, string topic, uint256 score, uint256 correct, uint256 attempts, uint256 day); @@ -65,24 +98,25 @@ contract MathBlocGame is Ownable, ReentrancyGuard { event CoinsEarned(address indexed player, uint256 amount, string reason); event CeloClaimed(address indexed player, uint256 amount); event RewardPoolFunded(uint256 amount); + event SessionSignerUpdated(address indexed oldSigner, address indexed newSigner); - // --- Modifiers --- + // ─── Modifiers ────────────────────────────────────────────────────────── modifier onlyRegistered() { require(players[msg.sender].exists, "Not registered"); _; } - // --- Constructor --- + // ─── Constructor ──────────────────────────────────────────────────────── - constructor() Ownable(msg.sender) {} + constructor() Ownable(msg.sender) EIP712("MathBlocGame", "2") {} - // --- Registration --- + // ─── Registration ─────────────────────────────────────────────────────── /** * @notice Register a new player wallet with a username. */ - function register(string calldata username) external { + function register(string calldata username) external whenNotPaused { require(!players[msg.sender].exists, "Already registered"); require(bytes(username).length > 0 && bytes(username).length <= 32, "Invalid username"); @@ -102,32 +136,65 @@ contract MathBlocGame is Ownable, ReentrancyGuard { emit PlayerRegistered(msg.sender, username, block.timestamp); } - // --- Daily Activity --- + // ─── Daily Activity ───────────────────────────────────────────────────── /** - * @notice Record a game session result. Can be called once per day per player. - * @param score Total score achieved in the session + * @notice Record a game session result with an optional signed attestation. + * @param score Total score achieved in the session (max MAX_SCORE_PER_SESSION) * @param correct Number of correct answers - * @param attempts Total questions attempted + * @param attempts Total questions attempted (max MAX_ATTEMPTS_PER_SESSION) * @param topic Topic played (e.g. "addition", "multiplication") + * @param deadline Attestation expiry timestamp (0 if signer not set) + * @param signature EIP-712 signature from sessionSigner (empty if signer not set) */ function recordActivity( uint256 score, uint256 correct, uint256 attempts, - string calldata topic - ) external onlyRegistered nonReentrant { + string calldata topic, + uint256 deadline, + bytes calldata signature + ) external onlyRegistered nonReentrant whenNotPaused { + // ── Input bounds ── require(attempts > 0, "No attempts"); + require(attempts <= MAX_ATTEMPTS_PER_SESSION, "Too many attempts"); require(correct <= attempts, "Invalid correct count"); - require(bytes(topic).length > 0, "Topic required"); + require(score <= MAX_SCORE_PER_SESSION, "Score exceeds maximum"); + require(bytes(topic).length > 0 && bytes(topic).length <= 32, "Invalid topic"); - Player storage p = players[msg.sender]; uint256 today = block.timestamp / 1 days; - // Allow multiple sessions per day but track streak by day + // ── Rate limit: max sessions per day ── + require(dailySessionCount[msg.sender][today] < MAX_SESSIONS_PER_DAY, "Daily session limit reached"); + + // ── Session attestation (when signer is configured) ── + if (sessionSigner != address(0)) { + require(block.timestamp <= deadline, "Attestation expired"); + + uint256 currentNonce = nonces[msg.sender]; + bytes32 structHash = keccak256(abi.encode( + SESSION_TYPEHASH, + msg.sender, + score, + correct, + attempts, + keccak256(bytes(topic)), + currentNonce, + deadline + )); + + bytes32 digest = _hashTypedDataV4(structHash); + address recovered = ECDSA.recover(digest, signature); + require(recovered == sessionSigner, "Invalid session signature"); + + nonces[msg.sender] = currentNonce + 1; + } + + Player storage p = players[msg.sender]; + + // Track streak by day (only update on first activity of the day) bool firstActivityToday = p.lastActivityDay != today; - // Update streak if (firstActivityToday) { if (p.lastActivityDay == today - 1) { p.streak += 1; @@ -143,6 +210,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { p.totalScore += score; p.totalCorrect += correct; p.totalAttempts += attempts; + dailySessionCount[msg.sender][today] += 1; // Record history activityHistory[msg.sender].push(DailyActivity({ @@ -153,36 +221,44 @@ contract MathBlocGame is Ownable, ReentrancyGuard { timestamp: block.timestamp })); - // -- Coin rewards -- - uint256 coinsToAward = DAILY_REWARD_COINS; - string memory reason = "daily activity"; + // ── Coin rewards (daily base reward at most once per day) ── + uint256 coinsToAward = 0; + string memory reason = "session recorded"; - // Streak milestone bonus (every 7 days) - if (p.streak > 0 && p.streak % 7 == 0) { - coinsToAward += STREAK_BONUS_COINS * (p.streak / 7); - reason = "streak milestone"; + if (!dailyRewardClaimed[msg.sender][today]) { + coinsToAward = DAILY_REWARD_COINS; + dailyRewardClaimed[msg.sender][today] = true; + reason = "daily activity"; + + // Streak milestone bonus (every 7 days) — only on the first session of the day + if (p.streak > 0 && p.streak % 7 == 0) { + coinsToAward += STREAK_BONUS_COINS * (p.streak / 7); + reason = "streak milestone"; + } } - // Perfect score bonus (100% correct) + // Perfect score bonus (100% correct, at least 5 questions) — awarded per session if (correct == attempts && attempts >= 5) { coinsToAward += PERFECT_SCORE_BONUS; - reason = "perfect score"; + reason = coinsToAward > PERFECT_SCORE_BONUS ? "daily + perfect" : "perfect score"; } - p.coinsEarned += coinsToAward; + if (coinsToAward > 0) { + p.coinsEarned += coinsToAward; + emit CoinsEarned(msg.sender, coinsToAward, reason); + } emit ActivityRecorded(msg.sender, topic, score, correct, attempts, today); emit StreakUpdated(msg.sender, p.streak); - emit CoinsEarned(msg.sender, coinsToAward, reason); } - // --- CELO Reward Claim --- + // ─── CELO Reward Claim ────────────────────────────────────────────────── /** * @notice Claim CELO reward when coin balance reaches threshold. * Burns the coins and sends CELO from reward pool. */ - function claimCeloReward() external onlyRegistered nonReentrant { + function claimCeloReward() external onlyRegistered nonReentrant whenNotPaused { Player storage p = players[msg.sender]; require(p.coinsEarned >= CELO_REWARD_THRESHOLD, "Not enough coins"); require(rewardPool >= CELO_REWARD_AMOUNT, "Reward pool empty"); @@ -196,7 +272,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { emit CeloClaimed(msg.sender, CELO_REWARD_AMOUNT); } - // --- Owner: Fund Reward Pool --- + // ─── Owner: Administration ────────────────────────────────────────────── /** * @notice Owner deposits CELO into the reward pool. @@ -216,7 +292,29 @@ contract MathBlocGame is Ownable, ReentrancyGuard { require(sent, "Withdraw failed"); } - // --- Views --- + /** + * @notice Set the trusted session signer. Set to address(0) to disable signature verification. + */ + function setSessionSigner(address _signer) external onlyOwner { + emit SessionSignerUpdated(sessionSigner, _signer); + sessionSigner = _signer; + } + + /** + * @notice Pause all activity recording and claims (emergency). + */ + function pause() external onlyOwner { + _pause(); + } + + /** + * @notice Unpause the contract. + */ + function unpause() external onlyOwner { + _unpause(); + } + + // ─── Views ────────────────────────────────────────────────────────────── function getPlayer(address addr) external view returns (Player memory) { return players[addr]; @@ -242,6 +340,14 @@ contract MathBlocGame is Ownable, ReentrancyGuard { return registeredPlayers.length; } + function getNonce(address addr) external view returns (uint256) { + return nonces[addr]; + } + + function getDailySessionCount(address addr, uint256 day) external view returns (uint256) { + return dailySessionCount[addr][day]; + } + /** * @notice Returns top N players sorted by totalScore descending. * Uses a bounded insertion-sort (O(total * topN)) instead of a @@ -259,6 +365,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { return new LeaderboardEntry[](0); } if (topN > total) topN = total; + if (total == 0 || topN == 0) return new LeaderboardEntry[](0); // Bounded top-K via insertion into a fixed-size array. // We maintain a sorted array of length topN. For every registered @@ -382,6 +489,13 @@ contract MathBlocGame is Ownable, ReentrancyGuard { return players[addr].lastActivityDay == block.timestamp / 1 days; } + /** + * @notice Returns the EIP-712 domain separator for off-chain signature construction. + */ + function domainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } + receive() external payable { rewardPool += msg.value; } diff --git a/contracts/scripts/daily-activity.ts b/contracts/scripts/daily-activity.ts index 22fb155..1f6a0fa 100644 --- a/contracts/scripts/daily-activity.ts +++ b/contracts/scripts/daily-activity.ts @@ -69,7 +69,9 @@ async function main() { 8, // correct 10, // attempts "keeper", // topic - { gasLimit: 300000 } + 0, // deadline (no attestation when signer not set) + "0x", // signature (empty when signer not set) + { gasLimit: 400000 } ); await tx.wait(); console.log(`✅ Keeper activity recorded. TX: ${tx.hash}`); diff --git a/contracts/test/MathBlocGame.test.ts b/contracts/test/MathBlocGame.test.ts index b6f8ec1..a7922ff 100644 --- a/contracts/test/MathBlocGame.test.ts +++ b/contracts/test/MathBlocGame.test.ts @@ -8,9 +8,53 @@ describe("MathBlocGame", function () { let owner: HardhatEthersSigner; let player1: HardhatEthersSigner; let player2: HardhatEthersSigner; + let signer: HardhatEthersSigner; + + // Helper: record activity without attestation (signer not set) + async function recordUnsigned( + player: HardhatEthersSigner, + score: number, + correct: number, + attempts: number, + topic: string + ) { + return contract.connect(player).recordActivity(score, correct, attempts, topic, 0, "0x"); + } + + // Helper: build EIP-712 signed attestation + async function signSession( + signerWallet: HardhatEthersSigner, + playerAddr: string, + score: number, + correct: number, + attempts: number, + topic: string, + nonce: bigint, + deadline: number + ) { + const domain = { + name: "MathBlocGame", + version: "2", + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await contract.getAddress(), + }; + const types = { + Session: [ + { name: "player", type: "address" }, + { name: "score", type: "uint256" }, + { name: "correct", type: "uint256" }, + { name: "attempts", type: "uint256" }, + { name: "topic", type: "string" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + const value = { player: playerAddr, score, correct, attempts, topic, nonce, deadline }; + return signerWallet.signTypedData(domain, types, value); + } beforeEach(async () => { - [owner, player1, player2] = await ethers.getSigners(); + [owner, player1, player2, signer] = await ethers.getSigners(); const Factory = await ethers.getContractFactory("MathBlocGame"); contract = (await Factory.deploy()) as unknown as MathBlocGame; await contract.waitForDeployment(); @@ -41,7 +85,7 @@ describe("MathBlocGame", function () { }); it("records activity and awards coins", async () => { - await contract.connect(player1).recordActivity(80, 8, 10, "addition"); + await recordUnsigned(player1, 80, 8, 10, "addition"); const p = await contract.getPlayer(player1.address); expect(p.totalScore).to.equal(80n); expect(p.totalCorrect).to.equal(8n); @@ -49,7 +93,7 @@ describe("MathBlocGame", function () { }); it("awards perfect score bonus", async () => { - await contract.connect(player1).recordActivity(100, 10, 10, "addition"); + await recordUnsigned(player1, 100, 10, 10, "addition"); const p = await contract.getPlayer(player1.address); // DAILY_REWARD_COINS(10) + PERFECT_SCORE_BONUS(20) = 30 expect(p.coinsEarned).to.equal(30n); @@ -57,166 +101,266 @@ describe("MathBlocGame", function () { it("rejects unregistered player", async () => { await expect( - contract.connect(player2).recordActivity(50, 5, 10, "counting") + recordUnsigned(player2, 50, 5, 10, "counting") ).to.be.revertedWith("Not registered"); }); it("rejects invalid correct count", async () => { await expect( - contract.connect(player1).recordActivity(50, 11, 10, "addition") + recordUnsigned(player1, 50, 11, 10, "addition") ).to.be.revertedWith("Invalid correct count"); }); it("tracks activity history", async () => { - await contract.connect(player1).recordActivity(70, 7, 10, "subtraction"); + await recordUnsigned(player1, 70, 7, 10, "subtraction"); const history = await contract.getActivityHistory(player1.address); expect(history.length).to.equal(1); expect(history[0].topicPlayed).to.equal("subtraction"); }); + + it("rejects score exceeding maximum", async () => { + await expect( + recordUnsigned(player1, 1001, 10, 10, "addition") + ).to.be.revertedWith("Score exceeds maximum"); + }); + + it("rejects attempts exceeding maximum", async () => { + await expect( + recordUnsigned(player1, 50, 5, 101, "addition") + ).to.be.revertedWith("Too many attempts"); + }); + + it("rejects topic longer than 32 bytes", async () => { + const longTopic = "a".repeat(33); + await expect( + recordUnsigned(player1, 50, 5, 10, longTopic) + ).to.be.revertedWith("Invalid topic"); + }); + }); + + describe("Daily Reward Cap", () => { + beforeEach(async () => { + await contract.connect(player1).register("Alice"); + }); + + it("awards daily base reward only once per day", async () => { + await recordUnsigned(player1, 50, 5, 10, "addition"); + const p1 = await contract.getPlayer(player1.address); + expect(p1.coinsEarned).to.equal(10n); // DAILY_REWARD_COINS + + // Second session same day: no base reward + await recordUnsigned(player1, 50, 5, 10, "addition"); + const p2 = await contract.getPlayer(player1.address); + expect(p2.coinsEarned).to.equal(10n); // unchanged — no second daily reward + }); + + it("awards daily reward again on a new day", async () => { + await recordUnsigned(player1, 50, 5, 10, "addition"); + + // Advance 1 day + await ethers.provider.send("evm_increaseTime", [86400]); + await ethers.provider.send("evm_mine", []); + + await recordUnsigned(player1, 50, 5, 10, "addition"); + const p = await contract.getPlayer(player1.address); + expect(p.coinsEarned).to.equal(20n); // 10 + 10 + }); + + it("still awards perfect score bonus on subsequent same-day sessions", async () => { + await recordUnsigned(player1, 50, 5, 10, "addition"); + // 10 coins (daily) + + // Perfect score session same day + await recordUnsigned(player1, 100, 10, 10, "addition"); + const p = await contract.getPlayer(player1.address); + // 10 (daily) + 20 (perfect) = 30. No second daily reward. + expect(p.coinsEarned).to.equal(30n); + }); + + it("prevents farming coins via repeated same-day sessions", async () => { + // Record 10 sessions in one day + for (let i = 0; i < 10; i++) { + await recordUnsigned(player1, 50, 5, 10, "addition"); + } + const p = await contract.getPlayer(player1.address); + // Only 1 daily reward of 10 coins, no other bonuses + expect(p.coinsEarned).to.equal(10n); + }); + }); + + describe("Rate Limiting", () => { + beforeEach(async () => { + await contract.connect(player1).register("Alice"); + }); + + it("enforces max sessions per day", async () => { + for (let i = 0; i < 10; i++) { + await recordUnsigned(player1, 10, 1, 5, "addition"); + } + await expect( + recordUnsigned(player1, 10, 1, 5, "addition") + ).to.be.revertedWith("Daily session limit reached"); + }); }); describe("Streak", () => { it("sets streak to 1 on first activity", async () => { await contract.connect(player1).register("Bob"); - await contract.connect(player1).recordActivity(50, 5, 10, "counting"); + await recordUnsigned(player1, 50, 5, 10, "counting"); const p = await contract.getPlayer(player1.address); expect(p.streak).to.equal(1n); }); }); - describe("Leaderboard", () => { - it("returns sorted leaderboard", async () => { + describe("EIP-712 Session Attestation", () => { + // Helper to get a future deadline from the blockchain clock + async function futureDeadline(): Promise { + const block = await ethers.provider.getBlock("latest"); + return (block!.timestamp) + 3600; + } + + beforeEach(async () => { await contract.connect(player1).register("Alice"); - await contract.connect(player2).register("Bob"); - await contract.connect(player1).recordActivity(100, 10, 10, "addition"); - await contract.connect(player2).recordActivity(50, 5, 10, "counting"); + // Enable session signer + await contract.connect(owner).setSessionSigner(signer.address); + }); - const board = await contract.getLeaderboard(2); - expect(board[0].username).to.equal("Alice"); - expect(board[1].username).to.equal("Bob"); + it("accepts valid signed session", async () => { + const nonce = await contract.getNonce(player1.address); + const deadline = await futureDeadline(); + const sig = await signSession(signer, player1.address, 80, 8, 10, "addition", nonce, deadline); + + await contract.connect(player1).recordActivity(80, 8, 10, "addition", deadline, sig); + const p = await contract.getPlayer(player1.address); + expect(p.totalScore).to.equal(80n); }); - it("returns empty array when no players are registered", async () => { - const board = await contract.getLeaderboard(10); - expect(board.length).to.equal(0); + it("rejects forged signature (wrong signer)", async () => { + const nonce = await contract.getNonce(player1.address); + const deadline = await futureDeadline(); + // player2 signs instead of the trusted signer + const sig = await signSession(player2, player1.address, 80, 8, 10, "addition", nonce, deadline); + + await expect( + contract.connect(player1).recordActivity(80, 8, 10, "addition", deadline, sig) + ).to.be.revertedWith("Invalid session signature"); }); - it("returns empty array when topN is 0", async () => { - await contract.connect(player1).register("Alice"); - const board = await contract.getLeaderboard(0); - expect(board.length).to.equal(0); + it("rejects expired attestation", async () => { + const nonce = await contract.getNonce(player1.address); + const deadline = 1; // already expired + const sig = await signSession(signer, player1.address, 80, 8, 10, "addition", nonce, deadline); + + await expect( + contract.connect(player1).recordActivity(80, 8, 10, "addition", deadline, sig) + ).to.be.revertedWith("Attestation expired"); }); - it("handles single player leaderboard", async () => { - await contract.connect(player1).register("Solo"); - await contract.connect(player1).recordActivity(42, 4, 10, "addition"); + it("rejects replayed signature (same nonce)", async () => { + const nonce = await contract.getNonce(player1.address); + const deadline = await futureDeadline(); + const sig = await signSession(signer, player1.address, 80, 8, 10, "addition", nonce, deadline); - const board = await contract.getLeaderboard(5); - expect(board.length).to.equal(1); - expect(board[0].username).to.equal("Solo"); - expect(board[0].totalScore).to.equal(42n); + // First use: succeeds + await contract.connect(player1).recordActivity(80, 8, 10, "addition", deadline, sig); + + // Replay: fails (nonce already consumed) + await expect( + contract.connect(player1).recordActivity(80, 8, 10, "addition", deadline, sig) + ).to.be.revertedWith("Invalid session signature"); }); - it("clamps topN to total players when topN > total", async () => { - await contract.connect(player1).register("Alice"); + it("rejects cross-wallet attestation (signature for different player)", async () => { await contract.connect(player2).register("Bob"); + const nonce = await contract.getNonce(player1.address); + const deadline = await futureDeadline(); + // Sign for player1 but submit from player2 + const sig = await signSession(signer, player1.address, 80, 8, 10, "addition", nonce, deadline); - const board = await contract.getLeaderboard(100); - expect(board.length).to.equal(2); + await expect( + contract.connect(player2).recordActivity(80, 8, 10, "addition", deadline, sig) + ).to.be.revertedWith("Invalid session signature"); }); - it("handles tied scores with stable ordering (earlier registration first)", async () => { - const signers = await ethers.getSigners(); - const p1 = signers[1]; - const p2 = signers[2]; - const p3 = signers[3]; + it("increments nonce after each valid session", async () => { + expect(await contract.getNonce(player1.address)).to.equal(0n); - await contract.connect(p1).register("First"); - await contract.connect(p2).register("Second"); - await contract.connect(p3).register("Third"); + const deadline = await futureDeadline(); + const sig0 = await signSession(signer, player1.address, 50, 5, 10, "addition", 0n, deadline); + await contract.connect(player1).recordActivity(50, 5, 10, "addition", deadline, sig0); - // All players score the same - await contract.connect(p1).recordActivity(50, 5, 10, "addition"); - await contract.connect(p2).recordActivity(50, 5, 10, "addition"); - await contract.connect(p3).recordActivity(50, 5, 10, "addition"); + expect(await contract.getNonce(player1.address)).to.equal(1n); - const board = await contract.getLeaderboard(3); - expect(board.length).to.equal(3); - // Earlier registration should come first for tied scores - expect(board[0].username).to.equal("First"); - expect(board[1].username).to.equal("Second"); - expect(board[2].username).to.equal("Third"); - }); - - it("handles many players requesting small topN", async () => { - const signers = await ethers.getSigners(); - // Register 10 players with different scores - for (let i = 1; i <= 10; i++) { - await contract.connect(signers[i]).register("Player" + i); - await contract.connect(signers[i]).recordActivity(i * 10, i, 10, "addition"); - } + const sig1 = await signSession(signer, player1.address, 50, 5, 10, "addition", 1n, deadline); + await contract.connect(player1).recordActivity(50, 5, 10, "addition", deadline, sig1); - const board = await contract.getLeaderboard(3); - expect(board.length).to.equal(3); - // Top 3 should be players 10, 9, 8 (highest scores) - expect(board[0].totalScore).to.equal(100n); - expect(board[1].totalScore).to.equal(90n); - expect(board[2].totalScore).to.equal(80n); + expect(await contract.getNonce(player1.address)).to.equal(2n); }); + }); + + describe("Emergency Pause", () => { + it("owner can pause and unpause", async () => { + await contract.connect(owner).pause(); + await expect(contract.connect(player1).register("Alice")).to.be.revertedWithCustomError(contract, "EnforcedPause"); - it("handles players with zero scores", async () => { + await contract.connect(owner).unpause(); await contract.connect(player1).register("Alice"); - await contract.connect(player2).register("Bob"); - // No activity recorded, all scores are 0 + expect((await contract.getPlayer(player1.address)).exists).to.be.true; + }); - const board = await contract.getLeaderboard(2); - expect(board.length).to.equal(2); - // Both have 0 score, earlier registration comes first - expect(board[0].username).to.equal("Alice"); - expect(board[1].username).to.equal("Bob"); + it("non-owner cannot pause", async () => { + await expect(contract.connect(player1).pause()).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); }); - }); - describe("Leaderboard Pagination", () => { - it("returns empty for zero offset beyond total", async () => { + it("paused contract blocks activity recording", async () => { await contract.connect(player1).register("Alice"); - const board = await contract.getLeaderboardPage(10, 5); - expect(board.length).to.equal(0); + await contract.connect(owner).pause(); + + await expect( + recordUnsigned(player1, 50, 5, 10, "addition") + ).to.be.revertedWithCustomError(contract, "EnforcedPause"); }); - it("returns empty when limit is 0", async () => { + it("paused contract blocks CELO claims", async () => { + await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); await contract.connect(player1).register("Alice"); - const board = await contract.getLeaderboardPage(0, 0); - expect(board.length).to.equal(0); - }); - it("returns correct page of results", async () => { - const signers = await ethers.getSigners(); - for (let i = 1; i <= 5; i++) { - await contract.connect(signers[i]).register("P" + i); - await contract.connect(signers[i]).recordActivity(i * 10, i, 10, "math"); + // Accumulate coins across multiple days to reach threshold + for (let day = 0; day < 4; day++) { + await recordUnsigned(player1, 100, 10, 10, "addition"); + if (day < 3) { + await ethers.provider.send("evm_increaseTime", [86400]); + await ethers.provider.send("evm_mine", []); + } } - // Page 1: top 2 (scores 50, 40) - const page1 = await contract.getLeaderboardPage(2, 0); - expect(page1.length).to.equal(2); - expect(page1[0].totalScore).to.equal(50n); - expect(page1[1].totalScore).to.equal(40n); + await contract.connect(owner).pause(); + await expect( + contract.connect(player1).claimCeloReward() + ).to.be.revertedWithCustomError(contract, "EnforcedPause"); + }); + }); - // Page 2: next 2 (scores 30, 20) - const page2 = await contract.getLeaderboardPage(2, 2); - expect(page2.length).to.equal(2); - expect(page2[0].totalScore).to.equal(30n); - expect(page2[1].totalScore).to.equal(20n); + describe("Leaderboard", () => { + it("returns sorted leaderboard", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(player2).register("Bob"); + await recordUnsigned(player1, 100, 10, 10, "addition"); + await recordUnsigned(player2, 50, 5, 10, "counting"); - // Page 3: last 1 (score 10) - const page3 = await contract.getLeaderboardPage(2, 4); - expect(page3.length).to.equal(1); - expect(page3[0].totalScore).to.equal(10n); + const board = await contract.getLeaderboard(2); + expect(board[0].username).to.equal("Alice"); + expect(board[1].username).to.equal("Bob"); }); - it("returns empty for no players", async () => { - const board = await contract.getLeaderboardPage(10, 0); + it("returns empty array when no players registered", async () => { + const board = await contract.getLeaderboard(10); + expect(board.length).to.equal(0); + }); + + it("returns empty array when topN is 0", async () => { + await contract.connect(player1).register("Alice"); + const board = await contract.getLeaderboard(0); expect(board.length).to.equal(0); }); }); @@ -227,22 +371,39 @@ describe("MathBlocGame", function () { expect(await contract.rewardPool()).to.equal(ethers.parseEther("0.1")); }); - it("allows claim when coins >= threshold", async () => { + it("cannot farm coins to reach threshold in one day", async () => { await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); await contract.connect(player1).register("Alice"); - // Record 4 perfect sessions to accumulate 30*4=120 coins > 100 threshold - for (let i = 0; i < 4; i++) { - await contract.connect(player1).recordActivity(100, 10, 10, "addition"); + // 10 non-perfect sessions in one day: only 10 coins (not 100) + for (let i = 0; i < 10; i++) { + await recordUnsigned(player1, 50, 5, 10, "addition"); } const p = await contract.getPlayer(player1.address); - if (p.coinsEarned >= 100n) { - const balBefore = await ethers.provider.getBalance(player1.address); - await contract.connect(player1).claimCeloReward(); - const balAfter = await ethers.provider.getBalance(player1.address); - expect(balAfter).to.be.gt(balBefore - ethers.parseEther("0.001")); - } + expect(p.coinsEarned).to.equal(10n); // only daily reward once + await expect( + contract.connect(player1).claimCeloReward() + ).to.be.revertedWith("Not enough coins"); + }); + }); + + describe("Session Signer Management", () => { + it("owner can set session signer", async () => { + await contract.connect(owner).setSessionSigner(signer.address); + expect(await contract.sessionSigner()).to.equal(signer.address); + }); + + it("owner can disable session signer", async () => { + await contract.connect(owner).setSessionSigner(signer.address); + await contract.connect(owner).setSessionSigner(ethers.ZeroAddress); + expect(await contract.sessionSigner()).to.equal(ethers.ZeroAddress); + }); + + it("non-owner cannot set session signer", async () => { + await expect( + contract.connect(player1).setSessionSigner(signer.address) + ).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); }); }); }); diff --git a/hardhat.config.ts b/hardhat.config.ts index f8476e6..09de303 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -10,7 +10,7 @@ const PRIVATE_KEY = process.env.DEPLOYER_PRIVATE_KEY || "0x" + "0".repeat(64); const config: HardhatUserConfig = { solidity: { version: "0.8.24", - settings: { optimizer: { enabled: true, runs: 200 } }, + settings: { evmVersion: "cancun", viaIR: true, optimizer: { enabled: true, runs: 200 } }, }, networks: { alfajores: { diff --git a/lib/useContract.ts b/lib/useContract.ts index eb0b846..f785a67 100644 --- a/lib/useContract.ts +++ b/lib/useContract.ts @@ -181,7 +181,10 @@ export function useContract() { if (!contract || !player?.exists) return; setLoading(true); try { - const tx = await contract.recordActivity(score, correct, attempts, topic, { gasLimit: 300000 }); + // Pass deadline=0 and empty signature when no session signer is configured. + // When a backend attestation service is deployed, this should be replaced with + // a fetch to the signer API that returns { deadline, signature }. + const tx = await contract.recordActivity(score, correct, attempts, topic, 0, "0x", { gasLimit: 400000 }); await tx.wait(); const p: OnChainPlayer = await contract.getPlayer(address!); setPlayer(p);