Puppy Raffle

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

# Quadratic duplicate-check in `PuppyRaffle::enterRaffle` lets the player list grow gas costs unboundedly (DoS)

[M-2] Quadratic duplicate-check in PuppyRaffle::enterRaffle lets the player list grow gas costs unboundedly (DoS)

Risk

Likelihood: Medium — Happens naturally as the raffle fills, and can be deliberately accelerated by an attacker who enters many addresses early and cheaply.

Impact: Medium — Entering becomes progressively, then prohibitively, expensive; later users are priced out and the raffle can be made effectively unusable (denial of service).

Severity: Medium (Medium impact × Medium likelihood).

Description

enterRaffle checks for duplicates with a nested loop over the entire players array, which is O(n^2) in the current number of players:

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(n^2)
}
}

Every new entry re-scans all existing players, so the gas cost of enterRaffle grows with the square of the number of participants. An attacker can cheaply enter many addresses first to inflate the array, making subsequent honest entries unaffordable.

Impact

  • Later entrants pay rapidly increasing gas, up to the block gas limit, at which point no one else can enter.

  • An attacker can grief the raffle (lock out competitors) for relatively little cost, undermining fairness and availability.

Proof of Concept

Entering the first 50 players vs the next 50 players, the second batch costs more than 2x the gas of the first (assertGt(gasB, gasA * 2) passes). The cost keeps climbing with each batch.

function test_enterRaffle_quadratic_gas_dos() public {
address[] memory batchA = new address[](50);
for (uint256 i = 0; i < 50; i++) batchA[i] = address(uint160(i + 1));
uint256 g1 = gasleft();
puppyRaffle.enterRaffle{value: entranceFee * 50}(batchA);
uint256 gasA = g1 - gasleft();
address[] memory batchB = new address[](50);
for (uint256 i = 0; i < 50; i++) batchB[i] = address(uint160(i + 51));
uint256 g2 = gasleft();
puppyRaffle.enterRaffle{value: entranceFee * 50}(batchB);
uint256 gasB = g2 - gasleft();
assertGt(gasB, gasA * 2); // entering later costs dramatically more -> DoS
}

Recommended Mitigation

Detect duplicates in O(1) per entry with a mapping instead of an O(n^2) scan (reset per round), or drop on-chain duplicate prevention entirely (duplicates do not benefit a player):

+mapping(uint256 => mapping(address => bool)) private enteredInRound;
+uint256 private roundId;
...
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(!enteredInRound[roundId][newPlayers[i]], "PuppyRaffle: Duplicate player");
+ enteredInRound[roundId][newPlayers[i]] = true;
players.push(newPlayers[i]);
}
- // remove the O(n^2) nested duplicate loop
emit RaffleEnter(newPlayers);
}

(Increment roundId when the raffle resets so the mapping effectively clears each round.)

Updates

Lead Judging Commences

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