RabbitHole Quest Protocol contest - MiniGlome's results

A protocol to distribute token rewards for completing on-chain tasks.

General Information

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

RabbitHole

Findings Distribution

Researcher Performance

Rank: 41/173

Findings: 3

Award: $119.15

Gas:
grade-a

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102

Vulnerability details

Impact

Once the Quest endTime is passed, anyone can call the withdrawFee() function multiple times. This means that the protocol fee can be sent to the protocolFeeRecipient several times, thus draining the Quest rewardToken. So the protocolFeeRecipient can collect all the remaining funds and tokens that have not already be claimed. Therfore, a malicious actor can prevent any player to claim their rewardTokens after the end of the Quest.

Proof of Concept

Erc20Quest.sol:

L102:	function withdrawFee() public onlyAdminWithdrawAfterEnd {
        IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee());
    }

https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102

// Withdraw nearly all the remaining tokens
while(IERC20(rewardToken).balanceOf(address(Erc20Quest)) > Erc20Quest.protocolFee()) {
	Erc20Quest.withdrawFee();
}

withdrawFee() should be callable only once.

bool feeWithdrawn;
// [...]
function withdrawFee() public onlyAdminWithdrawAfterEnd {
    require(!feeWithdrawn, "Fees already withdrawn");
    IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee());
    feeWithdrawn = true;
}

#0 - c4-judge

2023-02-05T12:05:35Z

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:56:42Z

kirk-baird marked the issue as satisfactory

Awards

7.046 USDC - $7.05

Labels

2 (Med Risk)
downgraded by judge
satisfactory
withdrawn by judge
duplicate-528

External Links

Judge has assessed an item in Issue #599 as 3 risk. The relevant finding follows:

[L-01] Erc1155Quest's tokens can be withdrawn before every reward has been claimed Impact The owner can withdraw all the remaining tokens after the Quest endTime. Thus, users who have not claimed their reward at the end of the quest may not be able to do so because the tokens can be withdrawn by the owner beforehand.

Proof Of Concept The withdrawRemainingTokens() function withdraws all token balance whithout checking unclaimed tokens.

File: Erc1155Quest.sol

L56: IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), '0x00' );

#0 - c4-judge

2023-02-06T22:46:00Z

kirk-baird marked the issue as duplicate of #42

#1 - c4-judge

2023-02-10T05:03:11Z

This auto-generated issue was withdrawn by kirk-baird

#2 - c4-judge

2023-02-10T05:12:14Z

This previously downgraded issue has been upgraded by kirk-baird

#3 - c4-judge

2023-02-14T09:25:35Z

kirk-baird marked the issue as satisfactory

#4 - c4-judge

2023-02-23T23:49:21Z

kirk-baird changed the severity to 2 (Med Risk)

Summary

This report does not include publicly known issues

Gas Optimizations

IssueInstances
[GAS-01]Comparing strings is not gas efficient2
[GAS-02]<x> += <y> costs more gas than <x> = <x> + <y> for state variables1
[GAS-03]++<x>/<x>++ should be unchecked when it is no possible for them to overflow10
[GAS-04]Optimize names to save gas4
[GAS-05]internal functions only called once can be inlined to save gas1
[GAS-06]Setting the constructor to payable6
[GAS-07]Use </> instead of >=/>=3
[GAS-08]Don't compare boolean expressions to boolean literals3

Total: 30 instances over 8 issues

Gas Optimizations

[GAS-01] Comparing strings is not gas efficient

It is not gas efficient to compare contractType_ as a string, as the keccak256 function is used twice to convert the string to a bytes32 value, and then to compare the two bytes32 values. It would be more efficient to compare contractType_ as an integer, which requires less computation and thus less gas.

File: QuestFactory.sol
L72:		if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc20')))
L105:		if (keccak256(abi.encodePacked(contractType_)) == keccak256(abi.encodePacked('erc1155')))

Recommended Mitigation Steps

