Platform: Code4rena
Start Date: 30/10/2023
Pot Size: $49,250 USDC
Total HM: 14
Participants: 243
Period: 14 days
Judge: 0xsomeone
Id: 302
League: ETH
Rank: 6/243
Findings: 3
Award: $1,659.88
🌟 Selected for report: 0
🚀 Solo Findings: 0
1383.7985 USDC - $1,383.80
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L369
NextGen allows payment of token proceeds to be split between the artist and the team. Payment splits and addresses are configured on NextGenMinterContract
in four steps:
setPrimaryAndSecondarySplits
to configure the percentage split between the artist and team for primary and secondary royalty sources.proposePrimaryAddressesAndPercentages
to provide three payment addresses among which the artist's percentage of primary income will be split. Splits between these addresses are given as percentages of the whole and must sum to the artist's split set in the function above.proposeSecondaryAddressesAndPercentages
to provide three payment addresses among which the artist's percentage of secondary income will be split. This functions the same as above.acceptAddressesAndPercentages
to accept the percentages and addresses proposed.These configuration steps need to take place before payArtist
can be called, as they determine the funds and addresses to be used for that function.
It is possible for an admin with the relevant permissions to call setPrimaryAndSecondarySplits
again for the same collection after acceptAddressesAndPercentages
has been called. This could be done maliciously or accidentally. As payArtist
calculates the payments to the artist based on the values set in proposePrimaryAddressesAndPercentages
and payments to the team based on the values set in setPrimaryAndSecondarySplits
, this could result in more Ether than is in a given collection being paid out by the function. This would lead to accounting imbalances, as the contract would likely not have enough Ether to pay out artists of other collections.
This bug is demonstrated in the following steps:
payArtist
for collection 1 with a 50/50 split between two team addresses.As a result of this, 10 ether is distributed between the artist's three addresses, and another 10 ether is distributed between the team's two addresses. The minter contract is left with a balance of 0 ether, even though the return value of collectionTotalAmount(2)
is 10 ether.
This is implemented in a Foundry test in the function testChangeSplits
below:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "../src/NextGenAdmins.sol"; import "../src/XRandoms.sol"; import "../src/NextGenCore.sol"; import "../src/RandomizerNXT.sol"; import "../src/NFTdelegation.sol"; import "../src/MinterContract.sol"; contract NGTest is Test { NextGenAdmins public admins; randomPool public randompool; NextGenCore public core; NextGenRandomizerNXT public randomizer; DelegationManagementContract public delegation; NextGenMinterContract public minter; string[] public collectionScript = ["desc"]; bytes32[] public rootArr = [bytes32(0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870)]; string[] public datas = ["a"]; address[] public airdropped = [address(10)]; uint[] public salts = [2]; uint[] public dropAmounts = [1]; function setUp() public { vm.warp(1641070800); // don't start at time 0 // deploy contracts admins = new NextGenAdmins(); randompool = new randomPool(); core = new NextGenCore( address(admins)); delegation = new DelegationManagementContract(); randomizer = new NextGenRandomizerNXT( address(randompool), address(admins), address(core)); minter = new NextGenMinterContract( address(core), address(delegation), address(admins)); core.addMinterContract(address(minter)); } function _createCollection(uint _id) private { // create collection core.createCollection("Test Collection", "Artist", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/","", collectionScript); admins.registerCollectionAdmin(_id, address(1), true); core.setCollectionData(_id, address(2), 2, 100, 0); minter.setCollectionCosts(_id, 1 ether, 1 ether, 0, 200, 1, address(0)); minter.setCollectionPhases(_id, block.timestamp - 3, block.timestamp - 2, block.timestamp - 1, block.timestamp + 3 days, rootArr[0]); core.addRandomizer(_id, address(randomizer)); } event PayArtist(address indexed _add, bool success, uint256 indexed funds); event PayTeam(address indexed _add, bool success, uint256 indexed funds); function testChangeSplits() public { _createCollection(1); // collection 1 _createCollection(2); // collection 2 assert(address(minter).balance == 0); // minter contract starts with 0 ether minter.setPrimaryAndSecondarySplits(1, 100, 0, 90, 10); // initial split, 100 to artist, 0 to team minter.proposePrimaryAddressesAndPercentages(1, address(5), address(3), address(4), 50, 40, 10); minter.acceptAddressesAndPercentages(1, true, false); minter.setPrimaryAndSecondarySplits(1, 0, 100, 90, 10); // second split, 0 to artist, 100 to team minter.mint{value: 10 ether}( 1, 1, 0, "test", address(1), rootArr, address(0), 2); // 10 ether paid to collection 1 vm.deal(address(9), 20 ether); vm.prank(address(9)); minter.mint{value: 10 ether}( 2, 1, 0, "test", address(1), rootArr, address(0), 2); // 10 ether paid to collection 2 assertEq(address(minter).balance, 20 ether); // 20 ether in minter contract assertEq(minter.collectionTotalAmount(1), 10 ether); // 10 ether in collection 1 assertEq(minter.collectionTotalAmount(2), 10 ether); // 10 ether in collection 2 // pay artist 100% of 10 eth vm.expectEmit(); emit PayArtist(address(5), true, 5 ether); vm.expectEmit(); emit PayArtist(address(3), true, 4 ether); vm.expectEmit(); emit PayArtist(address(4), true, 1 ether); vm.expectEmit(); // pay team 100% of 10 eth emit PayTeam(address(20), true, 5 ether); vm.expectEmit(); emit PayTeam(address(30), true, 5 ether); minter.payArtist(1, address(20), address(30), 50, 50); // payArtist called assertEq(minter.collectionTotalAmount(1), 0); // 0 ether in collection 1 assertEq(minter.collectionTotalAmount(2), 10 ether); // 10 ether in collection 2 assertEq(address(minter).balance, 0); // 0 ether left in minter contract } }
Manual review.
The function NextGenMinterContract.setPrimaryAndSecondarySplits
should revert if collectionArtistPrimaryAddresses[_collectionID].status == true
or collectionArtistSecondaryAddresses[_collectionID].status == true
. Additionally, acceptAddressesAndPercentages
should check that the artist's percentage splits sum to their total split and revert otherwise.
Alternatively, the payArtist
and artist percentage proposal functions could be redesigned to apportion funds to the artists' addresses as percentages of the artist's total share instead of percentages of the total amount.
Invalid Validation
#0 - c4-pre-sort
2023-11-19T14:00:11Z
141345 marked the issue as duplicate of #303
#1 - c4-judge
2023-12-01T23:27:26Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T23:28:09Z
alex-ppg marked the issue as duplicate of #1686
#3 - c4-judge
2023-12-08T21:22:44Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0x3b, Arabadzhiev, DeFiHackLabs, Fulum, Madalad, MrPotatoMagic, SpicyMeatball, Tadev, ZanyBonzy, ZdravkoHr, alexfilippov314, audityourcontracts, cheatc0d3, devival, dy, evmboi32, immeas, lsaudit, mrudenko, oakcobalt, oualidpro, pipidu83, r0ck3tz, rishabh, rotcivegaf, tpiliposian, xAriextz
13.3948 USDC - $13.39
Ownable
is used in NextGenMinterContract
, NextGenRandomizerRNG
or NextGenRandomizerVRF
and the owners of these contracts do not have any special rights. Therefore, the inheritance can be removed to save on contract size.address(contractInstance)
2.1. NextGenCore.collectionAdditionalDataStructure
contains the variables address randomizerContract
and IRandomizer randomizer
. The former could be removed.
2.2. NextGenRandomizerNXT
contains the state variables address gencore
and INextGenCore gencoreContract
. The former could be removed.Adminable
, which could then be inherited by NextGenCore
, NextGenMinterContract
, NextGenRandomizerNXT
, NextGenRandomizerRNG
and NextGenRandomizerVRF
.NextGenCore
newCollectionIndex
can be set to 1 directly instead of incremented from 0._setDefaultRoyalty
function call could take a constructor argument for the receiver address, rather than the address being hardcoded. This would make the contract more flexible and avoid the necessity to change the default royalty address after deployment, should the current hardcoded address be deprecated in future.artistSignature
could include a require statement ensuring that _signature
has a length greater than 0, to prevent artists from signing with empty strings.artistSigned
could be removed, and checks for artistSigned[_collectionID] == false
could be replaced with checks for artistsSignature[_collectionID].length > 0
.airDropTokens
), NextGenCore.sol#192 (in mint
) and NextGenCore.sol#217 (in burnToMint
) will never return false because collectionTotalSupply
is always set at the same time as reservedMaxTokensIndex
(here and here) and the current token index is always checked against reservedMaxTokensIndex
before NextGenCore.mint
or NextGenCore.burnToMint
are called (here, here, here, here and here). However, in the event that a future change to the code causes either of these if statements to return false, the transaction would complete without minting a token. This would lead to an excess of Ether in the collection, a missing token and a skipped mint index. Therefore, these if statements should either be removed, or an else clause should be added which reverts the transaction.collectionTotalSupply
could be removed from collectionAdditionalDataStructure
and its value calculated when needed as reservedMaxTokensIndex - reservedMinTokensIndex
.NextGenMinterContract
burnToMint
-- collectionTokenMintIndex
stores the same value as mintIndex
below it. mintIndex
can be removed and replaced with collectionTokenMintIndex
in the call to gencore.burnToMint
.require
statement could be added to ensure that _auctionEndTime
is in the future, i.e. > block.timestamp
. Optionally, a minimum auction timespan could be enforced here. This would prevent tokens from being locked in too-short auctions.require
could be added to the initializeExternalBurnOrSwap
to ensure _tokmax > _tokmin
.* 1
is unnecessary and can be removed.require
statement should be spelt "greater".status
must already be false
per the require
statement at the top of both proposePrimaryAddressesAndPercentages
and proposeSecondaryAddressesAndPercentages
, so setting it to false
again is unnecessary. These lines can be removed.XRandoms
XRandoms#18: wordsList
could be made into a private state variable rather than being recreated and loaded into memory every time getWord
is called. The contents of the word list would be visible to a sufficiently technical user in either case.
#0 - 141345
2023-11-25T08:04:42Z
1332 dy l r nc 3 0 10
L 1 d dup of https://github.com/code-423n4/2023-10-nextgen-findings/issues/1317 L 2 n L 3 n L 1 n L 2 i bot L 3 n L 4 i L 5 l L 6 i L 1 n L 2 l L 3 l L 4 n L 5 n L 6 n L 7 n L 1 n
#1 - c4-pre-sort
2023-11-25T08:05:39Z
141345 marked the issue as sufficient quality report
#2 - alex-ppg
2023-12-08T15:15:50Z
The Warden's QA report has been graded B based on a score of 19 combined with a manual review per the relevant QA guideline document located here.
The Warden's submission's score was assessed based on the following accepted findings:
#3 - c4-judge
2023-12-08T15:15:58Z
alex-ppg marked the issue as grade-b
🌟 Selected for report: MrPotatoMagic
Also found by: 3th, Fulum, JohnnyTime, K42, Kose, SAAJ, Toshii, catellatech, cats, clara, digitizeworx, dimulski, dy, glcanvas, hunter_w3b, ihtishamsudo, niki, peanuts
262.6934 USDC - $262.69
The NextGen protocol allows for the creation and distribution of generative art NFTs according to different mechanisms. All NFT collections reside in the same ERC-721-derived contract, NextGenCore
. Minting, airdrops and handling of royalties is done by a separate contract, NextGenMinterContract
, and the protocol's administration is controlled by NextGenAdmins
. These three contracts form the core of the protocol.
Periphery contracts include randomizers, which provide randomness for art generation using different methods, and can be assigned on a per-collection basis; an auction contract, used to auction tokens to the highest bidder; and DelegationManagementContract
, which allows for delegation by token owners to other parties. This final contract was out of scope for the Code4rena contest.
Operation of the NextGen protocol is highly dependent on administrators. The admin contract owner and appointed global administrators have the ability to execute any of the protocol's functions and make almost any change to the protocol at any time. The degree of power held by administrators and the protocol's reliance on them negates many of the inherent strengths of Ethereum's decentralization.
While it is common for protocols to have administrative functions and components that require active upkeep, more decentralized protocols empower users to do as much as possible, and the most decentralized protocols are designed to continue functioning even in the event that they are abandoned by developers. This is not the case for NextGen -- artists cannot create or configure their own collections, as all the functionality for this is available only to protocol administrators. This reduces artist control over their collections and ensures that the protocol will be unusable if abandoned by its developers.
Overall, rethinking the permission structure and workflows involved in creating a collection could increase the protocol's longevity and reduce the workload on NextGen administrators. Even if the protocol admins behave entirely ethically and diligently maintain the protocol for its entire lifespan, there remains a risk of compromise. The compromise of an administrator account could have devastating effects on the protocol.
The lack of events for the majority of functionality could also contribute to a perception that the protocol is not transparently run.
Below, some commentary on individual functions:
NextGenMinterContract.emergencyWithdraw
This function allows a protocol admin to withdraw the entire Ether balance of the minter contract to the owner's address at any time. The Withdraw
event it emits may be considered misleading, as it includes only the address which called the function, which may not be the address which received the funds.
Even if we assume trust on behalf of the admin addresses empowered to call this function, its presence could deter artists and other users from engaging with the protocol due to its obvious potential for abuse.
Users' and artists' potential concerns with this functionality could be mitigated by including conditions on its successful execution, such as a timelock of at least one year.
NextGenMinterContract.payArtist
This function must be called by a protocol admin to pay artists and teams their share of the Ether generated by a given collection. The active function of the protocol requires administrators to call this function periodically for every active collection. This allows administrators a large amount of power over when artists and teams are paid, and also prevents self-service use of the protocol by artists. It also opens up the possibility for arbitrarily different team addresses and percentages to be specified on each call.
A more conventional pattern for this process would be to implement an claim
function which artists and teams could call to claim their shared of funds for their collection. This could be implemented in such a way that administrators are still able to call it to pay the artist of a given collection, thus preserving current functionality. This would require team addresses and percentages to be stored in the contract beforehand.
NextGenCore.addMinterContract
, NextGenCore.updateAdminContract
, NextGenMinterContract.updateCoreContract
, NextGenMinterContract.updateAdminContract
These functions allow protocol admins to change individual contracts used by the protocol. This can be done at any time, with the only requirement being that the new contracts implement a single function that returns true
. Administrators could use this functionality to unilaterally and maliciously change the behavior of the protocol. The possibility of this eventuality reduces user trust, even if no malicious actions are ever taken by the protocol admins.
NextGenMinter.setCollectionCosts
, NextGenMinter.setCollectionPhases
Both of these functions can be called repeatedly with different values by either the relevant collection or function admins, or the global admins. This would allow mint costs, sales options and collection phases to change, even during an active minting phase.
Per the documentation, the protocol's intention is to allow an arbitrary number of allowlisted and public phases with different sales models. To allay user concerns about abuse of this functionality, conditions could be added to prevent these functions from being called during an active allowlist or public sales phase.
NextGenMinter.proposePrimaryAddressesAndPercentages
, NextGenMinter.proposeSecondaryAddressesAndPercentages
These functions can be called by the artist of a given collection, or by a function admin or the global admin for any collection. Allowing them to be called only by the artist would give artists greater assurance in their ability to control their payments.
This section provides commentary on notable controls and patterns implemented in the NextGen codebase.
NextGen makes extensive use of an administration pattern where the global administrator can assign rights to additional administrators on a per-function basis. This allows for granular access management, but has a couple of potential pitfalls:
registerFunctionAdmin
assigns function admins by storing an association between a function selector and an address. This is used for access control across multiple contracts. Therefore, if two or more contracts in the protocol contained functions with the same name and parameters, a function admin would be authorized to execute all of them, as they would have the same selector. This is already the case for NextGenMinterContract.emergencyWithdraw
and NextGenRandomizerRNG.emergencyWithdraw
, and should be kept in mind when assigning that permission, as well as during any further protocol development.setCollectionPhases
, for example, can call this function for any collection. Furthermore, function administrators with access to protocol-changing functions such as NextGenMinter.updateAdminContract
or NextGenCore.addMinterContract
should be considered to have privileges essentially equivalent to global administrators.NextGen provides three different randomizer contracts:
NextGenRandomizerVRF
: Provides random words via the Chainlink VRF oracleNextGenRandomizerRNG
: Provides random words using the ARRNG oracleNextGenRandomizerNXT
: Provides random words using on-chain dataGlobal and function administrators can assign any of these randomizers to any existing collection, and can change a given collection's randomizer at any time.
The documentation treats all three randomizers as essentially equivalent, but the NextGenRandomizerNXT
's behavior diverges significantly from the other two. Rather than receiving random values from an oracle, it computes randomness on-chain, in the two functions below:
function randomNumber() public view returns (uint256){ uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 1000; return randomNum; } function randomWord() public view returns (string memory) { uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 100; return getWord(randomNum); }
The values used for the randomization, block.prevrandao
, the blockhash
of a previous block, and block.timestamp
are all either known or controllable to some extent by miners and will lead to a truly random output.
This issue is mitigated by the fact that randomness has only a cosmetic function in the protocol and there are no direct financial incentives for manipulating it. However, clearly documenting the relative strengths and weaknesses of each of the generation methods would help artists to make a more informed decision about which one to use for their collection.
Many of the in-scope contracts implement a method named isNAMEContract
(e.g. isMinterContract
and isRandomizerContract
). This method returns true
and is called when adding or updating contract addresses. The method's presence is not a foolproof way of ensuring that the contract is of the right type, as a malicious contract could implement the same method, or return true
in its fallback method. As contract changes should be fairly rare occurences, aside from setting randomizers for collections, the majority of these methods could be safely removed. Any remaining functions of this nature could be converted to public boolean constant state variables set to true
, to save on gas costs and contract size.
If this functionality is strictly required, it could be implemented more robustly by supporting ERC-165.
Collections have a maxCollectionPurchases
value which limits the number of tokens a given address can mint. This has two potential flaws:
maxCollectionPurchases
by using more than one EOA.If the purpose of this control is to limit the number of tokens a single user can mint, it is ineffective. If the purpose of this control is merely to make it more difficult to mint all available tokens in a single transaction, it should serve this purpose. Furthermore, this second purpose could be served by the below line in NextGenMinterContract
on its own, with the tokensMintedPublicPerAddress
accounting being unnecessary:
require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens");
Function and collection admins could provide a collection script for collections. This script is intended to generate the token's artwork, but could contain malicious functionality. The function retrieveGenerativeScript
outputs the collection script as part of a JavaScript string, which may be run in users' browsers. Care should thus be taken to ensure that collection scripts do not contain malicious JavaScript, such as keyloggers or code that attempts to interact with user wallets.
This section provides a few high-level recommendations for streamlining NextGen's architecture and improving code quality.
Per the bot report and my own QA report, the following high-level suggestions could be followed to reduce code size and complexity:
NextGen allows a variety of different methods for minting tokens, spread across the following functions in NextGenMinterContract
:
airdropTokens
mint
burnToMint
mintAndAuction
burnOrSwapExternalToMint
And the following functions in NextGenCore
:
airdropTokens
mint
burnToMint
These functions all contain significant amounts of duplicated code and could be refactored using multiple private or internal functions to reduce this code duplication.
Complexity could also be reduced by introducing separate functions for allowlist and public phase minting. This would remove the need for public phase minters to provide unused function arguments such as _maxAllowance
and merkleProof
.
Similarly, the function NextGenCore.updateCollectionInfo
could be refactored into multiple functions instead of using an _index
parameter. This would increase the readability of the codebase and the legibility of the contract's external API, as represented by its public and external functions.
The AuctionDemo contract could be greatly simplified, though at the expense of some functionality. Instead of storing all bids and allowing multiple bids per address, the function participateToAuction
could compare the proposed bid with a stored highest bid value (starting at 0). If the proposed bid is lower, the function reverts. If the proposed bid is higher, it becomes the new highest bid, and the caller becomes the new highest bidder. Once the auction finishes, the token is distributed to the highest bidder and the payment to the minter.
While bidders would not be able to cancel their bids in this version of the contract, it would remove the requirement for complex logic in the contract's view functions, as the highest bid and bidder could be stored in a public mapping. It would also remove the requirement for complex logic in the claimAuction
function, for the same reasons.
In this way, the contract size could be reduced by approximately 50%.
20 hours
#0 - c4-pre-sort
2023-11-27T14:58:14Z
141345 marked the issue as sufficient quality report
#1 - c4-judge
2023-12-07T16:49:31Z
alex-ppg marked the issue as grade-a