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: 78/173
Findings: 3
Award: $26.84
π 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/RabbitHoleTickets.sol#L48 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L59
Without custom error or require statement at modifier onlyMinter
, anyone could mint
a receipt in the RabbitHoleReceipt
contract or a ticket in the RabbitHoleTickets
contract.
For RabbitHoleReceipt
add a test in RabbitHoleReceipt.spec.ts
describe('additional mint test', () => { it('check onlyMinter modifier', async () => { await RHReceipt.connect(minterAddress).mint(firstAddress.address, 'def456') expect(await RHReceipt.balanceOf(firstAddress.address)).to.eq(1) await RHReceipt.connect(firstAddress).mint(firstAddress.address, 'def456') expect(await RHReceipt.balanceOf(firstAddress.address)).to.eq(2) await RHReceipt.connect(royaltyRecipient).mint(firstAddress.address, 'def456') expect(await RHReceipt.balanceOf(firstAddress.address)).to.eq(3) expect(minterAddress.address != firstAddress.address && minterAddress.address != royaltyRecipient.address).to.equal(true) }) })
For RabbitHoleTickets
add a test in RabbitHoleTickets.spec.ts
describe('additional mint test', () => { it('check onlyMinter modifier', async () => { await RHTickets.connect(minterAddress).mint(firstAddress.address, 1, 5, "0x") expect(await RHTickets.balanceOf(firstAddress.address, 1)).to.eq(5) await RHTickets.connect(firstAddress).mint(firstAddress.address, 1, 5, "0x") expect(await RHTickets.balanceOf(firstAddress.address, 1)).to.eq(10) await RHTickets.connect(royaltyRecipient).mint(firstAddress.address, 1, 5, "0x") expect(await RHTickets.balanceOf(firstAddress.address, 1)).to.eq(15) expect(minterAddress.address != firstAddress.address && minterAddress.address != royaltyRecipient.address).to.equal(true) }) })
As it is seen from these tests code, it is possible from anyone to call the mint
operation in the RabbitHoleReceipt
and RabbitHoleTickets
(also, there is an additional except expect(minterAddress.address != firstAddress.address && minterAddress.address != royaltyRecipient.address).to.equal(true)
which shows that addresses that call mint
operation are different than minter address).
VSCode, Hardhat, Solidity Visual Developer plugin for VSCode
Create and add a custom error in the if
statement for a check is msg.sender
different than minterAddress
at lines RabbitHoleTickets.sol#L48 and RabbitHoleReceipt.sol#L59
modifier onlyMinter() { if (msg.sender != minterAddress) revert OnlyMinterAllowed(); _; }
#0 - c4-judge
2023-02-05T03:44:18Z
kirk-baird marked the issue as duplicate of #9
#1 - c4-judge
2023-02-14T08:39:24Z
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
When the quest ended, the ERC1155Quest
contract owner could withdraw all remaining tokens, including users' reward tokens which are not claimed before the end. Because of that, if the user wants to claim their rewards after the quest end and after the ERC1155Quest
contract owner calls a function withdrawRemainingTokens
it will get an error that there are not enough tokens at the ERC1155Quest
contract and not possible to claim a reward.
On the other side, in the ERC20Quest
contract by lines Erc20Quest.sol#L84:L86 there is calculation how many tokens are not claimed, so the contract owner will get the difference between current contract balance and not claimed rewards. Because of that calculation, users could claim their reward after the ERC20Quest
ended and the contract owner calls a function withdrawRemainingTokens
.
Add two tests in Erc1155Quest.spec.ts
it('after quest end, should claim and withdraw remaining tokens', async () => { await deployedRabbitholeReceiptContract.mint(firstAddress.address, questId) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) expect(await deployedSampleErc1155Contract.balanceOf(firstAddress.address, rewardAmount)).to.equal(0) expect(await deployedSampleErc1155Contract.balanceOf(owner.address, rewardAmount)).to.equal(0) await deployedQuestContract.connect(firstAddress).claim() await deployedQuestContract.connect(owner).withdrawRemainingTokens(owner.address) expect(await deployedSampleErc1155Contract.balanceOf(firstAddress.address, rewardAmount)).to.equal(1) expect(await deployedSampleErc1155Contract.balanceOf(owner.address, rewardAmount)).to.equal(99) await ethers.provider.send('evm_increaseTime', [-86400]) })
it('after quest end, should withdraw remaining tokens and cannot claim', async () => { await deployedRabbitholeReceiptContract.mint(firstAddress.address, questId) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) expect(await deployedSampleErc1155Contract.balanceOf(firstAddress.address, rewardAmount)).to.equal(0) expect(await deployedSampleErc1155Contract.balanceOf(owner.address, rewardAmount)).to.equal(0) await deployedQuestContract.connect(owner).withdrawRemainingTokens(owner.address) expect(await deployedSampleErc1155Contract.balanceOf(owner.address, rewardAmount)).to.equal(100) await expect( deployedQuestContract.connect(firstAddress).claim() ).to.be.revertedWith('ERC1155: insufficient balance for transfer') await ethers.provider.send('evm_increaseTime', [-86400]) })
As seen from the first test, if the account firstAddress
claims reward before the owner calls withdrawRemainingTokens
, all will be good, and firstAddress
will have 1 unit of reward token, and the contract owner will have 99 units.
But, if a contract owner calls withdrawRemainingTokens
before the account firstAddress
claim rewards, he will get all reward tokens (he will have 100 units), and on the other side, firstAddress
will get error ERC1155: insufficient balance for transfer
.
VSCode, Hardhat, Solidity Visual Developer
In the ERC1155Quest.sol
contract (function Erc1155Quest.sol#L54):
uint unclaimedRewards = questFactoryContract.getNumberMinted(questId) - redeemedTokens;
IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId) - unclaimedRewards
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint256 unclaimedRewards = questFactoryContract.getNumberMinted(questId) - redeemedTokens; uint256 nonClaimableRewards = IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId) - unclaimedRewards; IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, nonClaimableRewards, "0x00" ); }
#0 - c4-judge
2023-02-05T05:31:12Z
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:28:07Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2023-02-23T23:49:22Z
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
endTime
and startTime
checksWith a small refactor Quest.sol#L35:L37, one check could be deleted.
Instead of
if (endTime_ <= block.timestamp) revert EndTimeInPast(); if (startTime_ <= block.timestamp) revert StartTimeInPast(); if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime();
it could be used
if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime() if (startTime_ <= block.timestamp) revert StartTimeInPast();
because if endTime
is greater than startTime
and startTime
is greater than block.timestamp
, automatically endTime
will be greater than block.timestamp
.
Remove if (endTime_ <= block.timestamp) revert EndTimeInPast();
and use only
if (endTime_ <= startTime_) revert EndTimeLessThanOrEqualToStartTime() if (startTime_ <= block.timestamp) revert StartTimeInPast();
In all project contracts, it is using the OwnableUpgradeable
contract. The main problem with that contract is that Itβs possible that the onlyOwner
role mistakenly transfers ownership to the wrong address, resulting in a loss of the onlyOwner
role. Instead of using the OwnableUpgradeable
, my suggestion is to use the Ownable2StepUpgradeable
contract, so when an account with the onlyOwner
role transfer ownership to the other account, the other account (pendingOwner
) must confirm ownership by calling acceptOwnership
and then he will become the new owner. In that way, even if the account with the onlyOwner
mistake when calling the transferOwnership
transaction, it is possible to propose a new pending owner again and correct that mistake.
Add Ownable2StepUpgradeable
instead of OwnableUpgradeable
in all project contracts.
onlyNotStarted
modifier in start()
functionIn line Quest.sol#L50 it is missing onlyNotStarted
modifier. The reason why this modifier is missing is a case that this function has the same behavior as the unPause
function when the quest starts (if the quest starts and the user calls the pause
function, it is possible to unpause, and with start
and with unpause
functions).
Create modifier onlyNotStarted
and add to line Quest.sol#L50
modifier onlyNotStarted() { if (hasStarted) revert Started(); _; }
CREATE_QUEST_ROLE
role and with only owner and with DEFAULT_ADMIN_ROLE
roleIt is not so common that in the same contract exists Ownable and AccessControl access types. By default case in this contract, where onlyOwner
and DEFAULT_ADMIN_ROLE
are the same address, all will be without problems.
But, if the address for one of onlyOwner
or DEFAULT_ADMIN_ROLE
is changed, then it would be possible to call grantRole and revokeRole with the onlyOwner
account with function from the line QuestFactory.sol#L142. In the other case, it will be possible with DEFAULT_ADMIN_ROLE
directly call QuestFactoryContract.grantRole()
or QuestFactoryContract.revokeRole()
and this could cause potential divergence.
Remove the function from the line QuestFactory.sol#L142
#0 - c4-judge
2023-02-05T03:45:01Z
kirk-baird marked the issue as grade-b
#1 - c4-sponsor
2023-02-08T04:39:58Z
jonathandiep marked the issue as sponsor acknowledged