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: 13/110
Findings: 8
Award: $776.51
🌟 Selected for report: 0
🚀 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
ERC20TokenEmitter
leaves some amount of ETH undistributed after a buyToken
transaction. As a result, a significant sum of tokens will be left in the contract without a possibility to retrieve it.
During a buyToken
transaction after we take some fees
uint256 msgValueRemaining = _handleRewardsAndGetValueToSend( msg.value, protocolRewardsRecipients.builder, protocolRewardsRecipients.purchaseReferral, protocolRewardsRecipients.deployer );
msgValueRemaining
is split between treasury and creator addresses
uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000;
then creator's share is divided again
uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000;
creatorDirectPayment
and toPayTreasury
are distributed between the creator and the treasury accordingly, however the other part of creator's share will remain undistributed and therefore will be locked in the contract forever, because there are no other means to retrieve it.
Check this test case for ERC20TokenEmitter.t.sol
function testETHStuck() public { vm.startPrank(address(dao)); erc20TokenEmitter.setCreatorRateBps(1000); erc20TokenEmitter.setEntropyRateBps(5000); vm.startPrank(address(0)); address[] memory recipients = new address[](1); recipients[0] = address(1); uint256[] memory bps = new uint256[](1); bps[0] = 10_000; vm.deal(address(0), 100000 ether); console.log("BAL BEFORE: ", address(erc20TokenEmitter).balance); // try buy with - 1 ETH, to creator 10%, entropy 50% // after paying fees - 0.975 ETH // to treasury - 0.975 - 0.975 * 10 / 100 = 0.8775 ETH // to creator - (0.975 * 10 / 100) / 2 = 0.04875 ETH // stuck - 0.04875 ETH erc20TokenEmitter.buyToken{ value: 1e18 }( recipients, bps, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: address(0) }) ); console.log("BAL AFTER: ", address(erc20TokenEmitter).balance); assertEq(address(erc20TokenEmitter).balance, 0.04875 ether); }
Foundry
Assuming this part belongs to the treasury, we need to send it too
ETH-Transfer
#0 - c4-pre-sort
2023-12-22T03:05:36Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T03:05:44Z
raymondfam marked the issue as duplicate of #13
#2 - c4-pre-sort
2023-12-24T02:55:10Z
raymondfam marked the issue as duplicate of #406
#3 - c4-judge
2024-01-05T23:08:44Z
MarioPoneder marked the issue as satisfactory
🌟 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/main/packages/revolution/src/AuctionHouse.sol#L313 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L355-L359 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L226-L234
When an auction is created in the AuctionHouse
contract, we mint a Verb token, thus increasing the totalSupply
, but we don't know if the auction will be successful or not. Art pieces that were created during the auction may have their quorumVotes
unfairly increased, if the auction ends later without bidders and the token is burned (totalSupply
decreased).
create auction | mint token, supply increased | create piece and use current supply for quorumVotes calculation | auction failed | token burned, supply decreases
This issue may be especially harmful at the early stages, when there are not much tokens minted.
Check this test case for AuctionSettling.t.sol
function testInflateQuorum() public { uint256 pieceId0 = createDefaultArtPiece(); auction.unpause(); // art piece created during this auction will have increased quorumVotes compared to other // unfair because totalSupply will be decreased after settlement uint256 pieceId1 = createDefaultArtPiece(); vm.warp(block.timestamp + auction.duration()); // Fast forward time to end the auction auction.settleCurrentAndCreateNewAuction(); uint256 pieceId2 = createDefaultArtPiece(); ICultureIndex.ArtPiece memory piece0 = cultureIndex.getPieceById(pieceId0); ICultureIndex.ArtPiece memory piece1 = cultureIndex.getPieceById(pieceId1); ICultureIndex.ArtPiece memory piece2 = cultureIndex.getPieceById(pieceId2); console.log("Q1: ", piece0.quorumVotes); console.log("Q2: ", piece1.quorumVotes); console.log("Q3: ", piece2.quorumVotes); }
Forge
Consider minting tokens at the auction settlement phase rather than at auction creation. This way we'll be certain that Verb totalSupply
won't decrease in the end.
ERC721
#0 - c4-pre-sort
2023-12-22T02:28:54Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T02:29:14Z
raymondfam marked the issue as duplicate of #16
#2 - c4-pre-sort
2023-12-24T05:13:59Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2023-12-24T05:14:08Z
raymondfam marked the issue as duplicate of #18
#4 - c4-judge
2024-01-08T21:43:00Z
MarioPoneder marked the issue as duplicate of #409
#5 - c4-judge
2024-01-08T21:49:27Z
MarioPoneder marked the issue as satisfactory
#6 - c4-judge
2024-01-10T18:30:35Z
MarioPoneder marked the issue as not a duplicate
#7 - c4-judge
2024-01-10T18:30:42Z
MarioPoneder marked the issue as duplicate of #168
#8 - c4-judge
2024-01-10T18:32:36Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 Selected for report: deth
Also found by: 00xSEV, 0xCiphky, 0xHelium, ABAIKUNANBAEV, Aamir, AkshaySrivastav, King_, Pechenite, SpicyMeatball, Tricko, ast3ros, ayden, bart1e, deth, fnanni, ke1caM, mahdirostami, peanuts, pep7siup, pontifex, ptsanev, roland, rvierdiiev, y4y
7.2215 USDC - $7.22
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L498-L503 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L234
If the admin decides to reduce the quorum votes bps, the pieces created before this transaction will have to gather more votes than the new ones and thus will be at disadvantage.
When a creator creates a piece of art in CultureIndex.sol
, we get the total supply of ERC20 and ERC721 tokens that are used in voting and calculate the number of votes needed to mint that piece.
newPiece.pieceId = pieceId; 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;
Assume that we have a totalVotesSupply = 100_000 * 1e18
and quorumVotesBps = 3000 (30%)
, hence we need at least 30_000 * 1e18
votes for the piece to be eglible for minting.
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L523
require(totalVoteWeights[piece.pieceId] >= piece.quorumVotes, "Does not meet quorum votes to be dropped.");
After some time the admin sets quorumVotesBps = 1000 (10%)
, making quorumVotes = 10_000 * 1e18
for new pieces. While older ones still need to gather 30_000 * 1e18
because we save this value at the storage when an art piece is created.
Manual review
Consider using current quorumVotesBps
with stored piece.totalVotesSupply
in dropTopVotedPiece
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L519
ICultureIndex.ArtPiece memory piece = getTopVotedPiece(); - require(totalVoteWeights[piece.pieceId] >= piece.quorumVotes, "Does not meet quorum votes to be dropped."); + require(totalVoteWeights[piece.pieceId] >= quorumVotesBPS * piece.totalVotesSupply / 10_000, "Does not meet quorum votes to be dropped.");
Context
#0 - c4-pre-sort
2023-12-22T01:16:55Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T01:17:05Z
raymondfam marked the issue as duplicate of #16
#2 - c4-pre-sort
2023-12-24T15:11:01Z
raymondfam marked the issue as duplicate of #449
#3 - c4-judge
2024-01-06T15:57:27Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: deepplus
Also found by: 0xDING99YA, 0xmystery, Aymen0909, DanielArmstrong, Inference, KupiaSec, SadeeqXmosh, SpicyMeatball, Tricko, adeolu, jnforja, passteque, rvierdiiev, wangxx2026, zhaojie
25.1638 USDC - $25.16
ERC20TokenEmitter
uses VRGDA model to get the current price of the ERC20 token issued in exchange for ETH. Buyers are vulnerable to the price slippage due to price adjustments incurred by the VRGDA algorithm (price increases if amount sold is ahead of schedule and vice versa).
When user calls payable buyToken
function, the contract calculates the amount it will mint to the user based on msg.value and the amount of emitted tokens
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/ERC20TokenEmitter.sol#L184
return vrgdac.yToX({ timeSinceStart: toDaysWadUnsafe(block.timestamp - startTime), sold: emittedTokenWad, amount: int(etherAmount) }); }
If this transaction was frontrunned or executed later, the user may receive fewer tokens than he expected (e.g. the transaction was executed when the amount sold is ahead of schedule).
Manual review
Consider adding deadline
and minAmountOut
checks
function buyToken( address[] calldata addresses, uint[] calldata basisPointSplits, ProtocolRewardAddresses calldata protocolRewardsRecipients uint256 deadline, uint256 minAmountOut ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) { require(deadline > block.timestamp, "expired"); ... require(uint256(totalTokensForBuyers) >= minAmountOut), "amount too small")
Token-Transfer
#0 - c4-pre-sort
2023-12-22T02:30:26Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T02:30:45Z
raymondfam marked the issue as duplicate of #26
#2 - c4-pre-sort
2023-12-24T06:00:43Z
raymondfam marked the issue as duplicate of #397
#3 - c4-judge
2024-01-06T16:29:23Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: DanielArmstrong
Also found by: 0xDING99YA, MrPotatoMagic, SpicyMeatball, bart1e, mojito_auditor, nmirchev8, pep7siup
116.9139 USDC - $116.91
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L160-L161 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L105-L111 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L150
When we extract the maximum element, last element in the heap is placed on top without updating it's positionMapping
and previous heap
values
function extractMax() external onlyAdmin returns (uint256, uint256) { require(size > 0, "Heap is empty"); uint256 popped = heap[0]; heap[0] = heap[--size]; maxHeapify(0); return (popped, valueMapping[popped]); }
This leads to several issues:
Both of this issues arise if we decrease the value of the element, currently there is no way of it's happening because CultureIndex
contract can only increase the number of votes, however there is a condition
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L150
from which it follows that the developers have provided for such a possibility
Let's start with a collision. Consider initial heap
heap[0] = 50 id 1 heap[1] = 30 id 2 heap[2] = 20 id 3
After we extract the top element it will look like this
heap[0] = 30 id 2 heap[1] = 20 id 3 heap[2] = 20 id 3 // can be overwritten
Notice that although size = 2
we still have heap[2] = 20
in the storage. Next we update id 2 to 10, update(2, 10)
, this will give us
heap[0] = 20 id 3 heap[1] = 20 id 3 // 10 should be here heap[2] = 10 id 2 // can be overwritten
Instead of moving id 3 to the heap[1]
we swapped it with a dead position, because of this condition
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L105-L112
if leftValue = rightValue
, protocol will always swap with a right
position, which in our case can be overwritten with a newly inserted item leading to a collision.
Test case for MaxHeap.t.sol
function testExtractMaxBreak() public { // Fill the heap maxHeap.insert(1, 50); maxHeap.insert(2, 30); maxHeap.insert(3, 20); console.log(" ", maxHeap.valueMapping(maxHeap.heap(0))); console.log(" /", " \\ "); console.log( " ", maxHeap.valueMapping(maxHeap.heap(1)), " ", maxHeap.valueMapping(maxHeap.heap(2)) ); maxHeap.extractMax(); maxHeap.updateValue(2, 10); maxHeap.insert(5, 1); console.log("\nAFTER EXTRACTION AND UPDATE\n"); console.log(" ", maxHeap.valueMapping(maxHeap.heap(0))); console.log(" /", " \\ "); console.log( " ", maxHeap.valueMapping(maxHeap.heap(1)), " ", maxHeap.valueMapping(maxHeap.heap(2)) // Notice that id 3 was overwritten with a new element ); }
Now to the second issue, if the last element is the second greatest element in the heap
heap[0] = 50 id 1 heap[1] = 20 id 2 heap[2] = 30 id 3
After extraction
heap[0] = 30 id 3 heap[1] = 20 id 2
Notice that positionMapping[heap[0]] = 2
wasn't updated, this allows us to decrease the top element value without triggering maxHeapify
, after update(3, 1)
we are still at the top
heap[0] = 1 id 3 heap[1] = 20 id 2
function testExtractMaxBreak() public { // Fill the heap maxHeap.insert(1, 50); maxHeap.insert(2, 20); maxHeap.insert(3, 30); console.log(" ", maxHeap.valueMapping(maxHeap.heap(0))); console.log(" /", " \\ "); console.log( " ", maxHeap.valueMapping(maxHeap.heap(1)), " ", maxHeap.valueMapping(maxHeap.heap(2)) ); maxHeap.extractMax(); maxHeap.updateValue(3, 1); console.log("\nAFTER EXTRACTION AND UPDATE\n"); console.log(" ", maxHeap.valueMapping(maxHeap.heap(0))); console.log(" /", " \\ "); console.log( " ", maxHeap.valueMapping(maxHeap.heap(1)), " ", maxHeap.valueMapping(maxHeap.heap(2)) // DEAD ELEMENT ); }
Foundry
Clean and update values in extractMax
,
function extractMax() external onlyAdmin returns (uint256, uint256) { require(size > 0, "Heap is empty"); uint256 popped = heap[0]; heap[0] = heap[--size]; heap[size] = 0; positionMapping[heap[0]] = 0; maxHeapify(0); return (popped, valueMapping[popped]); }
Context
#0 - c4-pre-sort
2023-12-22T00:40:25Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-12-22T00:41:28Z
raymondfam marked the issue as duplicate of #41
#2 - c4-pre-sort
2023-12-22T05:26:20Z
raymondfam marked the issue as sufficient quality report
#3 - c4-pre-sort
2023-12-22T05:26:38Z
raymondfam marked the issue as duplicate of #266
#4 - c4-pre-sort
2023-12-24T03:55:37Z
raymondfam marked the issue as not a duplicate
#5 - c4-pre-sort
2023-12-24T03:55:50Z
raymondfam marked the issue as duplicate of #363
#6 - c4-pre-sort
2023-12-24T04:03:49Z
raymondfam marked the issue as duplicate of #266
#7 - c4-judge
2024-01-06T12:28:12Z
MarioPoneder changed the severity to QA (Quality Assurance)
#8 - MarioPoneder
2024-01-06T12:29:04Z
See comment on primary issue: https://github.com/code-423n4/2023-12-revolutionprotocol-findings/issues/266#issuecomment-1879666356
#9 - c4-judge
2024-01-07T16:22:21Z
MarioPoneder marked the issue as grade-b
#10 - c4-judge
2024-01-10T23:49:24Z
This previously downgraded issue has been upgraded by MarioPoneder
#11 - c4-judge
2024-01-10T23:50:06Z
MarioPoneder marked the issue as satisfactory
#12 - k4zanmalay
2024-01-11T07:50:19Z
Hi @MarioPoneder ! I believe this issue covers both #266 and #363:
#266 addresses the problem with heap
mapping not updated correctly (deleted item value is still in the storage), this issue is described in the first part of the report
Let's start with a collision. Consider initial heap
heap[0] = 50 id 1 heap[1] = 30 id 2 heap[2] = 20 id 3 After we extract the top element it will look like this
heap[0] = 30 id 2 heap[1] = 20 id 3 heap[2] = 20 id 3 // can be overwritten Notice that although size = 2 we still have heap[2] = 20 in the storage. Next we update id 2 to 10, update(2, 10), this will give us ...
#363 addresses the problem with not updating the positionMapping
and breaking the max heap order invariant (parent > child), which is described in the second part of this report
Now to the second issue, if the last element is the second greatest element in the heap
heap[0] = 50 id 1 heap[1] = 20 id 2 heap[2] = 30 id 3 After extraction
heap[0] = 30 id 3 heap[1] = 20 id 2 Notice that positionMapping[heap[0]] = 2 wasn't updated, this allows us to decrease the top element value without triggering >maxHeapify, after update(3, 1) we are still at the top ...
Mitigation report also includes fixes for both of them
function extractMax() external onlyAdmin returns (uint256, uint256) { require(size > 0, "Heap is empty"); uint256 popped = heap[0]; heap[0] = heap[--size]; + heap[size] = 0; + positionMapping[heap[0]] = 0; maxHeapify(0); return (popped, valueMapping[popped]); }
I think that we shouldn't split #363 and #266 and leave only one issue, because they have common root:
extractMax
function not correctly updating storage valuesThank you!
#13 - mcgrathcoutinho
2024-01-11T08:04:42Z
Hi @k4zanmalay, please check my comment here as to why these issues have different root causes.
#14 - k4zanmalay
2024-01-11T08:50:18Z
@mcgrathcoutinho after rereading your POC where you increase the value of the item, I agree with you and the judge that your finding is a separate issue. I still think that this report should be credited for it too (at least partially).
#15 - MarioPoneder
2024-01-11T16:00:21Z
Thank you for your comments!
If this issue was perfectly describing both core issues, I'd have to make it the primary one and duplicate #363 and #266 to it with partial credit. However, it's not quite there yet.
As I cannot duplicate to two issues (to one of them only partial credit), it seems appropriate to leave the duplication as it is since it is closer to #266 than to #363 from my point of view.
Thank you for your understanding.
🌟 Selected for report: osmanozdemir1
Also found by: 0xDING99YA, BARW, Brenzee, DanielArmstrong, KupiaSec, SpicyMeatball, bart1e, deepplus, haxatron, rouhsamad, rvierdiiev
51.1381 USDC - $51.14
The issue here is that we quote amounts for creators and buyers separately, knowing that VRGDA algorithm adjusts the price based on tokens sold inside a day, resulting amount will be different, getTokenQuoteForEther(toCreator) + getTokenQuoteForEther(buyerAmount) != getTokenQuoteForEther(toCreator + buyerAmount)
Thus, we mint more tokens than we should.
Assume that targetPrice = 1 ether
, perTimeUnit = 1000 ether
and we try to buy tokens with 20000 ether (somewhat exaggerated amount to highlight the issue)
function testC4_1() public { vm.startPrank(address(0)); int quote0 = erc20TokenEmitter.getTokenQuoteForEther(20000e18); int quote1 = erc20TokenEmitter.getTokenQuoteForEther(10000e18); console.log("QUOTES: "); console.logInt(quote0); console.logInt(quote1); }
quoting full amount yields 10760 * 1e18 tokens, but if we split 20000 * 1e18 in two 10000 * 1e18 quotes in the same transaction we'll receive 13659 * 1e18.
This is exactly what happens in buyToken
, we split the user's payment, and quote amounts for buyers and creators separately
int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0 ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment) : int(0); // Tokens to emit to buyers int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0);
Notice that emittedTokensWad
counter is updated later
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/ERC20TokenEmitter.sol#L187-L188
Foundry, ERC20TokenEmitter.t.sol
Perhaps we should use msgValueRemaining - creatorDirectPayment
to quote amount of tokens and split it between buyers and creators afterwards.
Math
#0 - c4-pre-sort
2023-12-22T04:23:53Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T04:24:03Z
raymondfam marked the issue as duplicate of #194
#2 - c4-judge
2024-01-06T13:55:14Z
MarioPoneder marked the issue as satisfactory
🌟 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/main/packages/revolution/src/AuctionHouse.sol#L383-L388 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L401-L402 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/NontransferableERC20Votes.sol#L128-L129
When the auction is successfully settled, the creators receive a share of the winning bid in two ways:
ERC20TokenEmitter
These amounts are configured using entropyRateBps
, based on it's value:The problem is that we initialize vrgdaReceivers
and vrgdaSplits
arrays only if entropyRateBps > 0
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L383-L388
if (creatorsShare > 0 && entropyRateBps > 0) { 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;
As a result this block may be executed with empty arrays if rate = 0 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L399-L409
if (creatorsShare > ethPaidToCreators) { creatorTokensEmitted = erc20TokenEmitter.buyToken{ value: creatorsShare - ethPaidToCreators }( vrgdaReceivers, vrgdaSplits, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: deployer }) ); }
which later leads to a revert in this check https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/NontransferableERC20Votes.sol#L128-L129
function testC4_2() public { auction.setEntropyRateBps(0); uint256 pieceId = createDefaultArtPiece(); auction.unpause(); vm.stopPrank(); vm.deal(address(1), 10000 ether); vm.prank(address(1)); auction.createBid{value: 1 ether}(pieceId, address(this)); vm.warp(block.timestamp + auction.duration()); // Fast forward time to end the auction // will revert with ERC20InvalidReceiver(0x0000000000000000000000000000000000000000) auction.settleCurrentAndCreateNewAuction(); }
Foundry, AuctionSettling.t.sol
Initialize vrgdaReceivers
and vrgdaSplits
arrays outside of the if statement
Token-Transfer
#0 - c4-pre-sort
2023-12-22T04:37:27Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-12-22T04:38:27Z
raymondfam marked the issue as primary issue
#2 - c4-pre-sort
2023-12-22T07:02:16Z
raymondfam marked the issue as duplicate of #335
#3 - c4-pre-sort
2023-12-22T07:02:21Z
raymondfam marked the issue as sufficient quality report
#4 - c4-judge
2024-01-06T01:07:50Z
MarioPoneder marked the issue as unsatisfactory: Overinflated severity
#5 - c4-judge
2024-01-06T01:23:12Z
MarioPoneder marked the issue as not a duplicate
#6 - c4-judge
2024-01-06T01:25:32Z
MarioPoneder changed the severity to QA (Quality Assurance)
#7 - c4-judge
2024-01-06T01:27:56Z
MarioPoneder marked the issue as grade-c
#8 - c4-judge
2024-01-06T15:14:25Z
This previously downgraded issue has been upgraded by MarioPoneder
#9 - c4-judge
2024-01-06T15:14:42Z
MarioPoneder marked the issue as duplicate of #160
#10 - c4-judge
2024-01-06T15:14:50Z
MarioPoneder marked the issue as satisfactory
🌟 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/main/packages/revolution/src/AuctionHouse.sol#L390-L391 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L399 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/protocol-rewards/src/abstract/RewardSplits.sol#L41
When the auction is successfully settled, the creators receive a share of the winning bid in two ways:
ERC20TokenEmitter
These amounts are configured using entropyRateBps
, based on it's value:
If the auction admin decides to fully pay creatorsShare
in ETH there are may be troubles as the contract assumes that in the case of entropyRateBps = 10_000
creatorsShare = ethPaidToCreators
therefore skipping ERC20 tokens minting.
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L399
if (creatorsShare > ethPaidToCreators) { creatorTokensEmitted = erc20TokenEmitter.buyToken{ value: creatorsShare - ethPaidToCreators }( vrgdaReceivers, vrgdaSplits, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: deployer }) ); }
However, this condition not always holds: depending on the bid amount and creators bps, a rounding error may occur making ethPaidToCreators
slightly smaller than creatorsShare
. As a result auction will try to mint this dust difference failing in process because of minPurchaseAmount
violation
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/protocol-rewards/src/abstract/RewardSplits.sol#L41
function testC4_3() public { auction.setCreatorRateBps(1000); //10 % auction.setEntropyRateBps(10000); //100 % address[] memory creators = new address[](3); uint256[] memory bps = new uint256[](3); creators[0] = address(1); creators[1] = address(1); creators[2] = address(1); bps[0] = 3333; bps[1] = 3333; bps[2] = 3334; uint256 pieceId = createArtPieceMultiCreator( "Mona Lisa", "A masterpiece", ICultureIndex.MediaType.IMAGE, "ipfs://legends", "", "", creators, bps ); auction.unpause(); vm.stopPrank(); vm.deal(address(1), 10000 ether); vm.prank(address(1)); auction.createBid{value: 1e18 + 12345678}(pieceId, address(1)); vm.warp(block.timestamp + auction.duration()); // Fast forward time to end the auction vm.expectRevert(abi.encodeWithSignature("INVALID_ETH_AMOUNT()")); auction.settleCurrentAndCreateNewAuction(); }
Foundry, AuctionSettling.t.sol
Add entropyRateBps
check in
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/AuctionHouse.sol#L399
if ((creatorsShare > ethPaidToCreators )&& (entropyRateBps != 10_000)) {
Math
#0 - c4-pre-sort
2023-12-22T16:52:50Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T16:53:27Z
raymondfam marked the issue as duplicate of #8
#2 - c4-pre-sort
2023-12-24T03:14:13Z
raymondfam marked the issue as duplicate of #160
#3 - c4-judge
2024-01-06T15:06:46Z
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
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/VerbsToken.sol#L130-L156 https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/master/contracts/utils/cryptography/EIP712Upgradeable.sol#L68-L91
Attempts to delegate verb tokens with a signature may fail.
VerbsToken NFT inherits from VotesUpgradeable.sol and EIP712Upgradeable.sol contracts, which allows users to use the token in the voting process. User can delegate votes directly or with a signature https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/base/VotesUpgradeable.sol#L180-L199
function delegateBySig( address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s ) public virtual { if (block.timestamp > expiry) { revert VotesExpiredSignature(expiry); } address signer = ECDSA.recover( _hashTypedDataV4(keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, expiry))), v, r, s ); _useCheckedNonce(signer, nonce); _delegate(signer, delegatee); }
In that case we use _hashTypedDataV4
function from EIP712Upgradeable.sol, unfortunately this contract isn't initialized, which means default empty name and version strings will be used to construct a domain separator.
function _buildDomainSeparator() private view returns (bytes32) { return keccak256(abi.encode(TYPE_HASH, _EIP712NameHash(), _EIP712VersionHash(), block.chainid, address(this))); } function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) { return MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), structHash); }
As a result decoded signer address will be different from the initial signer and won't have any tokens to delegate.
Manual review
Initialize EIP712Upgradeable https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/VerbsToken.sol#L130
function initialize( address _minter, address _initialOwner, address _descriptor, address _cultureIndex, IRevolutionBuilder.ERC721TokenParams memory _erc721TokenParams ) external initializer { require(msg.sender == address(manager), "Only manager can initialize"); require(_minter != address(0), "Minter cannot be zero address"); require(_initialOwner != address(0), "Initial owner cannot be zero address"); // Initialize the reentrancy guard __ReentrancyGuard_init(); // Setup ownable __Ownable_init(_initialOwner); // Initialize the ERC-721 token __ERC721_init(_erc721TokenParams.name, _erc721TokenParams.symbol); + __EIP712_init(_erc721TokenParams.name, "1"); _contractURIHash = _erc721TokenParams.contractURIHash; // Set the contracts minter = _minter; descriptor = IDescriptorMinimal(_descriptor); cultureIndex = ICultureIndex(_cultureIndex); }
en/de-code
#0 - c4-pre-sort
2023-12-21T23:59:41Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-12-21T23:59:45Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2023-12-22T00:00:02Z
This should only concern CultureIndex.sol:
// Initialize EIP-712 support __EIP712_init(string.concat(_cultureIndexParams.name, " CultureIndex"), "1");
#3 - MarioPoneder
2024-01-06T22:56:16Z
Technically correct, but not impacting intended functionality of VerbsToken
#4 - c4-judge
2024-01-06T22:56:26Z
MarioPoneder changed the severity to QA (Quality Assurance)
#5 - c4-judge
2024-01-06T22:56:32Z
MarioPoneder marked the issue as grade-b