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: 49/173
Findings: 4
Award: $49.82
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0xMAKEOUTHILL, 0xMirce, 7siech, AkshaySrivastav, AlexCzm, Awesome, Aymen0909, Cryptor, Deivitto, DimitarDimitrov, ElKu, Garrett, Jayus, Josiah, Kenshin, KrisApostolov, RaymondFam, SovaSlava, Timenov, UdarTeam, amaechieth, btk, c3phas, codeislight, fellows, frankudoags, gzeon, hansfriese, luxartvinsec, millersplanet, mookimgo, navinavu, oberon, paspe, pavankv, petersspetrov, pfapostol, prestoncodes, rbserver, sakshamguruji, shark, thekmj, trustindistrust, tsvetanovv, usmannk, vagrant, vanko1, xAriextz, yosuke
2.5852 USDC - $2.59
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L58-L61 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleTickets.sol#L47-L50
The current onlyMinter
modifier is implemented as shown
modifier onlyMinter() { msg.sender == minterAddress; _; }
This modifier does not perform any checks nor reverting. The sponsor may have intended this to be an access control mechanism.
As such, anyone can mint a receipt, as the contract is missing the important access control. This affects both RabbitHoleTicket
and RabbitHoleReceipt
.
Manual review
Correct the modifier with an appropriate require
statement
modifier onlyMinter() { require(msg.sender == minterAddress, "Only minter"); _; }
Alternatively, the sponsor may opt to use custom errors for better gas saving.
#0 - c4-judge
2023-02-03T02:10:59Z
kirk-baird marked the issue as satisfactory
#1 - c4-judge
2023-02-03T02:23:25Z
kirk-baird marked the issue as primary issue
#2 - c4-sponsor
2023-02-07T20:26:56Z
waynehoover marked the issue as sponsor acknowledged
#3 - c4-judge
2023-02-14T08:39:53Z
kirk-baird marked issue #608 as primary and marked this issue as a duplicate of 608
🌟 Selected for report: carlitox477
Also found by: 0xRobocop, 0xbepresent, ArmedGoose, Atarpara, IllIllI, Tointer, UdarTeam, adriro, betweenETHlines, cryptojedi88, evan, glcanvas, gzeon, horsefacts, ladboy233, libratus, lukris02, luxartvinsec, manikantanynala97, minhquanym, mookimgo, p4st13r4, simon135, thekmj, trustindistrust
18.6976 USDC - $18.70
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L96-L118 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L109-L135
Disclaimer: The warden has submitted a separate issue on the access control of the mint()
function, which would've made this issue to be of High risk. In this report, we assume that said issue has been mitigated.
The claim()
function will iterate over all owned receipts by a user, and also performs numerous storage writes. This opens up a self-DOS possibility that can lock funds under certain circumstances.
This report aims to give an estimation of the gas usage of the function, an analysis on the likelihood of a self-DOS happening, as well as the impact caused.
Let us first analyze the gas usage of storage accesses from the claim()
function in Quest.sol
contract:
uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender);
Since the getOwnedTokenIdsOfQuest()
is a view function, the gas cost can be estimated to be (Storage read * balance of user) multiplied by a constant factor. The length of the loop is bounded by the number of receipts said user owns across all quests.
_setClaimed(tokens);
This will cost significantly more gas than the former operation, since this is a storage write. The length of the loop is bounded by the number of receipts said user owns from this quest only.
We can see that the gas usage directly correlates to the number of receipts owned by the claiming user. The two likely scenario where a self-DOS happens might be:
_setClaimed()
loop.rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest()
loop.
The problem arises when the claim()
from Quests
does not have a backdoor mechanism for claiming by specific tokens:
The result is locking of rewards for a user, which can get costly to recover.
It is worth noting that, token ownership information from getOwnedTokenIdsOfQuest()
can technically be obtained from emitted events as well, making the claim()
loop to be the only one with a severity worth mentioning.
Manual review.
We propose mitigation methods for both unbounded loops.
_setClaimed()
loop problem, we recommend adding a claimByID(uint256[] calldata tokenIDs)
function, which will claim rewards from all of the given tokens, performing appropriate ownership checks. This will create a recovery backdoor for the existing problem.getOwnedTokenIdsOfQuest()
, we propose to burn the token from the user's wallet upon a successful claim.#0 - c4-judge
2023-02-05T05:15:08Z
kirk-baird marked the issue as duplicate of #135
#1 - c4-judge
2023-02-14T09:18:11Z
kirk-baird marked the issue as satisfactory
🌟 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
Finding | Details | Context |
---|---|---|
[L-01] | Missing sanity check for royalty fee | 2 |
[N-01] | Useful information in events should be indexed | 1 |
[N-02] | Don't compare a boolean with true | 2 |
[N-03] | Define a constant instead of using a magic value | 3 |
According to the function royaltyInfo
, the royalty fee is measured in BPS. However there is no sanity check for the amount of fee, meaning the owner can set it arbitrarily high. This is recognized as an issue given there is such a check on QuestFactory
.
The following event can use an indexed
field into address account
. Although indexed fields will incur slightly more gas (375 gas per field per event), it will make it easy to query useful info specific to the field (e.g. querying how many claims has this user done, total amounts, and such)
event Claimed(address account, uint256 amount);
true
A boolean expression in an if-condition is straightforwardly evaluated. There is no need to check if (condition == true)
.
Removing the check will also save some gas.
constant
instead of using a magic valueuint256 royaltyPayment = (salePrice_ * royaltyFee) / 10_000;
if (questFee_ > 10_000) revert QuestFeeTooHigh();
#0 - c4-judge
2023-02-05T05:21:33Z
kirk-baird marked the issue as grade-c
#1 - c4-judge
2023-02-16T07:38:02Z
kirk-baird marked the issue as grade-b
#2 - kirk-baird
2023-02-16T07:38:16Z
With #36 I'm going to raise this to grade-b
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0xAgro, 0xSmartContract, 0xhacksmithh, 0xngndev, Aymen0909, Bnke0x0, Breeje, Deivitto, Diana, Dug, Iurii3, LethL, MiniGlome, NoamYakov, RaymondFam, ReyAdmirado, Rolezn, SAAJ, adriro, ali, arialblack14, atharvasama, c3phas, carlitox477, catellatech, chaduke, cryptonue, cryptostellar5, ddimitrov22, dharma09, doublesharp, favelanky, georgits, glcanvas, gzeon, halden, horsefacts, jasonxiale, joestakey, karanctf, lukris02, matrix_0wl, nadin, navinavu, saneryee, shark, thekmj
11.3269 USDC - $11.33
Number | Details | Context | Approx. gas saved |
---|---|---|---|
[G-01.1-3] | Tightly pack storage for better gas saving | 3 | 57300 total |
QuestFactory contract storage | 4000 | ||
struct Quest | 34200 | ||
Quest contract storage | 19100 | ||
[G-02] | Don't initialize storage with default value | 1 | 2900 |
[G-03] | Redundant condition check | 1 | 25 |
[G-04] | Don't compare a boolean with true | 2 | 6 |
Total | 60231 gas saved |
This finding is divided into several findings due to distinction, but the main technique of storage packing is applied in all
QuestFactory
contract storageThe current storage layout is as follow:
address public claimSignerAddress; // one slot address public protocolFeeRecipient; // one slot mapping(string => Quest) public quests; // one slot RabbitHoleReceipt public rabbitholeReceiptContract; // one slot mapping(address => bool) public rewardAllowlist; // one slot uint public questFee; // one slot uint public questIdCount; // one slot
We can notice that questFee
and questIdCount
cannot be too large (with questFee
not exceeding 10000, and questIdCount
incremented one by one). We can use smaller value types and better pack storage as follow:
address public claimSignerAddress; // one slot address public protocolFeeRecipient; uint48 public questFee; uint48 public questIdCount; // end of slot mapping(string => Quest) public quests; RabbitHoleReceipt public rabbitholeReceiptContract; mapping(address => bool) public rewardAllowlist;
Since all of these storage variables are accessed every time a quest is created, this replaces two instances of Gwarmaccess (2100 gas) with Gcoldsload (100 gas), saving a total of 4000 gas per quest created.
This will also save some deployment gas, due to having to set up fewer storage slots than usual.
struct Quest
The current layout for Quest
struct is as follow:
struct Quest { mapping(address => bool) addressMinted; // 1 slot address questAddress; // 2 slot uint totalParticipants; // 3 slot uint numberMinted; // 4 slot }
Note that this is used in storage as the quests
mapping, so this is relevant.
There is room for optimization in totalParticipants
and numberMinted
:
totalParticipants
: Is the number of participants.numberMinted
: Is incremental.We can thus reason that the values will be reasonably small. Thus the following layout is recommended:
struct Quest { mapping(address => bool) addressMinted; // 1 slot address questAddress; // 2 slot uint48 totalParticipants; // 2 slot uint48 numberMinted; // 2 slot }
The change brings the following optimizations:
totalParticipants
not having to write to an empty storage. Equates to 17100 gas saved per quest.numberMinted
doesn't have to write to a zero storage slot. Thus another 17100 gas is saved in the same manner, per quest.The total gas saving is 34200 per quest created.
Quest
contract storageThe storage layout is as follow
bool public hasStarted; // slot 1 bool public isPaused; // slot 1 string public questId; // slot 2 uint256 public redeemedTokens; // slot 3
The redeemedTokens
is a count of the number of tokens redeemed by the user, which cannot be too large by definition. Thus we can tightly pack the storage as follow:
bool public hasStarted; // slot 1 bool public isPaused; // slot 1 uint240 public redeemedTokens; // slot 1 string public questId; // slot 2
The effect are as follow:
hasStarted
and redeemedTokens
is accessed whenever claim()
is (successfully) called, converts one Gcoldsload (2100 gas) into Gwarmaccess (100 gas) for 2000 gas in total.hasStarted
has to be already True
for the function to not revert, a write to redeemedTokens
will not use a Gsset (20000 gas), but rather use a Gsreset (2900 gas), saving 17100 gas for the first claim made by the user per quest.This saves a total of 19100 gas.
Affected instances:
redeemedTokens = 0; // @audit this is redundant
The given storage variable is guaranteed by logic to be default. Setting it with the same value will incur a redundant Gsreset (2900 gas).
This finding is similar to [GAS-4] of C4udit, but was not found by the automated tool.
Note the condition check at the Quest
constructor:
if (endTime_ <= block.timestamp) revert EndTimeInPast(); if (startTime_ <= block.timestamp) revert StartTimeInPast(); if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime();
The first condition is redundant. That is, if the latter two conditions are true, then the first condition is always true. Thus we can remove the first check entirely.
Saves 25 gas when run on hardhat gas report.
true
There is no need to check if (condition == true)
, but simply if (condition)
will suffice.
Saves 3 gas per instance.
#0 - c4-judge
2023-02-16T07:04:45Z
kirk-baird marked the issue as grade-b
#1 - kirk-baird
2023-02-16T07:05:15Z
This is a good quality report, with one or two more issues it would be grade-a