Puppy Raffle

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

Players Can Refund After Raffle Duration Ends

Root + Impact

Description

The refund() function contains no check on the current block timestamp relative to the raffle end time. This means players can call refund() at any point — including after the raffle duration has elapsed and selectWinner() can be called:// Root cause in the codebase with @> marks to highlight the relevant section

function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "...");
require(playerAddress != address(0), "...");
// @> NO TIME CHECK: players can refund even after raffle has ended
payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}

Compare this to selectWinner(), which correctly enforces a time boundary:

function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration,
"PuppyRaffle: Raffle not over"); // @> time check exists here but not in refund

This creates a window where a player can observe the blockchain state after the raffle ends — potentially front-running selectWinner() — and refund their ticket if they determine they are not the winner, while still having participated during the raffle period. This undermines the fairness of the protocol and can be combined with the weak randomness vulnerability to guarantee a free exit when losing.

Risk

Likelihood:

  • The raffle end time is publicly visible on-chain (raffleStartTime + raffleDuration)

  • Any player can monitor the blockchain and call refund() in the same block as the raffle end

  • Combined with the weak randomness vulnerability, a player can compute the winner before calling selectWinner(), and refund if they are not the winner — a risk-free strategy

  • This requires only basic blockchain monitoring tools available to any user

  • The attack is repeatable every raffle round

Impact:

  • Players can participate in the raffle with zero financial risk: enter, observe the outcome, and refund if they lose

  • Honest players who do not exploit this have a disadvantage compared to those who do

  • If many players refund at the last moment, the players array fills with address(0) slots, increasing the probability of the prize being sent to address(0) (separate finding)

  • The protocol's stated rules — "users are allowed to get a refund if they call the refund function" — do not specify this is intended to be possible after the raffle ends

  • The protocol may fail to reach minimum player count after last-minute refunds, blocking selectWinner() from executing

Proof of Concept

// Risk-free strategy for a sophisticated player:
// 1. Enter raffle during active period
raffle.enterRaffle{value: entranceFee}(myAddresses);
// 2. Wait until raffle ends (block.timestamp >= raffleStartTime + raffleDuration)
// 3. Simulate selectWinner() off-chain to check if I win
uint256 winnerIndex = uint256(keccak256(abi.encodePacked(
expectedCaller, block.timestamp, block.difficulty
))) % players.length;
// 4a. If I win → do nothing, let someone else call selectWinner()
// 4b. If I don't win → call refund() to recover entranceFee for free
// Result: I participate with zero financial risk
raffle.refund(myPlayerIndex); // succeeds even after raffle ended

Formal Verification Evidence (Certora Prover):

The Certora Prover rule refund_only_before_raffle_ends was formally verified and returned VIOLATED, providing mathematical proof that refund() succeeds even when block.timestamp >= raffleStartTime + raffleDuration:

Rule: refund\_only\_before\_raffle\_ends
Property: refund() reverts after raffle duration has passed
Result: VIOLATED — refund succeeds after raffle ends
Prover output: prover.certora.com/output/3088449/d25650413fc1466280b1d4664a294a41

Recommended Mitigation

Add a time boundary check to refund() that mirrors the one already present in selectWinner():

function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "...");
require(playerAddress != address(0), "...");
+ require(block.timestamp < raffleStartTime + raffleDuration,
+ "PuppyRaffle: Raffle has ended, refunds are closed");
payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}

This ensures refunds are only possible while the raffle is still active, preventing players from using post-raffle information to make risk-free decisions. Additionally, consider implementing a commit-reveal scheme for winner selection to prevent front-running based on predictable randomness.

Updates

Lead Judging Commences

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