Puppy Raffle

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

enterRaffle checks every pair in the whole players array on each call, so entry gas grows quadratically and, at 265 players under a 30M gas limit, no one can enter for the rest of the round

Root + Impact

Description

  • enterRaffle should add the new entrants to the current round and reject any address that has already entered (README rule 2: "Duplicate addresses are not allowed"). Any user should be able to enter at any point before the draw, for a gas cost that does not depend on how many players entered before them.

  • After pushing the new entrants (L81-83), enterRaffle compares every pair in the whole storage array (L86-90). That is n(n-1)/2 iterations on every call, each one reading storage, and nothing caps n. The PoC measures about 850 gas per pair: one ticket costs 55,517 gas as player 5 and 17.27M gas as player 201, and at 265 players a one-address entry needs 30,048,477 gas. That is more than a 30M-gas transaction can carry, so nobody can enter again until selectWinner resets the array.

// src/PuppyRaffle.sol L79-L92 (enterRaffle)
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:

  • No privilege or special setup is needed. enterRaffle is public and nothing caps the number of players, so a busy round reaches the ceiling through normal growth with no attacker: 265 players under a 30M gas limit, or 198 players under EIP-7825's 2^24 per-transaction cap.

  • A griefer can also reach the ceiling on purpose. The PoC does it in 14 transactions that each fit under 30M gas, about 182M gas in total. The griefer still pays entranceFee for each of its 261 tickets and loses about 20% of that stake to fees at the draw, so the deliberate path costs real money.

  • Cost becomes a problem well before the hard ceiling. A single ticket already costs 4.48M gas at player 101 and 17.27M gas at player 201, which discourages late entrants as the round fills.

Impact:

  • Entering is partly unavailable. Once the array reaches the ceiling, every enterRaffle call fails for lack of gas, so late users cannot join the round. In the PoC the honest entrant's call reverts, no entry is recorded, and the user keeps their ETH. No funds are stolen or lost.

  • Identical tickets cost very different amounts of gas. One ticket costs 311 times as much as player 201 as it does as player 5, and the same 100-address batch costs 6.52M gas as the first batch but 19.20M gas as the second.

  • The damage is limited to one round. selectWinner has no pair loop: it costs 1.74M gas with 300 players, runs delete players, and entry reopens for the next round. A griefer gains only ticket share (261 of 265), not profit. That keeps the impact at Medium.

Proof of Concept

test/PoC_PR-07.t.sol has five tests. They deploy PuppyRaffle directly and, before each measured call, cool its storage with Foundry's vm.cool cheatcode (declared through a local interface), so each figure matches a fresh transaction. The tests show that the same 100-address batch costs 6.52M gas first and 19.20M gas second, and that one ticket costs 55,517 gas as player 5 but 38.39M gas as player 301. Under a 30M cap entry closes at 265 players (198 under a 2^24 cap): the same call succeeds with unlimited gas, and the honest user keeps their ETH. A further test shows selectWinner still runs with 300 players and reopens entry. Because the capped calls give the callee the full cap with no intrinsic gas deducted, the measured ceilings are slightly generous; run the tests with forge test --match-path test/PoC_PR-07.t.sol -vv.

File test/PoC_PR-07.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_PR-07.t.sol -vv.

