Puppy Raffle

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

M-02: Duplicate entry checks may cause denial of service as the player list grows

Root + Impact

Description

  • Normal behavior: enterRaffle() should remain usable as participation grows. Preventing duplicate entries should be enforced in a gas-efficient way so that a popular raffle round does not become impractical to join.

  • Specific issue: enterRaffle() enforces uniqueness by scanning the entire players[] array using nested loops, comparing every pair of players. This results in O(n²) work, meaning gas cost grows quadratically as more players enter. As the round grows, the function will eventually become too expensive to execute within the block gas limit, causing transactions to revert and preventing new users from entering (denial of service).

@> PuppyRaffle.sol:L85-L90 (quadratic duplicate check over players array)
85: // Check for duplicates
@> 86: for (uint256 i = 0; i < players.length - 1; i++) {
@> 87: for (uint256 j = i + 1; j < players.length; j++) {
@> 88: require(players[i] != players[j], "PuppyRaffle: Duplicate player");
89: }
90: }
Why this is the root cause:
The code re-validates uniqueness by comparing every existing player against every other player on every new entry, rather than checking only the new entrant against prior entrants or using an O(1) membership structure.

Risk

Likelihood:

  • Reason 1 Quadratic gas growth is deterministic:
    The number of comparisons in nested loops grows roughly as n(n-1)/2. Even if each comparison is “cheap,” the total becomes large quickly as players.length increases. (L86-L89)

  • Reason 2 Attackers can accelerate the failure threshold:
    An attacker (or group of attackers) can intentionally fill the raffle with many entries early in the round, pushing players.length toward the point where future entries revert due to gas constraints. This can be done without breaking any rules, just by participating many times.

Impact:

  • Impact 1 When this will occur:
    As the raffle round accumulates more entrants and players.length becomes large enough that the nested-loop checks dominate gas usage.

  • Impact 2 Effect on users:
    New users attempting to enter will fail because enterRaffle() becomes too expensive to execute (transaction runs out of gas / exceeds practical gas limits).

  • Impact 3 Effect on protocol:
    Availability and UX degrade. A raffle that is “too popular” (or intentionally spammed) becomes unusable, which undermines protocol reliability and fairness.

Proof of Concept

Goal
Demonstrate that as players[] grows, enterRaffle() eventually becomes infeasible to execute, blocking new participants and effectively denying service.
Relevant code paths
Players are appended on entry: PuppyRaffle.sol:L81-L83 (growth of players[])
Quadratic duplicate scan: PuppyRaffle.sol:L86-L89 (every-pair comparison)
Explanation of what happens (why gas blows up)
For n = players.length, the nested loops perform approximately:
Comparisons = n(n-1)/2
Each new call repeats the scan across the entire array again.
So the cost is not linear per entry; it becomes increasingly expensive per entry. Eventually the transaction cannot fit within typical gas limits.
Step-by-step attack / reproduction
Grow players[]:
A single attacker (or multiple addresses controlled by the attacker) repeatedly calls enterRaffle() to add many entrants. This grows players.length each time (L81-L83).
Trigger the quadratic scan on every call:
Every new entry forces the contract to run the nested loops (L86-L89) comparing all existing players pairwise again.
Reach the failure threshold:
At some players.length = N, the duplicate-check computation dominates gas such that:
the attacker can still sometimes enter by using high gas settings early, but
normal users (and eventually everyone) cannot enter because the transaction reverts due to gas exhaustion or impractical gas cost.
Result: denial of service for the raffle round:
Once the function becomes too costly, even honest entrants cannot join, effectively freezing participation for the round.
Why this is a valid DoS even without a revert reason
Even if the transaction fails due to out-of-gas (rather than a specific require), the impact is the same: the function becomes uncallable in practice, blocking new entrants.

Recommended Mitigation

