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.
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.
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
Observed output (excerpt):
Add an organizer-only toggle so that deferred collections can go live and live collections can be paused.
# 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"); // ... } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.