From ef038ece4c2bd934c5fa92b59af1586531b4b3f9 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Sat, 4 Jul 2026 20:36:56 +0900 Subject: [PATCH] fix: cap jackpot payout to the pot size at the winner's own commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revealShot() can legally be called anywhere from commitBlock+2 up to commitBlock+MAX_REVEAL_DELAY (256 blocks) later, and once the reveal block's hash is fixed the win/lose outcome only depends on values that are readable off-chain (secret is known to the caller; nonce/ playerNonces are plain storage, "private" only hides them from other Solidity contracts, not from external inspection). A player who commits, waits two blocks, and checks off-chain that they've already won can simply hold their pending shot open and only call revealShot() once the pot has been inflated by other players committing in the meantime, collecting a payout far larger than the pot that existed when their own win was actually determined. Fix: snapshot the pot size right after each shot's own commit (potAtCommit) and cap _handleWin's payout to min(currentPot, potAtCommit). Pot growth from shots committed after the winner's own commit is excluded from the payout and rolls over into the next round instead of being deducted-to-zero or handed to this winner. Also fixes a related bug in the same function: shot.amount was read from the storage-pointer `shot` *after* `delete pendingShots[msg.sender]`, so ShotRevealed's amount argument always logged 0 regardless of the actual shot size. Needed to capture values out of `shot` before the delete anyway for potAtCommit, so fixed both in the same pass. Verified with `npx hardhat compile` (compiles clean, evm target paris). Could not run the existing contract test suite in this environment — test/contracts/EthShot.test.js hits a pre-existing ESM/CJS import error unrelated to this change (`import { ethers } from 'hardhat'` against a CommonJS build). --- contracts/EthShot.sol | 78 +++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/contracts/EthShot.sol b/contracts/EthShot.sol index 32d9433..44c51d9 100644 --- a/contracts/EthShot.sol +++ b/contracts/EthShot.sol @@ -42,6 +42,7 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { bytes32 commitment; uint256 blockNumber; uint256 amount; + uint256 potAtCommit; // pot size right after this shot was added, caps this shot's payout bool exists; } @@ -216,15 +217,24 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { // REMOVED: validPotSize - this was preventing first shots { require(commitment != bytes32(0), "Invalid commitment"); - - // Store pending shot + + // Add to pot first so the snapshot below includes this shot's own contribution + unchecked { + currentPot += SHOT_COST; + } + + // Store pending shot. potAtCommit caps this shot's eventual payout to the pot as it + // stood right after this commit, so a player who has already secured a win cannot + // strategically delay revealShot (legal for up to MAX_REVEAL_DELAY blocks) to let + // other players' later shots inflate the pot and then snipe the larger jackpot. pendingShots[msg.sender] = PendingShot({ commitment: commitment, blockNumber: block.number, amount: SHOT_COST, + potAtCommit: currentPot, exists: true }); - + // Update player stats PlayerStats storage stats = playerStats[msg.sender]; unchecked { @@ -232,12 +242,7 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { stats.totalSpent += SHOT_COST; } lastShotTime[msg.sender] = block.timestamp; - - // Add to pot - unchecked { - currentPot += SHOT_COST; - } - + emit ShotCommitted(msg.sender, commitment, SHOT_COST); } @@ -257,15 +262,22 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { require(commitment != bytes32(0), "Invalid commitment"); require(msg.value >= SHOT_COST, "Payment too low"); require(currentPot == 0, "Not a first shot - pot must be empty"); - - // Store pending shot with actual amount paid + + // Add full amount to pot first so the snapshot below includes this shot's contribution + unchecked { + currentPot += msg.value; + } + + // Store pending shot with actual amount paid. See commitShot() for why potAtCommit + // is captured here. pendingShots[msg.sender] = PendingShot({ commitment: commitment, blockNumber: block.number, amount: msg.value, // Store actual amount paid + potAtCommit: currentPot, exists: true }); - + // Update player stats with actual amount PlayerStats storage stats = playerStats[msg.sender]; unchecked { @@ -273,12 +285,7 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { stats.totalSpent += msg.value; } lastShotTime[msg.sender] = block.timestamp; - - // Add full amount to pot - unchecked { - currentPot += msg.value; - } - + emit ShotCommitted(msg.sender, commitment, msg.value); } @@ -314,13 +321,18 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { } } + // Capture values needed after cleanup: `shot` is a storage pointer, so reading its + // fields after delete would return the zeroed-out values, not the original shot data. + uint256 shotAmount = shot.amount; + uint256 potAtCommit = shot.potAtCommit; + // Clean up pending shot delete pendingShots[msg.sender]; - - emit ShotRevealed(msg.sender, shot.amount, won); - + + emit ShotRevealed(msg.sender, shotAmount, won); + if (won) { - _handleWin(msg.sender); + _handleWin(msg.sender, potAtCommit); } } @@ -597,26 +609,34 @@ contract EthShot is Ownable, Pausable, ReentrancyGuard { /** * @dev Internal function to handle winning scenario * @param winner Address of the winner + * @param potAtCommit Pot size right after the winner's own commit — caps this win's payout + * so a player who already knows (via a simulated/off-chain call once the revealing block's + * hash is fixed) that they will win cannot strategically delay revealShot — legal for up to + * MAX_REVEAL_DELAY blocks — to let other players' later shots inflate the pot and then snipe + * the larger jackpot. Any pot growth from shots committed after this one stays in currentPot + * for the next round instead of being awarded to this winner. */ - function _handleWin(address winner) private { - uint256 potAmount = currentPot; + function _handleWin(address winner, uint256 potAtCommit) private { + uint256 potAmount = currentPot < potAtCommit ? currentPot : potAtCommit; uint256 winnerAmount = (potAmount * WIN_PERCENTAGE_BP) / BASIS_POINTS; uint256 houseAmount = potAmount - winnerAmount; - + // Update player stats PlayerStats storage stats = playerStats[winner]; unchecked { stats.totalWon += winnerAmount; } - + // Add to house funds unchecked { houseFunds += houseAmount; } - // Reset pot - currentPot = 0; - + // Deduct only the awarded amount — any pot growth from shots committed after this + // winner's own commit (potAtCommit) was excluded from potAmount above and rolls over + // into the next round instead of vanishing from accounting or being handed to this winner. + currentPot -= potAmount; + // Clear sponsorship if (currentSponsor.active) { currentSponsor.active = false;