Primary fix: O(1) membership tracking
Replace nested duplicate scanning with a mapping-based membership check:
Maintain mapping(address => bool) hasEntered;
On entry, do:
require(!hasEntered[msg.sender], "Duplicate player");
hasEntered[msg.sender] = true;
players.push(msg.sender);
Why this works:
Mapping lookups are O(1), so gas cost stays roughly constant as the raffle grows, avoiding quadratic blowups.
Important implementation detail: per-round reset
Because raffles are typically round-based, membership must be reset when a new round starts. There are common approaches:
Round ID approach (recommended in many cases):
uint256 roundId;
mapping(address => uint256) enteredRound;
Entry check: require(enteredRound[msg.sender] != roundId, "Duplicate");
Set: enteredRound[msg.sender] = roundId;
On new round: roundId++
Why this is good: Avoids looping to clear mappings.
Explicit clear (only if player counts are bounded):
If you insist on mapping(address => bool), you’d need to clear it.
Clearing by looping over players[] can reintroduce gas scaling at reset time.
Avoid nested loops on the write path
Any logic that runs on user entry should avoid full-array scans. If you need uniqueness:
Check only the new entrant against O(1) structure, not against every pair in the array.
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] `PuppyRaffle: enterRaffle` Use of gas extensive duplicate check leads to Denial of Service, making subsequent participants to spend much more gas than prev ones to enter

## Description `enterRaffle` function uses gas inefficient duplicate check that causes leads to Denial of Service, making subsequent participants to spend much more gas than previous users to enter. ## Vulnerability Details In the `enterRaffle` function, to check duplicates, it loops through the `players` array. As the `player` array grows, it will make more checks, which leads the later user to pay more gas than the earlier one. More users in the Raffle, more checks a user have to make leads to pay more gas. ## Impact As the arrays grows significantly over time, it will make the function unusable due to block gas limit. This is not a fair approach and lead to bad user experience. ## POC In existing test suit, add this test to see the difference b/w gas for users. once added run `forge test --match-test testEnterRaffleIsGasInefficient -vvvvv` in terminal. you will be able to see logs in terminal. ```solidity function testEnterRaffleIsGasInefficient() public { vm.startPrank(owner); vm.txGasPrice(1); /// First we enter 100 participants uint256 firstBatch = 100; address[] memory firstBatchPlayers = new address[](firstBatch); for(uint256 i = 0; i < firstBatchPlayers; i++) { firstBatch[i] = address(i); } uint256 gasStart = gasleft(); puppyRaffle.enterRaffle{value: entranceFee * firstBatch}(firstBatchPlayers); uint256 gasEnd = gasleft(); uint256 gasUsedForFirstBatch = (gasStart - gasEnd) * txPrice; console.log("Gas cost of the first 100 partipants is:", gasUsedForFirstBatch); /// Now we enter 100 more participants uint256 secondBatch = 200; address[] memory secondBatchPlayers = new address[](secondBatch); for(uint256 i = 100; i < secondBatchPlayers; i++) { secondBatch[i] = address(i); } gasStart = gasleft(); puppyRaffle.enterRaffle{value: entranceFee * secondBatch}(secondBatchPlayers); gasEnd = gasleft(); uint256 gasUsedForSecondBatch = (gasStart - gasEnd) * txPrice; console.log("Gas cost of the next 100 participant is:", gasUsedForSecondBatch); vm.stopPrank(owner); } ``` ## Recommendations Here are some of recommendations, any one of that can be used to mitigate this risk. 1. User a mapping to check duplicates. For this approach you to declare a variable `uint256 raffleID`, that way each raffle will have unique id. Add a mapping from player address to raffle id to keep of users for particular round. ```diff + uint256 public raffleID; + mapping (address => uint256) public usersToRaffleId; . . 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++) { players.push(newPlayers[i]); + usersToRaffleId[newPlayers[i]] = true; } // Check for duplicates + for (uint256 i = 0; i < newPlayers.length; i++){ + require(usersToRaffleId[i] != raffleID, "PuppyRaffle: Already a participant"); - 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); } . . . function selectWinner() external { //Existing code + raffleID = raffleID + 1; } ``` 2. Allow duplicates participants, As technically you can't stop people participants more than once. As players can use new address to enter. ```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++) { players.push(newPlayers[i]); } emit RaffleEnter(newPlayers); } ```

Support

FAQs

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

Give us feedback!