Beatland Festival

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

MemorabiliaCollection.isActive is written only at creation and has no setter, so a collection created with activateNow = false can never be redeemed and a live collection can never be paused

Root + Impact

Description

  • Each memorabilia collection has an isActive flag that redeemMemorabilia requires. IFestivalPass describes it as "Whether redemption is currently enabled for this collection". It describes the creation parameter activateNow as "Whether redemption should be immediately available", which implies the collection can be activated later. The project's own test suite creates a "Future Release" collection with activateNow = false.

  • isActive is written exactly once, in createMemorabiliaCollection. No function in FestivalPass writes collections[id].isActive afterwards. A collection created for a deferred or scheduled drop can therefore never be activated: every redeem reverts with "Collection not active", forever. Conversely, a live collection can never be paused, for example after a wrong price or baseUri.

// src/FestivalPass.sol L176-L183 (createMemorabiliaCollection) and L190-L193 (redeemMemorabilia); @> on L182, L193
collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
currentItemId: 1, // Start item IDs at 1
@> isActive: activateNow
});
// ...
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:

  • It happens whenever the organizer uses the documented deferred mode (activateNow = false) to prepare a drop ahead of time, as the project's own test does.

  • It also happens whenever a live collection needs to be halted. This is the secondary case.

Impact:

  • Deferred collections are permanently unredeemable. The only workaround is to recreate the collection under a new ID, while the dead ID keeps resolving as a valid collection through uri() and getMemorabiliaDetails.

  • Live collections cannot be paused and keep selling. No funds are lost, because the revert happens before burnFrom.

Proof of Concept

  • test_baseline_activeCollectionRedeems shows that an active collection redeems normally.

  • test_deferredCollection_canNeverBeActivated creates a deferred collection, records storage writes with vm.record(), and then calls every state-changing external function the contract exposes, each from its permitted role. That covers the FestivalPass functions, the ERC1155 transfer/approval functions, the Ownable2Step round trip, and six plausible setter selectors, which all revert. It then warps 365 days. A positive control confirms that the recorder catches the isActive write of a collection created inside the window. The deferred collection's isActive slot is never written, redeem still reverts, the relaunch needs a new ID (100 is dead, 103 is the relaunch), and the dead ID still resolves in the views.

  • test_liveCollection_cannotBePaused shows that the same sweep never writes a live collection's isActive, and the collection keeps selling.

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

