Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

getUserMemorabiliaDetailed loops twice over every item minted in every collection, so its gas cost grows with global redemptions, not the user's holdings, and the view reverts for every user at scale

Root + Impact

Description

  • getUserMemorabiliaDetailed(user) is the interface's only on-chain way to list the memorabilia a user owns. Frontends and integrators would call it to show a user's collection.

  • The function walks collection IDs 1 .. nextCollectionId - 1, including IDs 1-99, which never exist because collection IDs start at 100. Inside each collection it walks every item ever minted and calls balanceOf on each. It then repeats the whole walk to fill the arrays. Its cost is proportional to the total number of memorabilia minted across all collections, whatever the queried user holds, and there is no pagination. Once total redemptions are large enough, the call exceeds common gas caps and reverts for every user, including users who hold one item or none.

// src/FestivalPass.sol L20 and L262-L297; @> on L20, L269, L270, L272, L284, L285, L287
@> uint256 public nextCollectionId = 100;
​
function getUserMemorabiliaDetailed(address user) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
// First, count how many memorabilia they own
uint256 count = 0;
@> for (uint256 cId = 1; cId < nextCollectionId; cId++) {
@> for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
@> if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
// ...
uint256 index = 0;
@> for (uint256 cId = 1; cId < nextCollectionId; cId++) {
@> for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
@> if (balanceOf(user, tokenId) > 0) {
tokenIds[index] = tokenId;
collectionIds[index] = cId;
itemIds[index] = iId;
index++;
}
}
}

Risk

Likelihood:

  • It happens once the organizer has configured, and users have redeemed, memorabilia in the low tens of thousands of items across all collections, which corresponds to a large festival. The organizer's maxSupply values bound the total, so users cannot push past it on their own; farmed BEAT only fills existing supply faster.

  • From that point every call fails for every user, whatever they hold.

Impact:

  • The view stops working. Measured cost is about 3,654 gas per globally minted item plus about 291k of fixed overhead. At 20,000 items a call needs about 73.4M gas and reverts under a 50M cap, both for a user holding 1 item and for a user holding none. By linear extrapolation it crosses 50M at about 13.6k items.

  • Frontends and integrators that rely on the documented enumeration lose it. No in-scope contract calls this view: state, balanceOf and balanceOfBatch keep working, and no funds are at risk.

Proof of Concept

All state is reached through the real flow: buyPass, attendPerformance, createMemorabiliaCollection and redeemMemorabilia. The PoC uses 4 organizer-configured collections of 5,000 items each. Gas metering is paused only while the crowd redeems, and vm.cool resets FestivalPass storage to cold before every measurement, as for a fresh eth_call.

  • test_Baseline_SmallFestivalViewWorks shows the view working at 50 items.

  • test_GasScalesWithGlobalMintsNotUserHoldings measures 1k, 2k, 4k and 8k items. The slope is identical on the two intervals, and a 1-item holder and a 0-item user cost the same to within 1%.

  • test_ViewRevertsForEveryoneAt20kItems runs staticcall{gas: 50_000_000} and shows it failing for both users at 20,000 items.

The PoC needs a Foundry version that supports vm.cool and vm.pauseGasMetering.

Save as test/PoC_BF-08.t.sol and run from the repository root:

