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: 33/110
Findings: 6
Award: $202.63
🌟 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
In the buyToken function of the ERC20TokenEmitter contract, when users buy tokens they send eth to treasury and creator, for creators only portion of creator reward transfered directly and remaining eth will be locked in the contract untouched.
As can be observed by looking the buyToken function of the ERC20TokenEmitter contract creator receive reward according to the entropyBps.
function buyToken( address[] calldata addresses, uint[] calldata basisPointSplits, ProtocolRewardAddresses calldata protocolRewardsRecipients ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) { //prevent treasury from paying itself require(msg.sender != treasury && msg.sender != creatorsAddress, "Funds recipient cannot buy tokens"); require(msg.value > 0, "Must send ether"); // ensure the same number of addresses and bps require(addresses.length == basisPointSplits.length, "Parallel arrays required"); // Get value left after protocol rewards uint256 msgValueRemaining = _handleRewardsAndGetValueToSend( msg.value, protocolRewardsRecipients.builder, protocolRewardsRecipients.purchaseReferral, protocolRewardsRecipients.deployer ); //Share of purchase amount to send to treasury uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000; //Share of purchase amount to reserve for creators //Ether directly sent to creators uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000; //Tokens to emit to creators int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0 //@audit remaining in the contract ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment) : int(0); // Tokens to emit to buyers int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0); //Transfer ETH to treasury and update emitted emittedTokenWad += totalTokensForBuyers; if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators; //Deposit funds to treasury (bool success, ) = treasury.call{ value: toPayTreasury }(new bytes(0)); require(success, "Transfer failed."); //Transfer ETH to creators if (creatorDirectPayment > 0) { (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0)); require(success, "Transfer failed."); } //Mint tokens for creators if (totalTokensForCreators > 0 && creatorsAddress != address(0)) { _mint(creatorsAddress, uint256(totalTokensForCreators)); } ... }
If entropyBps < 10_000, then (msgValueRemaining - toPayTreasury) - creatorDirectPayment > 0 and they mint tokens for creators with this amount of Eth, however, after that they don't transfer this to the creator and this will be locked in the contract forever. If more users buy, the locked amount will increase and there is no withdrawal system in this contract.
Send remaining amount to creators or treasury when entropyBps is lower than 10_000. If these amounts aren't for creators, there should be a method to withdraw from the contract.
Token-Transfer
#0 - c4-pre-sort
2023-12-22T19:47:40Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T19:47:49Z
raymondfam marked the issue as duplicate of #13
#2 - c4-pre-sort
2023-12-24T02:55:21Z
raymondfam marked the issue as duplicate of #406
#3 - c4-judge
2024-01-05T23:21:00Z
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
1.337 USDC - $1.34
The auction parameters can be changed anytime, even during ongoing auctions, and take effect immediately. Users may need time to react to the changes. The impacts maybe followings:
These params will affect a live auction after being changed.
File: AuctionHouse.sol function setTimeBuffer(uint256 _timeBuffer) external override onlyOwner { timeBuffer = _timeBuffer; emit AuctionTimeBufferUpdated(_timeBuffer); } /** * @notice Set the auction reserve price. * @dev Only callable by the owner. */ function setReservePrice(uint256 _reservePrice) external override onlyOwner { reservePrice = _reservePrice; emit AuctionReservePriceUpdated(_reservePrice); } /** * @notice Set the auction minimum bid increment percentage. * @dev Only callable by the owner. */ function setMinBidIncrementPercentage(uint8 _minBidIncrementPercentage) external override onlyOwner { minBidIncrementPercentage = _minBidIncrementPercentage; emit AuctionMinBidIncrementPercentageUpdated(_minBidIncrementPercentage); }
Other
#0 - c4-pre-sort
2023-12-22T19:51:26Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T19:51:34Z
raymondfam marked the issue as duplicate of #55
#2 - c4-pre-sort
2023-12-24T14:18:06Z
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:47Z
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:29Z
MarioPoneder marked the issue as duplicate of #515
#7 - c4-judge
2024-01-10T17:35:57Z
MarioPoneder marked the issue as partial-50
#8 - c4-judge
2024-01-11T18:03:12Z
MarioPoneder changed the severity to 2 (Med Risk)
🌟 Selected for report: ast3ros
Also found by: 0xG0P1, KupiaSec, Pechenite, cccz, deth, dimulski, mojito_auditor, osmanozdemir1, peanuts, rvierdiiev, zhaojie
51.1381 USDC - $51.14
The _vote function of CultureIndex contract, uses the past voting power of art pieces' creation block, but users can buy tokens at the same block of art creation and quorum can be bypassed easily
function _vote(uint256 pieceId, address voter) internal { require(pieceId < _currentPieceId, "Invalid piece ID"); require(voter != address(0), "Invalid voter address"); require(!pieces[pieceId].isDropped, "Piece has already been dropped"); require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted"); uint256 weight = _getPastVotes(voter, pieces[pieceId].creationBlock); require(weight > minVoteWeight, "Weight must be greater than minVoteWeight"); votes[pieceId][voter] = Vote(voter, weight); totalVoteWeights[pieceId] += weight; uint256 totalWeight = totalVoteWeights[pieceId]; // TODO add security consideration here based on block created to prevent flash attacks on drops? maxHeap.updateValue(pieceId, totalWeight); emit VoteCast(pieceId, voter, weight, totalWeight); } function getPastVotes(address account, uint256 blockNumber) external view override returns (uint256) { return _getPastVotes(account, blockNumber); } function _getPastVotes(address account, uint256 blockNumber) internal view returns (uint256) { return _calculateVoteWeight( erc20VotingToken.getPastVotes(account, blockNumber), erc721VotingToken.getPastVotes(account, blockNumber) ); }
erc20VotingToken.getPastVotes function will return the voting power of blockNumber.
By doing this, totalVoteWeights[pieceId] might be greater than totalVotesSupply of that piece and quorum of vote can be easily bypassed.
When getting past voting power creation of art block should not be contained. Also we should use a total supply of the previous block also.
function _vote(uint256 pieceId, address voter) internal { require(pieceId < _currentPieceId, "Invalid piece ID"); require(voter != address(0), "Invalid voter address"); require(!pieces[pieceId].isDropped, "Piece has already been dropped"); require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted"); - uint256 weight = _getPastVotes(voter, pieces[pieceId].creationBlock); + uint256 weight = _getPastVotes(voter, pieces[pieceId].creationBlock-1); require(weight > minVoteWeight, "Weight must be greater than minVoteWeight"); votes[pieceId][voter] = Vote(voter, weight); totalVoteWeights[pieceId] += weight; uint256 totalWeight = totalVoteWeights[pieceId]; // TODO add security consideration here based on block created to prevent flash attacks on drops? maxHeap.updateValue(pieceId, totalWeight); emit VoteCast(pieceId, voter, weight, totalWeight); }
Other
#0 - c4-pre-sort
2023-12-22T19:57:02Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T19:57:16Z
raymondfam marked the issue as duplicate of #260
#2 - c4-pre-sort
2023-12-24T05:40:03Z
raymondfam marked the issue as duplicate of #409
#3 - c4-judge
2024-01-05T22:39:45Z
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
The buyToken function of the ERC20TokenEmitter contract, enables users to buy tokens for list of addresses. The function doesn't have any type of slippage control, so if the price of the token is much higher or gets higher than users' expectation, the amount of token received in return may vary indefinitely while the request is waiting to be excuted.
Also the users will have no defense against price manipulation attacks.
As can be observed by looking the buyToken function of the ERC20TokenEmitter contract has on slippage protection.
function buyToken( address[] calldata addresses, uint[] calldata basisPointSplits, ProtocolRewardAddresses calldata protocolRewardsRecipients ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) { //prevent treasury from paying itself require(msg.sender != treasury && msg.sender != creatorsAddress, "Funds recipient cannot buy tokens"); require(msg.value > 0, "Must send ether"); // ensure the same number of addresses and bps require(addresses.length == basisPointSplits.length, "Parallel arrays required"); // Get value left after protocol rewards uint256 msgValueRemaining = _handleRewardsAndGetValueToSend( msg.value, protocolRewardsRecipients.builder, protocolRewardsRecipients.purchaseReferral, protocolRewardsRecipients.deployer ); //Share of purchase amount to send to treasury uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000; //Share of purchase amount to reserve for creators //Ether directly sent to creators uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000; //Tokens to emit to creators 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); //Transfer ETH to treasury and update emitted emittedTokenWad += totalTokensForBuyers; if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators; //Deposit funds to treasury (bool success, ) = treasury.call{ value: toPayTreasury }(new bytes(0)); require(success, "Transfer failed."); //Transfer ETH to creators if (creatorDirectPayment > 0) { (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0)); require(success, "Transfer failed."); } //Mint tokens for creators if (totalTokensForCreators > 0 && creatorsAddress != address(0)) { _mint(creatorsAddress, uint256(totalTokensForCreators)); } uint256 bpsSum = 0; //Mint tokens to buyers 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]; } require(bpsSum == 10_000, "bps must add up to 10_000"); emit PurchaseFinalized( msg.sender, msg.value, toPayTreasury, msg.value - msgValueRemaining, uint256(totalTokensForBuyers), uint256(totalTokensForCreators), creatorDirectPayment ); return uint256(totalTokensForBuyers); }
And this means users have no idea of how many tokens they will get in return.
The amount of token users will receive, depends on the price of the token and the number of tokens sold so far.
getTokenQuoteForEther function of ERC20TokenEmitter contract, shows the number of tokens with given etherAmount
.
function getTokenQuoteForEther(uint256 etherAmount) public view returns (int gainedX) { require(etherAmount > 0, "Ether amount must be greater than 0"); // Note: By using toDaysWadUnsafe(block.timestamp - startTime) we are establishing that 1 "unit of time" is 1 day. // solhint-disable-next-line not-rely-on-time return vrgdac.yToX({ timeSinceStart: toDaysWadUnsafe(block.timestamp - startTime), sold: emittedTokenWad, //@audit the number of tokens sold amount: int(etherAmount) }); }
If tokens are sold more than planned, the price gets much higher.
So, if users try to buy tokens, malicious users can front run and buy the tokens first. And this means the emittedTokenWad
increases as well as the price.
Since there is no slippage protection in the buyToken function, in worst case users will get no tokens and just wasting their funds.
Adding additional parameter could be added to the buyToken function, to let users decide the minimum amount of tokens to be received.
Other
#0 - c4-pre-sort
2023-12-22T19:44:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T19:44:28Z
raymondfam marked the issue as duplicate of #26
#2 - c4-pre-sort
2023-12-24T06:00:52Z
raymondfam marked the issue as duplicate of #397
#3 - c4-judge
2024-01-06T16:29:41Z
MarioPoneder changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-01-06T16:30:55Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: osmanozdemir1
Also found by: 0xDING99YA, BARW, Brenzee, DanielArmstrong, KupiaSec, SpicyMeatball, bart1e, deepplus, haxatron, rouhsamad, rvierdiiev
51.1381 USDC - $51.14
The buyToken function of the ERC20TokenEmitter contract, splits buying process into 2 steps, one for creators and one for users. But if buying is splitted like this users and creators will get more tokens than paid amount. In other words more tokens will be minted than the paid amount and this is contrary of price logic.
As we can se from the PoC, the curren implementation mints more tokens than expected.
function getTokenQuoteForEtherHelper(uint256 etherAmount, int256 supply) public view returns (int gainedX) { require(etherAmount > 0, "Ether amount must be greater than 0"); // Note: By using toDaysWadUnsafe(block.timestamp - startTime) we are establishing that 1 "unit of time" is 1 day. // solhint-disable-next-line not-rely-on-time return erc20TokenEmitter.vrgdac().yToX({ timeSinceStart: toDaysWadUnsafe(block.timestamp - erc20TokenEmitter.startTime()), sold: supply, amount: int(etherAmount) }); } function test_correctEmitted() public { uint256 creatorRateBps = 2_000; uint256 entropyRateBps = 8_000; vm.startPrank(erc20TokenEmitter.owner()); //set creatorRate and entropyRate erc20TokenEmitter.setCreatorRateBps(creatorRateBps); erc20TokenEmitter.setEntropyRateBps(entropyRateBps); vm.stopPrank(); vm.deal(address(this), 100000 ether); emit log_address(erc20TokenEmitter.creatorsAddress()); emit log_uint(erc20Token.balanceOf(erc20TokenEmitter.creatorsAddress())); //expect balance to start out at 0 assertEq(erc20Token.balanceOf(erc20TokenEmitter.creatorsAddress()), 0, "Balance should start at 0"); address[] memory recipients = new address[](1); recipients[0] = address(1); uint256[] memory bps = new uint256[](1); bps[0] = 10_000; //expect recipient0 balance to start out at 0 assertEq(erc20Token.balanceOf(address(1)), 0, "Balance should start at 0"); //get msg value remaining uint256 msgValueRemaining = 1 ether - erc20TokenEmitter.computeTotalReward(1 ether); //Share of purchase amount to send to treasury uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000; //Ether directly sent to creators uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000; //get expected tokens for creators int256 expectedAmountForCreators = erc20TokenEmitter.getTokenQuoteForEther( msgValueRemaining - toPayTreasury - creatorDirectPayment ); //get expected tokens for recipient0 int256 expectedAmountForRecipient0 = getTokenQuoteForEtherHelper(toPayTreasury, expectedAmountForCreators); erc20TokenEmitter.buyToken{ value: 1 ether }( recipients, bps, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: address(0) }) ); //log creatorsAddress balance emit log_uint(erc20Token.balanceOf(erc20TokenEmitter.creatorsAddress())); //assert that creatorsAddress balance is correct assertEq( uint(erc20Token.balanceOf(erc20TokenEmitter.creatorsAddress())), uint(expectedAmountForCreators), "Creators should have correct balance" ); //log recipient0 balance emit log_uint(erc20Token.balanceOf(address(1))); // assert that recipient0 balance is correct assertEq( uint(erc20Token.balanceOf(address(1))), uint(expectedAmountForRecipient0), "Recipient0 should have correct balance" ); }
You can update emittedTokenWad
before buying tokens for users
int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0 ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment) : int(0); + if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators; // Tokens to emit to buyers int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0); //Transfer ETH to treasury and update emitted emittedTokenWad += totalTokensForBuyers; - if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators;
Other
#0 - c4-pre-sort
2023-12-22T19:51:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T19:52:04Z
raymondfam marked the issue as duplicate of #194
#2 - c4-sponsor
2023-12-27T20:01:33Z
rocketman-21 (sponsor) confirmed
#3 - c4-judge
2024-01-06T14:02:03Z
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
If AuctionHouse.entropyRateBps
is zero, the auction can't be settled
function _settleAuction() internal { IAuctionHouse.Auction memory _auction = auction; require(_auction.startTime != 0, "Auction hasn't begun"); require(!_auction.settled, "Auction has already been settled"); //slither-disable-next-line timestamp require(block.timestamp >= _auction.endTime, "Auction hasn't completed"); auction.settled = true; uint256 creatorTokensEmitted = 0; // 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); } 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); if (_auction.amount > 0) { // Ether going to owner of the auction uint256 auctioneerPayment = (_auction.amount * (10_000 - creatorRateBps)) / 10_000; //Total amount of ether going to creator uint256 creatorsShare = _auction.amount - auctioneerPayment; uint256 numCreators = verbs.getArtPieceById(_auction.verbId).creators.length; address deployer = verbs.getArtPieceById(_auction.verbId).sponsor; //Build arrays for erc20TokenEmitter.buyToken uint256[] memory vrgdaSplits = new uint256[](numCreators); address[] memory vrgdaReceivers = new address[](numCreators); //Transfer auction amount to the DAO treasury _safeTransferETHWithFallback(owner(), auctioneerPayment); uint256 ethPaidToCreators = 0; //Transfer creator's share to the creator, for each creator, and build arrays for erc20TokenEmitter.buyToken if (creatorsShare > 0 && entropyRateBps > 0) { //@audit if entropyRateBps is zero this condition will not met for (uint256 i = 0; i < numCreators; i++) { ICultureIndex.CreatorBps memory creator = verbs.getArtPieceById(_auction.verbId).creators[i]; vrgdaReceivers[i] = creator.creator; //@audit and these will be zero addresses vrgdaSplits[i] = creator.bps; //@audit and these will be zero values //Calculate paymentAmount for specific creator based on BPS splits - same as multiplying by creatorDirectPayment uint256 paymentAmount = (creatorsShare * entropyRateBps * creator.bps) / (10_000 * 10_000); ethPaidToCreators += paymentAmount; //Transfer creator's share to the creator _safeTransferETHWithFallback(creator.creator, paymentAmount); } } //Buy token from ERC20TokenEmitter for all the creators if (creatorsShare > ethPaidToCreators) { //@audit and here try to buy tokens to receivers creatorTokensEmitted = erc20TokenEmitter.buyToken{ value: creatorsShare - ethPaidToCreators }( vrgdaReceivers, vrgdaSplits, IERC20TokenEmitter.ProtocolRewardAddresses({ builder: address(0), purchaseReferral: address(0), deployer: deployer }) ); } } } emit AuctionSettled(_auction.verbId, _auction.bidder, _auction.amount, creatorTokensEmitted); }
If entropyRateBps is zero, vrgdaSplits
, vrgdaReceivers
are zero values and zero addresses and these will be provided as parameters of erc20TokenEmitter.buyToken
File: ERC20TokenEmitter.sol 209 for (uint256 i = 0; i < addresses.length; i++) { 210 if (totalTokensForBuyers > 0) { 211 // transfer tokens to address 212 _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000)); 213 } 214 bpsSum += basisPointSplits[i]; 215 } 217 require(bpsSum == 10_000, "bps must add up to 10_000");
209-217
Here it will revert because of minting to zero address and check at L217
So, if entropyRateBps
is not set, the auction can't be settled and AuctionHouse will be bricked
vrgdaSplits
and vrgdaReceivers
should be set properly when entropyRateBps == 0
.
Other
#0 - c4-pre-sort
2023-12-22T19:52:15Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-22T19:52:33Z
raymondfam marked the issue as duplicate of #335
#2 - c4-judge
2024-01-06T01:25:32Z
MarioPoneder changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-06T15:11:22Z
This previously downgraded issue has been upgraded by MarioPoneder
#4 - c4-judge
2024-01-06T15:12:26Z
MarioPoneder marked the issue as duplicate of #160
#5 - c4-judge
2024-01-06T15:13:31Z
MarioPoneder marked the issue as satisfactory