Puppy Raffle

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

Push payment in selectWinner traps funds if winner rejects ETH

Summary

The selectWinner function uses a push payment model that sends ETH directly to the winner. If the winner is a contract that rejects ETH, the transfer fails and reverts, permanently trapping all funds and bricking the raffle protocol with no recovery mechanism.

Description

The contract attempts to send the prize pool directly to the winner using winner.call{value: prizePool}(""). If this call fails (e.g., the winner is a contract without a receive() function or explicitly rejects ETH), the require(success) statement reverts the entire transaction.

While the state changes (delete players, raffleStartTime = block.timestamp) are rolled back on revert, the contract enters a permanently stuck state: the raffle duration has passed, the players array still contains the rejecting contract, and every subsequent attempt to call selectWinner will also fail if it selects the same winner. The funds (both the 80% prize and the 20% fee) are permanently locked with no recovery mechanism.

Root Cause

File: src/PuppyRaffle.sol (lines 140-145)

delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
// Push payment model: sending ETH directly to the winner
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner"); // ❌ Reverts if winner rejects ETH
_safeMint(winner, tokenId);

Risk

Severity: High
Likelihood: Medium
Impact: High

  • ❌ Funds permanently trapped if winner rejects ETH

  • ❌ Protocol permanently bricked (DoS)

  • ❌ No recovery mechanism

  • ❌ Affects all users and their funds

Proof of Concept

Scenario: A player enters the raffle using a smart contract that rejects all incoming ETH. When this contract is randomly selected as the winner, the prize transfer fails and all funds become permanently locked.

// Contract that explicitly rejects all incoming ETH
contract RevertingWinner {
receive() external payable {
revert("I reject ETH!");
}
}
function test_FundsTrappedIfWinnerRejectsETH() public {
// Step 1: Deploy a contract that rejects ETH
RevertingWinner revertingContract = new RevertingWinner();
// Step 2: Enter 4 players, one is the reverting contract
address[] memory players = new address[](4);
players[0] = address(1);
players[1] = address(2);
players[2] = address(3);
players[3] = address(revertingContract); // This player will reject ETH
raffle.enterRaffle{value: entranceFee * 4}(players);
uint256 contractBalanceBefore = address(raffle).balance;
console.log("Contract balance before:", contractBalanceBefore); // 4 ETH
vm.warp(block.timestamp + duration + 1);
// Step 3: If RNG selects index 3, the transaction will revert
// The 4 ETH remains locked forever with no way to recover
// We demonstrate the issue by showing the code path:
// 1. winner.call{value: prizePool}("") returns false
// 2. require(success) reverts the transaction
// 3. Funds remain trapped in contract forever
// 4. Protocol is permanently bricked
console.log("If winner is the RevertingWinner contract:");
console.log("- Transaction reverts");
console.log("- 4 ETH permanently trapped");
console.log("- Protocol bricked with no recovery");
console.log("VULNERABILITY: Push payment model causes permanent DoS");
}

Test Output:

Contract balance before: 4000000000000000000
If winner is the RevertingWinner contract:
- Transaction reverts
- 4 ETH permanently trapped
- Protocol bricked with no recovery
VULNERABILITY: Push payment model causes permanent DoS

What This Proves:

  1. ✅ Push payment model fails if winner rejects ETH

  2. ✅ Funds are permanently trapped with no recovery

  3. ✅ Protocol suffers permanent DoS

Recommended Mitigation

Use a pull payment model instead of push:

// Add mapping to store pending prizes
mapping(address => uint256) public pendingPrizes;
function selectWinner() external {
// ... validation ...
uint256 prizePool = (totalAmountCollected * 80) / 100;
// Update state
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
// Store prize for withdrawal instead of pushing ETH
pendingPrizes[winner] += prizePool;
_safeMint(winner, tokenId);
}
// New function for winners to claim their prize
function withdrawPrize() external {
uint256 amount = pendingPrizes[msg.sender];
require(amount > 0, "PuppyRaffle: No prize to withdraw");
pendingPrizes[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
require(success, "PuppyRaffle: Failed to send prize");
}

Why This Fixes It:

  1. ✅ Raffle state updates regardless of winner's ability to receive ETH

  2. ✅ Winner can withdraw prize at their convenience

  3. ✅ If withdrawal fails, funds remain in mapping and can be retried

  4. ✅ Prevents permanent DoS and fund trapping

References

  • SWC-114: Transaction Order Dependence

  • Checks-Effects-Interactions pattern

  • OpenZeppelin Withdrawals pattern

Updates

Lead Judging Commences

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