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: 46/173
Findings: 3
Award: $52.84
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 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
24.3069 USDC - $24.31
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L99 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L117-L133
claim
function can be summaraized in next steps:
questId
using rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest
: DOS_calculateRewards(redeemableTokenCount);
redeemedTokens
The problem with this functions relays in its dependency on RabbitHoleReceipt.getOwnedTokenIdsOfQuest
. It's behaviour can be summarized in next steps:
If a user takes part in many quests and gets lot's of tokens, the claim function will eventually reach block gas limit. Therefore, it will be unable to claim their tokens.
If a user actively participates in multiple quests and accumulates a large number of tokens, the claim function may eventually reach the block gas limit. As a result, the user may be unable to successfully claim their earned tokens.
It can be argued that function ERC721.burn
can address the potential DOS risk in the claim process. However, it is important to note the following limitations and drawbacks associated with this approach:
ERC721.burn
does not prevent the user from incurring network fees if a griefer, who has already claimed their rewards, sends their tokens to the user with the intent of causing a DOS and inducing loss of gas.Then, Alice can only burn her some of her tokens to claim at least some rewards.
If a user can sent a token list by parameter to claim function, then this vector attack can be mitigated.
To do this add next function to RabbitHoleReceipt.sol
:
function checkTokenCorrespondToQuest(uint tokenId, string memory questId_) external view returns(bool){ return keccak256(bytes(questIdForTokenId[tokenId])) == keccak256(bytes(questId_)); }
Then modify Quest.claim
:
// Quest.sol - function claim() public virtual onlyQuestActive { + function claim(uint[] memory tokens) public virtual onlyQuestActive { if (isPaused) revert QuestPaused(); - uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender); // require(tokens.length > 0) if (tokens.length == 0) revert NoTokensToClaim(); uint256 redeemableTokenCount = 0; for (uint i = 0; i < tokens.length; i++) { // Check that the token correspond to this quest require(rabbitHoleReceiptContract.checkTokenCorrespondToQuest(tokens[i],questId)) - if (!isClaimed(tokens[i])) { + if (!isClaimed(tokens[i]) && rabbitHoleReceiptContract.checkTokenCorrespondToQuest(tokens[i],questId)) { redeemableTokenCount++; } } if (redeemableTokenCount == 0) revert AlreadyClaimed(); uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); _setClaimed(tokens); _transferRewards(totalRedeemableRewards); redeemedTokens += redeemableTokenCount; emit Claimed(msg.sender, totalRedeemableRewards); }
#0 - c4-judge
2023-02-06T22:27:32Z
kirk-baird marked the issue as duplicate of #135
#1 - c4-judge
2023-02-14T09:17:06Z
kirk-baird changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-02-14T09:17:37Z
kirk-baird marked the issue as satisfactory
#3 - c4-judge
2023-02-14T09:21:15Z
kirk-baird marked the issue as selected for report
#4 - c4-sponsor
2023-04-11T20:03:15Z
waynehoover marked the issue as sponsor acknowledged
🌟 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
endTime_ > block.timestamp
check is redundantCurrently
// Quest constructor if (endTime_ <= block.timestamp) revert EndTimeInPast(); if (startTime_ <= block.timestamp) revert StartTimeInPast(); if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime();
This means thats next checks are done:
endTime_ > block.timestamp
startTime_ > block.timestampp
endTime_ > startTime_
From 2 and 3: endTime_ > startTime_ > block.timestampp
Then 1 can be omitted. Line if (endTime_ <= block.timestamp) revert EndTimeInPast();
should be removed, as well as EndTimeInPast()
error declaration.}
Quest.start
, Quest.pause
, Quest.unPause
should emit eventsGiven that this calls are significant for multiple core contract functionalities, emiting events when this function are called informing the quest start and its pause/unpause would be advisable.
Quest.pause
, Quest.unPause
, Quest.start
can be called at any timeQuest.pause
should check that isPaused = false
, while Quest.unPause
should check that isPaused = true
in order to execute a meaningful transaction.
Quest.start
should check that the quest has not already started.
Erc20Quest.withdrawFee
allows anyone to call this functionModifier onlyAdminWithdrawAfterEnd
does not check the admin/owner, meaning anyone can call withdrawFee
function.
Mittigation steps: Add onlyOwner
modifier or check ownership in onlyAdminWithdrawAfterEnd
modifier.
RabbitHoleTickets.royaltyInfo(uint256,uint256)
remove first parametertokenId_
is not used inside the function, then it can be removed
function royaltyInfo( - uint256 tokenId_, uint256 salePrice_ ) external view override returns (address receiver, uint256 royaltyAmount) { uint256 royaltyPayment = (salePrice_ * royaltyFee) / 10_000; return (royaltyRecipient, royaltyPayment); }
RabbitHoleTickets
intitalizer and setters should check non zero address assignationfunction initialize( address ticketRenderer_, address royaltyRecipient_, address minterAddress_, uint royaltyFee_ ) public initializer { __ERC1155_init(''); __Ownable_init(); __ERC1155Burnable_init(); + require(ticketRenderer_ != address(0), ERROR_ADDRESS_ZERO_ASSIGNATION()); + require(royaltyRecipient_ != address(0), ERROR_ADDRESS_ZERO_ASSIGNATION()); + require(minterAddress_ != address(0), ERROR_ADDRESS_ZERO_ASSIGNATION()); royaltyRecipient = royaltyRecipient_; minterAddress = minterAddress_; royaltyFee = royaltyFee_; TicketRendererContract = TicketRenderer(ticketRenderer_); } //... function setTicketRenderer(address ticketRenderer_) public onlyOwner { + require(ticketRenderer_ != address(0), ERROR_ADDRESS_ZERO_ASSIGNATION()); TicketRendererContract = TicketRenderer(ticketRenderer_); } //... function setRoyaltyRecipient(address royaltyRecipient_) public onlyOwner { + require(royaltyRecipient_ != address(0), ERROR_ADDRESS_ZERO_ASSIGNATION()); royaltyRecipient = royaltyRecipient_; } //... function setMinterAddress(address minterAddress_) public onlyOwner { + require(minterAddress_ != address(0), ERROR_ADDRESS_ZERO_ASSIGNATION()); minterAddress = minterAddress_; emit MinterAddressSet(minterAddress_); }
RabbitHoleTickets
setters should check if value are really changedfunction setTicketRenderer(address ticketRenderer_) public onlyOwner { require(ticketRenderer_ != address(TicketRendererContract)); TicketRendererContract = TicketRenderer(ticketRenderer_); } //... function setRoyaltyRecipient(address royaltyRecipient_) public onlyOwner { + require(royaltyRecipient != royaltyRecipient_); royaltyRecipient = royaltyRecipient_; } function setRoyaltyFee(uint256 royaltyFee_) public onlyOwner { + require(royaltyFee != royaltyFee_); royaltyFee = royaltyFee_; emit RoyaltyFeeSet(royaltyFee_); } //... function setMinterAddress(address minterAddress_) public onlyOwner { + require(minterAddress_ != minterAddress); minterAddress = minterAddress_; emit MinterAddressSet(minterAddress_); }
setTicketRenderer
and setRoyaltyRecipient
are calledThese seem important methods given they modifed state variables, it would be advisable to emit and event indicating the new values.
#0 - c4-judge
2023-02-06T23:20:59Z
kirk-baird marked the issue as grade-b
#1 - c4-sponsor
2023-02-07T22:04:58Z
waynehoover marked the issue as sponsor acknowledged
🌟 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
endTime_ > block.timestamp
check and EndTimeInPast error declaraion to save gasCurrently
// Quest constructor if (endTime_ <= block.timestamp) revert EndTimeInPast(); if (startTime_ <= block.timestamp) revert StartTimeInPast(); if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime();
This means thats next checks are done:
endTime_ > block.timestamp
startTime_ > block.timestampp
endTime_ > startTime_
From 2 and 3: endTime_ > startTime_ > block.timestampp
Then 1 can be omitted, saving gas. Line if (endTime_ <= block.timestamp) revert EndTimeInPast();
should be removed, as well as EndTimeInPast()
error declaration.
Erc20Quest.maxTotalRewards
and Erc20Quest.maxProtocolReward
can be immutable in order to save gasGiven than totalParticipants
, rewardAmountInWeiOrTokenId
and questFee
are immutable, and current function implementations, these values can calculated during construction to save gas each time they are used inside the contract. Then:
Erc20Quest.maxTotalRewards
and Erc20Quest.maxProtocolReward
// Erc20Quest.sol + uint256 public immutable maxTotalRewards; + uint256 public immutable maxProtocolReward; + uint256 internal immutable sumMaxRewards; constructor( address rewardTokenAddress_, uint256 endTime_, uint256 startTime_, uint256 totalParticipants_, uint256 rewardAmountInWeiOrTokenId_, string memory questId_, address receiptContractAddress_, uint256 questFee_, address protocolFeeRecipient_ ) Quest( rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountInWeiOrTokenId_, questId_, receiptContractAddress_ ) { questFee = questFee_; protocolFeeRecipient = protocolFeeRecipient_; questFactoryContract = QuestFactory(msg.sender); + maxTotalRewards = totalParticipants_ * rewardAmountInWeiOrTokenId; + maxProtocolReward = (maxTotalRewards * questFee) / 10_000; + sumMaxRewards = maxTotalRewards + maxProtocolReward; }
// Erc20Quest.sol function start() public override { - if (IERC20(rewardToken).balanceOf(address(this)) < maxTotalRewards() + maxProtocolReward()) + if (IERC20(rewardToken).balanceOf(address(this)) < sumMaxRewards) revert TotalAmountExceedsBalance(); super.start(); }
This would also prevent any possible overflow and it consequences, given that constract creation will revert if this is the case.
This can be avoided by just using the bool value:
// Quest.isClaimed - return claimedList[tokenId_] == true; + return claimedList[tokenId_]; // QuestFactory.mintReceipt - if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); + if (quests[questId_].addressMinted[msg.sender]) revert AddressAlreadyMinted();
generateTokenURI
is just used by external contracts, then it visibility should be change to external in ReceiptRender
as well as in TicketRender
to save gas
Then, next function are never used internally:
// Quest - function pause() public onlyOwner onlyStarted { + function pause() external onlyOwner onlyStarted { //... - function unPause() public onlyOwner onlyStarted { + function unPause() external onlyOwner onlyStarted { //... - function claim() public virtual onlyQuestActive { + function claim() external virtual onlyQuestActive { //... - function getRewardAmount() public view returns (uint256) { + function getRewardAmount() external view returns (uint256) { //... - function getRewardToken() public view returns (address) { + function getRewardToken() external view returns (address) { //... // Erc20Quest - function start() public override { + function start() external override { //... // RabbitHoleTickets.sol function mintBatch( address to_, uint256[] memory ids_, uint256[] memory amounts_, bytes memory data_ - ) public onlyMinter { + ) external onlyMinter { //... - function mint(address to_, uint256 id_, uint256 amount_, bytes memory data_) public onlyMinter { + function mint(address to_, uint256 id_, uint256 amount_, bytes memory data_) external onlyMinter { //... - function setMinterAddress(address minterAddress_) public onlyOwner { + function setMinterAddress(address minterAddress_) external onlyOwner { //... - function setRoyaltyFee(uint256 royaltyFee_) public onlyOwner { + function setRoyaltyFee(uint256 royaltyFee_) external onlyOwner { //... - function setRoyaltyRecipient(address royaltyRecipient_) public onlyOwner { + function setRoyaltyRecipient(address royaltyRecipient_) external onlyOwner { //... - function setTicketRenderer(address ticketRenderer_) public onlyOwner { + function setTicketRenderer(address ticketRenderer_) external onlyOwner { //...
QuestFactory.createQuest
can be refactor to save gas during deploymentNext lines are repeated twice in the function
quests[questId_].questAddress = address(newQuest); quests[questId_].totalParticipants = totalParticipants_; newQuest.transferOwnership(msg.sender); ++questIdCount; return address(newQuest);
This can be avoid by refactoring the code:
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) { // require(quests[questId_].questAddress != 0) if (quests[questId_].questAddress != address(0)) revert QuestIdUsed(); if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc20'))) { // require(rewardAllowlist[rewardTokenAddress_]) if (rewardAllowlist[rewardTokenAddress_] == false) revert RewardNotAllowed(); Erc20Quest newQuest = new Erc20Quest( rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountOrTokenId_, questId_, address(rabbitholeReceiptContract), questFee, protocolFeeRecipient ); emit QuestCreated( msg.sender, address(newQuest), questId_, contractType_, rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountOrTokenId_ ); - quests[questId_].questAddress = address(newQuest); - quests[questId_].totalParticipants = totalParticipants_; - newQuest.transferOwnership(msg.sender); - ++questIdCount; - return address(newQuest); } - if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc1155'))) { + else if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc1155'))) { // require(msg.sender == owner()) if (msg.sender != owner()) revert OnlyOwnerCanCreate1155Quest(); Erc1155Quest newQuest = new Erc1155Quest( rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountOrTokenId_, questId_, address(rabbitholeReceiptContract) ); emit QuestCreated( msg.sender, address(newQuest), questId_, contractType_, rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountOrTokenId_ ); - quests[questId_].questAddress = address(newQuest); - quests[questId_].totalParticipants = totalParticipants_; - newQuest.transferOwnership(msg.sender); - ++questIdCount; - return address(newQuest); } + else{ + revert QuestTypeInvalid(); + } - revert QuestTypeInvalid(); + quests[questId_].questAddress = address(newQuest); + quests[questId_].totalParticipants = totalParticipants_; + newQuest.transferOwnership(msg.sender); + unchecked{++questIdCount;} + return address(newQuest); }
// Quest function _setClaimed(uint256[] memory tokenIds_) private { + mapping(address =>bool) storage _claimedList = claimedList; for (uint i = 0; i < tokenIds_.length; i++) { - claimedList[tokenIds_[i]] = true; + _claimedList[tokenIds_[i]] = true; } } // RabbitHoleReceipt.getOwnedTokenIdsOfQuest(string memory, address) + mapping(uint => string) _questIdForTokenId = questIdForTokenId; for (uint i = 0; i < msgSenderBalance; i++) { uint tokenId = tokenOfOwnerByIndex(claimingAddress_, i); - if (keccak256(bytes(questIdForTokenId[tokenId])) == keccak256(bytes(questId_))) { + if (keccak256(bytes(_questIdForTokenId[tokenId])) == keccak256(bytes(questId_))) { tokenIdsForQuest[i] = tokenId; foundTokens++; } }
In the case of claim
function in Quest
contract, function isClaimed
is called inside the loop, which make use of claimedList
state variable. In order to save gas using a storage pointer, the loop should be refactored to:
// Quest.claim uint256 redeemableTokenCount = 0; + mapping(address =>bool) storage _claimedList = claimedList; for (uint i = 0; i < tokens.length; i++) { - if (!isClaimed(tokens[i])) { + if (!_claimedList(tokens[i])) { redeemableTokenCount++; } }
If a function modifier such as onlyOwner is used, the function will revert if a normal user tries to pay the function. Marking the function as payable will lower the gas cost for legitimate callers because the compiler will not include checks for whether a payment was provided.
//Erc20Quest constructor( address rewardTokenAddress_, uint256 endTime_, uint256 startTime_, uint256 totalParticipants_, uint256 rewardAmountInWeiOrTokenId_, string memory questId_, address receiptContractAddress_, uint256 questFee_, address protocolFeeRecipient_ ) Quest( rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountInWeiOrTokenId_, questId_, receiptContractAddress_ - ) + ) payable //... - function start() public override { + function start() public payable override { //... - function withdrawRemainingTokens(address to_) public override onlyOwner { + function withdrawRemainingTokens(address to_) public payable override onlyOwner { //... // Erc1155Quest constructor( address rewardTokenAddress_, uint256 endTime_, uint256 startTime_, uint256 totalParticipants_, uint256 rewardAmountInWeiOrTokenId_, string memory questId_, address receiptContractAddress_ ) Quest( rewardTokenAddress_, endTime_, startTime_, totalParticipants_, rewardAmountInWeiOrTokenId_, questId_, receiptContractAddress_ - ){} + ) payable {} //... - function withdrawRemainingTokens(address to_) public override onlyOwner { + function withdrawRemainingTokens(address to_) public payable override onlyOwner //... - function start() public override { + function start() public payable override { //... // Quest constructor( address rewardTokenAddress_, uint256 endTime_, uint256 startTime_, uint256 totalParticipants_, uint256 rewardAmountInWeiOrTokenId_, string memory questId_, address receiptContractAddress_ - ) { + ) payable { //... - function start() public virtual onlyOwner { + function start() public virtual payable onlyOwner { //... - function pause() public onlyOwner onlyStarted { + function pause() public payable onlyOwner onlyStarted { //... - function unPause() public onlyOwner onlyStarted { + function unPause() public payable onlyOwner onlyStarted { - function withdrawRemainingTokens(address to_) public virtual onlyOwner onlyAdminWithdrawAfterEnd {} + function withdrawRemainingTokens(address to_) public payable virtual onlyOwner onlyAdminWithdrawAfterEnd {} //... // QuestFactory - constructor() initializer {} + constructor() payable initializer {} //... function initialize( address claimSignerAddress_, address rabbitholeReceiptContract_, address protocolFeeRecipient_ - ) public initializer { + ) public payable initializer { //... 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) { + ) public payable onlyRole(CREATE_QUEST_ROLE) returns (address) { //... - function changeCreateQuestRole(address account_, bool canCreateQuest_) public onlyOwner { + function changeCreateQuestRole(address account_, bool canCreateQuest_) public payable onlyOwner { //... - function setClaimSignerAddress(address claimSignerAddress_) public onlyOwner { + function setClaimSignerAddress(address claimSignerAddress_) public payable onlyOwner { //... - function setProtocolFeeRecipient(address protocolFeeRecipient_) public onlyOwner { + function setProtocolFeeRecipient(address protocolFeeRecipient_) public payable onlyOwner { //... - function setRabbitHoleReceiptContract(address rabbitholeReceiptContract_) public onlyOwner { + function setRabbitHoleReceiptContract(address rabbitholeReceiptContract_) public payable onlyOwner { //... - function setRewardAllowlistAddress(address rewardAddress_, bool allowed_) public onlyOwner { + function setRewardAllowlistAddress(address rewardAddress_, bool allowed_) public payable onlyOwner { //... - function setQuestFee(uint256 questFee_) public onlyOwner { + function setQuestFee(uint256 questFee_) public payable onlyOwner { //..
#0 - c4-judge
2023-02-14T09:53:05Z
kirk-baird marked the issue as grade-b