// Costs around 2000 less gas
if (contractType_ == 1)

Alternatively keccak256(abi.encodePacked('erc20'))) and keccak256(abi.encodePacked('erc1155'))) could be pre-computed.

[GAS-02] <x> += <y> costs more gas than <x> = <x> + <y> for state variables

Using the addition operator instead of plus-equals saves 113 gas

File: Quest.sol

115:         redeemedTokens += redeemableTokenCount;

[GAS-03] ++<x>/<x>++ should be unchecked when it is no possible for them to overflow

The unchecked keyword is new in solidity version 0.8.0, so this only applies to that version or higher, which these instances are. This saves 30-40 gas PER LOOP

File: QuestFactory.sol

L101:		++questIdCount;
L132:		++questIdCount;
L226:		quests[questId_].numberMinted++;
File: Quest.sol

L70:		for (uint i = 0; i < tokenIds_.length; i++) {
L104:		for (uint i = 0; i < tokens.length; i++) {
L106:		redeemableTokenCount++;
File: RabbitHoleReceipt.sol

L117:		for (uint i = 0; i < msgSenderBalance; i++) {
L121:		foundTokens++;
L128:		for (uint i = 0; i < msgSenderBalance; i++) {
L131:		filterTokensIndexTracker++;

[GAS-04] Optimize names to save gas

Contracts most called functions could simply save gas by function ordering via Method ID. Calling a function at runtime will be cheaper if the function is positioned earlier in the order (has a relatively lower Method ID) because 22 gas are added to the cost of a function for every position that came before it. The caller can save on gas if you prioritize most called functions.

See more here

File: Quest.sol File: Erc20Quest.sol File: Erc1155Quest.sol File: RabbitHoleReceipt.sol

Recommended Mitigation Steps Find a lower method ID name for the most called functions for example b_A6Q() is cheaper than b().

[GAS-05] internal functions only called once can be inlined to save gas

Not inlining costs 20 to 40 gas because of two extra JUMP instructions and additional stack operations needed for function calls.

File: QuestFactory.sol

L152:		function grantDefaultAdminAndCreateQuestRole(address account_) internal {

[GAS-06] Setting the constructor to payable

Saves ~13 gas per instance

File: QuestFactory.sol

L35:		constructor() initializer {}
File: Quest.sol

L26:		constructor([...]) {
File: Erc20Quest.sol

L17:		constructor([...]) Quest([...]){
File: Erc1155Quest.sol

L13:		constructor([...]) Quest([...]){
File: RabbitHoleReceipt.sol

L39:		constructor(){
File: RabbitHoleTickets.sol

L28:		constructor(){

[GAS-07] Use </> instead of >=/>=

In Solidity, there is no single op-code for ≤ or ≥ expressions. What happens under the hood is that the Solidity compiler executes the LT/GT (less than/greater than) op-code and afterwards it executes an ISZERO op-code to check if the result of the previous comparison (LT/ GT) is zero and validate it or not. Example:

// Gas cost: 21391
function check() exernal pure returns (bool) {
		return 3 > 2;
}
// Gas cost: 21394
function check() exernal pure returns (bool) {
		return 3 >= 3;
}

The gas cost between these contract differs by 3 which is the cost executing the ISZERO op-code, making the use of < and > cheaper than ≤ and ≥.

File: Quest.sol

L35:		if (endTime_ <= block.timestamp) revert EndTimeInPast();
L36:		if (startTime_ <= block.timestamp) revert StartTimeInPast();
L37:		if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime();

[GAS-08] Don't compare boolean expressions to boolean literals

if (<x> == true) => if (<x>), if (<x> == false) => if (!x>)

File: QuestFactory.sol

L73:		if (rewardAllowlist[rewardTokenAddress_] == false) revert RewardNotAllowed();
L221:		if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted();
File: Quest.sol

L136:		return claimedList[tokenId_] == true;

#0 - c4-judge

2023-02-15T21:51:34Z

kirk-baird marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter