Puppy Raffle

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

selectWinner reverts if the winner can't accept ETH or the ERC721 — a contract entrant without receive/onERC721Received freezes payout and the next raffle

Description

selectWinner pays the prize pool with a low-level call and then mints the NFT with _safeMint, both of which invoke code on the winner if the winner is a contract:

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

The winner is simply an entry in the players array, and entrants can be arbitrary contract addresses (the protocol explicitly supports entering "yourself and a group of your friends" as an address[]). Two ways the payout can be forced to revert:

  1. Prize call fails. If the winning address is a contract with no receive/fallback (or one whose fallback reverts / consumes too much gas), winner.call{value: prizePool}("") returns success == false, and the require(success, ...) reverts the whole selectWinner.

  2. _safeMint acceptance check fails. _safeMint calls onERC721Received on a contract recipient; if the winner doesn't implement IERC721Receiver, _safeMint reverts.

Because the winner is chosen deterministically from the current players/block data, re-calling selectWinner in the same conditions selects the same un-payable winner and reverts again. The raffle cannot complete: the prize can't be paid, the puppy can't be minted, delete players never executes, and raffleStartTime is never reset — so the next raffle can't start either. A single contract entrant that can't receive ETH or the NFT freezes the whole mechanism.

This is both an availability bug (griefing/DoS of the draw) and, depending on timing, a way to stall payouts indefinitely.

Risk

Impact: Medium. The core selectWinner flow can be permanently blocked by an entrant that cannot accept the prize or the NFT, freezing the current raffle and preventing the next one from starting. Funds aren't stolen but the protocol is stuck.

Likelihood: Medium. Any entrant may be a non-receiving contract — accidentally (a multisig/contract without receive or onERC721Received) or deliberately (a griefer who enters such a contract and hopes/forces it to be selected). No privileges required.

Proof of Concept

contract NonReceiver {
// no receive(), no fallback(), no onERC721Received -> cannot accept ETH or the NFT
function enter(PuppyRaffle raffle, uint256 fee) external payable {
address[] memory me = new address[](1);
me[0] = address(this);
raffle.enterRaffle{value: fee}(me);
}
}
function test_nonReceivingWinnerBlocksDraw() public {
// 3 normal players + 1 NonReceiver, raffle duration elapsed
NonReceiver bad = new NonReceiver();
vm.deal(address(bad), entranceFee);
bad.enter(puppyRaffle, entranceFee);
// ... ensure block data selects `bad` as winner (deterministic from block props) ...
vm.warp(block.timestamp + raffleDuration + 1);
vm.expectRevert("PuppyRaffle: Failed to send prize pool to winner");
puppyRaffle.selectWinner(); // reverts; players never reset, next raffle blocked
}

Expected: selectWinner always completes and the raffle rolls over. Actual: a non-receiving winner makes it revert, and the deterministic selection keeps picking the same winner, freezing the raffle.

Recommended Mitigation

Decouple payout from selection so one bad recipient cannot block the draw. Use a pull-payment pattern: record the winner's prize and let them withdraw it themselves, and avoid _safeMint blocking the flow (use _mint, or mint and let the winner claim). For example:

mapping(address => uint256) public pendingPrize;
...
// in selectWinner, instead of the call + require:
pendingPrize[winner] += prizePool;
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
_mint(winner, tokenId); // non-blocking mint
function claimPrize() external {
uint256 amount = pendingPrize[msg.sender];
require(amount > 0, "no prize");
pendingPrize[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}

With pull payments and a non-blocking mint, an un-receiving winner only affects their own claim and never freezes the raffle.

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!