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: 12/110
Findings: 6
Award: $781.30
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xG0P1, King_, SpicyMeatball, hals, ktg, rvierdiiev
494.8735 USDC - $494.87
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/CultureIndex.sol#L226-L235 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L357-L359
Revolution protocol is meant to enable users from adding their art pieces to be voted on and auctioned if the piece passes quorumVotes
assigned to it when it was created.
The voting quorum for each piece varies based on the totalSupply of the ERC20 voting token and the totalSupply of the verbs token at the time when the piece is created:
newPiece.totalVotesSupply = _calculateVoteWeight( erc20VotingToken.totalSupply(), erc721VotingToken.totalSupply() ); newPiece.totalERC20Supply = erc20VotingToken.totalSupply(); //some code... newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000;
The verbs token (erc721VotingToken
) is minted by the AuctionHouse
only when an auction is created , and when the auction time ended with a winning bidder; then this bidder will receive this verbs token, and if there's no bidders on the auction; the minted verbs token for that auction is burnt:
//If no one has bid, burn the Verb if (_auction.bidder == address(0)) verbs.burn(_auction.verbId);
So as can be noticed: at the time the art piece is added; the quorumVotes
assigned to that piece will take into account the verbs token that is currently under auction, while this token might be burned if it has no bidders.
And by knowing that only one auction can exist at a time; the assigned quorumVotes
of the art piece can represent a higher threshold than the actual one (if the auction verbs is burned) by a value of (erc721VotingToken.totalSupply() * erc721VotingTokenWeight * 1e18) * (quorumVotesBPS / 10_000)
.
CultureIndex.createPiece function/L226-L235
newPiece.totalVotesSupply = _calculateVoteWeight( erc20VotingToken.totalSupply(), erc721VotingToken.totalSupply() ); newPiece.totalERC20Supply = erc20VotingToken.totalSupply(); newPiece.metadata = metadata; newPiece.sponsor = msg.sender; newPiece.creationBlock = block.number; newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000;
AuctionHouse._settleAuction function/ L357-L359
//If no one has bid, burn the Verb if (_auction.bidder == address(0)) verbs.burn(_auction.verbId);
Manual Review.
Check if there's a running auction and deduct 1 from the erc721VotingToken.totalSupply()
when assigning the newpiece.quorumVotes
.
Context
#0 - c4-pre-sort
2023-12-22T22:39:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T22:40:58Z
raymondfam marked the issue as duplicate of #18
#2 - c4-judge
2024-01-08T21:43:05Z
MarioPoneder marked the issue as duplicate of #409
#3 - c4-judge
2024-01-08T21:46:52Z
MarioPoneder marked the issue as satisfactory
#4 - c4-judge
2024-01-10T18:29:38Z
MarioPoneder marked the issue as not a duplicate
#5 - c4-judge
2024-01-10T18:29:50Z
MarioPoneder marked the issue as duplicate of #168
#6 - c4-judge
2024-01-10T18:32:36Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 Selected for report: jnforja
Also found by: 0x175, 0xCiphky, 0xDING99YA, 0xmystery, ArmedGoose, Aymen0909, Franklin, KupiaSec, McToady, MrPotatoMagic, Ocean_Sky, PNS, Pechenite, TermoHash, Topmark, _eperezok, alexbabits, deth, hals, imare, jeff, ktg, leegh, mahdirostami, marqymarq10, mojito_auditor, neocrao, ptsanev, twcctop, zraxx
1.337 USDC - $1.34
reservePrice
represents the minimum price accepted in an auction, so when a user creates a bid on an going auction; the value sent with the function is checked to be greater than the reservePrice
.
The AuctionHouse
contract owner (which is the DAO) can change this value at anytime via AuctionHouse.setReservePrice
function, and this can be done without checking if there's an ongoing auction or not.
Changing this value while there's an ongoing auction is unfair for the bidders as it creates unequal opportunities.
If reservePrice
value is increased during an auction: this can block the createBid
function when a bidder creates a bid with higher amount as the previous bidder can't be refunded due to this check in the _safeTransferETHWithFallback
function (called to refund the previous bidder):
// Ensure the contract has enough ETH to transfer if (address(this).balance < _amount) revert("Insufficient balance");
If reservePrice
value is increased during an auction and no more bids are made after this change: this will result in considering the auction as a failed one because of the following check when settling an auction (the case is present if few bidders have made bids on an auction -low auction amount increment enforced by minBidIncrementPercentage
- where the contract balance is almost equal to the increased reservePrice
but less than it):
// Check if contract balance is greater than reserve price if (address(this).balance < reservePrice) { // If contract balance is less than reserve price, refund to the last bidder if (_auction.bidder != address(0)) { _safeTransferETHWithFallback(_auction.bidder, _auction.amount); } // And then burn the Noun verbs.burn(_auction.verbId);
where the bid amount is returned to the bidder and the verbs token is burnt; so the bidder has lost while he is entitled to win.
AuctionHouse.setReservePrice function
function setReservePrice(uint256 _reservePrice) external override onlyOwner { reservePrice = _reservePrice; emit AuctionReservePriceUpdated(_reservePrice); }
Manual Review.
Enable updating reservePrice
only when there's no auction (or bidders on that auction):
function setReservePrice(uint256 _reservePrice) external override onlyOwner { + require(auction.bidder == address(0),"cant update the value during an ongoing auction"); reservePrice = _reservePrice; emit AuctionReservePriceUpdated(_reservePrice); }
Context
#0 - c4-pre-sort
2023-12-22T22:45:13Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T22:45:26Z
raymondfam marked the issue as duplicate of #55
#2 - c4-pre-sort
2023-12-24T14:18:07Z
raymondfam marked the issue as duplicate of #495
#3 - c4-judge
2024-01-06T18:14:50Z
MarioPoneder changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-01-07T16:03:51Z
MarioPoneder marked the issue as grade-c
#5 - c4-judge
2024-01-10T17:32:52Z
This previously downgraded issue has been upgraded by MarioPoneder
#6 - c4-judge
2024-01-10T17:33:30Z
MarioPoneder marked the issue as duplicate of #515
#7 - c4-judge
2024-01-10T17:36:02Z
MarioPoneder marked the issue as partial-50
#8 - c4-judge
2024-01-11T18:03:12Z
MarioPoneder changed the severity to 2 (Med Risk)
116.9139 USDC - $116.91
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/VerbsToken.sol#L193-L195 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/Descriptor.sol#L143-L150
VerbsToken.tokenURI
function is intended to return the token uri for an official verbs token, but it was noticed that this function doesn't validate if the token exists before returning the token uri; thus violating ERC-721 standards when not reverting when called with a nonexistent tokenId.
function tokenURI(uint256 tokenId) public view override returns (string memory) { return descriptor.tokenURI(tokenId, artPieces[tokenId].metadata); }
function tokenURI( uint256 tokenId, ICultureIndex.ArtPieceMetadata memory metadata ) external view returns (string memory) { if (isDataURIEnabled) return dataURI(tokenId, metadata); return string(abi.encodePacked(baseURI, tokenId.toString())); }
Manual Review.
Update VerbsToken.tokenURI
function to revert on nonexistent token:
function tokenURI(uint256 tokenId) public view override returns (string memory) { + require(tokenId < _currentVerbId, "token doesnt exist"); return descriptor.tokenURI(tokenId, artPieces[tokenId].metadata); }
ERC721
#0 - c4-pre-sort
2023-12-22T23:05:54Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-12-22T23:06:06Z
raymondfam marked the issue as duplicate of #110
#2 - c4-judge
2024-01-06T20:52:24Z
MarioPoneder changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-06T20:56:51Z
MarioPoneder marked the issue as grade-b
#4 - DevHals
2024-01-09T17:45:52Z
Hi @MarioPoneder ,
this issue describes how tokenURI
function violates ERC-721 specifications, and same issues were judged as medium vulnerability in past audits; examples:
https://github.com/code-423n4/2023-10-opendollar-findings/issues/243 https://github.com/code-423n4/2023-04-caviar-findings/issues/44
I kindly ask you to take a second look and re-evaluate this issue,
Thanks!
#5 - EperezOk
2024-01-09T20:35:43Z
Hey, I submitted issue #191 which describes the same violation to the EIP721 spec (and provides a coded PoC).
In addition to the previous comment, I would like to point out that in the README of the contest, it is clearly mentioned that VerbsToken should comply with ERC721
.
Therefore, I also believe this is a valid medium.
#6 - MarioPoneder
2024-01-09T21:39:09Z
Hi @MarioPoneder ,
this issue describes how
tokenURI
function violates ERC-721 specifications, and same issues were judged as medium vulnerability in past audits; examples:code-423n4/2023-10-opendollar-findings#243 code-423n4/2023-04-caviar-findings#44
I kindly ask you to take a second look and re-evaluate this issue,
Thanks!
Thank you for your comment!
Agree with Medium
severity due to precedent cases on C4 with similar limited impact.
#7 - c4-judge
2024-01-09T21:39:45Z
This previously downgraded issue has been upgraded by MarioPoneder
#8 - c4-judge
2024-01-09T21:44:14Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: bart1e
Also found by: 00xSEV, 0xDING99YA, Ryonen, Tricko, hals, wintermute
37.1155 USDC - $37.12
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/CultureIndex.sol#L74-L75 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L429 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L311
Art pieces are created in the CultureIndex
contract with an array of creators of a maximum of 100 addresses.
Users vote on art pieces, and with each vote; the maxHeap is re-arranged to preserve the characteristics of a max heap where the top voted pieces is set at first the top of the tree as parents and the children below each parent represent the pieces with lower votes, the cost of this operation increases as the number of added pieces is increased.
Then the top voted art piece can be dropped to be auctioned by the AuctionHouse
; where users can create bids, and the winner bid will win the minted verbs tokens and governance ERC20 tokens.
The process of creating an auction in the AuctionHouse
is done only if after settling the previous auction, and when an auction is settled; multiple operations are done to transfer proceeds to the art piece creators and the protocol.
But it was noticed that the AuctionHouse
contract limits the gas amount associated with _createAuction
to be > MIN_TOKEN_MINT_GAS_THRESHOLD
which equals to 750_000
, and with each external call; it limits the gas to 50_000:
success := call(50000, _to, _amount, 0, 0, 0, 0)
But setting such limits on gas can be exploited by griefers and blocking the AuctionHouse
, let's take the following scenario:
settleCurrentAndCreateNewAuction
function to settle and create a new auction by dropping the next top voted art piece._createAuction
will check if the remaining gas is > 750_000
to proceed; if not the transaction would revert.> 750_000
; it doesn't account for the operations of extracting the top voted art piece from the max heap which might have a large entries that requires more gas to re-arrange.Also, if the above scenario is present where it doesn't revert; the cost of transaction would be hefty (knowing that the protocol is going to be deployed on the Ethereum mainnet); which might not be called by anyone even the protocol members as the cost of transaction might be larger than the gained rewards.
CultureIndex.MAX_NUM_CREATORS constant
// Constant for max number of creators uint256 public constant MAX_NUM_CREATORS = 100;
AuctionHouse._safeTransferETHWithFallback function/L429
success := call(50000, _to, _amount, 0, 0, 0, 0)
AuctionHouse._createAuction function/L311
require(gasleft() >= MIN_TOKEN_MINT_GAS_THRESHOLD, "Insufficient gas for creating auction");
Manual Review.
MIN_TOKEN_MINT_GAS_THRESHOLD
check in the _createAuction
contract.CulturalIndex
to be way less than 100 (10 for example).DoS
#0 - c4-pre-sort
2023-12-23T03:12:21Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-23T03:12:47Z
raymondfam marked the issue as duplicate of #93
#2 - c4-pre-sort
2023-12-24T14:36:22Z
raymondfam marked the issue as duplicate of #195
#3 - c4-judge
2024-01-06T13:18:09Z
MarioPoneder changed the severity to 2 (Med Risk)
#4 - MarioPoneder
2024-01-06T13:28:13Z
See comment on primary issue: https://github.com/code-423n4/2023-12-revolutionprotocol-findings/issues/195#issuecomment-1879684718
#5 - c4-judge
2024-01-06T13:28:17Z
MarioPoneder marked the issue as partial-25
#6 - c4-judge
2024-01-11T18:19:26Z
MarioPoneder marked the issue as not a duplicate
#7 - c4-judge
2024-01-11T18:28:52Z
MarioPoneder marked the issue as duplicate of #195
#8 - c4-judge
2024-01-11T18:29:09Z
MarioPoneder marked the issue as partial-25
#9 - DevHals
2024-01-11T18:52:50Z
Hi @MarioPoneder ,,
This issue is a valid duplicate of issue #93 not 195,, Could you please have a second look and re-evaluate it?
Thanks!
#10 - MarioPoneder
2024-01-12T00:12:06Z
Thank you for your comment!
I agree, however you also correctly pointed to require(gasleft() >= MIN_TOKEN_MINT_GAS_THRESHOLD, "Insufficient gas for creating auction");
which is part of the core issue of 195 which is the "stronger" issue than 93.
🌟 Selected for report: ayden
Also found by: 0xCiphky, AS, Brenzee, Inference, KupiaSec, SpicyMeatball, ast3ros, ayden, fnanni, hals, ktg, mahdirostami, nmirchev8, rvierdiiev, wangxx2026
29.8238 USDC - $29.82
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L399-L409 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/ERC20TokenEmitter.sol#L164-L170 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/protocol-rewards/src/abstract/TokenEmitter/TokenEmitterRewards.sol#L12-L18 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/protocol-rewards/src/abstract/RewardSplits.sol#L40-L41
In AuctionHouse
contract: the auction is settled if it's end time is passed, and the proceeds of the auction is distributed between the protocol and creators.
The creators will first receive an ETH amount based on their bps, and this total amount is determined by the entropyRateBps
which represents the split of (auction proceeds * creatorRate) that is sent to the creator as ether (or wrapped ether) in basis points.
Then the remaining creatorsShare - ethPaidToCreators
(if any is left) is used to buy governance ERC20 tokens for the creators.
if (creatorsShare > ethPaidToCreators) { creatorTokensEmitted = erc20TokenEmitter.buyToken{ value: creatorsShare - ethPaidToCreators }( vrgdaReceivers, vrgdaSplits, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: deployer }) ); }
When erc20TokenEmitter.buyToken
function is called; it will check if the sent value (creatorsShare - ethPaidToCreators
) is > minPurchaseAmount
and maxPurchaseAmount
(which is set as 0.0000001 ether and 50_000 ether); if not, the transaction will revert resulting in blocking AuctionHouse
contract.
This case likely to happen in the following scenarios:
ethPaidToCreators
very large (almost reaching the creatorsShare
), and this can happen if the entropyRateBps
is set to a high value.Note: the case where the creatorsShare - ethPaidToCreators > maxPurchaseAmount
is ignored as it requires the griefer bidder to bid with a very large amount to block the AuctionHouse
contract causing him a huge loss for griefing operation (with an ether price of ~$2260 : this will require creatorsShare - ethPaidToCreators
to be greater than 50_000 ether * 2260 ~= $113_000_000
).
AuctionHouse._settleAuction function/L399-L409
if (creatorsShare > ethPaidToCreators) { creatorTokensEmitted = erc20TokenEmitter.buyToken{ value: creatorsShare - ethPaidToCreators }( vrgdaReceivers, vrgdaSplits, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: deployer }) ); }
ERC20TokenEmitter.buyToken function/L164-L170
// Get value left after protocol rewards uint256 msgValueRemaining = _handleRewardsAndGetValueToSend( msg.value, protocolRewardsRecipients.builder, protocolRewardsRecipients.purchaseReferral, protocolRewardsRecipients.deployer );
TokenEmitterRewards._handleRewardsAndGetValueToSend function/L12-L18
function _handleRewardsAndGetValueToSend( uint256 msgValue, address builderReferral, address purchaseReferral, address deployer ) internal returns (uint256) { if (msgValue < computeTotalReward(msgValue)) revert INVALID_ETH_AMOUNT();sss
RewardSplits.computeTotalReward function/L40-L41
function computeTotalReward(uint256 paymentAmountWei) public pure returns (uint256) { if (paymentAmountWei <= minPurchaseAmount || paymentAmountWei >= maxPurchaseAmount) revert INVALID_ETH_AMOUNT();
Manual Review.
Add another mechanism to handle the described issue without reverting the transaction that will result in blocking the AuctionHouse
contract.
DoS
#0 - c4-pre-sort
2023-12-22T22:49:57Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T22:50:05Z
raymondfam marked the issue as duplicate of #8
#2 - c4-pre-sort
2023-12-24T03:14:14Z
raymondfam marked the issue as duplicate of #160
#3 - c4-judge
2024-01-06T15:07:25Z
MarioPoneder marked the issue as satisfactory
🌟 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
101.2429 USDC - $101.24
ID | Title | Severity |
---|---|---|
L-01 | VerbsToken contract: changing the descriptor will leave the previously minted tokens un-renderd | Low |
L-02 | VerbsToken contract: changing the minter during an ongoing auction might block the AuctionHouse contract | Low |
L-03 | CulturalIndex.hasVoted function doesn't check if the pieceId exists | Low |
L-04 | Users can't vote in the same block the artpiec is created | Low |
L-05 | ERC20TokenEmitter.buyToken function: creatorsAddress and treasury can receive tokens without buying it | Low |
L-06 | ERC20TokenEmitter.buyToken function: totalTokensForCreators is added to the emittedTokenWad without checking if it's really minted | Low |
L-07 | The protocol might be eventually not gaining auction profits as the minCreatorRateBps can be increased only | Low |
L-08 | ERC20TokenEmitter contrat: block.timestamp can be manipulated by miners | Low |
L-09 | AuctionHouse contrat: users can create bids while the contract is paused | Low |
I-01 | CulturalIndex.createPiece : creatorArray could have duplicate addresses | Informational |
I-02 | Wrong check placement in ERC20TokenEmitter.buyToken function | Informational |
I-03 | creatorDirectPayment is sent to the creatorsAddress without checking if the address is not address(0) | Informational |
I-04 | Incorrect documentation for MaxHeap.insert function | Informational |
I-05 | AuctionHouse._settleAuction function contains excessive calling for a storage variable | Informational |
I-06 | Incorrect documentation for CulturIndex.getVotes function | Informational |
I-07 | Incorrect documentation for CultureIndex.getPastVotes function | Informational |
I-08 | Redundant check in CultureIndex.topVotedPieceId function | Informational |
I-09 | Redundant check in CultureIndex._verifyVoteSignature function | Informational |
I-10 | Incorrect comment line in _verifyVoteSignature function | Informational |
I-11 | A check needs to be refactored in CultureIndex._vote | Informational |
I-12 | VerbsToken._authorizeUpgrade function has a commited documentation< | Informational |
I-13 | ERC20TokenEmitter.setCreatorsAddress function has a redundant modifier | Informational |
R-01 | Add a function to enable users from disabling their signatures | Recommendation |
R-02 | CultureIndex contract: signatures become invalid if one of the signed art pieces is dropped | Recommendation |
R-03 | Pause voting in CulturalIndex contract if the AuctionHouse contract is paused | Recommendation |
Severity | Description | Instances |
---|---|---|
Low severity | Vulnerabilities with medium-low impact and low likelihood | 9 |
Informational | Suggestions on best practices and code readability | 13 |
Low Recommendation | Design recommendations | 3 |
VerbsToken
contract: changing the descriptor
will leave the previously minted tokens un-renderd<a id="l-01" ></a>VerbsToken
contract: the descriptor
is the contract that's responsible of returning tokens uri to be rendered, and this contract can be changed via VerbsToken.setDescriptor
function.descriptor.tokenURI
will be invalid.function tokenURI(uint256 tokenId) public view override returns (string memory) { return descriptor.tokenURI(tokenId, artPieces[tokenId].metadata); }
VerbsToken.setDescriptor function
function setDescriptor( IDescriptorMinimal _descriptor ) external override onlyOwner nonReentrant whenDescriptorNotLocked { descriptor = _descriptor; emit DescriptorUpdated(_descriptor); }
Either lock the descriptor once initialized or add a mapping to save each minted verbs with the relevant descriptor contract that will render it.
VerbsToken
contract: changing the minter during an ongoing auction might block the AuctionHouse
contract<a id="l-02" ></a>AuctionHouse
contract supposed to have the minter
role in the VerbsToken
contract; where it can mint new verbs token when an auction is created, and burn verbs token if the auction fails.
So if the owner of the VerbsToken
changes the minter
role during an ongoing auction in the AuctionHouse
, this might result in blocking the AuctionHouse
if the auction failed as the contract can't burn the minted verbs token for an auction.
AuctionHouse._settleAuction function/L348-L361
if (address(this).balance < reservePrice) { // If contract balance is less than reserve price, refund to the last bidder if (_auction.bidder != address(0)) { _safeTransferETHWithFallback(_auction.bidder, _auction.amount); } // And then burn the Noun verbs.burn(_auction.verbId); } else { //If no one has bid, burn the Verb if (_auction.bidder == address(0)) verbs.burn(_auction.verbId); //If someone has bid, transfer the Verb to the winning bidder else verbs.transferFrom(address(this), _auction.bidder, _auction.verbId);
Prevent updating the minter role in VerbsToken
if there's an ongoing auction.
CulturalIndex.hasVoted
function doesn't check if the pieceId
exists<a id="l-03" ></a>CulturalIndex.hasVoted
function returns whether an account has voted for an art piece or not regardless of the this piece being existent or not.
CultureIndex.hasVoted function
function hasVoted(uint256 pieceId, address voter) external view returns (bool) { return votes[pieceId][voter].voterAddress != address(0); }
Update CultureIndex.hasVoted
function to check if the id exists or not:
function hasVoted(uint256 pieceId, address voter) external view returns (bool) { + require(pieceId < _currentPieceId, "Invalid piece ID"); return votes[pieceId][voter].voterAddress != address(0); }
In CulturalIndex
contract: when a user votes on an art piece, the weight of his votes is calculated based on the weight of the past votes of ERC20VotingToken and ERC721VotingToken (which are governance token and verbs NFTs), where _getPastVotes
is invoked with the address of the user and the blockNumber of the art piece, and this will invoke the getPastVotes
that will check the blocknumber and retrieve the votes weight at the end of that blocknumber:
function _getPastVotes(address account, uint256 blockNumber) internal view returns (uint256) { return _calculateVoteWeight( erc20VotingToken.getPastVotes(account, blockNumber), erc721VotingToken.getPastVotes(account, blockNumber) ); }
But if the block number of the art piece is similar to the current block number; the function will revert and the user can't vote:
function getPastVotes(address account, uint256 timepoint) public view virtual returns (uint256) { VotesStorage storage $ = _getVotesStorage(); uint48 currentTimepoint = clock();// this will return Time.blockNumber() which is SafeCast.toUint48(block.number) if (timepoint >= currentTimepoint) { revert ERC5805FutureLookup(timepoint, currentTimepoint); } return $._delegateCheckpoints[account].upperLookupRecent(SafeCast.toUint48(timepoint)); }
CultureIndex._getPastVotes function
function _getPastVotes(address account, uint256 blockNumber) internal view returns (uint256) { return _calculateVoteWeight( erc20VotingToken.getPastVotes(account, blockNumber), erc721VotingToken.getPastVotes(account, blockNumber) ); }
VotesUpgradeable.getPastVotes function
function getPastVotes(address account, uint256 timepoint) public view virtual returns (uint256) { VotesStorage storage $ = _getVotesStorage(); uint48 currentTimepoint = clock(); if (timepoint >= currentTimepoint) { revert ERC5805FutureLookup(timepoint, currentTimepoint); } return $._delegateCheckpoints[account].upperLookupRecent(SafeCast.toUint48(timepoint)); }
Enable voting to start on the same timeblock of the created artpiece:
function getPastVotes(address account, uint256 timepoint) public view virtual returns (uint256) { VotesStorage storage $ = _getVotesStorage(); uint48 currentTimepoint = clock(); - if (timepoint >= currentTimepoint) { + if (timepoint > currentTimepoint) { revert ERC5805FutureLookup(timepoint, currentTimepoint); } return $._delegateCheckpoints[account].upperLookupRecent(SafeCast.toUint48(timepoint)); }
ERC20TokenEmitter.buyToken
function: creatorsAddress
and treasury
can receive tokens without buying it<a id="l-05" ></a>In ERC20TokenEmitter.buyToken
function: a check is made to ensuer that the caller is not the creatorsAddress
or treasury
address as they are allowed to have voting ERC20 tokens by minting them part of the bought tokens amount but not via direct buy:
//prevent treasury from paying itself require(msg.sender != treasury && msg.sender != creatorsAddress, "Funds recipient cannot buy tokens");
But these addresses can receive tokens if their addresses are set in the addresses
argument of the buyToken
function; as there's no check on this addresses array if it has creatorsAddress
or treasury
addresses when minting them tokens:
for (uint256 i = 0; i < addresses.length; i++) { if (totalTokensForBuyers > 0) { // transfer tokens to address _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000)); } bpsSum += basisPointSplits[i]; }
ERC20TokenEmitter.buyToken function/ L209-L215
for (uint256 i = 0; i < addresses.length; i++) { if (totalTokensForBuyers > 0) { // transfer tokens to address _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000)); } bpsSum += basisPointSplits[i]; }
Either remove this check that prevents creatorsAddress
and treasury
from buying tokens, or check the addresses
array to skip minting if it contains any of these addresses.
ERC20TokenEmitter.buyToken
function: totalTokensForCreators is added to the emittedTokenWad
without checking if it's really minted<a id="l-06" ></a>In ERC20TokenEmitter.buyToken
function: the values of the totalTokensForCreators
and totalTokensForBuyers
represent the amounts of voting tokens to be minted for these addresses and these values are added to the total emittedTokenWad
before minting.
But if the creatorsAddress
is address(0)
; then this address will not be minted the totalTokensForCreators
amount:
if (totalTokensForCreators > 0 && creatorsAddress != address(0)) { _mint(creatorsAddress, uint256(totalTokensForCreators)); }
This will result in an incorrect value of emittedTokenWad
as it has counted the un-minted amount of totalTokensForCreators tokens, and this will adversly affect the price of the voting token (the price will increase if the emittedTokenWad
is increased to a value ahead of the intended amount to be minted per day).
ERC20TokenEmitter.buyToken function/L186-L203
//Transfer ETH to treasury and update emitted emittedTokenWad += totalTokensForBuyers; if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators; // @audit: the amount is added before checking if it's minted //some code... //Mint tokens for creators if (totalTokensForCreators > 0 && creatorsAddress != address(0)) { _mint(creatorsAddress, uint256(totalTokensForCreators)); }
Update buyToken
function to account for the totalTokensForCreators
amount after minting it
to the creatorsAddress
:
//Transfer ETH to treasury and update emitted emittedTokenWad += totalTokensForBuyers; -if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators; //some code... //Mint tokens for creators if (totalTokensForCreators > 0 && creatorsAddress != address(0)) { _mint(creatorsAddress, uint256(totalTokensForCreators)); + emittedTokenWad += totalTokensForCreators; }
minCreatorRateBps
can be increased only<a id="l-07" ></a>The creatorRateBps
represents the split of the winning bid that is reserved for the creator of the Verb in basis points, and this value can be changed by the DAO via AuctionHouse.setCreatorRateBps
function; where the new value is checked if it's > minCreatorRateBps
(which represents the all time minimum split of the winning bid that is reserved for the creator of the Verb in basis points) before being assigned.
require(_creatorRateBps >= minCreatorRateBps, "Creator rate must be greater than or equal to minCreatorRateBps");
And the value of the minCreatorRateBps
can be updated by the DAO via calling AuctionHouse.setMinCreatorRateBps
function; but it was noticed that the minimum value can be updated if it's greater than the previous one:
require(_minCreatorRateBps > minCreatorRateBps, "Min creator rate must be greater than previous minCreatorRateBps");
This will result in minCreatorRateBps
never being decreased, and the creatorRateBps
will follow this trend as well, so this will result in the protocol gaining less and less of bidding proceeds with each update of the creatorRateBps
(as it will be incremented only if the minCreatorRateBps
is updated ==> and the higher the creatorRateBps
the lower the gains of the protocol from auctions).
AuctionHouse.setMinCreatorRateBps function
function setMinCreatorRateBps(uint256 _minCreatorRateBps) external onlyOwner { require(_minCreatorRateBps <= creatorRateBps, "Min creator rate must be less than or equal to creator rate"); require(_minCreatorRateBps <= 10_000, "Min creator rate must be less than or equal to 10_000"); //ensure new min rate cannot be lower than previous min rate require( _minCreatorRateBps > minCreatorRateBps, "Min creator rate must be greater than previous minCreatorRateBps" ); minCreatorRateBps = _minCreatorRateBps; emit MinCreatorRateBpsUpdated(_minCreatorRateBps); }
Update setMinCreatorRateBps
function to accept values less than the previously assigned value:
function setMinCreatorRateBps(uint256 _minCreatorRateBps) external onlyOwner { require(_minCreatorRateBps <= creatorRateBps, "Min creator rate must be less than or equal to creator rate"); require(_minCreatorRateBps <= 10_000, "Min creator rate must be less than or equal to 10_000"); - //ensure new min rate cannot be lower than previous min rate - require( - _minCreatorRateBps > minCreatorRateBps, - "Min creator rate must be greater than previous minCreatorRateBps" - ); minCreatorRateBps = _minCreatorRateBps; emit MinCreatorRateBpsUpdated(_minCreatorRateBps); }
ERC20TokenEmitter
contrat: block.timestamp
can be manipulated by miners <a id="l-08" ></a>ERC20TokenEmitter
contract functions use block.timestamp
to evaluate the price of the ERC20 voting tokens, where the price depends on the number of sold tokens per day, and if the number is ahead a limit set by the VRGDAC
contract then the price increases and if it's behind the scheduled limit then the price decreases.
block.timestamp
is used to calculate the passed days (timeSinceStart: toDaysWadUnsafe(block.timestamp - startTime)
), and this can be manupulated by miners to manipulate the price of the voting token:
block.timestamp
, then the price would return normal.block.timestamp
, then the price would be decreasedblock.timestamp
, then the price would return normal.block.timestamp
, then the price would be decreased more.ERC20TokenEmitter.buyTokenQuote function/L243
timeSinceStart: toDaysWadUnsafe(block.timestamp - startTime),
ERC20TokenEmitter.getTokenQuoteForEther function/L260
timeSinceStart: toDaysWadUnsafe(block.timestamp - startTime),
ERC20TokenEmitter.getTokenQuoteForPayment function/L277
timeSinceStart: toDaysWadUnsafe(block.timestamp - startTime),
block.number
can be used instead of block.timestamp
as it can't be manipulated by miners.
AuctionHouse
contrat: users can create bids while the contract is paused <a id="l-09" ></a>Users can create bids on an auction while the contract is paused.
ERC20TokenEmitter.createBid function
function createBid(uint256 verbId, address bidder) external payable override nonReentrant {
Add whenNotPaused
modifier to the createBid
function:
- function createBid(uint256 verbId, address bidder) external payable override nonReentrant { + function createBid(uint256 verbId, address bidder) external payable override nonReentrant whenNotPaused {
CulturalIndex.createPiece
: creatorArray
could have duplicate addresses<a id="i-01" ></a>CulturalIndex.createPiece
function enables anyone from adding art pieces to be voted on and auctioned, and the proceeds of the auction is distributed between the deployer (the one who added the art piece) and the creators which their addresses are appeneded to the art piece as the pieces[pieceId].creators
, but it was noticed that the creatorArray
is only checked if any of the addresses is a zero address, but there's no check for duplicate addresses.
CultureIndex.validateCreatorsArray function
function validateCreatorsArray(CreatorBps[] calldata creatorArray) internal pure returns (uint256) { uint256 creatorArrayLength = creatorArray.length; //Require that creatorArray is not more than MAX_NUM_CREATORS to prevent gas limit issues require(creatorArrayLength <= MAX_NUM_CREATORS, "Creator array must not be > MAX_NUM_CREATORS"); uint256 totalBps; for (uint i; i < creatorArrayLength; i++) { require(creatorArray[i].creator != address(0), "Invalid creator address"); totalBps += creatorArray[i].bps; } require(totalBps == 10_000, "Total BPS must sum up to 10,000"); return creatorArrayLength; }
Ensure that the creatorArray
has no duplicate addresses.
ERC20TokenEmitter.buyToken
function<a id="i-02" ></a>A chek made in ERC20TokenEmitter.buyToken
function to ensure that the totalTokensForBuyers > 0
is placed inside the for-loop while it should only present once before entering the loop.
ERC20TokenEmitter.buyToken function/ L209-L215
for (uint256 i = 0; i < addresses.length; i++) { if (totalTokensForBuyers > 0) { // transfer tokens to address _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000)); } bpsSum += basisPointSplits[i]; }
+ if (totalTokensForBuyers > 0) { for (uint256 i = 0; i < addresses.length; i++) { - if (totalTokensForBuyers > 0) { // transfer tokens to address _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000)); - } bpsSum += basisPointSplits[i]; } + }
creatorDirectPayment
is sent to the creatorsAddress
without checking if the address is not address(0)<a id="i-03" ></a>In ERC20TokenEmitter.buyToken
function; the creatorDirectPayment
is sent directly to the creatorsAddress
without checking if that address != address(0)
which will result in losing these sent payments.
ERC20TokenEmitter.buyToken function/L194-L198
//Transfer ETH to creators if (creatorDirectPayment > 0) { (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0)); require(success, "Transfer failed."); }
//Transfer ETH to creators - if (creatorDirectPayment > 0) { + if (creatorDirectPayment > 0 && creatorsAddress != address(0)) { (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0)); require(success, "Transfer failed."); }
MaxHeap.insert
function<a id="i-04" ></a>The documentation of the function indicates that the heap will revert if it's full, while there's no maximum value set for the size of the heap to compare against to check if it's full or not:
```javascript /// @dev The function will revert if the heap is full ```
/// @notice Insert an element into the heap /// @dev The function will revert if the heap is full /// @param itemId The item ID to insert /// @param value The value to insert 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++; }
Update the documentation to match function intention.
AuctionHouse._settleAuction
function contains excessive calling for a storage variable<a id="i-05" ></a>In AuctionHouse._settleAuction
function: the creatorsShare * entropyRateBps
is called with each creator payment calculation in a for-loop while it should be calculated only once and used:
```javascript uint256 paymentAmount = (creatorsShare _ entropyRateBps _ creator.bps) / (10_000 \* 10_000); ```
AuctionHouse._settleAuction function / L390
uint256 paymentAmount = (creatorsShare * entropyRateBps * creator.bps) / (10_000 * 10_000);
if (creatorsShare > 0 && entropyRateBps > 0) { + uint256 totalEth=creatorsShare * entropyRateBps; for (uint256 i = 0; i < numCreators; i++) { ICultureIndex.CreatorBps memory creator = verbs.getArtPieceById(_auction.verbId).creators[i]; vrgdaReceivers[i] = creator.creator; vrgdaSplits[i] = creator.bps; //Calculate paymentAmount for specific creator based on BPS splits - same as multiplying by creatorDirectPayment - uint256 paymentAmount = (creatorsShare * entropyRateBps * creator.bps) / (10_000 * 10_000); + uint256 paymentAmount = (totalEth * creator.bps) / (10_000 * 10_000); ethPaidToCreators += paymentAmount; //Transfer creator's share to the creator _safeTransferETHWithFallback(creator.creator, paymentAmount); } }
CulturIndex.getVotes
function<a id="i-06" ></a>The documentation of function indicates that it will return the voting power of a voter at the current block, while it actually returns the value in the most recent checkpoint which doesn't necessarily be the current block
```javascript @notice Returns the voting power of a voter at the current block. ```
CultureIndex.getVotes function
/** * @notice Returns the voting power of a voter at the current block. * @param account The address of the voter. * @return The voting power of the voter. */ function getVotes(address account) external view override returns (uint256) { return _getVotes(account); }
Update the documentation to match function intention.
CultureIndex.getPastVotes
function<a id="i-07" ></a>The documentation of the function indicates that it will return the voting power of a voter at the current block, while it actually returns the voting power at a specific blockNumber:
```javascript * @notice Returns the voting power of a voter at the current block. ```
CultureIndex.getPastVotes function
/** * @notice Returns the voting power of a voter at the current block. * @param account The address of the voter. * @return The voting power of the voter. */ function getPastVotes(address account, uint256 blockNumber) external view override returns (uint256) { return _getPastVotes(account, blockNumber); }
Update the documentation to match function intention.
CultureIndex.topVotedPieceId
function<a id="i-08" ></a>The function checks if maxHeap.size() > 0
before calling maxHeap.getMax()
, while the same check is done in the maxHeap.getMax()
function.
CultureIndex.topVotedPieceId function
function topVotedPieceId() public view returns (uint256) { require(maxHeap.size() > 0, "Culture index is empty"); //slither-disable-next-line unused-return (uint256 pieceId, ) = maxHeap.getMax(); return pieceId; }
function getMax() public view returns (uint256, uint256) { require(size > 0, "Heap is empty"); return (heap[0], valueMapping[heap[0]]); }
Remove the redundant check in the topVotedPieceId
function:
function topVotedPieceId() public view returns (uint256) { - require(maxHeap.size() > 0, "Culture index is empty"); //slither-disable-next-line unused-return (uint256 pieceId, ) = maxHeap.getMax(); return pieceId; }
CultureIndex._verifyVoteSignature
function<a id="i-09" ></a>Thhere's a check in this function to ensure that from
address is not zero address:
```javascript if (from == address(0)) revert ADDRESS_ZERO(); ```
while this check is implicitly done again in the next line:
```javascript if (recoveredAddress == address(0) || recoveredAddress != from) revert INVALID_SIGNATURE(); ```
CultureIndex._verifyVoteSignature function/L438
if (from == address(0)) revert ADDRESS_ZERO();
Remove this redundant check:
-if (from == address(0)) revert ADDRESS_ZERO();
_verifyVoteSignature
function<a id="i-10" ></a>A comment indicating that the check is made on to
address instead of from
:
```javascript // Ensure to address is not 0 if (from == address(0)) revert ADDRESS_ZERO(); ```
CultureIndex._verifyVoteSignature function/L437
// Ensure to address is not 0
- // Ensure to address is not 0 + // Ensure from address is not 0 if (from == address(0)) revert ADDRESS_ZERO();
CultureIndex._vote
<a id="i-11" ></a>This line of code:
```javascript require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted"); ```
can be refactored as follows to decrease the number of operations by one:
```javascript require(votes[pieceId][voter].voterAddress == address(0), "Already voted"); ```
CultureIndex._vote function/L311
require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted");
-require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted"); +require(votes[pieceId][voter].voterAddress == address(0), "Already voted");
VerbsToken._authorizeUpgrade
function has a commited documentation<a id="i-12" ></a>It seems like the function documentation is re-commited by mistake (as it matches the same documentation for that function all over the protocol contracts).
CultureIndex._authorizeUpgrade function documentation
// /// @notice Ensures the caller is authorized to upgrade the contract and that the new implementation is valid // /// @dev This function is called in `upgradeTo` & `upgradeToAndCall` // /// @param _newImpl The new implementation address function _authorizeUpgrade(address _newImpl) internal view override onlyOwner {
Consider either removing the committed function documentation or removing the second commit.
ERC20TokenEmitter.setCreatorsAddress
function has a redundant modifier<a id="i-13" ></a>ERC20TokenEmitter.setCreatorsAddress
function has nonReentrant
modifier; while using this modifier is redundant in this function as it's a setter and doesn't make any extrenal calls.
ERC20TokenEmitter.setCreatorsAddress function
function setCreatorsAddress(address _creatorsAddress) external override onlyOwner nonReentrant {
Consider removing nonReentrant
modifier from ERC20TokenEmitter.setCreatorsAddress
function.
CulturalIndex
contract has a functionality that enables users to vote onbehalf of other users on specific art pieces by signatures; but if it was noticed that there's no functionality to revoke/dinvalidate signatures.
Signatures got invalidated after using them (as the nonces of the user is increased and the votehash will result in a wrong recovered address) or after the deadline of that signature is passed.
So whenever a user changes his mind on voting on a specific artpiece after creating a signature; he can't do so as there's no mechanism to invalidate signatures.
Add a function in the CulturalIndex
contract to invalidate signatures by increasing the nonce of the user:
function invalidateSignature() public{ nonces[msg.sender]++; }
CultureIndex
contract: signatures become invalid if one of the signed art pieces is dropped<a id="r-02" ></a>CultureIndex
contract has a functionality to enable anyone from executing a vote on a specific art pieces by signature, and each signature has a deadline after which the signature becomes invalid.
And in the _vote
function; the art piece is checked if dropped (auctioned) before voting and if so; the transaction reverts:
require(!pieces[pieceId].isDropped, "Piece has already been dropped");
So if the signature has any artpiece that has been dropped after the signature created; it becomes invalid before its deadline.
CultureIndex._vote function/L310
require(!pieces[pieceId].isDropped, "Piece has already been dropped");
Update _vote
function to return false instead of reverting on a dropped art piece.
CulturalIndex
contract if the AuctionHouse
contract is paused <a id="r-03" ></a>Pausing the AuctionHouse
contract means that the top voted art piece can't be extracted to be auctioned; while in the same time voting in the CulturalIndex
contract is not affected by this pause; this will result in unequal/unfair opportunities for art piec deployers as some of them might be the top voted and elligible for auction at the time the aution is paused, then when the auction is unpaused they might lose their top voted positions and other art pieces are auctioned before them.
Pause voting if the auction house is paused.
#0 - raymondfam
2023-12-24T17:12:14Z
Possible upgrades:
L-04 --> #409
#1 - c4-pre-sort
2023-12-24T18:03:39Z
raymondfam marked the issue as high quality report
#2 - c4-sponsor
2024-01-04T23:58:36Z
rocketman-21 (sponsor) acknowledged
#3 - MarioPoneder
2024-01-07T18:42:27Z
Possible upgrades:
L-04 --> #409
More similar to previous duplicates of that issue that are now QA.
#4 - c4-judge
2024-01-07T20:00:28Z
MarioPoneder marked the issue as grade-a