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: 19/173
Findings: 5
Award: $271.96
🌟 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
The Erc20Quest.withdrawFee
function is used to withdraw the protocol fee to the protocolFeeRecipient
(https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102-L104).
The protocolFee
is a percentage of the total rewards.
This function has the onlyAdminWithdrawAfterEnd
modifier which other than it name suggests does not check that the msg.sender
is the admin. It only checks that the function is called after the quest has ended (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L76-L79).
Also there is no check that the function is only called once.
So to summarize, the Erc20Quest.withdrawFee
function can be called by any user multiple times.
This means that this can be used to withdraw all funds in the contract to the protocolFeeRecipient
.
This situation is made worse by the fact that users can claim their rewards at any point in time. Even when the quest has ended. This was confirmed by the sponsor to be intended. So the end time of the quest is only really relevant to withdrawing fees (i.e. fees must be withdrawn after the end).
This means the users that have not yet claimed their rewards can lose them because all funds can be sent to the protocolFeeRecipient
. The fact that users lose their rewards makes me estimate this issue to be "High" severity.
Assume the following situation:
Erc20Quest.withdrawFee
multiple times, until as much funds as possible are sent to the protocolFeeRecipient
VSCode
The Erc20Quest.withdrawFee
function should only be called once. This can easily be implemented by adding a isFeeWitdrawn
flag.
function withdrawFee() public onlyAdminWithdrawAfterEnd { if (isFeeWithdrawn) revert FeeAlreadyWithdrawn; IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); isFeeWithdrawn = true; }
#0 - c4-judge
2023-02-03T05:11:59Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-03T05:12:06Z
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
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L219-L229
When the endTime
is reached, any unclaimed tokens can be withdrawn from the Erc20Quest
contract via the withdrawRemainingTokens
function (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87).
The endTime
is checked in the onlyAdminWithdrawAfterEnd
modifier in the parent function (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L150).
This is the calculation to determine the amount to withdraw:
uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens);
You can see that the rewards for any minted receipts that are not yet redeemed will stay in the contract.
The issue with this is that there is no check in the QuestFactory.mintReceipt
function that makes sure that receipts cannot be minted after the endTime
of a quest is reached (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L219-L229).
Therefore when Erc20Quest.withdrawRemainingTokens
is called and then a new receipt is minted, there won't be enough rewards in the Erc20Quest
contract for everyone to redeem.
The sponsor provided the information that the claimSigner
will sign mintReceipt
requests only when the endTime
is not yet reached.
I think however that this is not sufficient and the endTime
check must occur on-chain in the QuestFactory.mintReceipt
function.
This is because a mintReceipt
request might be signed off-chain in time (i.e. before the endTime
) but is only processed on-chain after the endTime
.
Think of the following scenario:
claimSigner
signs a mintReceipt
request for User A before the endTime
is reachedendTime
is reached and all remaining funds are withdrawn from the Erc20Quest
contract. Say there is funds for one receipt left in the contract which can be redeemed by User BmintReceipt
request is processed on-chain and User A redeems his rewardErc20Quest
contractYou can see from this scenario how - when a mintReceipt
request is processed on-chain after the endTime
- users can miss out on their rewards.
VSCode
The QuestFactory
contract should have access to the endTime
of each Quest and not mint receipts after the endTime
has been reached.
This can be achieved by extending the Quest
struct to include the endTime
:
struct Quest { mapping(address => bool) addressMinted; address questAddress; uint totalParticipants; uint numberMinted; uint endTime; }
The endTime
can then be checked in the QuestFactory.mintReceipt
function:
if (quests[questId_].endTime <= block.timestamp) revert QuestExpired();
#0 - c4-judge
2023-02-05T02:40:07Z
kirk-baird marked the issue as duplicate of #22
#1 - c4-judge
2023-02-14T08:48:02Z
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
The Erc1155Quest.withdrawRemainingTokens
function is used to withdraw the remaining funds from the contract after the quest has ended (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc1155Quest.sol#L54-L63).
It is intended that users that have a valid receipt can claim their reward even after the quest has ended. This was confirmed by the sponsor.
The issue is that the Erc1155Quest.withdrawRemainingTokens
function withdraws ALL remaining tokens and does not leave unclaimed tokens in the contract. This means that any users that want to claim their token after Erc1155Quest.withdrawRemainingTokens
was called cannot do so anymore because there are no tokens left.
You can see in the Erc20Quest.withdrawRemainingTokens
function (which is implemented correctly) how it should work:
uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens);
The unclaimed tokens should remain in the contract.
Erc1155Quest
contractendTime
is reached and the owner calls Erc1155Quest.withdrawRemainingTokens
which transfers all reward tokens to the to
addressVSCode
The Erc1155Quest.withdrawRemainingTokens
function should make sure that receiptRedeemers() - redeemedTokens
tokens remain in the contract.
Therefore it also needs to implement the receiptReedemers()
function which can be the same as in the Erc20Quest
contract.
#0 - c4-judge
2023-02-03T08:27:49Z
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:14Z
This previously downgraded issue has been upgraded by kirk-baird
#3 - c4-judge
2023-02-14T09:28:20Z
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
The Erc20Quest.withdrawRemainingTokens
function (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87) is used to withdraw all remaining reward tokens once the endTime
is reached.
The issue is that this function incorporates the protocolFee
into its calculation.
The protocolFee
however can already be paid out when the Erc20Quest.withdrawRemainingTokens
function is called. There is no mechanism by which Erc20Quest.withdrawFee()
must be called after Erc20Quest.withdrawRemainingTokens
.
At the very least this results in a withdrawn amount that is too small if protocol fees have been withdrawn before.
However the fact that protocol fees are subtracted that are not in the contract anymore can also make the calculation underflow which causes an amount up to the protocol fees to be stuck in the contract.
The calculation to determine the amount of tokens to withdraw is this:
uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens;
Assume the following:
IERC20(rewardToken).balanceOf(address(this)) = 40 USDC protocolFee() = 50 USDC unclaimedTokens = 0 USDC
In this situation all 40 USDC should be withdrawn because there are no unclaimedTokens
and the protocolFee
is already paid out.
However the calculation reverts which causes the 40 USDC to be stuck in the contract.
VSCode
I discussed this issue with the sponsor and it was decided that probably the best solution is to save the yet to be paid fees in a uint
variable that is subtracted from when fees are paid out.
This variable is added to when receipts are minted.
This solution also requires a modification of the data flow, i.e. the quest contract must know when a new receipt is minted.
The sponsor mentioned that this will be investigated as part of a broader refactoring of the data flow.
#0 - c4-judge
2023-02-03T10:29:25Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-06T08:18:08Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2023-02-06T08:18:14Z
kirk-baird marked the issue as primary issue
#3 - c4-sponsor
2023-02-07T20:47:05Z
waynehoover marked the issue as sponsor confirmed
#4 - c4-judge
2023-02-14T10:02:06Z
kirk-baird changed the severity to 3 (High Risk)
#5 - c4-judge
2023-02-14T10:02:58Z
kirk-baird marked issue #122 as primary and marked this issue as a duplicate of 122
#6 - c4-judge
2023-02-14T10:03:10Z
kirk-baird marked the issue as satisfactory
#7 - c4-judge
2023-02-23T23:48:11Z
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
119.6013 USDC - $119.60
Risk | Title | File | Instances |
---|---|---|---|
L-01 | Unlocked pragma | - | 10 |
L-02 | Ownable + OwnableUpgradeable: Owner can renounce ownership | - | 6 |
L-03 | Ownable + OwnableUpgradeable: Does not implement 2-Step-Process for transferring ownership | - | 6 |
L-04 | questIdCount should start at 0 | QuestFactory.sol | 1 |
L-05 | Check value of royaltyFee | RabbitHoleReceipt.sol | 1 |
L-06 | initialize function should call setters | RabbitHoleReceipt.sol | 1 |
L-07 | rabbitHoleReceiptContract should not be mutable | QuestFactory.sol | 1 |
N-01 | Lines too long | - | 3 |
N-02 | Remove TODO comments | interfaces/IQuest.sol | 1 |
N-03 | Remove unnecessary imports | Quest.sol | 1 |
N-04 | questId can be immutable | Quest.sol | 1 |
Currently the Solidity source files will compile with any version >=0.8.15
and <0.9.0
.
It is best practice to use a fixed compiler version.
There are 10 instances of this issue:
Multiple of the in-scope contracts inherit from openzeppelin's Ownable
or OwnableUpgradeable
contract.
Both contracts contain a renounceOwnership
function that allows the owner of the contract to transfer ownership to the zero address. So ownership of the contract is lost.
You should consider implementing this function in the child contracts to disable it, i.e. override it and revert when it is called.
There are 4 instances of this issue:
Multiple of the in-scope contracts inherit from openzeppelin's Ownable
or OwnableUpgradeable
contract.
This contract does not implement a 2-Step-Process
for transferring ownership.
So ownership of a contract can easily be lost when making a mistake when transferring ownership.
Consider using the Ownable2Step
and Ownable2StepUpgradeable
contracts instead.
Note:
Switching to Ownable2Step
and Ownable2StepUpgradeable
does not solve the [L-02] issue. So also override and disable the renounceOwnership
function.
There are 4 instances of this issue:
questIdCount
should start at 0
The questIdCount
is set to 1
in the constructor (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L49).
And it is incremented every time a quest is created.
This means after the first quest is created it is set to 2
, after the second quest is created it is set to 3
and so on.
It was determined with the sponsor that this variable should count the number of quests created.
Therefore the constructor should not set it to 1
but instead leave it at its default value which is 0
.
royaltyFee
The RabbitHoleReceipt.setRoyaltyFee
function does not check the value that royaltyFee
is set to (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L90-L93).
It should be checked that the value is not greater than 10,000 which is 100%.
initialize
function should call settersThe RabbitHoleReceipt.initialize
function sets the royaltyFee
and minterAddress
directly (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L43-L56).
However it should call setMinterAddress
and setRoyaltyFee
such that the MinterAddressSet
and RoyaltyFeeSet
events are emitted.
rabbitHoleReceiptContract
should not be mutableThe QuestFactory.setRabbitHoleReceiptContract
function (https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L172-L174) allows to set the rabbitHoleReceiptContract
variable.
This is dangerous because all quest contracts rely on this for their operation.
I propose that the rabbitHoleReceiptContract
should only be set once in the initialize
function.
The maximum line length should be 164 characters.
That's because GiHub will not display lines longer than that on the screen without scrolling.
There are 3 instances of this:
TODO
comments should be resolved and not be included in production code.
There is 1 instance of this:
Solidity files should only import the dependencies that are necessary to make the code cleaner and make it easier to understand.
There is 1 instance of this:
questId
can be immutableThe questId
is only set in the constructor, so it can be declared immutable
.
#0 - c4-sponsor
2023-02-08T14:56:46Z
GarrettJMU marked the issue as sponsor confirmed
#1 - c4-judge
2023-02-16T07:36:46Z
kirk-baird marked the issue as grade-a