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: 4/110
Findings: 6
Award: $1,577.15
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xDING99YA, 0xlemon, 0xluckhu, AS, Abdessamed, BARW, KupiaSec, MrPotatoMagic, SovaSlava, SpicyMeatball, ast3ros, bart1e, hakymulla, ktg, n1punp, plasmablocks, shaka, twcctop
44.0266 USDC - $44.03
In buyToken() function, after calculating the rewards for the ProtocolRewards contract, treasury and creatorDirectPayment, the remaining ether used to calculate the ERC20 tokens to be minted is permanently locked in the ERC20TokenEmitter.sol contract. The greater the creatorRateBps and smaller the entropyRateBps, the higher is the amount of ETH permanently locked in the contract.
Greater the creatorRateBps = higher is the cut for creatorsAddress compared to treasury Smaller the entropyRateBps = lower is the ETH cut and higher is the ETH (which is locked) used to calculate ERC20 cut for creatorsAddress
Here is the whole process:
creatorRateBps = 5000 entropyRateBps = 5000 Initial value = 10 ETH = 10 * (10 ^ 18)
Protocol Rewards contract
Protocol Rewards split = 2.5 % of 10 ETH = 2.5 * (10 ^ 17) ETH
Remaining ETH = Initial value - Protocol Rewards split = 9.75 * (10 ^ 18) ETH
Treasury
Treasury split = (Remaining ETH * (10000 - 5000)) / 10000 = 4.875 * (10 ^ 18) ETH
Remaining ETH = Remaining ETH - Treasury split = 4.875 * (10 ^ 18) ETH
Creator Direct Payment
Creator direct payment in ETH = (Remaining ETH * 5000) / 10000 = 2.4375 * (10 ^ 18) ETH
Remaining ETH = Remaining ETH - Creator direct payment = 2.4375 * (10 ^ 18) ETH
This final amount of remaining ETH is used to calculate ERC20 tokens to mint to the creatorsAddress but after the calculation is done, the ETH is never withdrawn or able to be withdrawn, leaving it permanently locked in the ERC20TokenEmitter.sol contract.
In this case, the greater the creatorRateBps and smaller the entropyRateBps, the higher is the amount of ETH permanently locked in the contract.
Note: All calculations done above are based on 1, 2, 3, 4 respective equations and rates.
Manual Review
Implement a withdrawal function to withdraw this locked ETH, which could be used for other protocol related purposes.
Other
#0 - c4-pre-sort
2023-12-22T17:31:21Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T17:31:34Z
raymondfam marked the issue as duplicate of #13
#2 - c4-pre-sort
2023-12-24T02:55:20Z
raymondfam marked the issue as duplicate of #406
#3 - c4-judge
2024-01-05T23:20:37Z
MarioPoneder marked the issue as satisfactory
🌟 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
2.6741 USDC - $2.67
After reservePrice has been updated mid-auction or towards the end of the auction, the last bidder's bid still exists and cannot be cancelled unless someone else places a higher bid above the new reservePrice. We assume bidders refrain from placing bids due to the high cost reservePrice now as well as end of the auction approaching,
In this case, the malicious last bidder can win the auction without placing a new bid. This is possible because this check here in the AuctionHouse.sol contract uses address(this).balance to determine whether to burn an NFT or not. A malicious bidder can send ETH to the contract through selfdestruct() or a coinbase transaction. In both cases, the recipient will be set to the AuctionHouse.sol contract.
This way the bidder can use selfdestruct() (due to ease) to increase the balance of the AuctionHouse.sol contract and avoid entering this check here which refunds the ETH to the bidder and burns the NFT.
These are the following impacts:
Here is the whole process:
File: AuctionHouse.sol 176: function createBid(uint256 verbId, address bidder) external payable override nonReentrant { 177: IAuctionHouse.Auction memory _auction = auction; 178: 179: //require bidder is valid address 180: require(bidder != address(0), "Bidder cannot be zero address"); 181: require(_auction.verbId == verbId, "Verb not up for auction"); 182: //slither-disable-next-line timestamp 183: require(block.timestamp < _auction.endTime, "Auction expired"); 184: require(msg.value >= reservePrice, "Must send at least reservePrice"); 185: require( 186: msg.value >= _auction.amount + ((_auction.amount * minBidIncrementPercentage) / 100), 187: "Must send more than last bid by minBidIncrementPercentage amount" 188: ); 189: 190: address payable lastBidder = _auction.bidder; 191: 192: auction.amount = msg.value; 193: auction.bidder = payable(bidder); 194: 195: // Extend the auction if the bid was received within `timeBuffer` of the auction end time 196: bool extended = _auction.endTime - block.timestamp < timeBuffer; 197: if (extended) auction.endTime = _auction.endTime = block.timestamp + timeBuffer; 198: 199: // Refund the last bidder, if applicable 200: if (lastBidder != address(0)) _safeTransferETHWithFallback(lastBidder, _auction.amount); 201: 202: emit AuctionBid(_auction.verbId, bidder, msg.sender, msg.value, extended); 203: 204: if (extended) emit AuctionExtended(_auction.verbId, _auction.endTime); 205: }
File: AuctionHouse.sol 295: function setReservePrice(uint256 _reservePrice) external override onlyOwner { 296: reservePrice = _reservePrice; 297: 298: emit AuctionReservePriceUpdated(_reservePrice); 299: }
This refrains bidders from further participating in the auction since the reservePrice now demands more than what they could have bid previously with the old reserve price and minBidIncrementPercentage.
During this period of timeBuffer (15 minutes), the malicious last bidder sends 1 ETH to this contract through selfdestruct() in his malicious contract that is destroyed. This will increase the address(this).balance of the AuctionHouse contract from 3 ETH to 4 ETH, which meets the reservePrice criteria. (Note: Here auction.amount is still 3 ETH since a new bid is not being placed by the bidder).
Once the current timestamp exceeds the auction.endTime, the malicious bidder or anyone can call the settleCurrentAndCreateNewAuction() issue. This function internally calls _settleAuction(), where the following occurs:
File: AuctionHouse.sol 344: function _settleAuction() internal { 345: IAuctionHouse.Auction memory _auction = auction; 346: 347: require(_auction.startTime != 0, "Auction hasn't begun"); 348: require(!_auction.settled, "Auction has already been settled"); 349: //slither-disable-next-line timestamp 350: require(block.timestamp >= _auction.endTime, "Auction hasn't completed"); 351: 352: auction.settled = true; 353: 354: uint256 creatorTokensEmitted = 0; 355: // Check if contract balance is greater than reserve price 356: 358: if (address(this).balance < reservePrice) { 359: // If contract balance is less than reserve price, refund to the last bidder 360: if (_auction.bidder != address(0)) { 361: _safeTransferETHWithFallback(_auction.bidder, _auction.amount); 362: } 363: 364: // And then burn the Noun 365: verbs.burn(_auction.verbId); 366: } else { 367: //If no one has bid, burn the Verb 368: if (_auction.bidder == address(0)) 369: verbs.burn(_auction.verbId); 370: //If someone has bid, transfer the Verb to the winning bidder 371: else verbs.transferFrom(address(this), _auction.bidder, _auction.verbId); 372: 373: if (_auction.amount > 0) { 374: // Ether going to owner of the auction 375: uint256 auctioneerPayment = (_auction.amount * (10_000 - creatorRateBps)) / 10_000; 376: 377: //Total amount of ether going to creator 378: uint256 creatorsShare = _auction.amount - auctioneerPayment;
Manual Review
Instead of using address(this).balance, use auction.amount when comparing to reservePrice.
Invalid Validation
#0 - c4-pre-sort
2023-12-22T19:50:40Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-12-22T19:51:16Z
raymondfam marked the issue as duplicate of #72
#2 - c4-pre-sort
2023-12-22T23:19:21Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2023-12-22T23:19:26Z
raymondfam marked the issue as sufficient quality report
#4 - c4-pre-sort
2023-12-22T23:19:35Z
raymondfam marked the issue as duplicate of #515
#5 - c4-judge
2024-01-05T22:07:47Z
MarioPoneder marked the issue as satisfactory
#6 - c4-judge
2024-01-11T18:03:12Z
MarioPoneder changed the severity to 2 (Med Risk)
🌟 Selected for report: MrPotatoMagic
Also found by: cccz
1143.9691 USDC - $1,143.97
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/MaxHeap.sol#L156 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/MaxHeap.sol#L94
During the extraction of the max element through the function extractMax(), the positionMapping
for the last element in the heap tree is not updated when the last element is equal to its parent.
These are the following impacts:
Note: Point 3 in the impacts above breaks an invariant of the MaxHeap tree mentioned in the README.
The MaxHeap should always maintain the property of being a binary tree in which the value in each internal node is greater than or equal to the values in the children of that node.
Here is the whole process:
File: MaxHeap.sol 171: function extractMax() external onlyAdmin returns (uint256, uint256) { 172: require(size > 0, "Heap is empty"); 173: 174: uint256 popped = heap[0]; 175: heap[0] = heap[--size]; 176: 177: 178: maxHeapify(0); 179: 180: return (popped, valueMapping[popped]); 181: }
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; 102: 103: uint256 posValue = valueMapping[heap[pos]]; 104: uint256 leftValue = valueMapping[heap[left]]; 105: uint256 rightValue = valueMapping[heap[right]]; 106: 107: if (pos >= (size / 2) && pos <= size) return; 108: 109: if (posValue < leftValue || posValue < rightValue) { 110: 111: if (leftValue > rightValue) { 112: swap(pos, left); 113: maxHeapify(left); 114: } else { 115: swap(pos, right); 116: maxHeapify(right); 117: } 118: } 119: }
File: MaxHeap.sol 130: function insert(uint256 itemId, uint256 value) public onlyAdmin { 131: heap[size] = itemId; 132: valueMapping[itemId] = value; // Update the value mapping 133: positionMapping[itemId] = size; // Update the position mapping 134: 135: uint256 current = size; 136: while (current != 0 && valueMapping[heap[current]] > valueMapping[heap[parent(current)]]) { 137: swap(current, parent(current)); 138: current = parent(current); 139: } 140: size++; 141: }
File: MaxHeap.sol 150: function updateValue(uint256 itemId, uint256 newValue) public onlyAdmin { 151: uint256 position = positionMapping[itemId]; 152: uint256 oldValue = valueMapping[itemId]; 153: 154: // Update the value in the valueMapping 155: valueMapping[itemId] = newValue; 156: 157: // Decide whether to perform upwards or downwards heapify 158: if (newValue > oldValue) { 159: // Upwards heapify 160: while (position != 0 && valueMapping[heap[position]] > valueMapping[heap[parent(position)]]) { 161: swap(position, parent(position)); 162: position = parent(position); 163: } 164: } else if (newValue < oldValue) maxHeapify(position); // Downwards heapify 165: }
File: MaxHeap.sol 159: // Upwards heapify 160: while (position != 0 && valueMapping[heap[position]] > valueMapping[heap[parent(position)]]) { 161: swap(position, parent(position)); 162: position = parent(position); 163: }
File: MaxHeap.sol 164: } else if (newValue < oldValue) maxHeapify(position); // Downwards heapify
maxHeapify() function:
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; 102: 103: uint256 posValue = valueMapping[heap[pos]]; 104: uint256 leftValue = valueMapping[heap[left]]; 105: uint256 rightValue = valueMapping[heap[right]]; 106: 107: if (pos >= (size / 2) && pos <= size) return; 108: 109: if (posValue < leftValue || posValue < rightValue) { 110: 111: if (leftValue > rightValue) { 112: swap(pos, left); 113: maxHeapify(left); 114: } else { 115: swap(pos, right); 116: maxHeapify(right); 117: } 118: } 119: }
Some points to note about this issue:
Manual Review
The most straightforward solution to this would be to consider this type of case in the extractMax() function itself.
The following check ensures that if the last element is equal to the parent and it is greater than equal to the right index of the root node (i.e. index 2), we update the positionMapping correctly to 0. This check needs to be placed after this statement.
if (heap[size] == parent(size) && heap[size] >= heap[2]) { positionMapping[heap[0]] = 0; }
Error
#0 - c4-pre-sort
2023-12-22T07:50:26Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T07:51:12Z
raymondfam marked the issue as duplicate of #266
#2 - c4-pre-sort
2023-12-24T03:50:40Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2023-12-24T03:50:45Z
raymondfam marked the issue as primary issue
#4 - c4-pre-sort
2023-12-24T04:03:53Z
raymondfam marked the issue as duplicate of #266
#5 - c4-judge
2024-01-06T12:28:13Z
MarioPoneder changed the severity to QA (Quality Assurance)
#6 - MarioPoneder
2024-01-06T12:30:14Z
See comment on primary issue: https://github.com/code-423n4/2023-12-revolutionprotocol-findings/issues/266#issuecomment-1879666356
#7 - c4-judge
2024-01-07T16:22:53Z
MarioPoneder marked the issue as grade-a
#8 - mcgrathcoutinho
2024-01-10T12:05:50Z
Hi @MarioPoneder, this issue has been incorrectly duplicated.
This issue is pointing out that positionMapping is not updated while the primary is talking about heap mapping not being updated. In my issue, I've clearly pointed out that the incorrect access of the positionMapping can lead to incorrect indexing and further complications, affecting other elements' position as well.
Note: This issue arises purely on functionality that is allowed in the current protocol (i.e. no downvoting). Though it would also affect the downvoting feature if introduced in the future.
Here is the POC:
cd packages/revolution
directory and run pnpm run dev
console.log
to work, add console
as such import { RevolutionBuilderTest, console } from "../RevolutionBuilder.t.sol";
at the top of MaxHeap.t.sol
and import { Test, console } from "forge-std/Test.sol";
at the top of RevolutionBuilder.t.sol
File: MaxHeap.t.sol 200: function testPositionMappingIssue() public { 201: //Initial State 202: maxHeap.insert(1, 50); //ItemId 1 with value 50 203: maxHeap.insert(2, 20); //ItemId 2 with value 20 204: maxHeap.insert(3, 15); //ItemId 3 with value 15 205: maxHeap.insert(4, 10); //ItemId 4 with value 10 206: maxHeap.insert(5, 20); //ItemId 5 with value 20 207: 208: //State changes causing issue 209: 210: //1. Extract max - here positionMapping is not updated here 211: maxHeap.extractMax(); 212: assertEq(maxHeap.positionMapping(5), 4); // This proves the index is still 4 and not 0 213: 214: //2. Inrease value of itemId 5 at root 215: maxHeap.updateValue(5, 25); 216: 217: //3. Final state - which is incorrect 218: assertEq(maxHeap.positionMapping(5), 1); //ItemId 5 should be at index 0 i.e. root but is at index 1 now 219: assertEq(maxHeap.positionMapping(2), 4); //ItemId 2 should be at 1 but is incorrectly swapped with index 4 220: assertEq(maxHeap.valueMapping(5), 25); 221: assertEq(maxHeap.valueMapping(2), 20); 222: 223: console.log("Item at index 1 (duplicated):", maxHeap.heap(1)); 224: console.log("Item at index 4:", maxHeap.heap(4)); 225: console.log("Item at root:", maxHeap.heap(0)); // Result is that now itemId 5 is now both at root and index 1 while itemId 2 is forced to index 4. This can lead to further complications based on the combination of function calls used. 226: 227: // Another complication - When new itemId 6 is inserted (i.e. at index 4), itemId 2 shares index 4 with it since itemId 2 was already existing there. 228: maxHeap.insert(6, 10); 229: assertEq(maxHeap.positionMapping(2), 4); 230: assertEq(maxHeap.positionMapping(6), 4); 231: 232: console.log("ItemId 2 index:", maxHeap.positionMapping(2)); 233: console.log("ItemId 6 index:", maxHeap.positionMapping(6)); 234: }
#9 - MarioPoneder
2024-01-10T21:04:58Z
@rocketman-21 Tagging sponsor due to added value. Will re-check issue
#10 - rocketman-21
2024-01-10T22:03:18Z
not updating the positionMapping after extract max was an oversight on my end, I think this is legit
new extract max
/// @notice Extract the maximum element from the heap /// @dev The function will revert if the heap is empty /// The values for the popped node are removed from the items mapping /// @return The maximum element from the heap function extractMax() external onlyAdmin returns (uint256, uint256) { if (size == 0) revert EMPTY_HEAP(); // itemId of the node with the max value at the root of the heap uint256 popped = heap[0]; // get priority value of the popped node uint256 returnValue = items[popped].value; // remove popped node values from the items mapping for the popped node delete items[popped]; // set the root node to the farthest leaf node and decrement the size heap[0] = heap[--size]; // update the heap index for the previously farthest leaf node items[heap[0]].heapIndex = 0; //delete the farthest leaf node delete heap[size]; //maintain heap property maxHeapify(0); return (popped, returnValue); }
#11 - c4-judge
2024-01-10T23:49:24Z
This previously downgraded issue has been upgraded by MarioPoneder
#12 - c4-judge
2024-01-10T23:50:40Z
MarioPoneder marked the issue as not a duplicate
#13 - c4-judge
2024-01-10T23:50:47Z
MarioPoneder marked the issue as satisfactory
#14 - c4-judge
2024-01-10T23:50:54Z
MarioPoneder marked the issue as selected for report
#15 - c4-judge
2024-01-10T23:52:55Z
MarioPoneder marked the issue as primary issue
#16 - NicolaMirchev
2024-01-11T07:15:39Z
Hey, Why this issue, which is duplicate of opened #266 is also opened(counted as different issue)?
#17 - mcgrathcoutinho
2024-01-11T07:59:31Z
Hi @NicolaMirchev, both issues have different root causes. This speaks about positionMapping not being updated to 0 for the case where last element is equal to its parent while the other talks about heap mapping not being updated. Solving #266 does not solve the issue here. Additionally, this issue affects functionality of the current protocol (i.e. no downvoting), though it would also affect the downvoting feature if introduced in the future. See this for proof.
#18 - mcgrathcoutinho
2024-01-11T08:28:19Z
Hi @MarioPoneder, I believe this issue is of high-severity since it causes the max heap tree to break in the current codebase, which is the core data structure for the protocol to function correctly and store the art pieces.
#19 - MarioPoneder
2024-01-11T16:38:24Z
Thank you for your comment!
Although I acknowledge that this is a good and severe find, any PoC so far was only focused on MaxHeap
itself and not its current usage through the main entry points of the protocol. Therefore, Medium severity seems appropriate since further impacts on the main functionality of the protocol need to be proven.
🌟 Selected for report: DanielArmstrong
Also found by: 0xDING99YA, MrPotatoMagic, SpicyMeatball, bart1e, mojito_auditor, nmirchev8, pep7siup
116.9139 USDC - $116.91
During extraction of the max element through function extractMax(), the last element is set to the root node index and heapified downwards to some index X. But when this is done, the last element is not cleared from its previous index. This creates a duplicate value of the last element in the previous index it was located in. Due to this, a bunch of attack pathways open up.
Note: The first thought one might get after reading the code is that the duplicate value is anyway overwritten by the new item that is inserted. Although this is true, the attack pathway described in this issue demonstrates how another existing element (not the duplicated value) in the tree can be overwritten and removed from existence when a new item is inserted.
The overwritten element will be loss for the creator and a waste of community votes for that item.
Here is the whole process:
File: MaxHeap.sol 171: function extractMax() external onlyAdmin returns (uint256, uint256) { 172: require(size > 0, "Heap is empty"); 173: 174: uint256 popped = heap[0]; 175: heap[0] = heap[--size]; 176: 177: 178: maxHeapify(0); 179: 180: return (popped, valueMapping[popped]); 181: }
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; 102: 103: uint256 posValue = valueMapping[heap[pos]]; 104: uint256 leftValue = valueMapping[heap[left]]; 105: uint256 rightValue = valueMapping[heap[right]]; 106: 107: if (pos >= (size / 2) && pos <= size) return; 108: 109: if (posValue < leftValue || posValue < rightValue) { 110: 111: if (leftValue > rightValue) { 112: swap(pos, left); 113: maxHeapify(left); 114: } else { 115: swap(pos, right); 116: maxHeapify(right); 117: } 118: } 119: }
File: MaxHeap.sol 150: function updateValue(uint256 itemId, uint256 newValue) public onlyAdmin { 151: uint256 position = positionMapping[itemId]; 152: uint256 oldValue = valueMapping[itemId]; 153: 154: // Update the value in the valueMapping 155: valueMapping[itemId] = newValue; 156: 157: // Decide whether to perform upwards or downwards heapify 158: if (newValue > oldValue) { 159: // Upwards heapify 160: while (position != 0 && valueMapping[heap[position]] > valueMapping[heap[parent(position)]]) { 161: swap(position, parent(position)); 162: position = parent(position); 163: } 164: } else if (newValue < oldValue) maxHeapify(position); // Downwards heapify 165: }
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; 102: 103: uint256 posValue = valueMapping[heap[pos]]; 104: uint256 leftValue = valueMapping[heap[left]]; 105: uint256 rightValue = valueMapping[heap[right]]; 106: 107: if (pos >= (size / 2) && pos <= size) return; 108: 109: if (posValue < leftValue || posValue < rightValue) { 110: 111: if (leftValue > rightValue) { 112: swap(pos, left); 113: maxHeapify(left); 114: } else { 115: swap(pos, right); 116: maxHeapify(right); 117: } 118: } 119: }
File: MaxHeap.sol 130: function insert(uint256 itemId, uint256 value) public onlyAdmin { 131: heap[size] = itemId; 132: valueMapping[itemId] = value; // Update the value mapping 133: positionMapping[itemId] = size; // Update the position mapping 134: 135: uint256 current = size; 136: while (current != 0 && valueMapping[heap[current]] > valueMapping[heap[parent(current)]]) { 137: swap(current, parent(current)); 138: current = parent(current); 139: } 140: size++; 141: }
Some points to note about this issue:
Manual Review
The solution to this would be to empty out the last node where the duplicated value exists i.e. update the heap mapping for that index to 0 in extractMax() function itself.
Error
#0 - c4-pre-sort
2023-12-22T16:27:22Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T16:27:49Z
raymondfam marked the issue as duplicate of #266
#2 - c4-judge
2024-01-06T12:28:13Z
MarioPoneder changed the severity to QA (Quality Assurance)
#3 - MarioPoneder
2024-01-06T12:31:19Z
See comment on primary issue: https://github.com/code-423n4/2023-12-revolutionprotocol-findings/issues/266#issuecomment-1879666356
#4 - c4-judge
2024-01-07T16:23:44Z
MarioPoneder marked the issue as grade-a
#5 - c4-judge
2024-01-10T23:49:24Z
This previously downgraded issue has been upgraded by MarioPoneder
#6 - c4-judge
2024-01-10T23:52:14Z
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
7.359 USDC - $7.36
ID | Issues |
---|---|
[N-01] | Require check in getArtPieceById() function should use < instead of <= |
[N-02] | Redundant check in _mintTo() function can be removed |
[N-03] | Incorrect comment for mapping heap |
[N-04] | No need to initialize unsigned integer to 0 |
[N-05] | Remove redundant check from TokenEmitterRewards.sol |
[L-01] | PUSH0 opcode is not supported on BASE AND Optimism chains |
[L-02] | Prefer prioritizing the left hand side of the tree than right in binary tree |
[L-03] | createPiece() can be spammed on cheap fee chains like Base and Optimism |
[L-04] | Return value of extractMax() not checked in function dropTopVotedPiece() in CultureIndex |
[L-05] | 1.75% of ETH provided to buyToken() can be refunded by users to themselves when buying the ERC20 tokens |
[L-06] | Missing field and check for audio metadata type length to be greater than 0 |
[L-07] | ERC20Votes liquidity fragmentation and art theft can occur if contracts are deployed across three different chains |
[L-08] | Use >= instead of > when using minVoteWeight as criteria to start voting |
[L-09] | Code does not follow spec and breaks soft invariant in pause/unpause functionalities |
[R-01] | Expose burn() function with access control in NontransferableERC20Votes.sol for any future supply mechanism purposes |
[R-02] | Consider removing the _minCreatorRateBps > minCreatorRateBps check |
Check should be < and not <= since mint happens following which an increment occurs for the next token which will be minted.
File: VerbsToken.sol 274: function getArtPieceById(uint256 verbId) public view returns (ICultureIndex.ArtPiece memory) { 275: require(verbId <= _currentVerbId, "Invalid piece ID"); 276: return artPieces[verbId]; 277: }
Check not required since when a piece is created, it undergoes this check.
File: VerbsToken.sol 287: require( 288: artPiece.creators.length <= cultureIndex.MAX_NUM_CREATORS(), 289: "Creator array must not be > MAX_NUM_CREATORS" 290: );
Correct struct to mapping in the notice tag on Line 64.
File: MaxHeap.sol 64: /// @notice Struct to represent an item in the heap by it's ID 66: mapping(uint256 => uint256) public heap;
no need to initialize to 0 since it is the default value of unsigned integers.
File: MaxHeap.sol 67: uint256 public size = 0;
The check below will always return false i.e. we never revert since computeTotalReward() always returns 2.5% of the msgValue. Now since msgValue can never be less than 2.5% of itself, the check can be removed.
File: TokenEmitterRewards.sol 19: if (msgValue < computeTotalReward(msgValue)) revert INVALID_ETH_AMOUNT();
All contracts in scope.txt use mostly v0.8.22
Make sure to either use version lower than 0.8.20 or set evm-version to Paris to deploy on the above mentioned chains.
In the below code snippet, we can observe that if the leftValue > rightValue, the left side is preferred as expected. But for the case where leftValue = rightValue, currently the right hand side of the tree is prioritized when swapping. This can lead to some edge case scenarios where the property of ACBT (almost complete binary tree) can be broken. Thus use >= to prioritize the left side of the tree.
File: MaxHeap.sol 109: if (leftValue > rightValue) { 110: swap(pos, left); 111: maxHeapify(left); 112: } else { 113: swap(pos, right); 114: maxHeapify(right); 115: }
Currently, there is not limit on how many pieces can be inserted into the max heap tree. This can lead to a DOS in the max heap tree down the line (due to unnecessary mountain of heapify operations required to be performed by legitimate new elements inserted) and frontend spamming if too many pieces are brought into existence. Additionally, since this codebase will be deployed on Base and Optimism, which have cheaper fees, the resources to perform such an attack increases.
To resolve this issue, consider adding a rate limiter per address, which would limit users from creating more pieces in a certain amount of time. For example, not more than 20-25 pieces per day.
The extractMax() function returns two uint256s i.e. the itemId at the top and its value, but neither of them are checked in the dropTopVotedPiece() function.
File: MaxHeap.sol 160: function extractMax() external onlyAdmin returns (uint256, uint256) { 161: require(size > 0, "Heap is empty"); 162: 163: uint256 popped = heap[0]; 164: heap[0] = heap[--size]; 165: maxHeapify(0); 166: 167: return (popped, valueMapping[popped]); 168: }
Buyers of the ERC20 tokens can pass in their addresses as protocolRewardsRecipients. This would refund them their 1.75% of ETH through the ProtocolRewards contract.
File: ERC20TokenEmitter.sol 152: function buyToken( 153: address[] calldata addresses, 154: uint[] calldata basisPointSplits, 155: ProtocolRewardAddresses calldata protocolRewardsRecipients 156: ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) {
Missing field in metadata for audio as "string audio" and thus missing check for it in the function below.
File: CultureIndex.sol 159: function validateMediaType(ArtPieceMetadata calldata metadata) internal pure { 160: require(uint8(metadata.mediaType) > 0 && uint8(metadata.mediaType) <= 5, "Invalid media type"); 161: 162: if (metadata.mediaType == MediaType.IMAGE) 163: require(bytes(metadata.image).length > 0, "Image URL must be provided"); 164: else if (metadata.mediaType == MediaType.ANIMATION) 165: require(bytes(metadata.animationUrl).length > 0, "Animation URL must be provided"); 166: else if (metadata.mediaType == MediaType.TEXT) 167: require(bytes(metadata.text).length > 0, "Text must be provided"); 168: 169: }
The issue is that currently all ERC20Votes tokens are non transferrable, thus limiting their usage as wrapped assets for bridging across chains. Each of the contracts will have their independent states, which allows for art pieces to be copied and sold across other chains.
For art theft - consider adding artist signatures as a part of the ArtPieceMetadata.
Minimum values usually represent the value required to cast a vote. But currently only > is considered instead of >=. This breaks a soft invariant of the protocol indirectly. Additionally users having the minimum amount of voting weight will not be allowed to vote, which is a loss for the creators of the art piece and the overall community supporting that art piece.
File: CultureIndex.sol 316: require(weight > minVoteWeight, "Weight must be greater than minVoteWeight");
The function pause() can be called even when the function is already paused. The same goes for unpause(). Thus, this does not follow the expected code spec.
File: AuctionHouse.sol 203: /** 204: * @notice Pause the Verbs auction house. 205: * @dev This function can only be called by the owner when the 206: * contract is unpaused. While no new auctions can be started when paused, 207: * anyone can settle an ongoing auction. 208: */ 209: 210: function pause() external override onlyOwner { 211: _pause(); 212: }
Currently in the NontransferableERC20Votes contract, only the mint() function has been externally exposed, which is called from the ERC20TokenEmitter. Although not necessary, it would be good to expose the burn() function with the onlyOwner access control as well, which could be used as some mintpass mechanism or supply mechanism in the future.
_minCreatorRateBps > minCreatorRateBps
checkThe below check should be removed in function setMinCreatorRateBps() since it limits the new minimum to always be greater. This is not a logical check since the the function itself is used to set the minCreatorRateBps.
File: AuctionHouse.sol 241: require( 242: _minCreatorRateBps > minCreatorRateBps, 243: "Min creator rate must be greater than previous minCreatorRateBps" 244: );
#0 - raymondfam
2023-12-24T16:22:24Z
Possible upgrades:
L-05 --> #342
#1 - c4-pre-sort
2023-12-24T16:22:39Z
raymondfam marked the issue as sufficient quality report
#2 - MarioPoneder
2024-01-07T18:17:07Z
Possible upgrades:
L-05 --> #342
No upgrade #342 is QA now.
#3 - c4-judge
2024-01-07T19:53:37Z
MarioPoneder marked the issue as grade-b
#4 - mcgrathcoutinho
2024-01-10T12:06:51Z
Hi @MarioPoneder, I believe N-01 should be dup of #110
#5 - MarioPoneder
2024-01-11T19:05:21Z
Thank you for your comment!
#110 is now QA.
🌟 Selected for report: MrPotatoMagic
Also found by: 0x11singh99, 0xAnah, IllIllI, JCK, Raihan, SAQ, Sathish9098, SovaSlava, c3phas, donkicha, fnanni, hunter_w3b, lsaudit, naman1778, pavankv, sivanesh_808
262.2099 USDC - $262.21
ID | Optimization |
---|---|
[G-01] | MAX_NUM_CREATORS check is not required when minting in VerbsToken.sol contract |
[G-02] | Mappings not used externally/internally can be marked private |
[G-03] | No need to initialize variable size to 0 |
[G-04] | Use left + 1 to calculate value of right in maxHeapify() to save gas |
[G-05] | Place size check at the start of maxHeapify() to save gas on returning case |
[G-06] | Cache parent(current) in insert() function to save gas |
[G-07] | else-if block in function updateValue() can be removed since newValue can never be less than oldValue |
[G-08] | size > 0 check not required in function getMax() |
[G-09] | Cache msgValueRemaining - toPayTreasury in buyToken() to save gas |
[G-10] | creatorsAddress != address(0) check not required in buyToken() |
[G-11] | Cache return value of _calculateTokenWeight() function to prevent SLOAD |
[G-12] | Cache erc20VotingToken.totalSupply() to save gas |
[G-13] | Unnecessary for loop can be removed by shifting its statements into an existing for loop |
[G-14] | Return memory variable pieceId instead of storage variable newPiece.pieceId to save gas |
[G-15] | Calculate creatorsShare before auctioneerPayment in buyToken() to prevent unnecessary SUB operation |
[G-16] | Remove msgValue < computeTotalReward(msgValue check from TokenEmitterRewards.sol contract |
[G-17] | Optimize computeTotalReward() and computePurchaseRewards into one function to save gas |
[G-18] | Calculation in computeTotalReward() can be simplified to save gas |
[G-19] | Negating twice in require check is not required in _vote() function |
Optimizations have been focused on specific contracts/functions as requested by the sponsor in the README here under Gas Reports.
MAX_NUM_CREATORS
check is not required when minting in VerbsToken.sol contractThe check below in the _mintTo() function can be removed since it is already checked when creating a piece in CultureIndex.sol (see here).
File: VerbsToken.sol 288: require( 289: artPiece.creators.length <= cultureIndex.MAX_NUM_CREATORS(), 290: "Creator array must not be > MAX_NUM_CREATORS" 291: );
The below mappings from the MaxHeap.sol contract can be marked private since they're not used by any other contracts.
File: MaxHeap.sol 66: /// @notice Struct to represent an item in the heap by it's ID 67: mapping(uint256 => uint256) public heap; 68: 69: uint256 public size = 0; 70: 71: /// @notice Mapping to keep track of the value of an item in the heap 72: mapping(uint256 => uint256) public valueMapping; 73: 74: /// @notice Mapping to keep track of the position of an item in the heap 75: mapping(uint256 => uint256) public positionMapping;
size
to 00 is the default value for an unsigned integer, thus setting it to 0 again is not required.
File: MaxHeap.sol 69: uint256 public size = 0;
left + 1
to calculate value of right
in maxHeapify()
to save gasThe variable right
can be re-written as 2 * pos + 1 + 1. Since we already know left
is 2 * pos + 1, we can use left + 1 to save gas. This would mainly remove the MUL opcode (5 gas) and CALLDATALOAD (3 gas) and add just an MLOAD opcode (3 gas) instead. Thus saving a net of 5 gas per call.
Instead of this:
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; //@audit Gas - Just use left + 1
Use this:
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = left + 1;
The check on Line 107 can be moved to the start of the maxHeapify() function. This will save gas when the condition becomes true because the computations from Line 100 to 105 will not be executed for the returning case since we will return early.
Instead of this:
File: MaxHeap.sol 099: function maxHeapify(uint256 pos) internal { 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; 102: 103: uint256 posValue = valueMapping[heap[pos]]; 104: uint256 leftValue = valueMapping[heap[left]]; 105: uint256 rightValue = valueMapping[heap[right]]; 106: 107: if (pos >= (size / 2) && pos <= size) return; 109: 111: if (posValue < leftValue || posValue < rightValue) { 113: 114: if (leftValue > rightValue) { 115: swap(pos, left); 116: maxHeapify(left); 117: } else { 118: swap(pos, right); 119: maxHeapify(right); 120: } 121: } 122: }
Use this (see line 99):
File: MaxHeap.sol 098: function maxHeapify(uint256 pos) internal { 099: if (pos >= (size / 2) && pos <= size) return; 100: uint256 left = 2 * pos + 1; 101: uint256 right = 2 * pos + 2; 102: 103: uint256 posValue = valueMapping[heap[pos]]; 104: uint256 leftValue = valueMapping[heap[left]]; 105: uint256 rightValue = valueMapping[heap[right]]; 106: 109: 111: if (posValue < leftValue || posValue < rightValue) { 113: 114: if (leftValue > rightValue) { 115: swap(pos, left); 116: maxHeapify(left); 117: } else { 118: swap(pos, right); 119: maxHeapify(right); 120: } 121: } 122: }
parent(current)
in insert() function to save gasLink to instance Link to another instance
Caching the parent() function call will save an unnecessary JUMPDEST and internal operations in the function itself.
Instead of this:
File: MaxHeap.sol 138: swap(current, parent(current)); 139: current = parent(current);
Use this:
File: MaxHeap.sol 137: uint256 parentOfCurrent = parentCurrent(current); 138: swap(current, parentOfCurrent); 139: current = parentOfCurrent;
The else-if block below is present in the updateValue() function. This can be removed because votes made from function _vote() cannot be cancelled or decreased, thus this case is never true. This will save both function execution cost and deployment cost.
File: MaxHeap.sol 164: } else if (newValue < oldValue) maxHeapify(position);
size > 0
check not required in function getMax()The check size > 0
is already implemented in the function topVotedPieceId() here (which is accessed from dropTopVotedPiece()) in CultureIndex.sol, thus implementing it in getMax() function in MaxHeap.sol is not required.
File: MaxHeap.sol 188: require(size > 0, "Heap is empty");
msgValueRemaining - toPayTreasury
in buyToken() to save gasThe same subtraction operation with the same variables is carried out in three different steps. Consider caching this value to save gas.
Instead of this:
File: ERC20TokenEmitter.sol 178: uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000; 179: 180: //Tokens to emit to creators 181: int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0 182: ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment) 183: : int(0);
Use this (see Line 177):
File: ERC20TokenEmitter.sol 177: uint256 cachedValue = msgValueRemaining - toPayTreasury; 178: uint256 creatorDirectPayment = ((cachedValue) * entropyRateBps) / 10_000; 179: 180: //Tokens to emit to creators 181: int totalTokensForCreators = ((cachedValue) - creatorDirectPayment) > 0 182: ? getTokenQuoteForEther((cachedValue) - creatorDirectPayment) 183: : int(0);
creatorsAddress != address(0)
check not required in buyToken()The check below is from the buyToken() function (see here). The second condition creatorsAddress != address(0)
can be removed since creatorsAddress
can never be the zero address. This is because function setCreatorsAddress() implements this check already.
File: ERC20TokenEmitter.sol 205: if (totalTokensForCreators > 0 && creatorsAddress != address(0)) {
In the function createPiece() here, the totalVotesSupply returned can be cached into a memory variable and then assigned to Line 229 and 237. This will prevent the unnecessary SLOAD of newPiece.totalVoteSupply
on Line 237.
File: CultureIndex.sol 229: newPiece.totalVotesSupply = _calculateVoteWeight( 230: erc20VotingToken.totalSupply(), 231: erc721VotingToken.totalSupply() 232: ); 233: newPiece.totalERC20Supply = erc20VotingToken.totalSupply(); 234: newPiece.metadata = metadata; 235: newPiece.sponsor = msg.sender; 236: newPiece.creationBlock = block.number; 237: newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000;
erc20VotingToken.totalSupply()
to save gasThe value of the call erc20VotingToken.totalSupply()
is used on Line 230 and Line 233. This can save an unnecessary totalSupply() call on the erc20VotingToken if cached.
File: CultureIndex.sol 229: newPiece.totalVotesSupply = _calculateVoteWeight( 230: erc20VotingToken.totalSupply(), 231: erc721VotingToken.totalSupply() 232: ); 233: newPiece.totalERC20Supply = erc20VotingToken.totalSupply();
The for loop on Line 247 is not required since the for loop on Line 239 already loops through the same range [0, creatorArrayLength). Thus the for loop on Line 247 can be removed and the event emissions of PieceCreatorAdded() can be moved into the for loop on Line 239. This would save gas on both deployment and during function execution.
File: CultureIndex.sol 239: for (uint i; i < creatorArrayLength; i++) { 240: newPiece.creators.push(creatorArray[i]); 241: } 242: 243: emit PieceCreated(pieceId, msg.sender, metadata, newPiece.quorumVotes, newPiece.totalVotesSupply); 244: 245: // Emit an event for each creator 246: 247: for (uint i; i < creatorArrayLength; i++) { 248: emit PieceCreatorAdded(pieceId, creatorArray[i].creator, msg.sender, creatorArray[i].bps); 249: }
pieceId
instead of storage variable newPiece.pieceId
to save gasReturn memory variable pieceId
instead of accessing value from storage and returning. This would replace the SLOAD (100 gas) with an MLOAD (3 gas) operation.
File: CultureIndex.sol 250: return newPiece.pieceId;
creatorsShare
before auctioneerPayment
in buyToken() to prevent unnecessary SUB operationOn Line 388, when calculating the auctioneerPayment
, we find out the bps for the auctioneer by subtracting 10000 from creatorRateBps. This SUB operation will not be required if we calculate the creatorsShare
on Line 391 before the auctioneerPayment
. In this case the creatorsShare
can just be calculated using _auction.amount * creatorRateBps / 10000
. Following which the auctioneerPayment
can just be calculated using _auction.amount - creatorsShare
(as done by creatorsShare previously on Line 391). Through this we can see a redundant SUB operation can be removed which helps save gas.
File: AuctionHouse.sol 388: uint256 auctioneerPayment = (_auction.amount * (10_000 - creatorRateBps)) / 10_000; 389: 390: //Total amount of ether going to creator 391: uint256 creatorsShare = _auction.amount - auctioneerPayment;
msgValue < computeTotalReward(msgValue
check from TokenEmitterRewards.sol contractOn Line 18, when passing msgValue as parameter to computeTotalReward(), we will always pass this check because computeTotalReward always returns 2.5% of the msgValue. Thus since msgValue can never be less than 2.5% of itself, the condition never evaluates to true and we never revert.
File: TokenEmitterRewards.sol 12: function _handleRewardsAndGetValueToSend( 13: uint256 msgValue, 14: address builderReferral, 15: address purchaseReferral, 16: address deployer 17: ) internal returns (uint256) { 18: if (msgValue < computeTotalReward(msgValue)) revert INVALID_ETH_AMOUNT(); 19: 20: return msgValue - _depositPurchaseRewards(msgValue, builderReferral, purchaseReferral, deployer); 21: }
computeTotalReward()
and computePurchaseRewards
into one function to save gasBoth the functions below do the same computation, which is not required if the functions are combined into one. This will also prevent the rounding issue mentioned here.
Instead of this:
File: RewardSplits.sol 41: function computeTotalReward(uint256 paymentAmountWei) public pure returns (uint256) { 42: if (paymentAmountWei <= minPurchaseAmount || paymentAmountWei >= maxPurchaseAmount) revert INVALID_ETH_AMOUNT(); 43: 45: return 46: (paymentAmountWei * BUILDER_REWARD_BPS) / 47: 10_000 + 48: (paymentAmountWei * PURCHASE_REFERRAL_BPS) / 49: 10_000 + 50: (paymentAmountWei * DEPLOYER_REWARD_BPS) / 51: 10_000 + 52: (paymentAmountWei * REVOLUTION_REWARD_BPS) / 53: 10_000; 54: } 55: 56: function computePurchaseRewards(uint256 paymentAmountWei) public pure returns (RewardsSettings memory, uint256) { 57: return ( 58: RewardsSettings({ 59: builderReferralReward: (paymentAmountWei * BUILDER_REWARD_BPS) / 10_000, 60: purchaseReferralReward: (paymentAmountWei * PURCHASE_REFERRAL_BPS) / 10_000, 61: deployerReward: (paymentAmountWei * DEPLOYER_REWARD_BPS) / 10_000, 62: revolutionReward: (paymentAmountWei * REVOLUTION_REWARD_BPS) / 10_000 63: }), 64: computeTotalReward(paymentAmountWei) 65: ); 66: }
Use this:
File: RewardSplits.sol 41: function computeTotalPurchaseRewards(uint256 paymentAmountWei) public pure returns (RewardsSettings memory, uint256) { 42: if (paymentAmountWei <= minPurchaseAmount || paymentAmountWei >= maxPurchaseAmount) revert INVALID_ETH_AMOUNT(); 43: 44: uint256 br = (paymentAmountWei * BUILDER_REWARD_BPS) / 10_000; 45: uint256 pr = (paymentAmountWei * PURCHASE_REFERRAL_BPS) / 10_000; 46: uint256 dr = (paymentAmountWei * DEPLOYER_REWARD_BPS) / 10_000; 47: uint256 rr = (paymentAmountWei * REVOLUTION_REWARD_BPS) / 10_000; 48: 49: return ( 50: RewardsSettings({ 51: br, 52: pr, 53: dr, 54: rr 55: }), 56: br + pr + dr + rr 57: ); 58: }
Currently the calculation is very repetitive as seen below and some similar operations occur internally. If we observe closely, each reward separated by +
have paymentAmountWei and 10000 common in them.
Let us assume x = paymentAmountWei, y = 10000 and a,b,c,d = each of the reward bps types respectively.
Current equation = (x * a)/y + (x * b)/y + (x * c)/y + (x * d)/y
Let's take x / y common,
Modified Equation = x/y * a + x/y * b + x/y * c + x/y * d
Further let's take x/y common from all terms,
Modified Equation = x/y * (a + b + c + d)
Now since we need to get rid of the rounding, we just multiply first instead of divide.
Final Equation = (x * (a + b + c + d)) / y
This final equation will save us alot of gas without compromising on any rounding issues.
Instead of this:
File: RewardSplits.sol 41: function computeTotalReward(uint256 paymentAmountWei) public pure returns (uint256) { 42: if (paymentAmountWei <= minPurchaseAmount || paymentAmountWei >= maxPurchaseAmount) revert INVALID_ETH_AMOUNT(); 43: 44: 45: return 46: (paymentAmountWei * BUILDER_REWARD_BPS) / 47: 10_000 + 48: (paymentAmountWei * PURCHASE_REFERRAL_BPS) / 49: 10_000 + 50: (paymentAmountWei * DEPLOYER_REWARD_BPS) / 51: 10_000 + 52: (paymentAmountWei * REVOLUTION_REWARD_BPS) / 53: 10_000; 54: }
Use this:
File: RewardSplits.sol 41: function computeTotalReward(uint256 paymentAmountWei) public pure returns (uint256) { 42: if (paymentAmountWei <= minPurchaseAmount || paymentAmountWei >= maxPurchaseAmount) revert INVALID_ETH_AMOUNT(); 43: 44: 45: return 46: (paymentAmountWei * (BUILDER_REWARD_BPS + PURCHASE_REFERRAL_BPS + DEPLOYER_REWARD_BPS + REVOLUTION_REWARD_BPS)) / 10_000; 47: }
The require check below can just use == instead of negating twice to ensure voter has already voted or not.
Instead of this:
File: CultureIndex.sol 315: require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted");
Use this:
File: CultureIndex.sol 315: require(votes[pieceId][voter].voterAddress == address(0), "Already voted");
#0 - c4-pre-sort
2023-12-24T01:54:43Z
raymondfam marked the issue as high quality report
#1 - c4-sponsor
2024-01-04T20:34:46Z
rocketman-21 (sponsor) confirmed
#2 - c4-judge
2024-01-07T13:26:30Z
MarioPoneder marked the issue as grade-a
#3 - MarioPoneder
2024-01-07T13:49:29Z
Selected for report due to providing value from a high-level contract/function point of view.
#4 - c4-judge
2024-01-07T13:49:37Z
MarioPoneder marked the issue as selected for report