Puppy Raffle

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

enterRaffle() duplicate-check is O(n^2) over the full players array, causing gas cost to blow up and eventually exceed the block gas limit

Root + Impact

Description

  • enterRaffle() checks for duplicate addresses with a nested loop that compares every pair of entries in the entire, ever-growing players array, not just the newly added ones. Its cost is O(n^2) in the current total number of players.

  • This means the gas cost of an identical-sized batch (e.g. 20 new addresses) grows dramatically as the raffle accumulates more historical entrants - purely from organic growth, no attacker or malicious input required. Eventually a normal-sized enterRaffle() call can require more gas than fits in a single Ethereum block, making it impossible to enter at all.

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]);
}
// 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");
}
}
emit RaffleEnter(newPlayers);
}

Risk

Likelihood:

  • Reason 1 // No exploit or malicious input is needed - this is triggered purely by the raffle becoming popular and accumulating entrants over time, which is the intended, desired usage pattern for the protocol.

  • Reason 2 // The gas cost curve is quadratic, so it worsens quickly and predictably as players.length grows; a popular raffle can realistically reach the sizes needed to hit this well within normal operation.

Impact:

  • Impact 1 // Later, honest players attempting to enter a normal-sized batch pay dramatically more gas than earlier entrants for the identical action, which is already a meaningful fairness/cost problem.

  • Impact 2 // Beyond a certain player count, a single enterRaffle() call can exceed the block gas limit entirely, making the raffle permanently unable to accept new entrants - a full denial of service on the contract's primary function, reachable through nothing but normal popularity.

Proof of Concept

Ran with forge test --match-path "test/PoC_5.t.sol" -vv: both [PASS] testEnterRaffleGasGrowsQuadratically() and [PASS] testEnterRaffleEventuallyExceedsBlockGasLimit(). The first shows that entering the same-size batch of 20 addresses costs more than 10x the gas once players.length has organically grown to 260, versus the baseline call at players.length == 0. The second keeps adding batches of 20 and shows a single enterRaffle() call's gas cost exceeds a realistic 30,000,000 mainnet block gas limit well before reaching 1000 total participants - a perfectly realistic size for a popular NFT raffle - confirming the DoS is reachable, not just theoretically quadratic.

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
pragma experimental ABIEncoderV2;
import {Test, console} from "forge-std/Test.sol";
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
contract PoC_5_QuadraticEnterRaffle is Test {
PuppyRaffle raffle;
uint256 constant ENTRANCE_FEE = 1 ether;
function setUp() public {
raffle = new PuppyRaffle(ENTRANCE_FEE, address(this), 1 days);
}
function _freshAddresses(uint256 n, uint256 seed) internal returns (address[] memory addrs) {
addrs = new address[](n);
for (uint256 i = 0; i < n; i++) {
address a = address(uint160(uint256(keccak256(abi.encodePacked("player", seed, i)))));
vm.deal(a, 1000 ether);
addrs[i] = a;
}
}
function testEnterRaffleGasGrowsQuadratically() public {
address[] memory batch1 = _freshAddresses(20, 1);
uint256 gasBefore1 = gasleft();
raffle.enterRaffle{value: ENTRANCE_FEE * 20}(batch1);
uint256 gasUsed1 = gasBefore1 - gasleft();
for (uint256 b = 2; b <= 13; b++) {
address[] memory batch = _freshAddresses(20, b);
raffle.enterRaffle{value: ENTRANCE_FEE * 20}(batch);
}
address[] memory batch14 = _freshAddresses(20, 14);
uint256 gasBefore2 = gasleft();
raffle.enterRaffle{value: ENTRANCE_FEE * 20}(batch14);
uint256 gasUsed2 = gasBefore2 - gasleft();
assertGt(gasUsed2, gasUsed1 * 10, "Expected >10x gas blowup due to O(n^2) duplicate check");
uint256 MAINNET_BLOCK_GAS_LIMIT = 30_000_000;
if (gasUsed2 > MAINNET_BLOCK_GAS_LIMIT) {
console.log("Single enterRaffle call already EXCEEDS mainnet block gas limit");
}
}
function testEnterRaffleEventuallyExceedsBlockGasLimit() public {
uint256 MAINNET_BLOCK_GAS_LIMIT = 30_000_000;
uint256 batchSize = 20;
uint256 totalEntered = 0;
uint256 lastGasUsed = 0;
bool exceededBlockLimit = false;
for (uint256 b = 1; b <= 40 && !exceededBlockLimit; b++) {
address[] memory batch = _freshAddresses(batchSize, 1000 + b);
uint256 gasBefore = gasleft();
raffle.enterRaffle{value: ENTRANCE_FEE * batchSize}(batch);
lastGasUsed = gasBefore - gasleft();
totalEntered += batchSize;
if (lastGasUsed > MAINNET_BLOCK_GAS_LIMIT) {
exceededBlockLimit = true;
}
}
assertTrue(exceededBlockLimit, "Expected enterRaffle gas cost to exceed mainnet block gas limit");
}
}

Recommended Mitigation

+ mapping(address => bool) public isActivePlayer;
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(!isActivePlayer[newPlayers[i]], "PuppyRaffle: Duplicate player");
+ isActivePlayer[newPlayers[i]] = true;
players.push(newPlayers[i]);
}
-
- // 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");
- }
- }
emit RaffleEnter(newPlayers);
}

Using a mapping to track active players turns the duplicate check into O(1) per new entrant instead of O(n) per new entrant (O(n^2) overall), removing the unbounded gas growth entirely. (Remember to clear isActivePlayer entries when a player refunds, and when selectWinner() resets the array for the next round.)

Updates

Lead Judging Commences

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