Platform: Code4rena
Start Date: 25/01/2023
Pot Size: $36,500 USDC
Total HM: 11
Participants: 173
Period: 5 days
Judge: kirk-baird
Total Solo HM: 1
Id: 208
League: ETH
Rank: 26/173
Findings: 5
Award: $169.56
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0xRobocop, 0xmrhoodie, 0xngndev, AkshaySrivastav, ArmedGoose, Atarpara, Bauer, CodingNameKiki, ElKu, Garrett, HollaDieWaldfee, IllIllI, Iurii3, KIntern_NA, KmanOfficial, Lotus, M4TZ1P, MiniGlome, Ruhum, SovaSlava, bin2chen, bytes032, carrotsmuggler, cccz, chaduke, codeislight, cryptonue, doublesharp, evan, fs0c, glcanvas, gzeon, hansfriese, hihen, hl_, holme, horsefacts, ladboy233, lukris02, mahdikarimi, manikantanynala97, martin, mert_eren, mrpathfindr, omis, peakbolt, peanuts, prestoncodes, rbserver, rvierdiiev, sashik_eth, timongty, tnevler, trustindistrust, usmannk, wait, yixxas, zadaru13, zaskoh
0.7512 USDC - $0.75
Quest deployer will lose funds / users cannot collect their funds with their receipt.
The withdrawFee() function in Erc20Quest.sol only checks whether the caller is the owner and whether the block.timestamp is greater than the endTime.
function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
The quest owner may accidentally call withdrawFee multiple times. If that happens, funds will go to the protocolFeeRecipient, and successful quest participants will not be able to retrieve their reward because the contract balance will be lesser than intended
Manual Review
Make sure the withdrawFee() function can only be called once, after the endTime has passed.
#0 - c4-judge
2023-02-05T05:47:05Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T08:54:38Z
kirk-baird changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-14T08:59:44Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: 0xRobocop, 0xbepresent, Breeje, CodingNameKiki, HollaDieWaldfee, Kenshin, M4TZ1P, Ruhum, Tricko, badman, bin2chen, carrotsmuggler, cccz, csanuragjain, glcanvas, joestakey, lukris02, m9800, mert_eren, peakbolt, peanuts, prestoncodes, rvierdiiev, sashik_eth
21.6061 USDC - $21.61
Successful participants can still mint a receipt after endTime has passed. They will not be able to get their rewards if withdrawRemainingTokens() is called. protocolFee() can keep changing if there are more receiptRedeemers.
A successful user can mint a receipt and trade their receipt for a token reward. However, there is no check for endTime; the participant can still participate and mint a receipt even after the quest ends.
function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { if (quests[questId_].numberMinted + 1 > quests[questId_].totalParticipants) revert OverMaxAllowedToMint(); if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); if (recoverSigner(hash_, signature_) != claimSignerAddress) revert AddressNotSigned(); quests[questId_].addressMinted[msg.sender] = true; quests[questId_].numberMinted++; emit ReceiptMinted(msg.sender, questId_); rabbitholeReceiptContract.mint(msg.sender, questId_); }
Successful participants who participated after the endTime will not get their rewards if withdrawRemainingTokens() is called already.
VSCode
Make sure participants cannot mint any receipt after endTime. Add a endTime_ check in the mintReceipt() function. Store the endTime variable in the QuestFactory and use it in mintReceipt().
uint endTime; function createQuest( address rewardTokenAddress_, uint256 endTime_, uint256 startTime_, uint256 totalParticipants_, uint256 rewardAmountOrTokenId_, string memory contractType_, string memory questId_ ) public onlyRole(CREATE_QUEST_ROLE) returns (address) { if (quests[questId_].questAddress != address(0)) revert QuestIdUsed(); + endTime = endTime_;
if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc20'))) { function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { + if (block.timestamp < endTime) revert NotAllowedToMintAfterQuestEnd(); if (quests[questId_].numberMinted + 1 > quests[questId_].totalParticipants) revert OverMaxAllowedToMint(); if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); if (recoverSigner(hash_, signature_) != claimSignerAddress) revert AddressNotSigned(); quests[questId_].addressMinted[msg.sender] = true; quests[questId_].numberMinted++; emit ReceiptMinted(msg.sender, questId_); rabbitholeReceiptContract.mint(msg.sender, questId_); }
#0 - c4-judge
2023-02-05T05:58:11Z
kirk-baird marked the issue as duplicate of #22
#1 - c4-judge
2023-02-14T08:45:16Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: RaymondFam
Also found by: 0xMirce, AkshaySrivastav, AlexCzm, Aymen0909, BClabs, CodingNameKiki, ElKu, HollaDieWaldfee, Josiah, KIntern_NA, MiniGlome, StErMi, adriro, bin2chen, cccz, chaduke, csanuragjain, gzeon, hihen, holme, libratus, minhquanym, omis, peakbolt, peanuts, rbserver, rvierdiiev, timongty, ubermensch, usmannk, wait, zaskoh
7.046 USDC - $7.05
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc1155Quest.sol#L54-L62 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L96-L118 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87
Participants cannot exchange their ERC721 Receipt for their ERC1155 reward token if withdrawRemainingTokens() is called.
The withdrawRemainingTokens() in ERC20Quest.sol and ERC1155Quest.sol is written differently. In ERC20Quest.sol, after the quest endtime, participants who still has a receipt but didn't call claim can do so because there will be a portion of the rewards left inside the ERC20Quest contract. withdrawRemainingTokens() in ERC20Quest.sol only withdraws the amount that is for those that didn't manage to mint a receipt.
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); }
However, in ERC1155Quest.sol, when withdrawRemainingTokens() is called, it just withdraws the leftover tokens in the contract. Those that minted a ERC721 receipt but did not call claim cannot receive their reward after withdrawRemainingToken() is called. Also, there is no protocol fee to pay, unlike ERC20Quest.sol.
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), '0x00' );
Manual Review
Make sure the functions in both ERC1155Quest and ERC20Quest work the same way, otherwise it will be unfair for the users (cannot claim in ERC1155Quest) or for the quest deployer (no need to pay protocol fees in ERC1155Quest).
#0 - c4-judge
2023-02-06T09:22:17Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-10T05:03:11Z
kirk-baird changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-02-10T05:12:15Z
This previously downgraded issue has been upgraded by kirk-baird
#3 - c4-judge
2023-02-14T09:27:48Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2023-02-23T23:49:21Z
kirk-baird changed the severity to 2 (Med Risk)
🌟 Selected for report: carrotsmuggler
Also found by: AkshaySrivastav, ElKu, HollaDieWaldfee, Iurii3, KmanOfficial, adriro, bin2chen, evan, hansfriese, hl_, mert_eren, omis, peanuts
122.948 USDC - $122.95
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L91-L93 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L96-L98
Erc20Quest#withdrawRemainingTokens() may count remaining tokens incorrectly if Erc20Quest#withdrawFee() is called first.
Quest deployer cannot get back the funds from those participants that did not successfully mint a ticket.
In Erc20Quest#withdrawRemainingTokens(), the owner can withdraw the remaining tokens that is left in the contract.
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); }
Firstly, the function tabulates the amount of unclaimed tokens, which is the total amount of people that has minted a receipt minus those that already claimed, multiplied by the rewardAmount.
Next, the function calculates the nonClaimableTokens, which is the total amount in the contract minus protocolFee() and unclaimedTokens. ProtocolFee() is calculated by taking the total amount of people that has minted a receipt * amount * questFee and divided by BPS.
function protocolFee() public view returns (uint256) { return (receiptRedeemers() * rewardAmountInWeiOrTokenId * questFee) / 10_000; }
However, the function did not take into account that the protocolFee() might already been withdrawn.
uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens;
If the protocolFee() is already withdrawn, then the nonClimableTokens should not account for the protocolFee anymore. Otherwise, the quest deployer will not be able to withdraw their reward.
Eg There is 1500 USDC in the contract. 500 USDC is already distributed to those that minted and claimed.
There is now 1000 USDC in the contract. 200 USDC is protocol fee, 700 USDC is unclaimed tickets and 100 USDC is leftover for failed quests. Quest deployer calls withdrawRemainingTokens and gets 100 USDC back.
However, if protocolFees is withdrawn already (1000-200 = 800), there will be 800 USDC in the contract (address(this)). 200 USDC is protocol fee (protocolFee), 700 USDC is unclaimed tickets (unclaimedTokens), 100 USDC is leftover. Quest deployer calls withdrawRemainingTokens and attempt to withdraw 100 USDC . However, function will result in an overflow because 800 - 200 - 700 = -100.
Manual Review
The function should check whether the protocol fee is withdrawn already. A suggestion is to create a modifier in the protocolFee with a boolean, and check that the protocolFee() is not withdrawn yet before calling withdrawRemainingTokens(). Another suggestion would be to put withdrawFee() and withdrawRemainingTokens() in the same function
#0 - c4-judge
2023-02-05T05:44:33Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-06T08:24:35Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2023-02-06T08:24:41Z
kirk-baird marked the issue as duplicate of #61
#3 - c4-judge
2023-02-14T10:00:55Z
kirk-baird changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-14T10:01:08Z
kirk-baird marked the issue as satisfactory
#5 - c4-judge
2023-02-23T23:48:12Z
kirk-baird changed the severity to 2 (Med Risk)
🌟 Selected for report: CodingNameKiki
Also found by: 0x1f8b, 0x4non, 0x5rings, 0xAgro, 0xMirce, 0xRobocop, 0xSmartContract, 0xackermann, AkshaySrivastav, ArmedGoose, Aymen0909, BClabs, Breeje, Dewaxindo, Diana, HollaDieWaldfee, IceBear, IllIllI, Iurii3, Josiah, Krayt, PaludoX0, Phenomana, PrasadLak, RaymondFam, Rolezn, SaeedAlipoor01988, SaharDevep, SleepingBugs, adriro, arialblack14, bin2chen, brevis, btk, carlitox477, carrotsmuggler, catellatech, chaduke, chrisdior4, cryptonue, cryptostellar5, csanuragjain, ddimitrov22, fellows, glcanvas, halden, hl_, horsefacts, jat, joestakey, kenta, libratus, lukris02, luxartvinsec, manikantanynala97, martin, matrix_0wl, nadin, nicobevi, oberon, peanuts, prestoncodes, rbserver, sakshamguruji, sashik_eth, sayan, seeu, thekmj, tnevler, trustindistrust, tsvetanovv, xAriextz, zadaru13, zaskoh
17.196 USDC - $17.20
transferOwnership function is used to change Ownership from QuestFactory.sol. Use a 2 structure transferOwnership (Ownable2Step) which is safer. safeTransferOwnership, use it is more secure due to 2-stage ownership transfer.
When creating a quest, make sure to check the 0 value of rewardTokenAddress_, totalParticipants, rewardAmountOrTokenId, contractType, questId etc. Also, sanity check for endTime > startTime_, endTime_ > block.timestamp and startTime_ > block.timestamp should come earlier.
function createQuest( address rewardTokenAddress_, uint256 endTime_, uint256 startTime_, uint256 totalParticipants_, uint256 rewardAmountOrTokenId_, string memory contractType_, string memory questId_
In Quest.sol, the pause functions pauses the quest and the unPause function unpauses the quest. However, the pause and unpause function still works even after the endTime has passed. The claim() function can still be paused by the quest deployer after the endTime has passed, which is a centralization issue and shouldn't be the case. Pause and unpause should only work during the quest duration, and not before or after. Also, minting receipts can still be allowed when the quest is paused.
Non-library/interface files should use fixed compiler versions, not floating ones
eg. 0.8.15 instead of ^0.8.15
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc1155Quest.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleTickets.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/ReceiptRenderer.sol#L2 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/TicketRenderer.sol#L2
Rather than using abi.encodePacked for appending bytes, since version 0.8.4, bytes.concat() is enabled. Since version 0.8.4 for appending bytes, bytes.concat() can be used instead of abi.encodePacked(,)
if (quests[questId_].questAddress != address(0)) revert QuestIdUsed(); if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc20'))) { if (rewardAllowlist[rewardTokenAddress_] == false) revert RewardNotAllowed(); Erc20Quest newQuest = new Erc20Quest(
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; import '@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol'; import '@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol';
eg.
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
In Erc20Quest.sol#withdrawRemainingTokens(), nonClaimableTokens should be renamed to unsuccessfulTokensToBeReclaimed. nonClaimableTokens sounds as though it is not claimable by anyone, which induces confusion, as the intention of the function is to just withdraw the excess tokens that have been deposited but participants were unable to successfully complete the quest.
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); }
The test coverage rate of the project is 89%. Testing all functions is best practice in terms of security criteria. Due to its capacity, test coverage is expected to be 100%.
Order of Functions; ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier. But there are contracts in the project that do not comply with this.
https://docs.soliditylang.org/en/v0.8.17/style-guide.html
Functions should be grouped according to their visibility and ordered:
All contracts, eg: https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L1-L151
#0 - c4-judge
2023-02-06T22:28:34Z
kirk-baird marked the issue as grade-b
#1 - c4-sponsor
2023-02-07T23:06:05Z
waynehoover marked the issue as sponsor confirmed