Beatland Festival

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

# M-3: Memorabilia `isActive` is write-once — collections cannot be activated or paused

Root + Impact

A collection's isActive flag is set only at creation from the activateNow argument and is read at redemption, but there is no function anywhere that writes isActive after creation. A collection created with activateNow = false is therefore permanently unredeemable — the organizer cannot enable it later — and an active collection can never be paused during an incident. The documented intent (a boolean argument that implies later activation) is impossible to fulfil.

Description

isActive is written exactly once, in the collection constructor-equivalent createMemorabiliaCollection, and read exactly once, in the redemption guard:

  • FestivalPass.sol:182 — isActive: activateNow

  • FestivalPass.sol:193 — require(collection.isActive, "Collection not active");

A grep over the whole contract shows no other isActive write site. There is no setCollectionActive / pause / unpause function on FestivalPass, and the MemorabiliaCollection struct is only ever fully overwritten at creation.

// src/FestivalPass.sol:162-187 — isActive set once from activateNow
function createMemorabiliaCollection(
string memory name, string memory baseUri,
uint256 priceInBeat, uint256 maxSupply, bool activateNow
) external onlyOrganizer returns (uint256) {
// ... validation ...
uint256 collectionId = nextCollectionId++;
collections[collectionId] = MemorabiliaCollection({
name: name, baseUri: baseUri, priceInBeat: priceInBeat,
maxSupply: maxSupply, currentItemId: 1,
@> isActive: activateNow
});
emit CollectionCreated(collectionId, name, maxSupply);
return collectionId;
}
​
// src/FestivalPass.sol:190-194 — read only
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
@> require(collection.isActive, "Collection not active");

Risk

Likelihood: Medium. It triggers whenever an organizer schedules a "future release" collection with activateNow = false — the natural use of the flag — or needs to halt redemptions (bug, legal, migration). Both are normal operational events.

Impact: A collection created inactive is a permanent feature denial: it can never be redeemed, though it is still advertised via CollectionCreated. There is also no incident-response lever to stop BEAT redemption if a bug surfaces, so a discovered exploit cannot be contained. No funds are directly lost, but intended functionality is irreversibly unreachable.

Proof of Concept

The test creates a collection with activateNow = false and shows redemption reverts with "Collection not active". Because the only way to set isActive is at creation, and creation has already happened, there is no call that can make the subsequent assertion in the second half true — the collection stays inactive forever.

// test/Exploits.t.sol (add)
function test_InactiveCollectionCannotBeActivated() public {
vm.prank(organizer);
uint256 col = fp.createMemorabiliaCollection("Future", "ipfs://x", 1e18, 10, false);
​
address u = makeAddr("u");
vm.prank(address(fp));
beat.mint(u, 10e18);
​
vm.prank(u);
vm.expectRevert("Collection not active");
fp.redeemMemorabilia(col);
​
// There is no function on FestivalPass that writes collections[col].isActive,
// so the collection is permanently unredeemable. (No call to attempt here.)
}

Recommended Mitigation

Add an organizer-gated setCollectionActive so a collection can be enabled after creation and paused during incidents. Optionally add a collection-independent pause around redeemMemorabilia (and reward minting) for defence in depth.

+ function setCollectionActive(uint256 collectionId, bool active) external onlyOrganizer {
+ MemorabiliaCollection storage collection = collections[collectionId];
+ require(collection.priceInBeat > 0, "Collection does not exist");
+ collection.isActive = active;
+ }

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours 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!