Platform: Code4rena
Start Date: 13/12/2023
Pot Size: $36,500 USDC
Total HM: 18
Participants: 110
Period: 8 days
Judge: 0xTheC0der
Id: 311
League: ETH
Rank: 31/110
Findings: 3
Award: $207.58
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: deth
Also found by: 00xSEV, 0xCiphky, 0xHelium, ABAIKUNANBAEV, Aamir, AkshaySrivastav, King_, Pechenite, SpicyMeatball, Tricko, ast3ros, ayden, bart1e, deth, fnanni, ke1caM, mahdirostami, peanuts, pep7siup, pontifex, ptsanev, roland, rvierdiiev, y4y
7.2215 USDC - $7.22
Judge has assessed an item in Issue #643 as 2 risk. The relevant finding follows:
[L-2] Setting Auction::reservePrice equal to 0 can create a chain of 0 price bids
#0 - c4-judge
2024-01-07T18:36:10Z
MarioPoneder marked the issue as duplicate of #449
#1 - c4-judge
2024-01-07T18:36:19Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: Aamir
Also found by: 0xCiphky, SovaSlava, bart1e, osmanozdemir1, rvierdiiev, shaka
193.0007 USDC - $193.00
Encoding of encodedData
is not done correctly for the verification of EIP712 signed messages.
CultureIndex::_verifyVoteSignature()
is called by other functions like CultureIndex::voteForManyWithSig()
and CultureIndex::batchVoteForManyWithSig()
for the verification of the signed messages in order to cast vote. But the encoding of hashStruct
is not done correctly in the function.
function _verifyVoteSignature( address from, uint256[] calldata pieceIds, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) internal returns (bool success) { require(deadline >= block.timestamp, "Signature expired"); bytes32 voteHash; @> voteHash = keccak256(abi.encode(VOTE_TYPEHASH, from, pieceIds, nonces[from]++, deadline)); bytes32 digest = _hashTypedDataV4(voteHash); address recoveredAddress = ecrecover(digest, v, r, s); // Ensure to address is not 0 // @audit check this in the beginning if (from == address(0)) revert ADDRESS_ZERO(); // Ensure signature is valid if (recoveredAddress == address(0) || recoveredAddress != from) revert INVALID_SIGNATURE(); return true; }
hashStruct
is combination of two things. typeHash
and encodedData
. Read more here.
hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))
If any one of them is constructed in a wrong way then the verification will not work.
In CultureIndex::_verifyVoteSignature()
, typeHash
is calculated like this which is correct:
bytes32 public constant VOTE_TYPEHASH = keccak256("Vote(address from,uint256[] pieceIds,uint256 nonce,uint256 deadline)");
But encodedData
is not right. According to EIP712, the encoding of array should be done like this before hashing the struct data:
The array values are encoded as the keccak256 hash of the concatenated encodeData of their contents (i.e. the encoding of SomeType[5] is identical to that of a struct containing five members of type SomeType).
Read More here
But in CultureIndex::_verifyVoteSignature()
, simply pieceIds
is passed to keccak256
to calculate the struct hash. This will result in the improper functioning of the function and will not let anybody to cast vote.
All of the link mentioned above. Also Read this Ethereum exchange conversation.
Use keccak256
hash of the pieceIds
before constructing the struct hash.
function _verifyVoteSignature( address from, uint256[] calldata pieceIds, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) internal returns (bool success) { require(deadline >= block.timestamp, "Signature expired"); bytes32 voteHash; // @audit shouldn't this use keccak256 hash of the pieceIds? - voteHash = keccak256(abi.encode(VOTE_TYPEHASH, from, pieceIds, nonces[from]++, deadline)); + voteHash = keccak256(abi.encode(VOTE_TYPEHASH, from, keccak256(abi.encodePacked(pieceIds), nonces[from]++, deadline)); bytes32 digest = _hashTypedDataV4(voteHash); address recoveredAddress = ecrecover(digest, v, r, s); // Ensure to address is not 0 // @audit check this in the beginning if (from == address(0)) revert ADDRESS_ZERO(); // Ensure signature is valid if (recoveredAddress == address(0) || recoveredAddress != from) revert INVALID_SIGNATURE(); return true; }
Error
#0 - c4-pre-sort
2023-12-22T00:09:14Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T00:09:35Z
raymondfam marked the issue as duplicate of #20
#2 - c4-judge
2024-01-06T15:19:01Z
MarioPoneder marked the issue as satisfactory
#3 - c4-judge
2024-01-06T15:21:45Z
MarioPoneder marked the issue as selected for report
#4 - c4-sponsor
2024-01-09T15:36:41Z
rocketman-21 (sponsor) confirmed
🌟 Selected for report: 0xmystery
Also found by: 00xSEV, 0xCiphky, 0xDING99YA, 0xhitman, ABAIKUNANBAEV, Aamir, BARW, IllIllI, King_, MrPotatoMagic, Pechenite, SovaSlava, SpicyMeatball, Topmark, Ward, ZanyBonzy, ast3ros, bart1e, cheatc0d3, deth, developerjordy, hals, imare, kaveyjoe, ktg, leegh, osmanozdemir1, peanuts, roland, rvierdiiev, shaka, sivanesh_808, spacelord47
7.359 USDC - $7.36
Issue | Instances | |
---|---|---|
[L-0] | Incorrect or Incomplete Natspac Comments | 3 |
[L-1] | Creating a new Piece when ERC20 and ERC721 token supply is zero will cause issues | 1 |
[L-2] | Setting Auction::reservePrice equal to 0 can create a chain of 0 price bids | 1 |
[L-3] | Auction::_settleAuction() can cause DoS if the remaining amount sent to ERCTokenEmitter::buyToken() is less than RewardsSplit::minPurchaseAmount | 1 |
[L-4] | ERC20TokenEmitter allow for buying token by passing deployer, builder and purchase refferal to his own address | 1 |
[L-5] | MaxHeap is inheriting from ReentrancyGuardUpgradeable , but non reentrant check is not done anywhere in the contract | 1 |
[L-6] | MaxHeap::insert does not check if the value already exists for particular id and overwrites it | 1 |
[L-7] | CultureIndex::createPiece() and creation of art piece with empty metadata | 1 |
[N-0] | Solidity function naming convention Not followed for external functions | 1 |
[N-1] | Solidity function naming convention Not followed for ineternal functions | 2 |
[N-2] | Contracts that are not mean't to be upgradeable should use normal inherited contracts | 2 |
[N-3] | Add Min and Max values for various setter function | 3 |
Natspac is showing incorrect information:
Instances: 1
Function returns creator array length not total basis points.
File: 2023-12-revolutionprotocol/packages/revolution/src/CultureIndex.sol 173 * @return Returns the total basis points calculated from the array of creators.
GitHub: 173
Instances: 2
timeBuffer
is the time that the contest end time will be increased by when the ending is in timeBuffer
seconds. So the comment below is incorrect or showing incomplete info.
File: 2023-12-revolutionprotocol/packages/revolution/src/AuctionHouse.sol 56 // The minimum amount of time left in an auction after a new bid is created
GitHub: 56
Instances: 3
MaxHeap::heap
is a mapping, not a struct.
64 /// @notice Struct to represent an item in the heap by it's ID 65 mapping(uint256 => uint256) public heap;
Github: 64
CultureIndex::createPiece(...)
allow creation of the peice even when erc20VotingToken.totalSupply()
and erc721VotingToken.totalSupply()
is zero will lead to creation of the peice with totalVotesSupply
equal to zero which will lead to quorumVotes
equal to zero. This will create the following issues:
minVoteWeight
will not met.quorumVotes
will enable AuctionHouse
to mint the tokens when this quorum is met. But if it is set to zero this will always enable AuctionHouse
to mint the token even if nobody voted on the piece.instance: 1
File: 2023-12-revolutionprotocol/packages/revolution/src/CultureIndex.sol 226 newPiece.totalVotesSupply = _calculateVoteWeight( 227 erc20VotingToken.totalSupply(), 228 erc721VotingToken.totalSupply() 229 );
Github: 226-229
Recommendations:
Consider Implementing Initial Token Supply or move to redesign it.
Auction::reservePrice
equal to 0 can create a chain of 0 price bids <a id="low-2"></a>After confirming from sponsor, it is allowed that reservePrice
can be equal to zero. But this can create a chain of zero price bids and chances are that the auction may never stop as the time can increase by timeBuffer
when time equal to or less timeBuffer
is left for ending of the auction and new bid is raised. In order to correct that, there will be need to set reservePrice
again to correct value. Here is test that proves that:
function test_settingReservePriceEqualToZeroWillCreateChainOfZeroPriceBids() public { //////////////////////////////////////////// /////// SETUP /////// //////////////////////////////////////////// address alice = makeAddr("alice"); address bob = makeAddr("bob"); address rachel = makeAddr("rachel"); address[] memory tokenRecievers = new address[](1); tokenRecievers[0] = alice; uint256[] memory tokenSplitBps = new uint256[](1); tokenSplitBps[0] = 10000; IERC20TokenEmitter.ProtocolRewardAddresses memory protocolRewardsRecipients = IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: address(0) }); /////////////////////////////////////////////// //// Giving some ETH to users /////// /////////////////////////////////////////////// vm.deal(alice, 100 ether); vm.deal(bob, 100 ether); ////////////////////////////////////// // Alice buying voting token //// ////////////////////////////////////// uint256 buyAmount = 10 ether; vm.startPrank(alice); erc20TokenEmitter.buyToken{value: buyAmount}(tokenRecievers, tokenSplitBps, protocolRewardsRecipients); vm.stopPrank(); /////////////////////////////////////// //// Creating New Token //////// /////////////////////////////////////// uint256 artPieceId = createDefaultArtPiece(); ///////////////////////////////////////////// /// Checking Created ArtPiece Info /// ///////////////////////////////////////////// ICultureIndex.ArtPiece memory artPeiceInfo = cultureIndex.getPieceById(artPieceId); assertEq(address(this), artPeiceInfo.sponsor, "sponsor is not correct"); require(maxHeap.size() > 0, "MaxHeap is empty"); ////////////////////////// /// Moving Blocks ///// ////////////////////////// vm.roll(block.number + 1); //////////////////////////////////////////////////////////////////////// //// Alice Saw the new peice And call vote function to vote. //// //////////////////////////////////////////////////////////////////////// vm.prank(alice); cultureIndex.vote(artPieceId); /////////////////////////////////////////// //// starting auction for the piece //// /////////////////////////////////////////// vm.startPrank(auction.owner()); auction.unpause(); ////////////////////////////////////////////// //// Setting reservePrice to zero //// ////////////////////////////////////////////// vm.startPrank(auction.owner()); auction.setReservePrice(0); ///////////////////////////////////////////////////////////// //// bob bids on the artpeice for 0 eth ///// ///////////////////////////////////////////////////////////// vm.startPrank(bob); auction.createBid{value: 0}(artPieceId, bob); //////////////////////////////////////////////////////////////// //// rachel bids on the artpeice for 0 eth ///// //////////////////////////////////////////////////////////////// vm.startPrank(rachel); auction.createBid{value: 0}(artPieceId, rachel); /////////////////////////////////////////////////////////////////// //// bob again bids on the artpeice for 0 eth ///// /////////////////////////////////////////////////////////////////// vm.startPrank(bob); auction.createBid{value: 0}(artPieceId, bob); }
</details>function createDefaultArtPiece() public returns (uint256) { return createArtPiece( "Mona Lisa", "A masterpiece", ICultureIndex.MediaType.IMAGE, "ipfs://legends", "", "", address(0x1), 10000 ); }
Recommendations:
Consider Adding min And max limit for the reservePrice
and never let it set equal to zero.
Auction::_settleAuction()
can cause DoS if the remaining amount sent to ERCTokenEmitter::buyToken()
is less than RewardsSplit::minPurchaseAmount
<a id="low-3"></a>The function Auction::_settleAuction()
settles the current running auction by transferring the amounts to creators and other parties. But if the winning bid amount is less than RewardsSplit::minPurchaseAmount
or creatorBPS
(including entropy) is big enough that leaves only amount less than RewardsSplit::minPurchaseAmount
, then the _settleAuction
function will revert as the buyToken
will not work because RewardsSplit::computeTotalReward()
will revert if the amount is less than this min amount. The settleAuction
will not be work and settling the auction will not be possible. The only way to solve this issue will be to raise reservePrice
more than the current bid that will just refund the winning amount to deployer and will burn the current NFT.
Here is test that proves that:
<details> <summary>Code</summary>function test_settlingAuctionWillNotBePossibleIfTheAmountSentToBuyTokenIsLessThanLimit() public { //////////////////////////////////////////// /////// SETUP /////// //////////////////////////////////////////// address alice = makeAddr("alice"); address bob = makeAddr("bob"); address rachel = makeAddr("rachel"); address[] memory tokenRecievers = new address[](1); tokenRecievers[0] = alice; uint256[] memory tokenSplitBps = new uint256[](1); tokenSplitBps[0] = 10000; IERC20TokenEmitter.ProtocolRewardAddresses memory protocolRewardsRecipients = IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: address(0) }); /////////////////////////////////////////////// //// Giving some ETH to users /////// /////////////////////////////////////////////// vm.deal(alice, 100 ether); vm.deal(bob, 100 ether); ////////////////////////////////////// // Alice buying voting token //// ////////////////////////////////////// uint256 buyAmount = 10 ether; vm.startPrank(alice); erc20TokenEmitter.buyToken{value: buyAmount}(tokenRecievers, tokenSplitBps, protocolRewardsRecipients); vm.stopPrank(); /////////////////////////////////////// //// Creating New Token //////// /////////////////////////////////////// uint256 artPieceId = createDefaultArtPiece(); ///////////////////////////////////////////// /// Checking Created ArtPiece Info /// ///////////////////////////////////////////// ICultureIndex.ArtPiece memory artPeiceInfo = cultureIndex.getPieceById(artPieceId); assertEq(address(this), artPeiceInfo.sponsor, "sponsor is not correct"); require(maxHeap.size() > 0, "MaxHeap is empty"); ////////////////////////// /// Moving Blocks ///// ////////////////////////// vm.roll(block.number + 1); //////////////////////////////////////////////////////////////////////// //// Alice Saw the new peice And call vote function to vote. //// //////////////////////////////////////////////////////////////////////// vm.prank(alice); cultureIndex.vote(artPieceId); /////////////////////////////////////////// //// starting auction for the piece //// /////////////////////////////////////////// vm.startPrank(auction.owner()); auction.unpause(); /////////////////////////////////////////////////// //// Setting reservePrice to low value //// /////////////////////////////////////////////////// vm.startPrank(auction.owner()); auction.setReservePrice(1000000); ///////////////////////////////////////////////////////////// //// bob bids on the artpeice for 0 eth ///// ///////////////////////////////////////////////////////////// vm.startPrank(bob); auction.createBid{value: 0.00000001 ether}(artPieceId, bob); //////////////////////////////////// //// Moving Ahead in time //// //////////////////////////////////// vm.warp(block.timestamp + 1 days); ///////////////////////////////////// //// Settling the auction ///// ///////////////////////////////////// vm.expectRevert(); auction.settleCurrentAndCreateNewAuction(); }
</details>function createDefaultArtPiece() public returns (uint256) { return createArtPiece( "Mona Lisa", "A masterpiece", ICultureIndex.MediaType.IMAGE, "ipfs://legends", "", "", address(0x1), 10000 ); }
Recommendations:
Add a limit for the reservePrice
keeping in mind the RewardsSplit::minPurchaseAmount
. Make sure that value sent to ERC20TokenEmitter:_handleRewardsAndGetValueToSend()
in ERC20TokenEmitter::buyToken()
is never less than this min purchase amount.
Determining this value is kind of hard. If not possible than try to approach the whole thing in different way
ERC20TokenEmitter
allow for buying token by passing deployer, builder and purchase refferal to his own address <a id="low-4">A buyer can pass his own address as a deployer, builder and purchase refferal to get back his 2.5
percent ethers back. This is allowed for the external protocol integration but a normal user can take benefits from it. For external protocol integration, consider implementing this in a different way.
File: 2023-12-revolutionprotocol/packages/revolution/src/ERC20TokenEmitter.sol function buyToken( address[] calldata addresses, uint[] calldata basisPointSplits, @> ProtocolRewardAddresses calldata protocolRewardsRecipients ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) { ... emit PurchaseFinalized( msg.sender, msg.value, toPayTreasury, msg.value - msgValueRemaining, uint256(totalTokensForBuyers), uint256(totalTokensForCreators), creatorDirectPayment ); return uint256(totalTokensForBuyers); }
GitHub: 152-230
[L-5] MaxHeap
is inheriting from ReentrancyGuardUpgradeable
, but non reentrant check is not done anywhere in the contract. <a id="low-5"></a>
contract MaxHeap
is inheriting from ReentrancyGuardUpgradeable
but nonReentrant
modifier is not used anywhere in the contract. Although the functions from the contract can be called by the admin only but it is good idea to add non reentrant modifier to be sure of reentrancy attacks. If not required then do not inherit from it.
Instances: 1
File: packages/revolution/src/MaxHeap.sol 14. contract MaxHeap is VersionedContract, UUPS, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable {
Github: 14
[L-6] MaxHeap::insert()
does not check if the value already exists for particular id and overwrites it <a id="low-6"></a>
MaxHeap::insert()
does not check if the value already exists or not and overrides the old value. consider adding checks for that
function insert(uint256 itemId, uint256 value) public onlyAdmin { heap[size] = itemId; valueMapping[itemId] = value; // Update the value mapping positionMapping[itemId] = size; // Update the position mapping uint256 current = size; while (current != 0 && valueMapping[heap[current]] > valueMapping[heap[parent(current)]]) { swap(current, parent(current)); current = parent(current); } size++; }
GitHub: 119-130
CultureIndex::createPiece()
and creation of art piece with empty metadata <a id="low-7"></a>New pieces can be created without giving any info for the metadata as there is no check in the function for the same. This could lead to creation of invalid tokens. Also validateMediaType
only check for 3 types that allows creation of other tokens without having any of the info.
Here is a test that proves that:
function test_createAPieceWithoutAnymetadata() public { createArtPiece( "", "", ICultureIndex.MediaType.OTHER, "", "", "", address(0x1), 10000 ); }
Recommendations: Add better checks for NFT metadata.
According to solidity naming convetions, internal function name should start with _
. But it is also used for an external function which might create confusion.
Instance: 1
File: 2023-12-revolutionprotocol/packages/revolution/src/CultureIndex.sol 498 function _setQuorumVotesBPS(uint256 newQuorumVotesBPS) external onlyOwner {
Github: 498
According to solidity naming convetions, internal function name should start with _
. But it is not used for some internal functions.
Instance: 2
File: 2023-12-revolutionprotocol/packages/revolution/src/CultureIndex.sol 159 function validateMediaType(ArtPieceMetadata calldata metadata) internal pure {
Github: 159
File: 2023-12-revolutionprotocol/packages/revolution/src/CultureIndex.sol 179 function validateCreatorsArray(CreatorBps[] calldata creatorArray) internal pure returns (uint256) {
Github: 179
[N-2] Contracts that are not mean't to be upgradeable should use normal inherited contracts <a id="Noncritical-2"></a>
Contracts like NontransferableERC20Votes
and ERC20TokenEmitter
are not inheriting from UUPS
. Sponsor confirmed that these contracts should be non upgradeable. If that is the case then other inherited upgradeable contracts like ReentrancyGuardUpgradeable
, Ownable2StepUpgradeable
etc. should not be used to prevent unnecessary complexity and UX.
[N-3] Add Min and Max values for various setter function <a id="Noncritical-3"></a>
Giving infinte limit to variables might cause calculation and rounding issues. consider implementing the bounding limits.
Instances AuctionHouse.sol: 3
GitHub: 277
GitHub: 287
GitHub: 297
#0 - raymondfam
2023-12-24T16:32:31Z
Possible upgrades:
L1 --> #449 L-3 --> #160 L-4 --> #342
#1 - c4-pre-sort
2023-12-24T16:32:37Z
raymondfam marked the issue as sufficient quality report
#2 - MarioPoneder
2024-01-07T18:24:53Z
Possible upgrades:
L1 --> #449 L-3 --> #160 L-4 --> #342
L-1: Upgrade L-3: More similar to previous duplicates that have been downgraded to QA L-4: #342 is now QA
#3 - c4-judge
2024-01-07T19:51:26Z
MarioPoneder marked the issue as grade-b