Puppy Raffle

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

Unbounded Gas Consumption in enterRaffle Due to Duplicate Check

Description

The PuppyRaffle::enterRaffle function uses nested loops to check for duplicate players, creating O(n²) time complexity. Gas costs grow exponentially as the array size increases, eventually exceeding block gas limits and preventing anyone from entering.

Expected behavior: Users should be able to enter the raffle regardless of how many existing players there are, as long as they pay the correct entrance fee.

Actual behavior: As the number of players grows, the gas required to enter becomes prohibitively expensive and eventually exceeds the block gas limit (~30M gas), making it impossible for anyone to enter the raffle.

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²) complexity - gas grows exponentially with array size
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);
}

With n players, the duplicate check performs approximately n²/2 comparisons. At 100 players: 5,000 comparisons. At 800 players: 320,000 comparisons exceeding 30M gas block limit.

Risk

Likelihood:

  • High - This will occur naturally as the raffle gains popularity (~800 entries)

  • Can be intentionally triggered by a malicious actor entering with multiple addresses

  • Requires only ~800 entries for the raffle to become completely unusable

  • No special privileges required - any user can contribute to array growth

Impact:

  • Complete denial of service - raffle becomes unusable for all participants

  • Legitimate users cannot enter even if willing to pay higher gas fees

  • Protocol reputation damage as users cannot participate

  • Funds remain locked until selectWinner() is called (requires 4 players and duration to pass)

  • Attacker can perform this at relatively low cost using multiple addresses

Proof of Concept

Add to test/PuppyRaffleTest.t.sol:

function test_denialOfServiceViaGasCosts() public {
// Step 1: Baseline - 1 player
address[] memory player = new address[](1);
player[0] = address(10);
vm.deal(address(10), entranceFee);
vm.prank(address(10));
uint256 gasStart = gasleft();
puppyRaffle.enterRaffle{value: entranceFee}(player);
uint256 gasUsed1 = gasStart - gasleft();
// Step 2: Enter 99 more players (total 100)
address[] memory players100 = new address[](99);
for (uint256 i = 0; i < 99; i++) {
players100[i] = address(uint160(i + 100));
}
vm.deal(address(100), entranceFee * 99);
vm.prank(address(100));
puppyRaffle.enterRaffle{value: entranceFee * 99}(players100);
// Measure with 100 players
address player101 = address(1000);
address[] memory playerArray101 = new address[](1);
playerArray101[0] = player101;
vm.deal(player101, entranceFee);
vm.prank(player101);
gasStart = gasleft();
puppyRaffle.enterRaffle{value: entranceFee}(playerArray101);
uint256 gasUsed100 = gasStart - gasleft();
// Step 3: Enter 699 more players (total 800)
address[] memory players800 = new address[](699);
for (uint256 i = 0; i < 699; i++) {
players800[i] = address(uint160(i + 2000));
}
vm.deal(address(2000), entranceFee * 699);
vm.prank(address(2000));
puppyRaffle.enterRaffle{value: entranceFee * 699}(players800);
// Measure with 800 players
address player801 = address(9000);
address[] memory playerArray801 = new address[](1);
playerArray801[0] = player801;
vm.deal(player801, entranceFee);
vm.prank(player801);
gasStart = gasleft();
puppyRaffle.enterRaffle{value: entranceFee}(playerArray801);
uint256 gasUsed800 = gasStart - gasleft();
emit log("=== Gas Cost Analysis ===");
emit log_named_uint("Gas with 1 player", gasUsed1);
emit log_named_uint("Gas with 100 players", gasUsed100);
emit log_named_uint("Gas with 800 players", gasUsed800);
emit log("Block gas limit: 30000000");
assertGt(gasUsed800, 30_000_000, "DoS: 800 players exceeds block gas limit");
}

Run: forge test --match-test test_denialOfServiceViaGasCosts -vv

Output:

Gas with 1 player: 59,526
Gas with 100 players: 4,277,223
Gas with 800 players: 267,505,923
Block gas limit: 30,000,000

At 800 players, gas exceeds block limit by ~8.9x making the raffle completely unusable.

Recommended Mitigation

Remove the nested loop and use a mapping to track duplicates in O(1) time:

+ 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]);
+ require(addressToRaffleId[newPlayers[i]] != raffleId, "PuppyRaffle: Duplicate player");
+ addressToRaffleId[newPlayers[i]] = raffleId;
}
- 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 ...
delete players;
+ raffleId++; // Reset for new raffle
raffleStartTime = block.timestamp;
// ...
}

This changes the duplicate check from O(n²) to O(1), keeping gas costs constant regardless of player count.


Updates

Lead Judging Commences

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