FOUNDRY_FFI=false forge test --match-path test/PoC_BF-08.t.sol -vv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
​
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
​
/// BF-08: getUserMemorabiliaDetailed walks every memorabilia item ever minted
/// (in every collection, twice), so its gas cost grows with GLOBAL redemptions,
/// not with the queried user's holdings. Past a few thousand / ~16k items it no
/// longer fits common gas caps and reverts for every user.
///
/// All state below is reached through the real protocol flow (buyPass,
/// attendPerformance, redeemMemorabilia). Gas metering is paused only while the
/// crowd redeems, so the test's own 1.07B gas budget is spent on the measured
/// view calls. Before every measurement vm.cool() resets FestivalPass storage
/// to cold, modelling a fresh eth_call.
contract PoC_BF08 is Test {
FestivalPass festival;
BeatToken beat;
​
address organizer = makeAddr("organizer");
address alice = makeAddr("alice"); // holds exactly ONE memorabilia item
address stranger = makeAddr("stranger"); // holds none
address crowd = makeAddr("crowd"); // stands in for many festival-goers redeeming
​
uint256 constant GENERAL_PASS = 1;
uint256 constant BACKSTAGE_PASS = 3;
uint256 constant ITEMS_PER_COLLECTION = 5000; // maxSupply 5001 -> items 1..5000
uint256 constant NUM_COLLECTIONS = 4; // organizer-configured: 20,000 items total
​
uint256[] cols;
uint256 colIdx; // collection the crowd is currently redeeming from
uint256 minted; // total memorabilia minted globally (alice + crowd)
uint256 aliceToken;
​
function setUp() public {
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
​
vm.startPrank(organizer);
festival.configurePass(GENERAL_PASS, 0.05 ether, 1000);
festival.configurePass(BACKSTAGE_PASS, 0.25 ether, 100);
uint256 perf = festival.createPerformance(block.timestamp + 1 hours, 2 hours, 10_000e18);
for (uint256 i; i < NUM_COLLECTIONS; i++) {
cols.push(festival.createMemorabiliaCollection("Merch", "ipfs://merch", 1e18, ITEMS_PER_COLLECTION + 1, true));
}
vm.stopPrank();
​
// Earn BEAT through the normal flow.
vm.deal(alice, 1 ether);
vm.deal(crowd, 1 ether);
vm.prank(alice);
festival.buyPass{value: 0.05 ether}(GENERAL_PASS);
vm.prank(crowd);
festival.buyPass{value: 0.25 ether}(BACKSTAGE_PASS);
vm.warp(block.timestamp + 1 hours);
vm.prank(alice);
festival.attendPerformance(perf);
vm.prank(crowd);
festival.attendPerformance(perf);
​
// Alice redeems exactly one item: collection 100, item #1.
vm.prank(alice);
festival.redeemMemorabilia(cols[0]);
aliceToken = festival.encodeTokenId(cols[0], 1);
minted = 1;
assertEq(festival.balanceOf(alice, aliceToken), 1);
}
​
// ---------------- helpers ----------------
​
/// Crowd redeems until `target` items exist globally (real redeemMemorabilia calls).
function _growTo(uint256 target) internal {
vm.pauseGasMetering();
vm.startPrank(crowd);
while (minted < target) {
(,,,, uint256 cur,) = festival.collections(cols[colIdx]);
if (cur > ITEMS_PER_COLLECTION) colIdx++; // sold out -> next collection
festival.redeemMemorabilia(cols[colIdx]);
minted++;
}
vm.stopPrank();
vm.resumeGasMetering();
}
​
/// Gas used by a fresh (cold-storage) call of the view for `who`.
function _gasOf(address who) internal returns (uint256 used, uint256 count) {
vm.cool(address(festival));
uint256 g0 = gasleft();
(uint256[] memory ids,,) = festival.getUserMemorabiliaDetailed(who);
used = g0 - gasleft();
count = ids.length;
}
​
/// Does the view succeed when the caller only has `cap` gas (e.g. an RPC eth_call cap)?
function _fitsIn(address who, uint256 cap) internal returns (bool ok) {
vm.cool(address(festival));
(ok,) = address(festival).staticcall{gas: cap}(abi.encodeCall(festival.getUserMemorabiliaDetailed, (who)));
}
​
// ---------------- tests ----------------
​
/// Baseline: at small scale the view works and returns the right holdings.
function test_Baseline_SmallFestivalViewWorks() public {
_growTo(50);
(uint256[] memory ids, uint256[] memory cIds, uint256[] memory iIds) =
festival.getUserMemorabiliaDetailed(alice);
assertEq(ids.length, 1);
assertEq(ids[0], aliceToken);
assertEq(cIds[0], cols[0]);
assertEq(iIds[0], 1);
​
(uint256 gAlice, uint256 nAlice) = _gasOf(alice);
(uint256 gStranger, uint256 nStranger) = _gasOf(stranger);
console.log("minted=50 gas(alice, 1 item)=%s gas(stranger, 0 items)=%s", gAlice, gStranger);
assertEq(nAlice, 1);
assertEq(nStranger, 0);
assertTrue(_fitsIn(alice, 50_000_000), "baseline call fits in 50M");
assertTrue(_fitsIn(stranger, 50_000_000), "baseline call fits in 50M");
}
​
/// Cost is linear in GLOBAL mints and (almost) identical for a 1-item holder and a 0-item user.
function test_GasScalesWithGlobalMintsNotUserHoldings() public {
uint256[4] memory ns = [uint256(1000), 2000, 4000, 8000];
uint256[4] memory gA;
uint256[4] memory gS;
for (uint256 k; k < 4; k++) {
_growTo(ns[k]);
(gA[k],) = _gasOf(alice);
(gS[k],) = _gasOf(stranger);
console.log("minted=%s gas(alice,1 item)=%s gas(stranger,0 items)=%s", ns[k], gA[k], gS[k]);
// Holding 1 item vs 0 items changes cost by well under 1%.
assertApproxEqRel(gA[k], gS[k], 0.01e18);
}
// Marginal cost per globally minted item, measured on two disjoint intervals.
uint256 slopeLow = (gS[1] - gS[0]) / (ns[1] - ns[0]);
uint256 slopeHigh = (gS[3] - gS[2]) / (ns[3] - ns[2]);
uint256 fixedCost = gS[0] - slopeLow * ns[0]; // includes the 99 dead cIds 1..99
console.log("marginal gas/item: %s (1k-2k), %s (4k-8k)", slopeLow, slopeHigh);
console.log("fixed overhead (incl. dead cId 1..99 loop): %s", fixedCost);
console.log("est. items to exceed 2^24 (16,777,216): %s", (16_777_216 - fixedCost) / slopeHigh);
console.log("est. items to exceed 30M: %s", (30_000_000 - fixedCost) / slopeHigh);
console.log("est. items to exceed 50M: %s", (50_000_000 - fixedCost) / slopeHigh);
assertApproxEqRel(slopeLow, slopeHigh, 0.05e18); // linear
assertGt(slopeHigh, 2_500); // > 2.5k gas per item ever minted, regardless of the user
}
​
/// At organizer-configured scale (20,000 items in 4 collections, all redeemed),
/// the view reverts under a 50M gas cap for a 1-item holder and for a 0-item user.
function test_ViewRevertsForEveryoneAt20kItems() public {
// Baseline at 1,000 items: fits comfortably.
_growTo(1000);
assertTrue(_fitsIn(alice, 50_000_000));
assertTrue(_fitsIn(stranger, 50_000_000));
​
// Under the 2^24 per-tx cap it already fails by 6,000 items.
_growTo(6000);
assertFalse(_fitsIn(alice, 16_777_216), "6k items: exceeds 2^24");
​
// Fill the festival: 20,000 items minted globally.
_growTo(NUM_COLLECTIONS * ITEMS_PER_COLLECTION);
assertEq(minted, 20_000);
(,,,, uint256 lastCur,) = festival.collections(cols[NUM_COLLECTIONS - 1]);
assertEq(lastCur, ITEMS_PER_COLLECTION + 1); // every collection sold out
​
(uint256 gAlice, uint256 nAlice) = _gasOf(alice);
(uint256 gStranger,) = _gasOf(stranger);
console.log("minted=20000 gas(alice,1 item)=%s gas(stranger,0 items)=%s", gAlice, gStranger);
assertEq(nAlice, 1); // result is still correct when given unbounded gas...
assertGt(gAlice, 50_000_000); // ...but needs > 50M gas to compute
assertGt(gStranger, 50_000_000);
​
bool okAlice = _fitsIn(alice, 50_000_000);
bool okStranger = _fitsIn(stranger, 50_000_000);
console.log("fits in 50M cap? alice=%s stranger=%s", okAlice, okStranger);
assertFalse(okAlice, "1-item holder: view reverts under 50M cap");
assertFalse(okStranger, "0-item user: view reverts under 50M cap");
​
// View-only: state and other reads are unaffected.
assertEq(festival.balanceOf(alice, aliceToken), 1);
address[] memory a = new address[](1);
uint256[] memory ids = new uint256[](1);
a[0] = alice;
ids[0] = aliceToken;
assertEq(festival.balanceOfBatch(a, ids)[0], 1);
}
}

