Puppy Raffle

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

selectWinner can select a refunded player (address(0)) as the winner, sending prize pool to zero address and losing all funds.

Root + Impact

Description

When a player calls refund function (`https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L96`), their slot in the players array is set to address(0):
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L103
However, the array length is not decremented. When selectWinner function (`https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L125`) is called later, the winner index is computed as:
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L128
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L129
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L130
If winnerIndex points to a refunded player's slot, winner will be address(0). The contract then attempts:
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L151
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L152
A .call to address(0) with value succeeds in Solidity (it creates a new contract via the fallback, but the ETH is effectively burned/lost — actually, in practice address(0).call{value: x}("") returns success = true on EVM but the ETH goes to address(0) and is irrecoverable). The prize pool is permanently lost.
Additionally, the totalAmountCollected calculation is incorrect after refunds:
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L131
This counts address(0) slots as full players, overstating the pool. The contract may attempt to send more ETH than it actually holds.
// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

Prize pool can be sent to address(0), permanently losing all raffle funds
totalAmountCollected overstates the real pool after refunds, potentially causing the ETH transfer to revert due to insufficient balance (denial of service) or causing accounting mismatches
Breaks invariant I2 (conservation of value) and I8 (null player shouldn't win)

Proof of Concept

The following test demonsrates how address(0) could be chosen and sebt eth to.

contract RefundWinnerPoC is Test {
.....
.....
function testRefundedPlayerCanWin() public {
// 4 players enter
address player1 = address(10);
address player2 = address(11);
address player3 = address(12);
address player4 = address(13);
address[] memory players1 = new address[](1);
players1[0] = player1;
vm.deal(player1, entranceFee);
vm.prank(player1);
puppyRaffle.enterRaffle{value: entranceFee}(players1);
players1[0] = player2;
vm.deal(player2, entranceFee);
vm.prank(player2);
puppyRaffle.enterRaffle{value: entranceFee}(players1);
players1[0] = player3;
vm.deal(player3, entranceFee);
vm.prank(player3);
puppyRaffle.enterRaffle{value: entranceFee}(players1);
players1[0] = player4;
vm.deal(player4, entranceFee);
vm.prank(player4);
puppyRaffle.enterRaffle{value: entranceFee}(players1);
// Player 1 refunds - their slot becomes address(0)
vm.prank(player1);
puppyRaffle.refund(0);
// Verify player at index 0 is now address(0)
assertEq(puppyRaffle.players(0), address(0));
// The players array still has length 4
// If winnerIndex == 0, the winner is address(0)
// Prize pool would be sent to address(0)
// Verify the accounting is also broken:
// Contract balance = 3 * entranceFee (one refund happened)
// But totalAmountCollected = 4 * entranceFee
// prizePool = (4 * 1e18 * 80) / 100 = 3.2e18
// fee = (4 * 1e18 * 20) / 100 = 0.8e18
// Total to send = 4e18, but contract only has 3e18
// This will revert due to insufficient balance
// Even if the contract had enough ETH (e.g., from selfdestruct force-feeding),
// sending to address(0) loses the funds
assertEq(address(puppyRaffle).balance, 3 * entranceFee);
// Demonstrate that the raffle cannot complete correctly
// because totalAmountCollected > actual balance
vm.warp(block.timestamp + 1 days + 1);
// This will likely revert because the contract doesn't have enough ETH
// to cover the overstated totalAmountCollected
vm.expectRevert();
puppyRaffle.selectWinner();
}
}

Recommended Mitigation

Track the number of active players and skip address(0) entries during winner selection:
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
// Count active players and collect total real pool
uint256 activePlayers = 0;
for (uint256 i = 0; i < players.length; i++) {
if (players[i] != address(0)) {
activePlayers++;
}
}
require(activePlayers >= 1, "PuppyRaffle: No active players");
// Select winner, re-rolling if address(0)
address winner;
uint256 winnerIndex;
do {
winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty, winnerIndex))) % players.length;
winner = players[winnerIndex];
} while (winner == address(0));
uint256 totalAmountCollected = activePlayers * entranceFee;
// ... rest of function
}+ add this code
Updates

Lead Judging Commences

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

[H-01] Potential Loss of Funds During Prize Pool Distribution

## Description In the `selectWinner` function, when a player has refunded and their address is replaced with address(0), the prize money may be sent to address(0), resulting in fund loss. ## Vulnerability Details In the `refund` function if a user wants to refund his money then he will be given his money back and his address in the array will be replaced with `address(0)`. So lets say `Alice` entered in the raffle and later decided to refund her money then her address in the `player` array will be replaced with `address(0)`. And lets consider that her index in the array is `7th` so currently there is `address(0)` at `7th index`, so when `selectWinner` function will be called there isn't any kind of check that this 7th index can't be the winner so if this `7th` index will be declared as winner then all the prize will be sent to him which will actually lost as it will be sent to `address(0)` ## Impact Loss of funds if they are sent to address(0), posing a financial risk. ## Recommendations Implement additional checks in the `selectWinner` function to ensure that prize money is not sent to `address(0)`

Support

FAQs

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

Give us feedback!