// 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";
​
/// Foundry cheatcode (not declared in the pinned forge-std Vm): marks every storage slot of
/// `target` cold again, so each measured call pays the cold-SLOAD cost a fresh transaction pays.
interface ICool {
function cool(address target) external;
}
​
/// PR-07: enterRaffle (L79-92) pushes the new entrants (L81-83) and then compares EVERY pair of
/// the whole storage array (L86-90): n(n-1)/2 iterations, each re-reading storage.
/// Entry gas grows quadratically with the round size, so later tickets cost far more than early
/// ones and, at a few hundred players, one more entry no longer fits in a transaction at all.
contract PoC_PR07 is Test {
PuppyRaffle raffle;
uint256 constant FEE = 1e18; // entranceFee used by the repo's own tests
uint256 constant DURATION = 1 days;
uint256 constant BLOCK_GAS_30M = 30_000_000; // classic Ethereum block gas limit
uint256 constant TX_CAP_2_24 = 16_777_216; // EIP-7825 per-transaction gas cap (2^24)
​
uint160 nextAddr = 0x100000; // fresh, code-less player addresses
address honest1 = address(0xA1);
address honest2 = address(0xA2);
address honest3 = address(0xA3);
address honest4 = address(0xA4);
address lateHonest = address(0xB0B);
​
function setUp() public {
raffle = new PuppyRaffle(FEE, address(0xFEE), DURATION);
vm.deal(address(this), 1e30);
vm.deal(lateHonest, 10 ether);
}
​
// ---------------------------------------------------------------- helpers
​
function _fresh(uint256 k) internal returns (address[] memory a) {
a = new address[](k);
for (uint256 i = 0; i < k; i++) {
a[i] = address(nextAddr++);
}
}
​
function _one(address p) internal pure returns (address[] memory a) {
a = new address[](1);
a[0] = p;
}
​
function _len() internal view returns (uint256 n) {
// players is public but has no length getter; probe the last index
n = 0;
while (true) {
// out-of-bounds read hits INVALID (0.7.6) and burns all forwarded gas, so bound it
try raffle.players{gas: 100_000}(n) returns (address) {
n++;
} catch {
break;
}
}
}
​
/// Unbounded-gas entry, storage cooled first so the cost equals a fresh transaction's.
function _enterGas(address[] memory a) internal returns (uint256 used) {
ICool(address(vm)).cool(address(raffle));
uint256 g = gasleft();
raffle.enterRaffle{value: FEE * a.length}(a);
used = g - gasleft();
}
​
/// Entry under a hard gas ceiling (models a transaction that must fit a block / tx cap).
/// The callee is handed the FULL cap (no 21k intrinsic or calldata deducted): generous to the caller.
function _enterCapped(address[] memory a, uint256 cap) internal returns (bool ok) {
ICool(address(vm)).cool(address(raffle));
(ok,) = address(raffle).call{value: FEE * a.length, gas: cap}(
abi.encodeWithSelector(PuppyRaffle.enterRaffle.selector, a)
);
}
​
// ---------------------------------------------------------------- tests
​
/// Limb 1 (evidence suggested in the finding): the same 100-address purchase costs ~3x more
/// just because it is the second one. A linear duplicate check would cost about the same twice.
function test_PR07_sameBatchCostsMoreLater_100then100() public {
uint256 g1 = _enterGas(_fresh(100)); // 4,950 pair comparisons
uint256 g2 = _enterGas(_fresh(100)); // 19,900 pair comparisons
console.log("gas: first 100 addresses (players 1-100) :", g1);
console.log("gas: second 100 addresses (players 101-200):", g2);
console.log("ratio second/first (x100) :", (g2 * 100) / g1);
assertGt(g2, (g1 * 25) / 10, "second identical batch should cost > 2.5x the first");
}
​
/// Limb 1: a single identical ticket is priced by queue position, quadratically.
function test_PR07_singleTicketGasByPosition_quadratic() public {
address[] memory four = new address[](4);
four[0] = honest1;
four[1] = honest2;
four[2] = honest3;
four[3] = honest4;
raffle.enterRaffle{value: 4 * FEE}(four);
​
uint256 gAt5 = _enterGas(_fresh(1)); // 5th player: 10 pairs
uint256[3] memory fillTo = [uint256(100), 200, 300];
uint256[3] memory gAt;
uint256 n = 5;
for (uint256 k = 0; k < 3; k++) {
address[] memory fill = _fresh(fillTo[k] - n);
raffle.enterRaffle{value: FEE * fill.length}(fill);
gAt[k] = _enterGas(_fresh(1));
n = fillTo[k] + 1;
}
console.log("gas for ONE ticket as player 5:", gAt5);
console.log("gas for ONE ticket as player 101:", gAt[0]);
console.log("gas for ONE ticket as player 201:", gAt[1]);
console.log("gas for ONE ticket as player 301:", gAt[2]);
console.log("player 201 / player 5 ratio :", gAt[1] / gAt5);
​
// quadratic: doubling the position (101 -> 201) roughly quadruples the cost
assertGt(gAt[1], gAt[0] * 35 / 10, "201st ticket should cost > 3.5x the 101st");
// same ticket, same entranceFee, > 100x the gas
assertGt(gAt[1], gAt5 * 100, "201st ticket should cost > 100x the 5th");
// already beyond a 30M block on its own by player 301
assertGt(gAt[2], BLOCK_GAS_30M, "301st ticket alone exceeds a 30M-gas block");
}
​
/// Limbs 2+3: under a 30M block gas limit the array reaches a hard ceiling; after that NO
/// enterRaffle fits in a block, so honest late entrants are locked out for the round.
function test_PR07_entryLockedOut_under30MBlockGasLimit() public {
_lockout(BLOCK_GAS_30M);
}
​
/// Same under the EIP-7825 per-transaction cap (2^24 = 16,777,216 gas): the ceiling is lower.
function test_PR07_entryLockedOut_underEIP7825TxCap() public {
_lockout(TX_CAP_2_24);
}
​
function _lockout(uint256 cap) internal {
// Baseline: four honest players enter early and cheaply, under the cap.
address[] memory four = new address[](4);
four[0] = honest1;
four[1] = honest2;
four[2] = honest3;
four[3] = honest4;
assertTrue(_enterCapped(four, cap), "baseline: early honest entry fits under the cap");
​
// Attacker fills the round in batches of 20; every one of its txs fits under the cap.
uint256 n = 4;
uint256 attackerTxs = 0;
uint256 attackerGas = 0;
while (true) {
address[] memory batch = _fresh(20);
uint256 g = gasleft();
bool ok = _enterCapped(batch, cap);
if (!ok) {
nextAddr -= 20; // reuse those addresses later; nothing was written
break;
}
attackerGas += g - gasleft();
attackerTxs++;
n += 20;
}
// ...then single entries until even ONE more address no longer fits.
while (true) {
address[] memory single = _fresh(1);
uint256 g = gasleft();
bool ok = _enterCapped(single, cap);
if (!ok) break;
attackerGas += g - gasleft();
attackerTxs++;
n += 1;
}
uint256 ceiling = _len();
assertEq(ceiling, n, "array length tracks successful entries");
​
// The break: an honest late entrant with ONE address and the full cap cannot enter.
ICool(address(vm)).cool(address(raffle));
vm.prank(lateHonest);
(bool honestOk,) = address(raffle).call{value: FEE, gas: cap}(
abi.encodeWithSelector(PuppyRaffle.enterRaffle.selector, _one(lateHonest))
);
assertFalse(honestOk, "late honest entry must fail under the cap");
assertEq(_len(), ceiling, "no entry was recorded");
assertEq(lateHonest.balance, 10 ether, "honest user's ETH was not taken (tx reverted)");
​
// Proof the ONLY reason is gas: the very same call with unlimited gas succeeds, and needs > cap.
uint256 needed;
{
ICool(address(vm)).cool(address(raffle));
vm.prank(lateHonest);
uint256 g = gasleft();
raffle.enterRaffle{value: FEE}(_one(lateHonest));
needed = g - gasleft();
}
assertGt(needed, cap, "the honest one-address entry needs more gas than the cap allows");
​
console.log("gas cap per transaction :", cap);
console.log("hard ceiling (players when entry closes):", ceiling);
console.log("attacker txs to reach it (all under cap):", attackerTxs);
console.log("attacker total gas spent :", attackerGas);
console.log("attacker tickets / total tickets :", ceiling - 4, ceiling);
console.log("gas a 1-address entry needs at ceiling :", needed);
}
​
/// Scope bound: the lockout lasts one round. selectWinner has no pair loop, still runs after the
/// duration, and `delete players` reopens cheap entry for the next round.
function test_PR07_lockoutLastsOneRound_selectWinnerStillWorks() public {
// fill to a size where one more entry does not fit a 30M block
address[] memory fill = _fresh(150);
raffle.enterRaffle{value: FEE * fill.length}(fill);
fill = _fresh(150);
raffle.enterRaffle{value: FEE * fill.length}(fill);
assertFalse(_enterCapped(_one(lateHonest), BLOCK_GAS_30M), "entry closed at 300 players");
​
vm.warp(block.timestamp + DURATION + 1);
ICool(address(vm)).cool(address(raffle));
uint256 g = gasleft();
(bool ok,) = address(raffle).call{gas: BLOCK_GAS_30M}(abi.encodeWithSelector(PuppyRaffle.selectWinner.selector));
uint256 swGas = g - gasleft();
assertTrue(ok, "selectWinner still fits under 30M with 300 players");
assertEq(_len(), 0, "players reset");
​
bool reopened;
{
ICool(address(vm)).cool(address(raffle));
vm.prank(lateHonest);
(reopened,) = address(raffle).call{value: FEE, gas: BLOCK_GAS_30M}(
abi.encodeWithSelector(PuppyRaffle.enterRaffle.selector, _one(lateHonest))
);
}
assertTrue(reopened, "entry reopens next round");
console.log("selectWinner gas with 300 players:", swGas);
}
}

Recommended Mitigation

--- a/src/PuppyRaffle.sol
+++ b/src/PuppyRaffle.sol
@@ state variables @@
address[] public players;
+ // round in which each address entered; 0 = not entered
+ mapping(address => uint256) public addressToRaffleId;
+ uint256 public raffleId = 1;
uint256 public raffleDuration;
uint256 public raffleStartTime;
@@ function enterRaffle @@
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(addressToRaffleId[newPlayers[i]] != raffleId, "PuppyRaffle: Duplicate player");
+ addressToRaffleId[newPlayers[i]] = raffleId;
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);
}
@@ function refund @@
players[playerIndex] = address(0);
+ addressToRaffleId[playerAddress] = 0;
emit RaffleRefunded(playerAddress);
}
@@ function selectWinner @@
delete players;
+ raffleId++;
raffleStartTime = block.timestamp;
previousWinner = winner;
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!