Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

Memorabilia collection isActive can never be changed after creation

Summary

createMemorabiliaCollection(..., activateNow=false) creates a dormant memorabilia collection, but FestivalPass exposes no function to change isActive afterwards. The collection can never be activated (and an active one can never be paused). All collection parameters are immutable once the collection is created.

Root Cause

Contract: src/FestivalPass.sol
Function: createMemorabiliaCollection(...) — no setter exists for the collection's isActive flag

function createMemorabiliaCollection(..., bool activateNow) external returns (uint256) {
// ... stores collection with isActive = activateNow ...
// @> no setCollectionActive / activate / pause function exists anywhere in FestivalPass
}

Source inspection confirms: no function in FestivalPass mutates isActive after creation.

Impact

  • Permanently dormant collections: a collection created with activateNow = false is unusable forever — every redeemMemorabilia reverts with "Collection not active" and no organizer or owner function can flip the flag. The collection permanently occupies its id and misleads users who see it listed.

  • No emergency pause: conversely, an active collection cannot be paused (e.g., on discovering a wrong price or metadata), removing an operational safety lever.

  • Low severity: availability/UX issue only — no funds are at risk, and triggering it requires an organizer to create the collection in the dormant state (or to need a pause).

Description

createMemorabiliaCollection accepts an activateNow boolean that sets the collection's initial isActive state. redeemMemorabilia requires the collection to be active. However, the contract provides no setCollectionActive, activateCollection, or pauseCollection function, and no other function mutates the flag. The full set of collection parameters (price, maxSupply, metadata URI, isActive) is therefore frozen at creation time.

The PoC below creates a collection with activateNow = false, funds a user with BEAT via a VIP pass purchase, and shows that redemption reverts with "Collection not active" — a state that can never be exited.

Proof of Concept

From repo/test/PoC.t.sol (BeatlandPoC; all 5 tests pass with forge test):

// 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";
​
contract BeatlandPoC is Test {
FestivalPass public fp;
BeatToken public beat;
address public owner;
address public organizer;
address public user;
​
uint256 constant VIP = 2;
uint256 constant VIP_PRICE = 0.1 ether;
​
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
user = makeAddr("user");
vm.deal(user, 100 ether);
​
beat = new BeatToken();
fp = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(fp));
​
vm.startPrank(organizer);
fp.configurePass(1, 0.05 ether, 5000);
fp.configurePass(VIP, VIP_PRICE, 1000);
fp.configurePass(3, 0.25 ether, 100);
vm.stopPrank();
}
​
// ============ L-1: isActive can never be toggled after creation ============
function test_PoC_CollectionNeverActivatable() public {
vm.prank(organizer);
uint256 cid = fp.createMemorabiliaCollection("Dormant", "ipfs://qmz", 1e18, 10, false);
​
vm.prank(user);
fp.buyPass{value: VIP_PRICE}(VIP);
​
vm.prank(user);
vm.expectRevert("Collection not active");
fp.redeemMemorabilia(cid);
// NOTE: no setter for isActive exists anywhere in FestivalPass, so this
// collection is permanently unusable. Verified by source inspection.
}
}

Recommended Mitigation

Add an organizer-gated setter, e.g.:

function setCollectionActive(uint256 collectionId, bool active) external onlyOrganizer {
collections[collectionId].isActive = active;
emit CollectionStatusChanged(collectionId, active);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-01] Inactive Collections — Indefinite BEAT Lock-up

# Inactive Collections — Indefinite BEAT Lock-up ## Description * Normal behaviour: Organizer creates memorabilia collections with `activateNow = true` so users can immediately redeem BEAT for NFTs. * Issue: Collections can be created with `activateNow = false` and there is **no mechanism** to activate them later, nor any timeout. Users may acquire BEAT expecting to redeem specific memorabilia, but the organizer can indefinitely prevent access. ```solidity function createMemorabiliaCollection(..., bool activateNow) external onlyOrganizer { // ... validation ... @> collections[collectionId] = MemorabiliaCollection({ // ... isActive: activateNow // Can be false forever }); } function redeemMemorabilia(uint256 collectionId) external { @> require(collection.isActive, "Collection not active"); // Permanent block // ... } ``` ## Risk **Likelihood**: * Organizer may create collections in advance but forget to activate. * Intentional strategy to create hype then indefinitely delay launch. **Impact**: * Users hold BEAT tokens anticipating memorabilia that never becomes available. * Economic utility of BEAT reduced if major collections remain locked. ## Proof of Concept ```solidity function test_CollectionNeverActivated() public { // Alice gets BEAT tokens vm.prank(alice); festivalPass.buyPass{value: 0.1 ether}(VIP_PASS); // gets 5 BEAT bonus // Organizer creates inactive collection vm.prank(organizer); uint256 collectionId = festivalPass.createMemorabiliaCollection( "Limited Edition", "ipfs://limited", 3e18, 100, false // NOT activated ); // Alice tries to redeem but can't vm.prank(alice); vm.expectRevert("Collection not active"); festivalPass.redeemMemorabilia(collectionId); // Time passes, organizer chooses not to activate vm.warp(block.timestamp + 365 days); // Alice still can't redeem - funds effectively locked vm.prank(alice); vm.expectRevert("Collection not active"); festivalPass.redeemMemorabilia(collectionId); assertEq(beatToken.balanceOf(alice), 5e18, "Alice holds 'useless' BEAT"); } ``` ## Recommended Mitigation ```diff + mapping(uint256 => bool) public collectionActivated; + function activateCollection(uint256 collectionId) external onlyOrganizer { + require(collections[collectionId].priceInBeat > 0, "Collection does not exist"); + collections[collectionId].isActive = true; + collectionActivated[collectionId] = true; + } // Or add automatic timeout: + uint256 constant ACTIVATION_DEADLINE = 30 days; + mapping(uint256 => uint256) public collectionCreatedAt; function createMemorabiliaCollection(...) external onlyOrganizer { // ... + collectionCreatedAt[collectionId] = block.timestamp; } function redeemMemorabilia(uint256 collectionId) external { MemorabiliaCollection storage collection = collections[collectionId]; + bool autoActive = block.timestamp >= collectionCreatedAt[collectionId] + ACTIVATION_DEADLINE; + require(collection.isActive || autoActive, "Collection not active"); // ... } ```

Support

FAQs

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

Give us feedback!