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.
Risk
Likelihood:
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 {
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);
vm.prank(player1);
puppyRaffle.refund(0);
assertEq(puppyRaffle.players(0), address(0));
assertEq(address(puppyRaffle).balance, 3 * entranceFee);
vm.warp(block.timestamp + 1 days + 1);
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