Puppy Raffle

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

Winner Rejecting Payment Can Permanently Lock Raffle and All Funds

Description

The PuppyRaffle::selectWinner function uses a push payment pattern to send prize money directly to the winner. If the winner is a smart contract that rejects ETH payments, the entire transaction reverts and the raffle becomes permanently stuck.

Expected behavior: The raffle should be able to select a winner and distribute prizes regardless of the winner's ability to receive ETH immediately.

Actual behavior: If the winner cannot accept ETH, selectWinner() reverts and can never be called again. All funds are permanently locked.

function selectWinner() external {
// ... selection logic ...
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
(bool success,) = winner.call{value: prizePool}(""); // @> Can revert
require(success, "PuppyRaffle: Failed to send prize pool to winner"); // @> Bricks raffle
_safeMint(winner, tokenId);
}

Risk

Likelihood:

  • Medium - Requires winner to be a contract that rejects payments

  • Can be intentional (attacker) or accidental (contract without receive/fallback)

  • Attacker can enter multiple times to increase odds

Impact:

  • Complete denial of service - raffle permanently stuck

  • All player funds locked forever in the contract

  • No way to recover funds or restart raffle

Proof of Concept

Add to test/PuppyRaffleTest.t.sol:

function test_selectWinnerDoS() public {
AttackerContract attacker = new AttackerContract();
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = address(attacker);
vm.deal(address(this), entranceFee * 4);
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
emit log("=== Raffle Setup ===");
emit log("4 players entered (3 normal + 1 attacker)");
vm.deal(address(this), 1 ether);
(bool canReceive,) = address(attacker).call{value: 1 ether}("");
emit log_named_string("Can attacker receive ETH", canReceive ? "Yes" : "No");
assertEq(canReceive, false);
vm.warp(block.timestamp + duration + 1);
emit log("If attacker wins, selectWinner will revert");
emit log("Raffle becomes permanently stuck");
}
contract AttackerContract {
receive() external payable {
revert("I refuse payment!");
}
}

Run: forge test --match-test test_selectWinnerDoS -vv

Output:

4 players entered (3 normal + 1 attacker)
Can attacker receive ETH: No
If attacker wins, selectWinner will revert
Raffle becomes permanently stuck

Recommended Mitigation

Use a pull payment pattern instead of push:

function selectWinner() external {
// ... winner selection logic ...
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ claimablePrizes[winner] += prizePool;
_safeMint(winner, tokenId);
}

Let winners withdraw their prize instead of pushing payment to them.


Updates

Lead Judging Commences

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