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";
contract PoC_BF08 is Test {
FestivalPass festival;
BeatToken beat;
address organizer = makeAddr("organizer");
address alice = makeAddr("alice");
address stranger = makeAddr("stranger");
address crowd = makeAddr("crowd");
uint256 constant GENERAL_PASS = 1;
uint256 constant BACKSTAGE_PASS = 3;
uint256 constant ITEMS_PER_COLLECTION = 5000;
uint256 constant NUM_COLLECTIONS = 4;
uint256[] cols;
uint256 colIdx;
uint256 minted;
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();
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);
vm.prank(alice);
festival.redeemMemorabilia(cols[0]);
aliceToken = festival.encodeTokenId(cols[0], 1);
minted = 1;
assertEq(festival.balanceOf(alice, aliceToken), 1);
}
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++;
festival.redeemMemorabilia(cols[colIdx]);
minted++;
}
vm.stopPrank();
vm.resumeGasMetering();
}
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;
}
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)));
}
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");
}
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]);
assertApproxEqRel(gA[k], gS[k], 0.01e18);
}
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];
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);
assertGt(slopeHigh, 2_500);
}
function test_ViewRevertsForEveryoneAt20kItems() public {
_growTo(1000);
assertTrue(_fitsIn(alice, 50_000_000));
assertTrue(_fitsIn(stranger, 50_000_000));
_growTo(6000);
assertFalse(_fitsIn(alice, 16_777_216), "6k items: exceeds 2^24");
_growTo(NUM_COLLECTIONS * ITEMS_PER_COLLECTION);
assertEq(minted, 20_000);
(,,,, uint256 lastCur,) = festival.collections(cols[NUM_COLLECTIONS - 1]);
assertEq(lastCur, ITEMS_PER_COLLECTION + 1);
(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);
assertGt(gAlice, 50_000_000);
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");
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);
}
}
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];
+ }
}