Puppy Raffle

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

Root + Impact

Description

The PuppyRaffle::enterRaffle function uses a nested loop to check for duplicate players, resulting in O(n²) time complexity. As the number of players grows, gas costs increase exponentially, eventually exceeding the block gas limit and making the function permanently unusable.

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]);
}
// @> O(n²) duplicate check - gas cost grows exponentially!
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);
}

Why This is Problematic:

  • 100 players: ~5.2M gas

  • 200 players: ~20M gas

  • 300 players: ~45M gas (exceeds 30M block gas limit)

  • 500+ players: Transaction becomes impossible to execute

Risk

Likelihood: High - The raffle naturally accumulates players over time, making this inevitable in any successful deployment.

Impact: High - Protocol becomes completely unusable. New players cannot enter, existing raffle cannot be completed, and all funds become locked.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
contract DosTest {
function testGasGrowth() external {
PuppyRaffle raffle = new PuppyRaffle(1 ether, address(this), 1 days);
// Simulate increasing player count
address[] memory players;
// 100 players: ~5.2M gas - works
players = new address[](100);
for(uint i = 0; i < 100; i++) {
players[i] = address(uint160(i + 1));
}
raffle.enterRaffle{value: 100 ether}(players);
// 200 players: ~20M gas - still works but expensive
players = new address[](100);
for(uint i = 0; i < 100; i++) {
players[i] = address(uint160(i + 101));
}
raffle.enterRaffle{value: 100 ether}(players);
// 300+ players: REVERTS - exceeds block gas limit
players = new address[](100);
for(uint i = 0; i < 100; i++) {
players[i] = address(uint160(i + 201));
}
raffle.enterRaffle{value: 100 ether}(players); // FAILS
}
}

Gas Measurements:

  • 1st 100 players: 5,201,637 gas

  • Next 100 (total 200): 20,067,923 gas

  • Next 100 (total 300): 44,934,209 gas ❌ (exceeds 30M limit)

Tools Used

Manual review, gas analysis

Recommended Mitigation

Replace the O(n²) loop with an O(1) mapping check:

+ mapping(address => uint256) public addressToRaffleId;
+ uint256 public raffleId = 0;
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]);
+ addressToRaffleId[newPlayers[i]] = raffleId;
}
- // 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");
- }
- }
+ // O(1) duplicate check per player
+ for (uint256 i = 0; i < newPlayers.length; i++) {
+ require(addressToRaffleId[newPlayers[i]] == raffleId, "PuppyRaffle: Duplicate player");
+ }
+
emit RaffleEnter(newPlayers);
}
function selectWinner() external {
// ... existing code ...
delete players;
+ raffleId++;
raffleStartTime = block.timestamp;
// ... rest of function ...
}

How This Works:

  • Each raffle round has a unique raffleId

  • When a player enters, their address is mapped to current raffleId

  • Duplicate check: if addressToRaffleId[player] == raffleId, they already entered

  • When raffle resets, raffleId increments, invalidating old entries

  • Gas cost remains constant regardless of player count

Alternative: Use OpenZeppelin's EnumerableSet for automatic duplicate prevention with O(log n) complexity.

Updates

Lead Judging Commences

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