Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Medium 3 Check for Duplicate

Root + Impact

Description

enterRaffle() checks for duplicate players with a nested loop over the entire players array — and it re-runs that full scan on every single entry (lines 85-91):

// 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");
}
}

Gas cost therefore grows quadratically with the number of entrants, and there is no cap on players.length. Measured behavior: entering the 6th batch of 10 players costs ~5.6x the gas of the 1st batch (331k → 1.86M gas), and it keeps climbing. Two consequences follow:

  1. Unfair gas burden — later entrants pay for re-scanning all earlier players; the cost of entering depends on when you enter, not what you do.

  2. Griefing / DoS — an attacker enters many addresses (paying the entrance fees) to inflate the array until enterRaffle() becomes prohibitively expensive for honest players, or outright exceeds the block gas limit and halts the raffle entirely.

Risk

Likelihood: Medium — stuffing the array only costs entrance fees, which the attacker can partially reclaim via refund(); no privileged access needed.

Impact: Medium — honest players priced out of entry, or the raffle halted entirely once the check exceeds the block gas limit.

Proof of Concept

The Foundry test below measures the quadratic growth directly:

  1. Enter 6 consecutive batches of 10 new players (60 total).

  2. Record gasleft() before and after each enterRaffle() call.

  3. Assert the 6th batch costs more than 5x the 1st batch.

function test_PoC_M2_DuplicateCheckGasGrowth() public {
uint256 firstBatchGas;
uint256 lastBatchGas;
for (uint256 batch = 0; batch < 6; batch++) {
address[] memory p = new address[](10);
for (uint256 i = 0; i < 10; i++) {
p[i] = address(uint160(1000 + batch * 10 + i));
}
uint256 gBefore = gasleft();
puppyRaffle.enterRaffle{value: 10 ether}(p);
uint256 used = gBefore - gasleft();
if (batch == 0) firstBatchGas = used;
if (batch == 5) lastBatchGas = used;
}
assertGt(lastBatchGas, 5 * firstBatchGas); // quadratic growth confirmed
}

Test assertions and measured result (forge test --match-test test_PoC_M2_DuplicateCheckGasGrowth):

  • 1st batch of 10: ~331k gas,

  • 6th batch of 10: ~1.86M gas (5.6x),

  • growth is unbounded as players.length increases.

Recommended Mitigation

Replace the O(n²) scan with a mapping(address => bool) for O(1) duplicate detection per entrant. Entry gas then stays constant no matter how large the array grows — which simultaneously removes the unfair late-entrant burden and the griefing vector, since inflating the array no longer raises anyone's entry cost.

+ mapping(address => bool) private hasEntered;
+
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(!hasEntered[newPlayers[i]], "PuppyRaffle: Duplicate player");
+ hasEntered[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);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!