Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

Refunded players remain counted as active participants, breaking raffle accounting.

Root + Impact

Description

Refunded players are replaced with address(0) but remain included in the players.length-dependent logic.
The objecive is to have a fair randomanly generated winner after the expiration of the stated duration of the draw, and as such award the winning address 80% of the total entrance fee collected.
However The refund() function replaces refunded players with address(0) but does not remove them from the players array:
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L103
As a result, refunded participants remain included in all logic relying on players.length.
The protocol later uses players.length to validate minimum raffle participation, select winners and calculate prize amounts.
More so, refunded players are no longer active participants and should not be included in these calculations.
For example, the prize pool is derived from:
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L131
This assumes every array entry corresponds to a paid participant, which becomes false after refunds occur.
Additionally, winner selection may repeatedly target refunded entries (address(0)), causing raffle settlement to revert.
//in the refund() function user index is replaced with address(0)
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L103
//in the selectWinner() function player.length is used the preceeding code lines
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L131

Risk

Likelihood:

Likelihood is high as long as the refund function is called and used, the players array lenght becomes an unreliably meaure of any calcuation or assumptions.

Impact:

This issue breaks core raffle accounting assumptions and may result in such as inflated prize calculations, incorrect active participant counts and failed raffle settlement if a refunded slot is selected as winner.

Proof of Concept

players.length must equal the number of active paid participants, which is not the case as shown below.

// All 4 players enter raffle
vm.prank(player1);
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
// Player2 refunds
vm.prank(player2);
puppyRaffle.refund(1);
// Player3 refunds
vm.prank(player3);
puppyRaffle.refund(2);
/* Expected active participants: 2 Actual players.length: 4 players array now becomes: [ player1, address(0), address(0), player4 ] */
// Array length still incorrectly reports 4
assertEq(puppyRaffle.players(0), player1);
assertEq(puppyRaffle.players(1), address(0));
assertEq(puppyRaffle.players(2), address(0));
assertEq(puppyRaffle.players(3), player4);
// Contract balance only contains 2 active entry fees
assertEq(address(puppyRaffle).balance, entranceFee * 2);
// However selectWinner() still calculates:
// = 4 * entranceFee // // instead of: // // 2 * entranceFee
totalAmountCollected = players.length * entranceFee

Recommended Mitigation

The refund function must ensure that refunded player address are deleted and the players address array length reflects only active players in that raffle, as shown below. This ensures players.length accurately reflects active participants.
players[playerIndex] = address(0); - remove this code
//replace the refunded address index with the last index in the array
players[playerIndex] = players[players.length - 1]; + add this code
//pop to remove the last index
players.pop(); + add this code
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] `PuppyRaffle::refund` replaces an index with address(0) which can cause the function `PuppyRaffle::selectWinner` to always revert

## 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() ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!