From c228d44b3bf989ff6f67bb3774ceb2f6ca626e39 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 22 Aug 2026 19:44:07 +0100 Subject: [PATCH] fix: secure activity rewards against forged sessions and coin farming Threat model: any registered address could call recordActivity() with arbitrary scores and collect DAILY_REWARD_COINS on every call, draining the reward pool. No session verification existed. Contract changes (MathBlocGame.sol): - Daily reward cap: base 10-coin reward is awarded at most once per player per day via dailyRewardClaimed mapping. Perfect-score bonus (20 coins) is still awarded per qualifying session. - EIP-712 session attestation: when owner sets a sessionSigner, every recordActivity call must carry a valid EIP-712 signature covering (player, score, correct, attempts, topic, nonce, deadline). Forged, expired, replayed, and cross-wallet attestations revert. When sessionSigner == address(0), signatures are not required (migration path for the existing deployment). - Per-player nonces prevent replay attacks. - Input bounds: score <= 1000, attempts <= 100, topic <= 32 bytes. - Rate limit: max 10 sessions per player per day. - Emergency pause: owner can pause/unpause all activity recording, registration, and CELO claims via OpenZeppelin Pausable. - Leaderboard empty-array fix (topN=0 or 0 players returns []). Infrastructure: - hardhat.config.ts: enable Cancun EVM target + viaIR for OZ v5.6 compatibility. - lib/useContract.ts: pass deadline=0 and empty signature to the new recordActivity ABI (no-op when signer is not configured). - contracts/scripts/daily-activity.ts: updated keeper call signature. Tests (35 passing): - Daily reward cap: single-day farming yields only 10 coins. - Input bounds: score > 1000, attempts > 100, topic > 32 bytes revert. - Rate limit: 11th same-day session reverts. - EIP-712: valid signature accepted; wrong signer, expired, replayed, and cross-wallet attestations revert; nonce increments correctly. - Emergency pause: registration, activity, and claims blocked. - Session signer management: owner-only set/disable. Closes #9 Co-Authored-By: Claude Opus 4.6 --- contracts/contracts/MathBlocGame.sol | 192 ++++++++++++---- contracts/scripts/daily-activity.ts | 4 +- contracts/test/MathBlocGame.test.ts | 325 +++++++++++++++++++++++++-- hardhat.config.ts | 2 +- lib/useContract.ts | 5 +- 5 files changed, 465 insertions(+), 63 deletions(-) diff --git a/contracts/contracts/MathBlocGame.sol b/contracts/contracts/MathBlocGame.sol index ba688a6..58f4c58 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,12 +340,21 @@ 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 (simple bubble sort — fine for small sets). */ function getLeaderboard(uint256 topN) external view returns (LeaderboardEntry[] memory) { uint256 total = registeredPlayers.length; if (topN > total) topN = total; + if (total == 0 || topN == 0) return new LeaderboardEntry[](0); // Copy scores into memory array for sorting address[] memory addrs = new address[](total); @@ -284,6 +391,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 549acf7..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,44 +101,268 @@ 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("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"); + // Enable session signer + await contract.connect(owner).setSessionSigner(signer.address); + }); + + 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("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("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("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); + + // 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("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); + + await expect( + contract.connect(player2).recordActivity(80, 8, 10, "addition", deadline, sig) + ).to.be.revertedWith("Invalid session signature"); + }); + + it("increments nonce after each valid session", async () => { + expect(await contract.getNonce(player1.address)).to.equal(0n); + + 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); + + expect(await contract.getNonce(player1.address)).to.equal(1n); + + const sig1 = await signSession(signer, player1.address, 50, 5, 10, "addition", 1n, deadline); + await contract.connect(player1).recordActivity(50, 5, 10, "addition", deadline, sig1); + + 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"); + + await contract.connect(owner).unpause(); + await contract.connect(player1).register("Alice"); + expect((await contract.getPlayer(player1.address)).exists).to.be.true; + }); + + it("non-owner cannot pause", async () => { + await expect(contract.connect(player1).pause()).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); + }); + + it("paused contract blocks activity recording", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(owner).pause(); + + await expect( + recordUnsigned(player1, 50, 5, 10, "addition") + ).to.be.revertedWithCustomError(contract, "EnforcedPause"); + }); + + it("paused contract blocks CELO claims", async () => { + await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); + await contract.connect(player1).register("Alice"); + + // 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", []); + } + } + + await contract.connect(owner).pause(); + await expect( + contract.connect(player1).claimCeloReward() + ).to.be.revertedWithCustomError(contract, "EnforcedPause"); + }); + }); + describe("Leaderboard", () => { it("returns sorted leaderboard", 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"); + await recordUnsigned(player1, 100, 10, 10, "addition"); + await recordUnsigned(player2, 50, 5, 10, "counting"); const board = await contract.getLeaderboard(2); expect(board[0].username).to.equal("Alice"); expect(board[1].username).to.equal("Bob"); }); + + 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); + }); }); describe("CELO Rewards", () => { @@ -103,24 +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"); - // Manually set coins via multiple perfect sessions (mock: just check logic) - // Record 4 perfect sessions to accumulate 30*4=120 coins > 100 threshold - for (let i = 0; i < 4; i++) { - // Each call in same block counts as same day, only first updates streak - 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);