Normal behavior: The raffle tracks participants in the players array and calculates payouts based on the total number of players. After the raffle duration ends, selectWinner() distributes the prize pool to the winner and accrues protocol fees, which can later be withdrawn by feeAddress via withdrawFees(). Players are allowed to exit early using refund(), receiving back their entrance fee.
Problem: The refund() function removes a player by overwriting their slot with address(0) instead of removing the entry or decrementing an active player count. As a result, players.length no longer reflects the number of active participants or the actual ETH balance held by the contract.
Subsequent accounting in selectWinner() and withdrawFees() relies on players.length to compute the prize pool and fees. After one or more refunds, this mismatch leads to two critical outcomes:
selectWinner() may attempt to transfer more ETH than the contract holds, causing the raffle to revert and become unfinalizable.
Even when the winner payout succeeds, protocol fees become permanently locked because withdrawFees() enforces an invariant based on address(this).balance that can no longer be satisfied.
Both outcomes result in permanent loss of protocol liveness or funds.
Likelihood:
Players are explicitly allowed to call refund() at any time before raffle finalization, which deterministically introduces holes into the players array and breaks the invariant between players.length and the contract’s ETH balance.
Both selectWinner() and withdrawFees() unconditionally rely on players.length and totalFees for accounting, making the faulty state reachable through normal, intended user behavior without any privileged access or timing assumptions.
Impact:
The raffle can become permanently unfinalizable when selectWinner() attempts to transfer a prize pool larger than the available balance, resulting in a persistent denial of service for all participants.
Protocol fees can become permanently locked, preventing feeAddress from ever withdrawing accrued fees and causing irreversible loss of funds.
The following tests demonstrate two distinct manifestations of the same accounting flaw introduced by refund holes in the players array.
The first test shows that when more than half of the participants refund their tickets, the contract still satisfies the minimum player count check (players.length >= 4), yet the actual ETH balance becomes insufficient to cover the prize pool. As a result, selectWinner() reverts and the raffle cannot be finalized.
The second test shows that even when the winner payout succeeds (because sufficient funds remain to cover the prize pool), protocol fees become permanently locked. After refunds, the contract balance no longer matches totalFees, causing withdrawFees() to revert indefinitely despite the raffle being completed.
Do not represent refunded players as address(0) while still using players.length for payout and fee accounting. Instead, ensure that accounting is based on the number of active (non-refunded) players and the actual funds available for distribution.
A robust fix is to remove players from the array (swap-and-pop) on refund or maintain an activePlayersCount (and/or activePot) that is updated on enterRaffle() and refund(). Then compute prizePool and fee from these active values rather than players.length.
Additionally, avoid using address(this).balance == totalFees as a proxy for “no active players”, since forced ETH and accounting mismatches can break this invariant. Instead, gate withdrawFees() on an explicit active state (e.g., activePlayersCount == 0) and/or a finalized-round flag.
The fix also aligns getActivePlayerIndex with internal accounting by leveraging a constant-time index mapping, removing ambiguity between index 0 and inactive players.
## Description `PuppyRaffle::refund` is supposed to refund a player and remove him from the current players. But instead, it replaces his index value with address(0) which is considered a valid value by solidity. This can cause a lot issues because the players array length is unchanged and address(0) is now considered a player. ## Vulnerability Details ```javascript players[playerIndex] = address(0); @> uint256 totalAmountCollected = players.length * entranceFee; (bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner"); _safeMint(winner, tokenId); ``` If a player refunds his position, the function `PuppyRaffle::selectWinner` will always revert. Because more than likely the following call will not work because the `prizePool` is based on a amount calculated by considering that that no player has refunded his position and exit the lottery. And it will try to send more tokens that what the contract has : ```javascript uint256 totalAmountCollected = players.length * entranceFee; uint256 prizePool = (totalAmountCollected * 80) / 100; (bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner"); ``` However, even if this calls passes for some reason (maby there are more native tokens that what the players have sent or because of the 80% ...). The call will thankfully still fail because of the following line is minting to the zero address is not allowed. ```javascript _safeMint(winner, tokenId); ``` ## Impact The lottery is stoped, any call to the function `PuppyRaffle::selectWinner`will revert. There is no actual loss of funds for users as they can always refund and get their tokens back. However, the protocol is shut down and will lose all it's customers. A core functionality is exposed. Impact is high ### Proof of concept To execute this test : forge test --mt testWinnerSelectionRevertsAfterExit -vvvv ```javascript function testWinnerSelectionRevertsAfterExit() public playersEntered { vm.warp(block.timestamp + duration + 1); vm.roll(block.number + 1); // There are four winners. Winner is last slot vm.prank(playerFour); puppyRaffle.refund(3); // reverts because out of Funds vm.expectRevert(); puppyRaffle.selectWinner(); vm.deal(address(puppyRaffle), 10 ether); vm.expectRevert("ERC721: mint to the zero address"); puppyRaffle.selectWinner(); } ``` ## Recommendations Delete the player index that has refunded. ```diff - players[playerIndex] = address(0); + players[playerIndex] = players[players.length - 1]; + players.pop() ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.