FOUNDRY_FFI=false forge test --match-path test/PoC_BF-05.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-05: MemorabiliaCollection.isActive is written only at creation. A collection created with
/// activateNow=false can never be activated, and a live collection can never be paused.
contract PoC_BF05 is Test {
FestivalPass festival;
BeatToken beat;
​
address owner = makeAddr("owner");
address organizer = makeAddr("organizer");
​
uint256 constant GENERAL_PASS = 1;
uint256 constant BACKSTAGE_PASS = 3;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant ITEM_PRICE = 10e18; // a backstage pass grants 15 BEAT
uint256 constant COLLECTIONS_SLOT = 15; // storage slot of `collections` (self-checked below)
​
uint256 userNonce;
​
function setUp() public {
vm.startPrank(owner);
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
vm.stopPrank();
​
vm.prank(organizer);
festival.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, 1000);
}
​
// ---------------------------------------------------------------- helpers
​
/// Fresh user funded with 15 BEAT through the real flow (buying a backstage pass).
function _fundedUser() internal returns (address user) {
user = makeAddr(string(abi.encodePacked("fan", vm.toString(userNonce++))));
vm.deal(user, BACKSTAGE_PRICE);
vm.prank(user);
festival.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
assertEq(beat.balanceOf(user), 15e18, "user funded with 15 BEAT");
}
​
function _isActive(uint256 id) internal view returns (bool active) {
(,,,,, active) = festival.collections(id);
}
​
/// Storage slot of collections[id].isActive, with the layout verified against the public getter.
function _isActiveSlot(uint256 id) internal view returns (bytes32) {
uint256 base = uint256(keccak256(abi.encode(id, COLLECTIONS_SLOT)));
(,, uint256 price, uint256 maxSupply, uint256 cur, bool active) = festival.collections(id);
assertEq(uint256(vm.load(address(festival), bytes32(base + 2))), price, "layout: priceInBeat");
assertEq(uint256(vm.load(address(festival), bytes32(base + 3))), maxSupply, "layout: maxSupply");
assertEq(uint256(vm.load(address(festival), bytes32(base + 4))), cur, "layout: currentItemId");
assertEq(uint256(vm.load(address(festival), bytes32(base + 5))), active ? 1 : 0, "layout: isActive");
return bytes32(base + 5);
}
​
function _contains(bytes32[] memory arr, bytes32 x) internal pure returns (bool) {
for (uint256 i; i < arr.length; i++) if (arr[i] == x) return true;
return false;
}
​
/// Calls every state-changing external function FestivalPass exposes (its own, ERC1155's and
/// Ownable2Step's), from the role allowed to call it. Returns the id of a fresh collection that
/// was created with activateNow=true inside the recording window (positive control).
function _exerciseEveryStateChangingFunction() internal returns (uint256 controlId) {
// Organizer-only functions
vm.startPrank(organizer);
festival.configurePass(GENERAL_PASS, 0.05 ether, 100);
uint256 perfId = festival.createPerformance(block.timestamp + 1 hours, 2 hours, 1e18);
controlId = festival.createMemorabiliaCollection("Control", "ipfs://QmControl", ITEM_PRICE, 10, true);
festival.createMemorabiliaCollection("Another deferred", "ipfs://QmOther", ITEM_PRICE, 10, false);
vm.stopPrank();
​
// User functions
address u = _fundedUser(); // buyPass
address u2 = makeAddr("receiver");
vm.warp(block.timestamp + 1 hours + 1);
vm.prank(u);
festival.attendPerformance(perfId); // +3 BEAT (backstage x3)
vm.prank(u);
festival.redeemMemorabilia(controlId); // redeem in the control collection
vm.prank(u);
festival.setApprovalForAll(u2, true);
uint256 controlToken = festival.encodeTokenId(controlId, 1); // computed before prank
vm.prank(u);
festival.safeTransferFrom(u, u2, controlToken, 1, "");
uint256[] memory ids = new uint256[](1);
uint256[] memory amts = new uint256[](1);
ids[0] = BACKSTAGE_PASS;
amts[0] = 1;
vm.prank(u2); // approved operator moves the pass
festival.safeBatchTransferFrom(u, u2, ids, amts, "");
​
// Owner-only functions
vm.startPrank(owner);
festival.setOrganizer(organizer);
festival.withdraw(owner);
address newOwner = makeAddr("newOwner");
festival.transferOwnership(newOwner);
vm.stopPrank();
vm.prank(newOwner);
festival.acceptOwnership();
vm.prank(newOwner);
festival.transferOwnership(owner);
vm.prank(owner);
festival.acceptOwnership();
​
// Plausible setter names: no such function and no fallback, so every one reverts.
bytes[6] memory calls = [
abi.encodeWithSignature("setCollectionActive(uint256,bool)", uint256(100), true),
abi.encodeWithSignature("setActive(uint256,bool)", uint256(100), true),
abi.encodeWithSignature("activateCollection(uint256)", uint256(100)),
abi.encodeWithSignature("deactivateCollection(uint256)", uint256(100)),
abi.encodeWithSignature("toggleCollection(uint256)", uint256(100)),
abi.encodeWithSignature("pauseCollection(uint256)", uint256(100))
];
address[2] memory callers = [organizer, owner];
for (uint256 i; i < calls.length; i++) {
for (uint256 j; j < callers.length; j++) {
vm.prank(callers[j]);
(bool ok,) = address(festival).call(calls[i]);
assertFalse(ok, "no setter selector exists");
}
}
}
​
// ---------------------------------------------------------------- tests
​
/// Baseline: a collection created with activateNow=true redeems as intended.
function test_baseline_activeCollectionRedeems() public {
vm.prank(organizer);
uint256 id = festival.createMemorabiliaCollection("Live Drop", "ipfs://QmLive", ITEM_PRICE, 10, true);
address fan = _fundedUser();
​
vm.prank(fan);
festival.redeemMemorabilia(id);
​
assertEq(festival.balanceOf(fan, festival.encodeTokenId(id, 1)), 1, "NFT minted");
assertEq(beat.balanceOf(fan), 5e18, "10 BEAT burned");
}
​
/// Limbs 1+2: a deferred ("Future Release") collection can never be activated by anyone,
/// even after every state-changing function is called and a year passes. The re-launch
/// needs a new ID while the dead ID still resolves as a valid collection.
function test_deferredCollection_canNeverBeActivated() public {
vm.prank(organizer);
uint256 deferredId =
festival.createMemorabiliaCollection("Future Release", "ipfs://QmFuture", ITEM_PRICE, 10, false);
bytes32 slot = _isActiveSlot(deferredId);
assertFalse(_isActive(deferredId), "created inactive");
​
address fan = _fundedUser();
vm.prank(fan);
vm.expectRevert("Collection not active");
festival.redeemMemorabilia(deferredId);
​
// Record every storage write while the full API surface is exercised.
vm.record();
uint256 controlId = _exerciseEveryStateChangingFunction();
vm.warp(block.timestamp + 365 days); // no time-based activation either
(, bytes32[] memory writes) = vm.accesses(address(festival));
​
// Positive control: the recorder does catch isActive writes (at creation of a fresh ID) ...
assertTrue(_contains(writes, _isActiveSlot(controlId)), "control: creation write recorded");
// ... but nothing ever writes the deferred collection's isActive slot.
assertFalse(_contains(writes, slot), "no function writes deferredId.isActive");
console.log("storage writes recorded during full API sweep:", writes.length);
assertFalse(_isActive(deferredId), "still inactive after full API sweep + 1 year");
​
vm.prank(fan);
vm.expectRevert("Collection not active");
festival.redeemMemorabilia(deferredId);
assertEq(beat.balanceOf(fan), 15e18, "fan keeps BEAT, nothing redeemable");
​
// Only workaround: create a duplicate under a new ID.
vm.prank(organizer);
uint256 relaunchId =
festival.createMemorabiliaCollection("Future Release", "ipfs://QmFuture", ITEM_PRICE, 10, true);
assertTrue(relaunchId != deferredId, "re-launch consumes a new ID");
vm.prank(fan);
festival.redeemMemorabilia(relaunchId);
assertEq(festival.balanceOf(fan, festival.encodeTokenId(relaunchId, 1)), 1, "duplicate works");
​
// The dead ID still resolves as a valid collection in the view functions.
uint256 deadToken = festival.encodeTokenId(deferredId, 1);
assertEq(festival.uri(deadToken), "ipfs://QmFuture/metadata/1", "dead ID still has a URI");
(uint256 cId,, string memory cName,,,) = festival.getMemorabiliaDetails(deadToken);
assertEq(cId, deferredId);
assertEq(cName, "Future Release");
console.log("deferred id (permanently inactive):", deferredId);
console.log("re-launch id:", relaunchId);
}
​
/// Limb 3: a live collection can never be paused; after the full API sweep it keeps selling.
function test_liveCollection_cannotBePaused() public {
vm.prank(organizer);
uint256 liveId = festival.createMemorabiliaCollection("Live Drop", "ipfs://QmLive", ITEM_PRICE, 10, true);
bytes32 slot = _isActiveSlot(liveId);
​
vm.record();
_exerciseEveryStateChangingFunction();
(, bytes32[] memory writes) = vm.accesses(address(festival));
assertFalse(_contains(writes, slot), "no function writes liveId.isActive");
assertTrue(_isActive(liveId), "still active");
​
address fan = _fundedUser();
vm.prank(fan);
festival.redeemMemorabilia(liveId); // cannot be halted
assertEq(festival.balanceOf(fan, festival.encodeTokenId(liveId, 1)), 1, "still selling");
}
}

Observed output (excerpt):

[PASS] test_baseline_activeCollectionRedeems() (gas: 372012)
[PASS] test_deferredCollection_canNeverBeActivated() (gas: 1320400)
storage writes recorded during full API sweep: 40
deferred id (permanently inactive): 100
re-launch id: 103
[PASS] test_liveCollection_cannotBePaused() (gas: 1144744)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Add an organizer-only toggle so that deferred collections can go live and live collections can be paused.

emit CollectionCreated(collectionId, name, maxSupply);
return collectionId;
}
+ event CollectionActiveSet(uint256 indexed collectionId, bool active);
+
+ function setCollectionActive(uint256 collectionId, bool active) external onlyOrganizer {
+ require(collections[collectionId].priceInBeat > 0, "Collection does not exist");
+ collections[collectionId].isActive = active;
+ emit CollectionActiveSet(collectionId, active);
+ }
+
// Redeem a memorabilia NFT from a collection
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!