Observed output (excerpt):

[PASS] test_Baseline_SmallFestivalViewWorks() (gas: 2385004)
minted=50 gas(alice, 1 item)=475325 gas(stranger, 0 items)=473177
[PASS] test_GasScalesWithGlobalMintsNotUserHoldings() (gas: 112039589)
minted=1000 gas(alice,1 item)=3947300 gas(stranger,0 items)=3944841
minted=2000 gas(alice,1 item)=7602126 gas(stranger,0 items)=7599338
minted=4000 gas(alice,1 item)=14911659 gas(stranger,0 items)=14908214
minted=8000 gas(alice,1 item)=29530724 gas(stranger,0 items)=29525965
marginal gas/item: 3654 (1k-2k), 3654 (4k-8k)
fixed overhead (incl. dead cId 1..99 loop): 290841
est. items to exceed 50M: 13604
[PASS] test_ViewRevertsForEveryoneAt20kItems() (gas: 271540414)
minted=20000 gas(alice,1 item)=73387906 gas(stranger,0 items)=73379213
fits in 50M cap? alice=false stranger=false
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Bound the work per call. Scan one collection at a time over a caller-chosen item window, in a single pass, and let clients page through collections 100 .. nextCollectionId - 1. Update the interface to match.

- function getUserMemorabiliaDetailed(address user) external view returns (
+ /// @notice Scans at most `limit` items of one collection, starting at item `startItem`.
+ function getUserMemorabiliaDetailed(address user, uint256 collectionId, uint256 startItem, uint256 limit) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
- // First, count how many memorabilia they own
- uint256 count = 0;
- for (uint256 cId = 1; cId < nextCollectionId; cId++) {
- for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
- uint256 tokenId = encodeTokenId(cId, iId);
- if (balanceOf(user, tokenId) > 0) {
- count++;
- }
- }
- }
-
- // Then populate arrays
- tokenIds = new uint256[](count);
- collectionIds = new uint256[](count);
- itemIds = new uint256[](count);
-
- uint256 index = 0;
- for (uint256 cId = 1; cId < nextCollectionId; cId++) {
- for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
- uint256 tokenId = encodeTokenId(cId, iId);
- if (balanceOf(user, tokenId) > 0) {
- tokenIds[index] = tokenId;
- collectionIds[index] = cId;
- itemIds[index] = iId;
- index++;
- }
- }
- }
-
- return (tokenIds, collectionIds, itemIds);
+ uint256 end = collections[collectionId].currentItemId;
+ if (startItem == 0) startItem = 1;
+ if (startItem >= end) return (new uint256[](0), new uint256[](0), new uint256[](0));
+ if (end - startItem > limit) end = startItem + limit;
+
+ uint256[] memory found = new uint256[](end - startItem);
+ uint256 count = 0;
+ for (uint256 iId = startItem; iId < end; iId++) {
+ if (balanceOf(user, encodeTokenId(collectionId, iId)) > 0) found[count++] = iId;
+ }
+
+ tokenIds = new uint256[](count);
+ collectionIds = new uint256[](count);
+ itemIds = new uint256[](count);
+ for (uint256 i = 0; i < count; i++) {
+ tokenIds[i] = encodeTokenId(collectionId, found[i]);
+ collectionIds[i] = collectionId;
+ itemIds[i] = found[i];
+ }
}
// src/Interfaces/IFestivalPass.sol
- function getUserMemorabiliaDetailed(address user) external view returns (
+ function getUserMemorabiliaDetailed(address user, uint256 collectionId, uint256 startItem, uint256 limit) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
);
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!