Root + Impact
Description
The FestivalPass::attendPerformance function only checks if the msg.sender owns a pass, the pass isn't burnt, its still retained by the user after attending a performance and can be used to attend subsequent performances without ever needing to buy another pass. This will cause users to keep earning beat tokens(which can be used to redeem memorabilias) performance after performance, festival after festival, shortchanging the protocol and negatively impacting the worth of the memorabilia NFTs.
Risk
Likelihood:
Impact:
-
Protocol's revenue generation will decline substantially as there would be no need for users to buy a pass after the first one.
-
Memorabilia NFTs drastically reduces because it becomes cheap to redeem (one time purchase of pass to infinitely attend performances and earn beat tokens which is then used to redeem an NFT )
Proof of Concept
This test shows that a user can buy and use a single pass to attend more than one event and using the earned beat tokens to redeem multiple memorabilias.
function test_can_infinitely_attend_performances_with_single_pass() public {
vm.prank(user1);
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
uint256 startTime = block.timestamp + 1 hours;
vm.startPrank(organizer);
uint256 col1 = festivalPass.createMemorabiliaCollection(
"Col1",
"ipfs://1",
200e18,
5,
true
);
uint256 perfId1 = festivalPass.createPerformance(
startTime,
2 hours,
100e18
);
vm.stopPrank();
vm.warp(startTime + 30 minutes);
vm.startPrank(user1);
vm.expectEmit(true, true, false, true);
emit Attended(user1, perfId1, 300e18);
festivalPass.attendPerformance(perfId1);
festivalPass.redeemMemorabilia(col1);
vm.stopPrank();
vm.warp(2 hours);
uint256 startTime2 = block.timestamp + 1 hours;
vm.prank(organizer);
uint256 perfId2 = festivalPass.createPerformance(
startTime2,
2 hours,
100e18
);
vm.warp(startTime2 + 30 minutes);
vm.startPrank(user1);
vm.expectEmit(true, true, true, true);
emit Attended(user1, perfId2, 300e18);
festivalPass.attendPerformance(perfId2);
festivalPass.redeemMemorabilia(col1);
festivalPass.redeemMemorabilia(col1);
vm.stopPrank();
assertTrue(festivalPass.hasAttended(perfId1, user1));
assertTrue(festivalPass.hasAttended(perfId2, user1));
uint256 token1 = festivalPass.encodeTokenId(col1, 1);
uint256 token2 = festivalPass.encodeTokenId(col1, 2);
uint256 token3 = festivalPass.encodeTokenId(col1, 3);
assertEq(festivalPass.balanceOf(user1, token1), 1);
assertEq(festivalPass.balanceOf(user1, token2), 1);
assertEq(festivalPass.balanceOf(user1, token3), 1);
}
Recommended Mitigation
Firstly, modify the FestivalPass::hasPass function to also take a passId as parameter, require that its <= GENERAL_PASS, and check that the user has balance > 0 of the passId.
This is to ensure that the user owns at least one of that specific passId as opposed to checking that he owns any pass.
- function hasPass(address user) public view returns (bool) {
+ function hasPass(address user, uint256 passId) public view returns (bool) {
+ require(passId <= GENERAL_PASS, "Invalid Pass ID");
- return balanceOf(user, GENERAL_PASS) > 0 ||
- balanceOf(user, VIP_PASS) > 0 ||
- balanceOf(user, BACKSTAGE_PASS) > 0;
+ return balanceOf(user, passId) > 0;
}
Secondly, modify Festival::getMultiplier to take the passId as a parameter and return a multiplier based on equality check between the passId parameter and the three known passIds (i.e. GENERAL_PASS, VIP_PASS and BACKSTAGE_PASS).
- function getMultiplier(address user) public view returns (uint256) {
+ function getMultiplier(address user, uint256 passId) public view returns (uint256) {
- if (balanceOf(user, BACKSTAGE_PASS) > 0) {
+ if (passId == BACKSTAGE_PASS) {
return 3; // 3x for BACKSTAGE
- } else if (balanceOf(user, VIP_PASS) > 0) {
+ } else if (passId == VIP_PASS) {
return 2; // 2x for VIP
- } else if (balanceOf(user, GENERAL_PASS) > 0) {
+ } else if (passId == GENERAL_PASS) {
return 1; // 1x for GENERAL
}
return 0; // No pass
}
Finally, modify FestivalPass::attendPerformance to take the passId as a parameter, pass it into the calls to hasPass() and getMultiplier() functions, then call _burn() on msg.sender with the passId parameter as the id of the token type, and 1 as the amount to burn.
- function attendPerformance(uint256 performanceId) external {
+ function attendPerformance(uint256 performanceId, uint256 passId) external {
+ require(passId <= GENERAL_PASS, "Invalid Pass ID");
require(isPerformanceActive(performanceId), "Performance is not active");
- require(hasPass(msg.sender), "Must own a pass");
+ require(hasPass(msg.sender, passId), "Must own a pass");
require(!hasAttended[performanceId][msg.sender], "Already attended this performance");
require(block.timestamp >= lastCheckIn[msg.sender] + COOLDOWN, "Cooldown period not met");
hasAttended[performanceId][msg.sender] = true;
lastCheckIn[msg.sender] = block.timestamp;
- uint256 multiplier = getMultiplier(msg.sender);
+ uint256 multiplier = getMultiplier(msg.sender, passId);
+ _burn(msg.sender, passId, 1);
BeatToken(beatToken).mint(msg.sender, performances[performanceId].baseReward * multiplier);
emit Attended(msg.sender, performanceId, performances[performanceId].baseReward * multiplier);
}