Puppy Raffle

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

selectWinner() can be indefinitely griefed by an entrant using a receive()-reverting contract, blocking the entire round

Summary

selectWinner sends the entire prize pool to the chosen winner via a raw call, and requires that call to succeed. If the winner is a contract whose receive/fallback reverts, the entire selectWinner transaction reverts too -- including the state updates that were meant to advance the raffle (clearing players, resetting raffleStartTime). A participant can therefore grief prize distribution by entering with a contract that always rejects ETH.

Description

(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);

Because the ETH transfer and its require happen inline inside selectWinner, any revert on the receiving end propagates up and reverts the whole call. There is no pull-payment fallback and no mechanism to skip a reverting winner and re-roll -- selectWinner simply cannot succeed as long as the same reverting address would be selected again.

Risk

Likelihood:

  • Any participant can enter using a contract address whose receive() always reverts -- this requires no special access, just choosing which address to enter with, exactly like a normal entry.

  • Whether they land in the winner slot on a given call depends on msg.sender/block.timestamp/block.difficulty (see the related weak-randomness finding), which a motivated griefer can also influence by choosing which address calls selectWinner.

Impact:

  • The raffle round is bricked: no winner is paid, no puppy is minted, raffleStartTime doesn't advance, and players is not cleared, for as long as the reverting entry keeps being selected.

  • Combined with the predictable-randomness finding, a griefer can reliably force themselves into the winner slot purely to block the round, at no cost beyond their own entrance fee (which they can still refund).

Proof of Concept

function test_M1_revertingWinner_bricksSelectWinnerForThatRound() public {
RevertingReceiver griefer = new RevertingReceiver(); // receive() reverts
vm.warp(block.timestamp + duration);
address chosenCaller = address(0xB0B);
uint256 winnerIndex =
uint256(keccak256(abi.encodePacked(chosenCaller, block.timestamp, block.difficulty))) % 4;
address[] memory players = new address[](4);
players[0] = playerOne; players[1] = playerTwo;
players[2] = playerThree; players[3] = playerFour;
players[winnerIndex] = address(griefer);
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
vm.prank(chosenCaller);
vm.expectRevert("PuppyRaffle: Failed to send prize pool to winner");
puppyRaffle.selectWinner();
assertEq(puppyRaffle.totalSupply(), 0); // round never completed
}

Run with forge test --match-test test_M1_revertingWinner_bricksSelectWinnerForThatRound -vv. The griefer is placed at the exact index selectWinner's own formula will pick, and the call reverts as predicted, leaving the round permanently stuck until intervention.

Recommended Mitigation

Switch to a pull-payment pattern: record prizePool as withdrawable by winner in a mapping, and let the winner call a separate claimPrize() function to pull their funds. This decouples winner selection from the ETH transfer, so a reverting/malicious winner can no longer block the round for everyone else -- only their own claim fails.

Updates

Lead Judging Commences

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

[M-03] Impossible to win raffle if the winner is a smart contract without a fallback function

## Description If a player submits a smart contract as a player, and if it doesn't implement the `receive()` or `fallback()` function, the call use to send the funds to the winner will fail to execute, compromising the functionality of the protocol. ## Vulnerability Details The vulnerability comes from the way that are programmed smart contracts, if the smart contract doesn't implement a `receive() payable` or `fallback() payable` functions, it is not possible to send ether to the program. ## Impact High - Medium: The protocol won't be able to select a winner but players will be able to withdraw funds with the `refund()` function ## Recommendations Restrict access to the raffle to only EOAs (Externally Owned Accounts), by checking if the passed address in enterRaffle is a smart contract, if it is we revert the transaction. We can easily implement this check into the function because of the Adress library from OppenZeppelin. I'll add this replace `enterRaffle()` with these lines of code: ```solidity function enterRaffle(address[] memory newPlayers) public payable { require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle"); for (uint256 i = 0; i < newPlayers.length; i++) { require(Address.isContract(newPlayers[i]) == false, "The players need to be EOAs"); players.push(newPlayers[i]); } // Check for duplicates for (uint256 i = 0; i < players.length - 1; i++) { for (uint256 j = i + 1; j < players.length; j++) { require(players[i] != players[j], "PuppyRaffle: Duplicate player"); } } emit RaffleEnter(newPlayers); } ```

Support

FAQs

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

Give us feedback!